diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index a2d7c399c..46ec3eeb3 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -78,18 +78,13 @@ export const routes: Routes = [ path: 'profile', loadChildren: () => import('./modules/profile/profile.routes').then((m) => m.PROFILE_ROUTES), }, - { - path: 'me/events', - loadChildren: () => import('./modules/events/events.routes').then((m) => m.EVENTS_ROUTES), - }, { path: 'me/training', loadChildren: () => import('./modules/trainings/trainings.routes').then((m) => m.TRAINING_ROUTES), }, { path: 'events', - data: { lens: 'foundation' }, - loadChildren: () => import('./modules/events/events.routes').then((m) => m.FOUNDATION_EVENTS_ROUTES), + loadChildren: () => import('./modules/events/events.routes').then((m) => m.EVENTS_ROUTES), }, ], }, diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index ed61536ea..01595e063 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -84,7 +84,7 @@ export class MainLayoutComponent { { label: 'My Events', icon: 'fa-light fa-ticket', - routerLink: '/me/events', + routerLink: '/events', }, { label: 'My ' + COMMITTEE_LABEL.plural, diff --git a/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts b/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts index 04a07a976..1ac832fe3 100644 --- a/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts +++ b/apps/lfx-one/src/app/modules/events/components/events-top-bar/events-top-bar.component.ts @@ -64,6 +64,23 @@ export class EventsTopBarComponent { .subscribe(() => { this.searchForm.get('foundation')?.setValue(null, { emitEvent: false }); }); + + // Clear status when the available options change (e.g. switching between event tabs + // and visa/TF tabs which have different status sets). + toObservable(this.statusOptions) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe(() => { + this.searchForm.get('status')?.setValue(null); + }); + + // Clear role when the role filter is hidden so stale values don't persist. + toObservable(this.showRoleFilter) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe((show) => { + if (!show) { + this.searchForm.get('role')?.setValue(null); + } + }); } public clearSearch(): void { diff --git a/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html new file mode 100644 index 000000000..d861293f7 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.html @@ -0,0 +1,8 @@ + + + +@if (isFoundationLens()) { + +} @else { + +} diff --git a/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts new file mode 100644 index 000000000..b0ac162fe --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/events-dashboard/events-dashboard.component.ts @@ -0,0 +1,19 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { LensService } from '@app/shared/services/lens.service'; +import { FoundationEventDashboardComponent } from '../foundation-event-dashboard/foundation-event-dashboard.component'; +import { MyEventsDashboardComponent } from '../my-events-dashboard/my-events-dashboard.component'; + +@Component({ + selector: 'lfx-events-dashboard', + imports: [MyEventsDashboardComponent, FoundationEventDashboardComponent], + templateUrl: './events-dashboard.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventsDashboardComponent { + private readonly lensService = inject(LensService); + + protected readonly isFoundationLens = computed(() => this.lensService.activeLens() === 'foundation'); +} diff --git a/apps/lfx-one/src/app/modules/events/events.routes.ts b/apps/lfx-one/src/app/modules/events/events.routes.ts index b5d3277ae..393a60ed2 100644 --- a/apps/lfx-one/src/app/modules/events/events.routes.ts +++ b/apps/lfx-one/src/app/modules/events/events.routes.ts @@ -7,16 +7,7 @@ import { authGuard } from '@shared/guards/auth.guard'; export const EVENTS_ROUTES: Routes = [ { path: '', - loadComponent: () => import('./my-events-dashboard/my-events-dashboard.component').then((m) => m.MyEventsDashboardComponent), - canActivate: [authGuard], - data: { preload: true, preloadDelay: 1500 }, - }, -]; - -export const FOUNDATION_EVENTS_ROUTES: Routes = [ - { - path: '', - loadComponent: () => import('./foundation-event-dashboard/foundation-event-dashboard.component').then((m) => m.FoundationEventDashboardComponent), + loadComponent: () => import('./events-dashboard/events-dashboard.component').then((m) => m.EventsDashboardComponent), canActivate: [authGuard], data: { preload: true, preloadDelay: 1500 }, }, diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html new file mode 100644 index 000000000..e775e5ca6 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.html @@ -0,0 +1,185 @@ + + + +
+ +
+
+ + + @if (form.get('firstName')?.invalid && form.get('firstName')?.touched) { + First name is required + } +
+
+ + + @if (form.get('lastName')?.invalid && form.get('lastName')?.touched) { + Last name is required + } +
+
+ + +
+
+ + +
+
+ + + @if (form.get('citizenshipCountry')?.invalid && form.get('citizenshipCountry')?.touched) { + Country of citizenship is required + } +
+
+ + +
+
+ + + @if (form.get('profileLink')?.invalid && form.get('profileLink')?.touched) { + Profile link is required + } +
+
+ + + @if (form.get('company')?.invalid && form.get('company')?.touched) { + Company is required + } +
+
+ + +
+
+ + + @if (form.get('canReceiveFunds')?.invalid && form.get('canReceiveFunds')?.touched) { + This field is required + } +
+
+ + + @if (form.get('travelFromCountry')?.invalid && form.get('travelFromCountry')?.touched) { + Travel from country is required + } +
+
+ + +
+ + + @if (form.get('openSourceInvolvement')?.invalid && form.get('openSourceInvolvement')?.touched) { + This field is required + } +
+ + +
+ +
+ + + + + +
+
+ + +
+
+ + + @if (form.get('attendingForCompany')?.invalid && form.get('attendingForCompany')?.touched) { + This field is required + } +
+
+ + + @if (form.get('willingToBlog')?.invalid && form.get('willingToBlog')?.touched) { + This field is required + } +
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts new file mode 100644 index 000000000..b60a0ead4 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/about-me-form/about-me-form.component.ts @@ -0,0 +1,76 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserService } from '@app/shared/services/user.service'; +import { CheckboxComponent } from '@components/checkbox/checkbox.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { COUNTRIES } from '@lfx-one/shared/constants'; +import { TravelFundAboutMe } from '@lfx-one/shared/interfaces'; +import { YES_NO_OPTIONS } from '@lfx-one/shared/constants/events.constants'; +import { startWith } from 'rxjs'; + +@Component({ + selector: 'lfx-about-me-form', + imports: [ReactiveFormsModule, InputTextComponent, SelectComponent, TextareaComponent, CheckboxComponent], + templateUrl: './about-me-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AboutMeFormComponent { + private readonly userService = inject(UserService); + private readonly fb = inject(NonNullableFormBuilder); + + public readonly formValidityChange = output(); + public readonly formChange = output(); + + public readonly form = this.fb.group({ + firstName: ['', Validators.required], + lastName: ['', Validators.required], + email: [''], + citizenshipCountry: ['', Validators.required], + profileLink: ['', Validators.required], + company: ['', Validators.required], + canReceiveFunds: ['', Validators.required], + travelFromCountry: ['', Validators.required], + openSourceInvolvement: ['', Validators.required], + isLgbtqia: [false], + isWoman: [false], + isPersonWithDisability: [false], + isDiversityOther: [false], + preferNotToAnswer: [false], + attendingForCompany: ['', Validators.required], + willingToBlog: ['', Validators.required], + }); + + public readonly countryOptions = [...COUNTRIES]; + public readonly yesNoOptions = YES_NO_OPTIONS; + + public constructor() { + this.form.get('email')?.disable(); + + const user = this.userService.user(); + if (user) { + this.form.patchValue({ + firstName: user.given_name ?? '', + lastName: user.family_name ?? '', + email: user.email ?? '', + }); + } + + this.form.statusChanges.pipe(startWith(this.form.status), takeUntilDestroyed()).subscribe(() => { + this.formValidityChange.emit(this.form.valid); + }); + + this.form.valueChanges.pipe(startWith(this.form.getRawValue()), takeUntilDestroyed()).subscribe(() => { + this.formChange.emit(this.buildFormValue()); + }); + } + + private buildFormValue(): TravelFundAboutMe { + return this.form.getRawValue() as TravelFundAboutMe; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html new file mode 100644 index 000000000..10d175015 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.html @@ -0,0 +1,106 @@ + + + +
+ +
+ + + + + + + + + + + + + Status + + + + + + + + + {{ request.name }} + + + + + + {{ request.location }} + + + + + {{ request.applicationDate }} + + + + + + + + + + + + +
+
+
+ +

{{ config().emptyMessage }}

+
+
+
+ + +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts new file mode 100644 index 000000000..adc5cf549 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-request-list/event-request-list.component.ts @@ -0,0 +1,123 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, Type, computed, inject, input, Signal, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { EventRequestStatusSeverityPipe } from '@app/shared/pipes/event-request-status-severity.pipe'; +import { TableComponent } from '@components/table/table.component'; +import { TagComponent } from '@components/tag/tag.component'; +import { DEFAULT_EVENTS_PAGE_SIZE, EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE, EMPTY_VISA_REQUESTS_RESPONSE } from '@lfx-one/shared/constants'; +import { PageChangeEvent, RequestType, VisaRequestsResponse } from '@lfx-one/shared/interfaces'; +import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; +import { catchError, combineLatest, finalize, of, skip, switchMap, tap } from 'rxjs'; +import { TravelFundApplicationDialogComponent } from '../travel-fund-application-dialog/travel-fund-application-dialog.component'; +import { VisaRequestApplicationDialogComponent } from '../visa-request-application-dialog/visa-request-application-dialog.component'; +@Component({ + selector: 'lfx-event-request-list', + imports: [TableComponent, TagComponent, ButtonComponent, DynamicDialogModule, EventRequestStatusSeverityPipe], + providers: [DialogService], + templateUrl: './event-request-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventRequestListComponent { + private readonly eventsService = inject(EventsService); + private readonly dialogService = inject(DialogService); + + public readonly requestType = input.required(); + public readonly searchQuery = input(''); + public readonly status = input(null); + + protected readonly loading = signal(false); + protected readonly sortField = signal('APPLICATION_DATE'); + protected readonly sortOrder = signal<'ASC' | 'DESC'>('DESC'); + protected readonly page = signal({ offset: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE }); + + protected readonly requestsResponse: Signal = this.initRequests(); + + protected readonly config = computed(() => { + const isVisa = this.requestType() === 'visa'; + return { + dialogComponent: (isVisa ? VisaRequestApplicationDialogComponent : TravelFundApplicationDialogComponent) as Type, + dialogHeader: isVisa ? 'Visa Letter Application' : 'Travel Funding Application', + buttonLabel: isVisa ? 'New Letter Application' : 'New Funding Application', + emptyMessage: isVisa ? 'No visa requests found' : 'No travel fund requests found', + testIdPrefix: isVisa ? 'visa-request' : 'travel-funding', + }; + }); + + protected readonly sortIcons = computed(() => { + const field = this.sortField(); + const order = this.sortOrder(); + const getIcon = (f: string): string => { + if (field !== f) return 'fa-light fa-sort text-gray-300'; + return order === 'ASC' ? 'fa-solid fa-caret-up text-blue-500' : 'fa-solid fa-caret-down text-blue-500'; + }; + return { + EVENT_NAME: getIcon('EVENT_NAME'), + EVENT_CITY: getIcon('EVENT_CITY'), + APPLICATION_DATE: getIcon('APPLICATION_DATE'), + }; + }); + + public constructor() { + combineLatest([toObservable(this.searchQuery), toObservable(this.status)]) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe(() => { + this.page.set({ offset: 0, pageSize: this.page().pageSize }); + }); + } + + public openApplicationDialog(): void { + this.dialogService.open(this.config().dialogComponent, { + header: this.config().dialogHeader, + width: '800px', + modal: true, + closable: true, + closeOnEscape: true, + }); + } + + protected onPageChange(event: { first: number; rows: number }): void { + this.loading.set(true); + this.page.set({ offset: event.first, pageSize: event.rows }); + } + + protected onHeaderClick(field: string): void { + if (this.sortField() === field) { + this.sortOrder.set(this.sortOrder() === 'ASC' ? 'DESC' : 'ASC'); + } else { + this.sortField.set(field); + this.sortOrder.set('ASC'); + } + this.page.set({ offset: 0, pageSize: this.page().pageSize }); + } + + private initRequests(): Signal { + return toSignal( + toObservable( + computed(() => ({ + ...this.page(), + searchQuery: this.searchQuery() || undefined, + status: this.status() ?? undefined, + sortField: this.sortField(), + sortOrder: this.sortOrder(), + requestType: this.requestType(), + })) + ).pipe( + tap(() => this.loading.set(true)), + switchMap(({ offset, pageSize, searchQuery, status, sortField, sortOrder, requestType }) => { + const params = { offset, pageSize, searchQuery, status, sortField, sortOrder }; + const emptyResponse = requestType === 'visa' ? EMPTY_VISA_REQUESTS_RESPONSE : EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE; + const fetch$ = requestType === 'visa' ? this.eventsService.getVisaRequests(params) : this.eventsService.getTravelFundRequests(params); + return fetch$.pipe( + catchError(() => of(emptyResponse)), + finalize(() => this.loading.set(false)) + ); + }) + ), + { initialValue: EMPTY_VISA_REQUESTS_RESPONSE } + ); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html new file mode 100644 index 000000000..c378463de --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.html @@ -0,0 +1,89 @@ + + + +
+ +
+ + + + + + + + +
+ + +
+ @if (loading()) { +
+ +
+ } @else if (allEvents().length === 0) { +
+ +

No registered events found

+

Register for an event to continue

+
+ } @else { +
+ @for (event of allEvents(); track event.id) { +
+ +

+ {{ event.name }} +

+ +
+ + {{ event.date }} +
+ +
+ + {{ event.location }} +
+ + @if (selectedEvent()?.id === event.id) { + + } @else { + + } +
+ } +
+ + + @if (hasMore()) { +
+ +
+ } + } +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss new file mode 100644 index 000000000..948d1c945 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.scss @@ -0,0 +1,32 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +.events-scroll-area { + max-height: 340px; + scrollbar-width: thin; + scrollbar-color: theme('colors.slate.200') transparent; + + &::-webkit-scrollbar { + @apply w-1.5; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-sm bg-slate-200; + } +} + +.event-card { + cursor: default; + + &.registered { + cursor: pointer; + } + + &.selected { + @apply border-blue-500; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts new file mode 100644 index 000000000..2da70dde4 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/event-selection/event-selection.component.ts @@ -0,0 +1,155 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, computed, inject, model, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { SelectComponent } from '@components/select/select.component'; +import { EMPTY_MY_EVENTS_RESPONSE } from '@lfx-one/shared/constants'; +import { MyEvent, MyEventsResponse, TimeFilterValue } from '@lfx-one/shared/interfaces'; +import { catchError, combineLatest, debounceTime, EMPTY, finalize, of, scan, skip, switchMap, tap } from 'rxjs'; +import { EVENT_SELECTION_PAGE_SIZE } from '@lfx-one/shared/constants/events.constants'; +@Component({ + selector: 'lfx-event-selection', + imports: [ButtonComponent, ReactiveFormsModule, SelectComponent, InputTextComponent], + templateUrl: './event-selection.component.html', + styleUrl: './event-selection.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventSelectionComponent { + private readonly eventsService = inject(EventsService); + private readonly fb = inject(NonNullableFormBuilder); + + public selectedEvent = model(null); + + // Search via its own form (required by lfx-input-text); debounced separately to avoid extra API calls on each keystroke + public readonly searchForm = this.fb.group({ searchQuery: '' }); + + // Time and location filters via reactive form (required by lfx-select) + public readonly filtersForm = this.fb.group({ + timeFilter: 'any' as TimeFilterValue, + locationFilter: 'any' as string, + }); + + private readonly filtersValue = toSignal(this.filtersForm.valueChanges, { + initialValue: this.filtersForm.getRawValue(), + }); + + // Events & pagination + protected readonly loading = signal(true); + protected readonly loadingMore = signal(false); + private currentOffset = signal(0); + + // Location filter options loaded once + private readonly countriesResponse = this.initializeCountries(); + protected readonly availableLocations = computed(() => [ + { label: 'Anywhere', value: 'any' }, + ...this.countriesResponse().data.map((c) => ({ label: c, value: c })), + ]); + + protected readonly timeFilterOptions: { label: string; value: TimeFilterValue }[] = [ + { label: 'Any Time', value: 'any' }, + { label: 'This Month', value: 'this-month' }, + { label: 'Next 3 Months', value: 'next-3-months' }, + ]; + + // Debounced search to avoid API calls on every keystroke + private readonly debouncedSearch = toSignal(this.searchForm.get('searchQuery')!.valueChanges.pipe(debounceTime(500)), { initialValue: '' }); + + // Combined server-side filter params — changing this resets pagination and triggers a reload + private readonly activeFilters = computed(() => ({ + searchQuery: this.debouncedSearch() || undefined, + ...this.computeTimeFilterParams(this.filtersValue().timeFilter as TimeFilterValue), + country: this.filtersValue().locationFilter !== 'any' ? (this.filtersValue().locationFilter ?? undefined) : undefined, + })); + + // Initial events loaded reactively from activeFilters + private readonly initialEventsResponse = this.initializeEvents(); + + // Combine initial events with any "load more" results + protected readonly allEvents = computed(() => this.initialEventsResponse().data); + protected readonly hasMore = computed(() => this.allEvents().length < this.initialEventsResponse().total); + + public constructor() { + // Reset additional events when filters change (skip initial emission) + toObservable(this.activeFilters) + .pipe(skip(1), takeUntilDestroyed()) + .subscribe(() => { + this.currentOffset.set(0); + }); + } + + public onSelectEvent(event: MyEvent): void { + this.selectedEvent.set(event); + } + + public onLoadMore(): void { + this.currentOffset.update((current) => current + EVENT_SELECTION_PAGE_SIZE); + } + + private computeTimeFilterParams(timeFilter: TimeFilterValue): { startDateFrom?: string; startDateTo?: string } { + if (timeFilter === 'this-month') { + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + return { startDateFrom: firstDay.toISOString(), startDateTo: lastDay.toISOString() }; + } + + if (timeFilter === 'next-3-months') { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate(), 23, 59, 59, 999); + return { startDateFrom: now.toISOString(), startDateTo: threeMonthsLater.toISOString() }; + } + + return {}; + } + + private initializeEvents() { + return toSignal( + combineLatest([toObservable(this.activeFilters), toObservable(this.currentOffset)]).pipe( + tap(() => { + if (this.currentOffset() === 0) { + this.loading.set(true); + } else { + this.loadingMore.set(true); + } + }), + switchMap(([filters, offset]) => + this.eventsService.getMyEvents({ isPast: false, pageSize: EVENT_SELECTION_PAGE_SIZE, offset, registeredOnly: true, ...filters }).pipe( + catchError(() => { + if (offset === 0) { + // Initial load failed - show empty state + return of(EMPTY_MY_EVENTS_RESPONSE); + } + // Load more failed - revert offset so retry fetches the same page + this.currentOffset.update((curr) => Math.max(0, curr - EVENT_SELECTION_PAGE_SIZE)); + return EMPTY; // Don't emit to scan, preserving existing data + }), + finalize(() => { + this.loading.set(false); + this.loadingMore.set(false); + }) + ) + ), + scan((acc, curr) => { + // Reset when offset is 0 (filter changed), otherwise accumulate + if (curr.offset === 0) { + return curr; + } + return { ...curr, data: [...acc.data, ...curr.data] }; + }, EMPTY_MY_EVENTS_RESPONSE) + ), + { initialValue: EMPTY_MY_EVENTS_RESPONSE } + ); + } + + private initializeCountries() { + return toSignal(this.eventsService.getUpcomingCountries().pipe(catchError(() => of({ data: [] }))), { + initialValue: { data: [] as string[] }, + }); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html index 6eeb34755..edcdc3fdf 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.html @@ -42,10 +42,9 @@ (pageChange)="onPastPageChange($event)" (sortChange)="onPastSortChange($event)" data-testid="events-past-table" /> - } @else { -
- -

Coming soon

-
+ } @else if (activeTab() === 'visa-letters') { + + } @else if (activeTab() === 'travel-funding') { + } diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts index bbc8dc328..5663fba84 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/events-list/events-list.component.ts @@ -2,19 +2,21 @@ // SPDX-License-Identifier: MIT import { NgClass } from '@angular/common'; -import { Component, computed, inject, input, output, Signal, signal, WritableSignal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output, Signal, signal, WritableSignal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { EventsService } from '@app/shared/services/events.service'; import { DEFAULT_EVENTS_PAGE_SIZE, EMPTY_MY_EVENTS_RESPONSE } from '@lfx-one/shared/constants'; import { EventTab, EventTabId, MyEventsResponse, PageChangeEvent, SortChangeEvent } from '@lfx-one/shared/interfaces'; import { MessageService } from 'primeng/api'; import { catchError, combineLatest, debounceTime, finalize, of, skip, switchMap, tap } from 'rxjs'; +import { EventRequestListComponent } from '../event-request-list/event-request-list.component'; import { EventsTableComponent } from '../events-table/events-table.component'; @Component({ selector: 'lfx-events-list', - imports: [NgClass, EventsTableComponent], + imports: [NgClass, EventsTableComponent, EventRequestListComponent], templateUrl: './events-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class EventsListComponent { private readonly eventsService = inject(EventsService); diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/step-indicator/step-indicator.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/step-indicator/step-indicator.component.html new file mode 100644 index 000000000..b722f27f7 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/step-indicator/step-indicator.component.html @@ -0,0 +1,39 @@ + + + +
+ @for (s of stepStates(); track s.id; let last = $last) { +
+
+ +
+ @if (s.isCompleted) { + + } @else { + {{ s.number }} + } +
+ + + {{ s.label }} + +
+ + @if (!last) { +
+ } +
+ } +
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/step-indicator/step-indicator.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/step-indicator/step-indicator.component.ts new file mode 100644 index 000000000..43f2025be --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/step-indicator/step-indicator.component.ts @@ -0,0 +1,15 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { DialogStepState } from '@lfx-one/shared/interfaces'; + +@Component({ + selector: 'lfx-step-indicator', + templateUrl: './step-indicator.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StepIndicatorComponent { + public stepStates = input.required(); + public testIdPrefix = input.required(); +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html new file mode 100644 index 000000000..e2d83845c --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.html @@ -0,0 +1,95 @@ + + + +
+ +
+

Estimated Travel Expenses

+

Provide your best estimates for each category. All amounts in USD.

+
+ + +
+ +
+ Expense Category + Estimated Cost ($US) + Notes +
+ + +
+
+ + Airfare (coach) +
+
+ $ + +
+ +
+ + +
+
+ + Hotel accommodation +
+
+ $ + +
+ +
+ + +
+
+ + Ground transportation +
+
+ $ + +
+ +
+ + +
+ Estimated Total: + + {{ estimatedTotal() | currency: 'USD' : 'symbol' : '1.2-2' }} + +
+
+ + + + +

+ Reminder: + Lower cost requests are more likely to be approved. Funds may not be used for food, visa costs, non-airport transportation, or baggage fees. +

+
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts new file mode 100644 index 000000000..1c23af726 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-expenses-form/travel-expenses-form.component.ts @@ -0,0 +1,68 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CurrencyPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { MessageComponent } from '@components/message/message.component'; +import { TravelFundExpenses } from '@lfx-one/shared/interfaces'; + +@Component({ + selector: 'lfx-travel-expenses-form', + imports: [ReactiveFormsModule, InputTextComponent, MessageComponent, CurrencyPipe], + templateUrl: './travel-expenses-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelExpensesFormComponent { + private readonly fb = inject(NonNullableFormBuilder); + + public readonly formValidityChange = output(); + public readonly formChange = output(); + + public readonly form = this.fb.group({ + airfareCost: ['0'], + airfareNotes: [''], + hotelCost: ['0'], + hotelNotes: [''], + groundTransportCost: ['0'], + groundTransportNotes: [''], + }); + + private readonly formValues = toSignal(this.form.valueChanges, { initialValue: this.form.value }); + + public readonly estimatedTotal = computed(() => { + const v = this.formValues(); + return (parseFloat(v.airfareCost ?? '0') || 0) + (parseFloat(v.hotelCost ?? '0') || 0) + (parseFloat(v.groundTransportCost ?? '0') || 0); + }); + + public constructor() { + // No required fields — form is always valid + this.formValidityChange.emit(true); + + this.form.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formValidityChange.emit(this.form.valid); + }); + + this.form.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.formChange.emit(this.buildExpensesValue()); + }); + } + + private buildExpensesValue(): TravelFundExpenses { + const raw = this.form.getRawValue(); + const airfareCost = parseFloat(raw.airfareCost) || 0; + const hotelCost = parseFloat(raw.hotelCost) || 0; + const groundTransportCost = parseFloat(raw.groundTransportCost) || 0; + return { + airfareCost, + airfareNotes: raw.airfareNotes, + hotelCost, + hotelNotes: raw.hotelNotes, + groundTransportCost, + groundTransportNotes: raw.groundTransportNotes, + estimatedTotal: airfareCost + hotelCost + groundTransportCost, + }; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html new file mode 100644 index 000000000..7d16fc145 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.html @@ -0,0 +1,50 @@ + + + +
+ + + + +
+ @if (step() === 'select-event') { + + } @else if (step() === 'terms') { + + } @else if (step() === 'about-me') { + + } @else if (step() === 'expenses') { + + } +
+ + +
+ +
+ @if (step() !== 'select-event') { + + } + @if (step() === 'terms') { + + } @else if (step() === 'expenses') { + + } @else { + + } +
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss new file mode 100644 index 000000000..5675fc7a8 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.scss @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +.scroll-area { + max-height: 440px; + scrollbar-width: thin; + scrollbar-color: theme('colors.slate.200') transparent; + + &::-webkit-scrollbar { + @apply w-1.5; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-sm bg-slate-200; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts new file mode 100644 index 000000000..bbc4d5877 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-application-dialog/travel-fund-application-dialog.component.ts @@ -0,0 +1,130 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { MyEvent, TravelFundAboutMe, TravelFundApplication, TravelFundExpenses, TravelFundStep } from '@lfx-one/shared/interfaces'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { EventSelectionComponent } from '../event-selection/event-selection.component'; +import { StepIndicatorComponent } from '../step-indicator/step-indicator.component'; +import { TravelFundTermsComponent } from '../travel-fund-terms/travel-fund-terms.component'; +import { AboutMeFormComponent } from '../about-me-form/about-me-form.component'; +import { TravelExpensesFormComponent } from '../travel-expenses-form/travel-expenses-form.component'; +import { TRAVEL_FUND_STEP_ORDER } from '@lfx-one/shared/constants/events.constants'; + +@Component({ + selector: 'lfx-travel-fund-application-dialog', + imports: [ButtonComponent, EventSelectionComponent, StepIndicatorComponent, TravelFundTermsComponent, AboutMeFormComponent, TravelExpensesFormComponent], + templateUrl: './travel-fund-application-dialog.component.html', + styleUrl: './travel-fund-application-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelFundApplicationDialogComponent { + private readonly ref = inject(DynamicDialogRef); + private readonly eventsService = inject(EventsService); + private readonly messageService = inject(MessageService); + private readonly destroyRef = inject(DestroyRef); + + protected step = signal('select-event'); + protected selectedEvent = signal(null); + protected termsAccepted = signal(false); + protected aboutMeFormValid = signal(false); + protected expensesFormValid = signal(true); + protected aboutMeData = signal(null); + protected expensesData = signal(null); + protected submitting = signal(false); + + protected readonly isNextDisabled = computed(() => { + if (this.step() === 'select-event') return !this.selectedEvent(); + if (this.step() === 'about-me') return !this.aboutMeFormValid(); + return false; + }); + + protected readonly steps: { id: TravelFundStep; label: string; number: number }[] = [ + { id: 'select-event', label: 'Choose an Event', number: 1 }, + { id: 'terms', label: 'Terms and Conditions', number: 2 }, + { id: 'about-me', label: 'About Me', number: 3 }, + { id: 'expenses', label: 'Expenses', number: 4 }, + ]; + + protected readonly stepStates = computed(() => + this.steps.map((s) => ({ + ...s, + isActive: this.step() === s.id, + isCompleted: TRAVEL_FUND_STEP_ORDER.indexOf(s.id) < TRAVEL_FUND_STEP_ORDER.indexOf(this.step()), + })) + ); + + public onNextStep(): void { + if (this.step() === 'terms') { + this.termsAccepted.set(true); + } + const currentIndex = TRAVEL_FUND_STEP_ORDER.indexOf(this.step()); + if (currentIndex < TRAVEL_FUND_STEP_ORDER.length - 1) { + this.step.set(TRAVEL_FUND_STEP_ORDER[currentIndex + 1]); + } + } + + public onPreviousStep(): void { + const currentIndex = TRAVEL_FUND_STEP_ORDER.indexOf(this.step()); + if (currentIndex > 0) { + this.step.set(TRAVEL_FUND_STEP_ORDER[currentIndex - 1]); + } + } + + public onSubmitApplication(): void { + const event = this.selectedEvent(); + const aboutMe = this.aboutMeData(); + + if (!event || !aboutMe) return; + + const payload: TravelFundApplication = { + eventId: event.id, + eventName: event.name, + termsAccepted: this.termsAccepted(), + aboutMe, + expenses: this.expensesData() ?? { + airfareCost: 0, + airfareNotes: '', + hotelCost: 0, + hotelNotes: '', + groundTransportCost: 0, + groundTransportNotes: '', + estimatedTotal: 0, + }, + }; + + this.submitting.set(true); + + this.eventsService + .submitTravelFundApplication(payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response) => { + if (response.success) { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Your travel fund application has been submitted successfully.', + }); + this.ref.close({ submitted: true }); + } else { + this.messageService.add({ + severity: 'error', + summary: 'Submission Failed', + detail: response.message ?? 'Unable to submit your application. Please try again.', + }); + this.submitting.set(false); + } + }, + error: () => this.submitting.set(false), + }); + } + + public onCancel(): void { + this.ref.close(null); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html new file mode 100644 index 000000000..01457793f --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.html @@ -0,0 +1,45 @@ + + + +
+

The Linux Foundation Travel Fund

+ +

+ The Linux Foundation's Travel Fund is intended to enable open source developers and community members to attend events that they would otherwise be unable + to attend due to a lack of funding. We place an emphasis on funding applicants who are from historically underrepresented or untapped groups and/or those of + lower socioeconomic status. +

+ +
+

Travel fund assistance may only be used for:

+
    +
  • Coach airfare tickets
  • +
  • Accommodation for the dates of the event plus one night prior to the event start date
  • +
  • Ground transportation to/from the airport
  • +
+
+ +

+ Funds may not be used for miscellaneous travel expenses including food, visa costs, non-airport transportation, baggage fees, etc. +

+ +
+

Please note:

+
    +
  • We are often unable to fund one person across multiple events in a year
  • +
  • Lower cost travel funding requests are more likely to get approved
  • +
  • We typically do not fund the same person two years in a row
  • +
  • Decisions are final, and reapplying after a denial does not increase chances
  • +
+
+ +

+ Note: If approved, you will be required to show proof of attendance. Receipt of Travel Funding does not guarantee entry to the event. Recipients must adhere + to The Linux Foundation Health and Safety rules. +

+ +

+ I consent to the sharing and processing of my personal information, including sensitive data, by The Linux Foundation for the purposes of funds allocation, + compliance with sanction screening regulations, and communications related to the scholarship program. +

+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts new file mode 100644 index 000000000..0395c9650 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/travel-fund-terms/travel-fund-terms.component.ts @@ -0,0 +1,12 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'lfx-travel-fund-terms', + templateUrl: './travel-fund-terms.component.html', + styleUrl: './travel-fund-terms.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelFundTermsComponent {} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html new file mode 100644 index 000000000..5f0dc1e1f --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.html @@ -0,0 +1,48 @@ + + + +
+ + + + + @if (step() === 'select-event') { +
+ +
+ } @else if (step() === 'terms') { + + } @else if (step() === 'apply') { + + } + + +
+ +
+ @if (step() !== 'select-event') { + + } + @if (step() === 'terms') { + + } @else if (step() === 'apply') { + + } @else { + + } +
+
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss new file mode 100644 index 000000000..5675fc7a8 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.scss @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +.scroll-area { + max-height: 440px; + scrollbar-width: thin; + scrollbar-color: theme('colors.slate.200') transparent; + + &::-webkit-scrollbar { + @apply w-1.5; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-sm bg-slate-200; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts new file mode 100644 index 000000000..cebce190c --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-application-dialog/visa-request-application-dialog.component.ts @@ -0,0 +1,117 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EventsService } from '@app/shared/services/events.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { MyEvent, VisaRequestApplicantInfo, VisaRequestApplication, VisaRequestStep } from '@lfx-one/shared/interfaces'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { EventSelectionComponent } from '../event-selection/event-selection.component'; +import { StepIndicatorComponent } from '../step-indicator/step-indicator.component'; +import { VisaRequestApplyFormComponent } from '../visa-request-apply-form/visa-request-apply-form.component'; +import { VisaRequestTermsComponent } from '../visa-request-terms/visa-request-terms.component'; +import { VIS_REQUEST_STEP_ORDER } from '@lfx-one/shared/constants/events.constants'; + +@Component({ + selector: 'lfx-visa-request-application-dialog', + imports: [ButtonComponent, EventSelectionComponent, StepIndicatorComponent, VisaRequestTermsComponent, VisaRequestApplyFormComponent], + templateUrl: './visa-request-application-dialog.component.html', + styleUrl: './visa-request-application-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VisaRequestApplicationDialogComponent { + private readonly ref = inject(DynamicDialogRef); + private readonly eventsService = inject(EventsService); + private readonly messageService = inject(MessageService); + private readonly destroyRef = inject(DestroyRef); + + protected step = signal('select-event'); + protected selectedEvent = signal(null); + protected termsAccepted = signal(false); + protected applyFormValid = signal(false); + protected applicantData = signal(null); + protected submitting = signal(false); + + protected readonly isNextDisabled = computed(() => { + if (this.step() === 'select-event') return !this.selectedEvent(); + if (this.step() === 'apply') return !this.applyFormValid(); + return false; + }); + + protected readonly steps: { id: VisaRequestStep; label: string; number: number }[] = [ + { id: 'select-event', label: 'Choose an Event', number: 1 }, + { id: 'terms', label: 'Terms and Conditions', number: 2 }, + { id: 'apply', label: 'Apply', number: 3 }, + ]; + + protected readonly stepStates = computed(() => + this.steps.map((s) => ({ + ...s, + isActive: this.step() === s.id, + isCompleted: VIS_REQUEST_STEP_ORDER.indexOf(s.id) < VIS_REQUEST_STEP_ORDER.indexOf(this.step()), + })) + ); + + public onNextStep(): void { + if (this.step() === 'terms') { + this.termsAccepted.set(true); + } + const currentIndex = VIS_REQUEST_STEP_ORDER.indexOf(this.step()); + if (currentIndex < VIS_REQUEST_STEP_ORDER.length - 1) { + this.step.set(VIS_REQUEST_STEP_ORDER[currentIndex + 1]); + } + } + + public onPreviousStep(): void { + const currentIndex = VIS_REQUEST_STEP_ORDER.indexOf(this.step()); + if (currentIndex > 0) { + this.step.set(VIS_REQUEST_STEP_ORDER[currentIndex - 1]); + } + } + + public onSubmitApplication(): void { + const event = this.selectedEvent(); + const applicantInfo = this.applicantData(); + + if (!event || !applicantInfo) return; + + const payload: VisaRequestApplication = { + eventId: event.id, + eventName: event.name, + termsAccepted: this.termsAccepted(), + applicantInfo, + }; + + this.submitting.set(true); + + this.eventsService + .submitVisaRequestApplication(payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response) => { + if (response.success) { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Your visa letter application has been submitted successfully.', + }); + this.ref.close({ submitted: true }); + } else { + this.messageService.add({ + severity: 'error', + summary: 'Submission Failed', + detail: response.message ?? 'Unable to submit your application. Please try again.', + }); + this.submitting.set(false); + } + }, + error: () => this.submitting.set(false), + }); + } + + public onCancel(): void { + this.ref.close(null); + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html new file mode 100644 index 000000000..c14c053d0 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.html @@ -0,0 +1,130 @@ + + + +
+

Applicant Information

+ + +
+
+ + + @if (form.get('firstName')?.invalid && form.get('firstName')?.touched) { + First name is required + } +
+
+ + + @if (form.get('lastName')?.invalid && form.get('lastName')?.touched) { + Last name is required + } +
+
+ + +
+
+ + +
+
+ + + @if (form.get('passportNumber')?.invalid && form.get('passportNumber')?.touched) { + Passport number is required + } +
+
+ + +
+
+ + + @if (form.get('citizenshipCountry')?.invalid && form.get('citizenshipCountry')?.touched) { + Country of citizenship is required + } +
+
+ + + @if (form.get('passportExpiryDate')?.invalid && form.get('passportExpiryDate')?.touched) { + Passport expiry date is required + } +
+
+ + +
+
+ + + @if (form.get('embassyCity')?.invalid && form.get('embassyCity')?.touched) { + Embassy city is required + } +
+
+ + +
+
+ + +
+ + + @if (form.get('mailingAddress')?.invalid && form.get('mailingAddress')?.touched) { + Mailing address is required + } +
+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts new file mode 100644 index 000000000..6d1de4b23 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-apply-form/visa-request-apply-form.component.ts @@ -0,0 +1,67 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserService } from '@app/shared/services/user.service'; +import { CalendarComponent } from '@components/calendar/calendar.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { COUNTRIES } from '@lfx-one/shared/constants'; +import { VisaRequestApplicantInfo } from '@lfx-one/shared/interfaces'; +import { startWith } from 'rxjs'; + +@Component({ + selector: 'lfx-visa-request-apply-form', + imports: [ReactiveFormsModule, InputTextComponent, SelectComponent, TextareaComponent, CalendarComponent], + templateUrl: './visa-request-apply-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VisaRequestApplyFormComponent { + private readonly userService = inject(UserService); + private readonly fb = inject(NonNullableFormBuilder); + + public readonly formValidityChange = output(); + public readonly formChange = output(); + + public readonly form = this.fb.group({ + firstName: ['', Validators.required], + lastName: ['', Validators.required], + email: [''], + passportNumber: ['', Validators.required], + citizenshipCountry: ['', Validators.required], + passportExpiryDate: [null as Date | null, Validators.required], + embassyCity: ['', Validators.required], + company: [''], + mailingAddress: ['', Validators.required], + }); + + public readonly countryOptions = [...COUNTRIES]; + + public constructor() { + this.form.get('email')?.disable(); + + const user = this.userService.user(); + if (user) { + this.form.patchValue({ + firstName: user.given_name ?? '', + lastName: user.family_name ?? '', + email: user.email ?? '', + }); + } + + this.form.statusChanges.pipe(startWith(this.form.status), takeUntilDestroyed()).subscribe(() => { + this.formValidityChange.emit(this.form.valid); + }); + + this.form.valueChanges.pipe(startWith(this.form.getRawValue()), takeUntilDestroyed()).subscribe(() => { + this.formChange.emit(this.buildFormValue()); + }); + } + + private buildFormValue(): VisaRequestApplicantInfo { + return this.form.getRawValue() as VisaRequestApplicantInfo; + } +} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html new file mode 100644 index 000000000..6429fda0e --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.html @@ -0,0 +1,40 @@ + + + +
+

Visa Letter Request Terms and Conditions

+ +
    +
  • + Provision of a visa letter by The Linux Foundation does not guarantee visa approval, which is made at the sole discretion of the government of the event's + host country. +
  • +
  • + Visa letter requests should be made 90 days prior to an event and may be submitted no fewer than two weeks prior to the event. The Linux Foundation cannot + guarantee that letters will be provided in response to requests submitted fewer than two weeks prior to an event, though we will make every effort to + process them. +
  • +
  • + You must be registered for the event before requesting a visa letter. Please note: it can take up to an hour for our registration system and visa letter + system to sync. +
  • +
  • + The Linux Foundation processes most visa letter requests in (3) business days. Please complete the form below as accurately as possible. Failure to do so + may result in a delay to the typical processing time. +
  • +
  • + Some countries in which The Linux Foundation holds events require a local organization to provide a visa invitation letter. Thus, the Linux Foundation + must use a local third party to process letter requests from time to time. By completing the form below, you are authorizing The Linux Foundation to share + your details with third party vendors as needed to process your invitation request. +
  • +
  • + Receipt of a visa letter does not, on its own, guarantee entry to the event nor does it act as verification of health status or at a vaccination passport. + Each visa letter recipient will need to adhere to The Linux Foundation COVID-19 rules and regulations for events, as well as their own country's + requirements. Please Note: Attendance at in-person events requires proof of vaccination or negative COVID-19 test. +
  • +
+ +

+ For questions, please contact visaletter@linuxfoundation.org. +

+
diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss new file mode 100644 index 000000000..c32094b45 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.scss @@ -0,0 +1,2 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts new file mode 100644 index 000000000..70d9e0c99 --- /dev/null +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/components/visa-request-terms/visa-request-terms.component.ts @@ -0,0 +1,12 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'lfx-visa-request-terms', + templateUrl: './visa-request-terms.component.html', + styleUrl: './visa-request-terms.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VisaRequestTermsComponent {} diff --git a/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html b/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html index 6fafcab61..f7bc8cb4c 100644 --- a/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/events/my-events-dashboard/my-events-dashboard.component.html @@ -19,8 +19,10 @@

(null); protected readonly selectedSearchQuery = signal(''); + /** True when the active tab uses request-style filters (no role, no foundation, different statuses). */ + protected readonly isRequestTab = computed(() => this.activeTab() === 'visa-letters' || this.activeTab() === 'travel-funding'); + + protected readonly currentStatusOptions = computed(() => (this.isRequestTab() ? VISA_REQUEST_STATUS_OPTIONS : MY_EVENT_STATUS_OPTIONS)); + protected readonly foundationLabel: Signal = this.initFoundationLabel(); protected onFoundationChange(value: string | null): void { @@ -52,8 +57,11 @@ export class MyEventsDashboardComponent { protected onActiveTabChange(tab: EventTabId): void { this.activeTab.set(tab); - // Reset foundation filter when switching tabs — each tab has a different foundation list + // Reset all filters when switching tabs — each tab has different filter sets this.selectedFoundation.set(null); + this.selectedRole.set(null); + this.selectedStatus.set(null); + this.selectedSearchQuery.set(''); } private initFoundationLabel(): Signal { diff --git a/apps/lfx-one/src/app/shared/pipes/event-request-status-severity.pipe.ts b/apps/lfx-one/src/app/shared/pipes/event-request-status-severity.pipe.ts new file mode 100644 index 000000000..802adc0e1 --- /dev/null +++ b/apps/lfx-one/src/app/shared/pipes/event-request-status-severity.pipe.ts @@ -0,0 +1,21 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Pipe, PipeTransform } from '@angular/core'; +import { EVENT_REQUEST_STATUS_SEVERITY_MAP, TagSeverity } from '@lfx-one/shared'; + +/** + * Transforms event request status to tag severity for consistent styling + * @description Maps visa/travel fund request status values to appropriate tag colors + * @example + * + * {{ request.status }} + */ +@Pipe({ + name: 'eventRequestStatusSeverity', +}) +export class EventRequestStatusSeverityPipe implements PipeTransform { + public transform(status: string): TagSeverity { + return EVENT_REQUEST_STATUS_SEVERITY_MAP[status] ?? 'info'; + } +} diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts index 71a13dde3..24106421e 100644 --- a/apps/lfx-one/src/app/shared/services/events.service.ts +++ b/apps/lfx-one/src/app/shared/services/events.service.ts @@ -5,17 +5,24 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { EMPTY_ORGANIZATIONS_RESPONSE } from '@lfx-one/shared/constants'; import { EventsResponse, GetCertificateParams, GetEventOrganizationsParams, + GetEventRequestsParams, GetEventsParams, GetMyEventsParams, + GetUpcomingCountriesResponse, MyEventOrganizationsResponse, MyEventsResponse, + TravelFundApplication, + TravelFundApplicationResponse, + TravelFundRequestsResponse, + VisaRequestApplication, + VisaRequestApplicationResponse, + VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; -import { catchError, Observable, of } from 'rxjs'; +import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -36,6 +43,10 @@ export class EventsService { if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + if (params.registeredOnly) httpParams = httpParams.set('registeredOnly', 'true'); + if (params.startDateFrom) httpParams = httpParams.set('startDateFrom', params.startDateFrom); + if (params.startDateTo) httpParams = httpParams.set('startDateTo', params.startDateTo); + if (params.country) httpParams = httpParams.set('country', params.country); return this.http.get('/api/events', { params: httpParams }); } @@ -66,16 +77,52 @@ export class EventsService { if (params.projectName) httpParams = httpParams.set('projectName', params.projectName); if (params.isPast !== undefined) httpParams = httpParams.set('isPast', String(params.isPast)); - return this.http - .get('/api/events/organizations', { params: httpParams }) - .pipe(catchError(() => of(EMPTY_ORGANIZATIONS_RESPONSE))); + return this.http.get('/api/events/organizations', { params: httpParams }); } - public getCertificate(params: GetCertificateParams): Observable { + public getVisaRequests(params: GetEventRequestsParams = {}): Observable { + let httpParams = new HttpParams(); + + if (params.searchQuery) httpParams = httpParams.set('searchQuery', params.searchQuery); + if (params.status) httpParams = httpParams.set('status', params.status); + if (params.sortField) httpParams = httpParams.set('sortField', params.sortField); + if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); + if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); + if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + + return this.http.get('/api/events/visa-requests', { params: httpParams }); + } + + public getTravelFundRequests(params: GetEventRequestsParams = {}): Observable { + let httpParams = new HttpParams(); + + if (params.searchQuery) httpParams = httpParams.set('searchQuery', params.searchQuery); + if (params.status) httpParams = httpParams.set('status', params.status); + if (params.sortField) httpParams = httpParams.set('sortField', params.sortField); + if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize)); + if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset)); + if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + + return this.http.get('/api/events/travel-fund-requests', { params: httpParams }); + } + + public getUpcomingCountries(): Observable { + return this.http.get('/api/events/countries'); + } + + public submitVisaRequestApplication(payload: VisaRequestApplication): Observable { + return this.http.post('/api/events/visa-applications', payload); + } + + public submitTravelFundApplication(payload: TravelFundApplication): Observable { + return this.http.post('/api/events/travel-fund-applications', payload); + } + + public getCertificate(params: GetCertificateParams): Observable { let httpParams = new HttpParams(); if (params.eventId) httpParams = httpParams.set('eventId', params.eventId); - return this.http.get('/api/events/certificate', { params: httpParams, responseType: 'blob' }).pipe(catchError(() => of(null))); + return this.http.get('/api/events/certificate', { params: httpParams, responseType: 'blob' }); } } diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts index 47a9e271b..0b9b20f88 100644 --- a/apps/lfx-one/src/server/controllers/events.controller.ts +++ b/apps/lfx-one/src/server/controllers/events.controller.ts @@ -15,7 +15,18 @@ import { VALID_EVENT_STATUS_VALUES, VALID_MY_EVENT_STATUS_VALUES, } from '@lfx-one/shared/constants'; -import { EventSortOrder, EventStatusFilter, GetEventOrganizationsOptions, GetEventsOptions } from '@lfx-one/shared/interfaces'; +import { + EventSortOrder, + EventStatusFilter, + GetEventOrganizationsOptions, + GetEventRequestsOptions, + GetEventsOptions, + GetUpcomingCountriesResponse, + TravelFundApplication, + TravelFundRequestsResponse, + VisaRequestApplication, + VisaRequestsResponse, +} from '@lfx-one/shared/interfaces'; import { EventsService } from '../services/events.service'; import { PersonaDetectionService } from '../services/persona-detection.service'; import { getEffectiveEmail, getEffectiveName } from '../utils/auth-helper'; @@ -55,6 +66,10 @@ export class EventsController { const rawMyEventStatus = req.query['status'] ? String(req.query['status']) : undefined; const status = rawMyEventStatus && VALID_MY_EVENT_STATUS_VALUES.has(rawMyEventStatus) ? rawMyEventStatus : undefined; const sortField = req.query['sortField'] ? String(req.query['sortField']) : undefined; + const registeredOnly = req.query['registeredOnly'] === 'true'; + const startDateFrom = req.query['startDateFrom'] ? String(req.query['startDateFrom']) : undefined; + const startDateTo = req.query['startDateTo'] ? String(req.query['startDateTo']) : undefined; + const country = req.query['country'] ? String(req.query['country']) : undefined; const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE; const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; @@ -85,6 +100,10 @@ export class EventsController { pageSize, offset, sortOrder, + registeredOnly, + startDateFrom, + startDateTo, + country, affiliatedProjectSlugs, }); @@ -214,6 +233,46 @@ export class EventsController { } } + /** + * GET /api/events/visa-requests + * Get paginated visa letter requests for the authenticated user + * Query params: eventId (string), projectName (string), searchQuery (string), status (string), + * sortField (string), pageSize (number), offset (number), sortOrder (ASC|DESC) + */ + public async getVisaRequests(req: Request, res: Response, next: NextFunction): Promise { + return this.handleEventRequestsEndpoint(req, res, next, 'get_visa_requests', (r, email, opts) => this.eventsService.getVisaRequests(r, email, opts)); + } + + /** + * GET /api/events/travel-fund-requests + * Get paginated travel fund requests for the authenticated user + * Query params: eventId (string), projectName (string), searchQuery (string), status (string), + * sortField (string), pageSize (number), offset (number), sortOrder (ASC|DESC) + */ + public async getTravelFundRequests(req: Request, res: Response, next: NextFunction): Promise { + return this.handleEventRequestsEndpoint(req, res, next, 'get_travel_fund_requests', (r, email, opts) => + this.eventsService.getTravelFundRequests(r, email, opts) + ); + } + + /** + * GET /api/events/countries + * Get distinct country names for upcoming events (for the location filter dropdown) + */ + public async getUpcomingCountries(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_upcoming_countries', {}); + + try { + const response: GetUpcomingCountriesResponse = await this.eventsService.getUpcomingCountries(req); + + logger.success(req, 'get_upcoming_countries', startTime, { result_count: response.data.length }); + + res.json(response); + } catch (error) { + next(error); + } + } + /** * GET /api/events/certificate * Download attendance certificate as a PDF for the authenticated user @@ -261,4 +320,138 @@ export class EventsController { next(error); } } + + /** + * POST /api/events/visa-applications + * Submit a visa letter application + * TODO: Wire to upstream microservice once available. + */ + public async submitVisaRequestApplication(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'submit_visa_request_application', {}); + + try { + const userEmail = getEffectiveEmail(req); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: 'submit_visa_request_application' }); + } + + const payload = req.body as VisaRequestApplication; + + if (!payload?.eventId) { + throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_visa_request_application' }); + } + + if (!payload?.applicantInfo) { + throw ServiceValidationError.forField('applicantInfo', 'applicantInfo is required', { operation: 'submit_visa_request_application' }); + } + + if (!payload?.termsAccepted) { + throw ServiceValidationError.forField('termsAccepted', 'termsAccepted must be true', { operation: 'submit_visa_request_application' }); + } + + // Overwrite client-provided email with session email for data integrity + payload.applicantInfo.email = userEmail; + + const result = await this.eventsService.submitVisaRequestApplication(req, payload); + logger.success(req, 'submit_visa_request_application', startTime, { event_id: payload.eventId }); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * POST /api/events/travel-fund-applications + * Submit a travel fund application + * TODO: Wire to upstream microservice once available. + */ + public async submitTravelFundApplication(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'submit_travel_fund_application', {}); + + try { + const userEmail = getEffectiveEmail(req); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: 'submit_travel_fund_application' }); + } + + const payload = req.body as TravelFundApplication; + + if (!payload?.eventId) { + throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_travel_fund_application' }); + } + + if (!payload?.aboutMe) { + throw ServiceValidationError.forField('aboutMe', 'aboutMe is required', { operation: 'submit_travel_fund_application' }); + } + + if (!payload?.termsAccepted) { + throw ServiceValidationError.forField('termsAccepted', 'termsAccepted must be true', { operation: 'submit_travel_fund_application' }); + } + + if (!payload?.expenses) { + throw ServiceValidationError.forField('expenses', 'expenses is required', { operation: 'submit_travel_fund_application' }); + } + + // Overwrite client-provided email with session email for data integrity + payload.aboutMe.email = userEmail; + + const result = await this.eventsService.submitTravelFundApplication(req, payload); + logger.success(req, 'submit_travel_fund_application', startTime, { event_id: payload.eventId }); + res.json(result); + } catch (error) { + next(error); + } + } + + private async handleEventRequestsEndpoint( + req: Request, + res: Response, + next: NextFunction, + operationName: string, + fetchFn: (req: Request, userEmail: string, options: GetEventRequestsOptions) => Promise + ): Promise { + const startTime = logger.startOperation(req, operationName, { + has_query: Object.keys(req.query).length > 0, + }); + + try { + const userEmail = getEffectiveEmail(req); + + if (!userEmail) { + throw new AuthenticationError('User authentication required', { operation: operationName }); + } + + const rawPageSize = parseInt(String(req.query['pageSize'] ?? DEFAULT_EVENTS_PAGE_SIZE), 10); + const rawOffset = parseInt(String(req.query['offset'] ?? 0), 10); + const rawSortOrder = String(req.query['sortOrder'] ?? 'DESC').toUpperCase() as EventSortOrder; + + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE; + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + const sortOrder: EventSortOrder = VALID_EVENT_SORT_ORDERS.includes(rawSortOrder) ? rawSortOrder : 'DESC'; + + const options: GetEventRequestsOptions = { + eventId: req.query['eventId'] ? String(req.query['eventId']) : undefined, + projectName: req.query['projectName'] ? String(req.query['projectName']) : undefined, + searchQuery: req.query['searchQuery'] ? String(req.query['searchQuery']).trim() : undefined, + status: req.query['status'] ? String(req.query['status']) : undefined, + sortField: req.query['sortField'] ? String(req.query['sortField']) : undefined, + pageSize, + offset, + sortOrder, + }; + + const response = await fetchFn(req, userEmail, options); + + logger.success(req, operationName, startTime, { + result_count: response.data.length, + total: response.total, + }); + + res.json(response); + } catch (error) { + next(error); + } + } } diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html index ae58d24d7..688aff4e2 100644 --- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html +++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html @@ -92,7 +92,7 @@ Event Location: - {{#if event.location }}{{ event.location }}, {{/if}} {{#if event.city }}{{ event.city }}, {{/if}} {{#if event.state}}{{ event.state }}, {{/if}} + {{#if event.location }}{{ event.location }}, {{/if}} {{#if event.city }}{{ event.city }}, {{/if}} {{#if event.state }}{{ event.state }}, {{/if}} {{#if event.country }}{{ event.country }}{{/if}} diff --git a/apps/lfx-one/src/server/routes/events.route.ts b/apps/lfx-one/src/server/routes/events.route.ts index 37d0a8b0b..23931a45a 100644 --- a/apps/lfx-one/src/server/routes/events.route.ts +++ b/apps/lfx-one/src/server/routes/events.route.ts @@ -11,5 +11,10 @@ const eventsController = new EventsController(); router.get('/', (req, res, next) => eventsController.getMyEvents(req, res, next)); router.get('/all', (req, res, next) => eventsController.getEvents(req, res, next)); router.get('/organizations', (req, res, next) => eventsController.getEventOrganizations(req, res, next)); +router.get('/countries', (req, res, next) => eventsController.getUpcomingCountries(req, res, next)); +router.get('/visa-requests', (req, res, next) => eventsController.getVisaRequests(req, res, next)); +router.get('/travel-fund-requests', (req, res, next) => eventsController.getTravelFundRequests(req, res, next)); +router.post('/visa-applications', (req, res, next) => eventsController.submitVisaRequestApplication(req, res, next)); +router.post('/travel-fund-applications', (req, res, next) => eventsController.submitTravelFundApplication(req, res, next)); router.get('/certificate', (req, res, next) => eventsController.getCertificate(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts index b740b6c2b..452413e45 100644 --- a/apps/lfx-one/src/server/services/events.service.ts +++ b/apps/lfx-one/src/server/services/events.service.ts @@ -3,19 +3,35 @@ // Generated with [Claude Code](https://claude.ai/code) -import { COMING_SOON_SENTINEL, DEFAULT_EVENT_SORT_FIELD, VALID_EVENT_SORT_FIELDS } from '@lfx-one/shared/constants'; +import { + COMING_SOON_SENTINEL, + DEFAULT_EVENT_SORT_FIELD, + DEFAULT_VISA_REQUEST_SORT_FIELD, + VALID_EVENT_SORT_FIELDS, + VALID_VISA_REQUEST_SORT_FIELDS, +} from '@lfx-one/shared/constants'; import { EventRow, EventSortOrder, EventsResponse, FoundationEvent, GetEventOrganizationsOptions, + GetEventRequestsOptions, GetEventsOptions, GetMyEventsOptions, + GetUpcomingCountriesResponse, MyEvent, MyEventOrganizationsResponse, MyEventRow, MyEventsResponse, + TravelFundApplication, + TravelFundApplicationResponse, + TravelFundRequestsResponse, + VisaRequest, + VisaRequestApplication, + VisaRequestApplicationResponse, + VisaRequestRow, + VisaRequestsResponse, } from '@lfx-one/shared/interfaces'; import { Request } from 'express'; @@ -30,7 +46,23 @@ export class EventsService { } public async getMyEvents(req: Request, userEmail: string, options: GetMyEventsOptions): Promise { - const { isPast, eventId, projectName, searchQuery, role, status, sortField: rawSortField, pageSize, offset, sortOrder, affiliatedProjectSlugs } = options; + const { + isPast, + eventId, + projectName, + searchQuery, + role, + status, + sortField: rawSortField, + pageSize, + offset, + sortOrder, + registeredOnly, + startDateFrom, + startDateTo, + country, + affiliatedProjectSlugs, + } = options; const sortField = rawSortField && VALID_EVENT_SORT_FIELDS.has(rawSortField) ? rawSortField : DEFAULT_EVENT_SORT_FIELD; const normalizedSortOrder: EventSortOrder = sortOrder === 'DESC' ? 'DESC' : 'ASC'; const normalizedPageSize = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : 10; @@ -60,6 +92,10 @@ export class EventsService { const searchQueryFilter = searchQuery ? 'AND e.EVENT_NAME ILIKE ?' : ''; const roleFilterResult = role ? this.buildUpcomingRoleFilter(role) : { filter: '', binds: [] as string[] }; const statusFilterResult = status ? this.buildUpcomingStatusFilter(status) : { filter: '', binds: [] as string[] }; + const startDateFromFilter = startDateFrom ? 'AND e.EVENT_START_DATE >= ?' : ''; + const startDateToFilter = startDateTo ? 'AND e.EVENT_START_DATE <= ?' : ''; + const countryFilter = country ? 'AND e.EVENT_COUNTRY = ?' : ''; + const registeredOnlyFilter = registeredOnly ? "AND r.EVENT_ID IS NOT NULL AND r.REGISTRATION_STATUS = 'Accepted'" : ''; const slugs = affiliatedProjectSlugs ?? []; const hasAffiliatedSlugs = slugs.length > 0; @@ -166,6 +202,10 @@ export class EventsService { ${searchQueryFilter} ${roleFilterResult.filter} ${statusFilterResult.filter} + ${startDateFromFilter} + ${startDateToFilter} + ${countryFilter} + ${registeredOnlyFilter} ORDER BY ${sortField} ${normalizedSortOrder} LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset} `; @@ -428,6 +468,149 @@ export class EventsService { return { data }; } + public async getUpcomingCountries(req: Request): Promise { + logger.debug(req, 'get_upcoming_countries', 'Fetching distinct countries for upcoming events'); + + const sql = ` + SELECT DISTINCT EVENT_COUNTRY + FROM ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS + WHERE IS_PAST_EVENT = FALSE + AND EVENT_COUNTRY IS NOT NULL + ORDER BY EVENT_COUNTRY + `; + + let result; + try { + result = await this.snowflakeService.execute<{ EVENT_COUNTRY: string }>(sql, []); + } catch (error) { + logger.warning(req, 'get_upcoming_countries', 'Snowflake query failed, returning empty countries', { + error: error instanceof Error ? error.message : String(error), + }); + return { data: [] }; + } + + const data = result.rows.map((row) => row.EVENT_COUNTRY); + + logger.debug(req, 'get_upcoming_countries', 'Fetched countries', { count: data.length }); + + return { data }; + } + + public async getVisaRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise { + return this.executeEventRequestsQuery(req, userEmail, options, 'VL_REQUEST_STATUS', 'VL_APPLICATION_DATE', 'get_visa_requests'); + } + + public async getTravelFundRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise { + return this.executeEventRequestsQuery(req, userEmail, options, 'TF_REQUEST_STATUS', 'TF_APPLICATION_DATE', 'get_travel_fund_requests'); + } + + /** + * Stub: Submit a visa letter application. + * TODO: Replace with upstream microservice call once the API is available. + */ + public async submitVisaRequestApplication(req: Request, payload: VisaRequestApplication): Promise { + logger.debug(req, 'submit_visa_request_application', 'Received visa letter application', { + event_id: payload.eventId, + event_name: payload.eventName, + }); + + return { success: true, message: 'Your visa letter application has been submitted successfully.' }; + } + + /** + * Stub: Submit a travel fund application. + * TODO: Replace with upstream microservice call once the API is available. + */ + public async submitTravelFundApplication(req: Request, payload: TravelFundApplication): Promise { + logger.debug(req, 'submit_travel_fund_application', 'Received travel fund application', { + event_id: payload.eventId, + event_name: payload.eventName, + estimated_total: payload.expenses.estimatedTotal, + }); + + return { success: true, message: 'Your travel fund application has been submitted successfully.' }; + } + + private async executeEventRequestsQuery( + req: Request, + userEmail: string, + options: GetEventRequestsOptions, + statusColumn: string, + applicationDateColumn: string, + operationName: string + ): Promise { + const { eventId, projectName, searchQuery, status, sortField: rawSortField, pageSize, offset, sortOrder } = options; + const sortField = rawSortField && VALID_VISA_REQUEST_SORT_FIELDS.has(rawSortField) ? rawSortField : DEFAULT_VISA_REQUEST_SORT_FIELD; + const normalizedSortOrder: EventSortOrder = sortOrder === 'DESC' ? 'DESC' : 'ASC'; + const normalizedPageSize = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : 10; + const normalizedOffset = Number.isInteger(offset) && offset >= 0 ? offset : 0; + + logger.debug(req, operationName, 'Building event requests query', { + has_event_id: !!eventId, + has_project_name: !!projectName, + has_search_query: !!searchQuery, + status, + page_size: normalizedPageSize, + offset: normalizedOffset, + sort_order: normalizedSortOrder, + }); + + const eventIdFilter = eventId ? 'AND EVENT_ID = ?' : ''; + const projectNameFilter = projectName ? 'AND PROJECT_NAME = ?' : ''; + const searchQueryFilter = searchQuery ? 'AND EVENT_NAME ILIKE ?' : ''; + const statusFilter = status ? `AND ${statusColumn} = ?` : ''; + + const sql = ` + SELECT + EVENT_ID, + EVENT_NAME, + EVENT_URL, + EVENT_LOCATION, + EVENT_CITY, + EVENT_COUNTRY, + ${applicationDateColumn} AS APPLICATION_DATE, + ${statusColumn} AS REQUEST_STATUS, + COUNT(*) OVER() AS TOTAL_RECORDS + FROM ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS + WHERE ${statusColumn} IS NOT NULL + AND USER_EMAIL = ? + ${eventIdFilter} + ${projectNameFilter} + ${searchQueryFilter} + ${statusFilter} + -- sortField is validated against VALID_VISA_REQUEST_SORT_FIELDS allowlist above; safe to interpolate + ORDER BY ${sortField} ${normalizedSortOrder} + LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset} + `; + + const binds: string[] = [userEmail]; + if (eventId) binds.push(eventId); + if (projectName) binds.push(projectName); + if (searchQuery) binds.push(`%${searchQuery}%`); + if (status) binds.push(status); + + logger.debug(req, operationName, 'Executing event requests query', { bind_count: binds.length }); + + let result; + try { + result = await this.snowflakeService.execute(sql, binds); + } catch (error) { + logger.warning(req, operationName, 'Snowflake query failed, returning empty results', { + error: error instanceof Error ? error.message : String(error), + page_size: normalizedPageSize, + offset: normalizedOffset, + }); + return { data: [], total: 0, pageSize: normalizedPageSize, offset: normalizedOffset }; + } + + const total = result.rows.length > 0 ? result.rows[0].TOTAL_RECORDS : 0; + const data = result.rows.map((row) => this.mapRowToVisaRequest(row)); + + logger.debug(req, operationName, 'Fetched event requests', { count: data.length, total }); + + return { data, total, pageSize: normalizedPageSize, offset: normalizedOffset }; + } + /** Status filter for the past events query (unqualified column names, no IS NULL support). */ private buildStatusFilter(status: string): { filter: string; binds: string[] } { switch (status) { @@ -492,6 +675,19 @@ export class EventsService { } } + private mapRowToVisaRequest(row: VisaRequestRow): VisaRequest { + return { + id: row.EVENT_ID, + name: row.EVENT_NAME, + url: row.EVENT_URL ?? '', + location: this.formatLocation(row.EVENT_LOCATION, row.EVENT_CITY, row.EVENT_COUNTRY), + applicationDate: row.APPLICATION_DATE + ? new Date(row.APPLICATION_DATE).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : '—', + status: row.REQUEST_STATUS, + }; + } + private mapRowToFoundationEvent(row: EventRow): FoundationEvent { return { id: row.EVENT_ID, @@ -527,6 +723,7 @@ export class EventsService { url: row.EVENT_URL ?? '', registrationUrl: row.EVENT_REGISTRATION_URL ?? null, foundation: row.PROJECT_NAME, + startDate: new Date(row.EVENT_START_DATE).toISOString(), date: this.formatDateRange(row.EVENT_START_DATE, row.EVENT_END_DATE), location: this.formatLocation(row.EVENT_LOCATION, row.EVENT_CITY, row.EVENT_COUNTRY), role: row.USER_ROLE ?? '', diff --git a/packages/shared/src/constants/events.constants.ts b/packages/shared/src/constants/events.constants.ts index 5f4c943e8..89ae4f433 100644 --- a/packages/shared/src/constants/events.constants.ts +++ b/packages/shared/src/constants/events.constants.ts @@ -2,7 +2,17 @@ // SPDX-License-Identifier: MIT import { FoundationEventStatus } from '../enums'; -import { FilterOption, MyEventsResponse, EventsResponse, MyEventOrganizationsResponse, TagSeverity } from '../interfaces'; +import { + FilterOption, + MyEventsResponse, + EventsResponse, + MyEventOrganizationsResponse, + TagSeverity, + TravelFundRequestsResponse, + VisaRequestsResponse, + TravelFundStep, + VisaRequestStep, +} from '../interfaces'; export const EVENT_ROLE_OPTIONS: FilterOption[] = [ { label: 'All Roles', value: null }, @@ -19,6 +29,21 @@ export const MY_EVENT_STATUS_OPTIONS: FilterOption[] = [ { label: 'Not Registered', value: 'not-registered' }, ]; +export const VISA_REQUEST_STATUS_OPTIONS: FilterOption[] = [ + { label: 'All Statuses', value: null }, + { label: 'Submitted', value: 'Submitted' }, + { label: 'Approved', value: 'Approved' }, + { label: 'Denied', value: 'Denied' }, + { label: 'Expired', value: 'Expired' }, +]; + +export const EVENT_REQUEST_STATUS_SEVERITY_MAP: Partial> = { + Submitted: 'info', + Approved: 'success', + Denied: 'danger', + Expired: 'secondary', +}; + /** * Status filter options for Foundation Lens events. * Values are raw EVENT_STATUS DB values except 'coming-soon', which is a sentinel @@ -44,6 +69,8 @@ export const FOUNDATION_EVENT_STATUS_SEVERITY_MAP: Partial = new Set(['EVENT_NAME', 'PROJECT_NAME', 'EVENT_START_DATE', 'EVENT_CITY']); +export const VALID_VISA_REQUEST_SORT_FIELDS: ReadonlySet = new Set(['EVENT_NAME', 'EVENT_CITY', 'APPLICATION_DATE']); +export const DEFAULT_VISA_REQUEST_SORT_FIELD = 'APPLICATION_DATE'; export const DEFAULT_EVENT_SORT_FIELD = 'EVENT_START_DATE'; export const VALID_EVENT_SORT_ORDERS: readonly string[] = ['ASC', 'DESC']; export const DEFAULT_EVENTS_PAGE_SIZE = 10; @@ -52,3 +79,14 @@ export const MAX_EVENTS_PAGE_SIZE = 100; export const EMPTY_MY_EVENTS_RESPONSE: MyEventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; export const EMPTY_EVENTS_RESPONSE: EventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; export const EMPTY_ORGANIZATIONS_RESPONSE: MyEventOrganizationsResponse = { data: [] }; +export const EMPTY_VISA_REQUESTS_RESPONSE: VisaRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; +export const EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE: TravelFundRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 }; +export const YES_NO_OPTIONS: FilterOption[] = [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, +]; + +export const TRAVEL_FUND_STEP_ORDER: TravelFundStep[] = ['select-event', 'terms', 'about-me', 'expenses']; +export const VIS_REQUEST_STEP_ORDER: VisaRequestStep[] = ['select-event', 'terms', 'apply']; + +export const EVENT_SELECTION_PAGE_SIZE = 12; diff --git a/packages/shared/src/constants/pdf.constants.ts b/packages/shared/src/constants/pdf.constants.ts index b50d31da7..b16a1bbf7 100644 --- a/packages/shared/src/constants/pdf.constants.ts +++ b/packages/shared/src/constants/pdf.constants.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { PDFTemplateDetails } from '../interfaces/my-event.interface'; +import { PDFTemplateDetails } from '../interfaces/events.interface'; export const DEFAULT_TEMPLATE: PDFTemplateDetails = { link: 'https://www.linuxfoundation.org/', diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/events.interface.ts similarity index 59% rename from packages/shared/src/interfaces/my-event.interface.ts rename to packages/shared/src/interfaces/events.interface.ts index 5dbc28f16..01645202c 100644 --- a/packages/shared/src/interfaces/my-event.interface.ts +++ b/packages/shared/src/interfaces/events.interface.ts @@ -25,6 +25,8 @@ export interface MyEvent { registrationUrl: string | null; /** Foundation short name (e.g. "CNCF", "OpenSSF") */ foundation: string; + /** ISO 8601 event start date string (e.g. "2026-11-10T00:00:00.000Z") — used for date-range filtering */ + startDate: string; /** Human-readable date string (e.g. "Nov 10–13, 2026") */ date: string; /** Human-readable location string (e.g. "Salt Lake City, UT") */ @@ -177,6 +179,41 @@ export interface EventTab { countKey?: 'upcoming' | 'past'; } +/** + * Step identifiers for the visa letter application dialog + */ +export type VisaRequestStep = 'select-event' | 'terms' | 'apply'; + +/** + * Step identifiers for the travel fund application dialog + */ +export type TravelFundStep = 'select-event' | 'terms' | 'about-me' | 'expenses'; + +/** + * Represents the render state of a single step in a multi-step dialog's step indicator + */ +export interface DialogStepState { + id: string; + label: string; + number: number; + isActive: boolean; + isCompleted: boolean; +} + +/** + * Parameters for fetching event requests (visa letters or travel fund) from the API + */ +export interface GetEventRequestsParams { + eventId?: string; + projectName?: string; + searchQuery?: string; + status?: string; + sortField?: string; + pageSize?: number; + offset?: number; + sortOrder?: 'ASC' | 'DESC'; +} + /** * Parameters for fetching my events from the API */ @@ -191,6 +228,14 @@ export interface GetMyEventsParams { pageSize?: number; offset?: number; sortOrder?: 'ASC' | 'DESC'; + /** When true, only events the user has registered for are returned */ + registeredOnly?: boolean; + /** ISO 8601 date string — include only events starting on or after this date */ + startDateFrom?: string; + /** ISO 8601 date string — include only events starting on or before this date */ + startDateTo?: string; + /** Filter events by country (e.g. "United States") */ + country?: string; } /** @@ -252,11 +297,75 @@ export interface CertificateData { userName: string; } +/** + * Raw row returned from ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS for visa letter requests + */ +export interface VisaRequestRow { + EVENT_ID: string; + EVENT_NAME: string; + EVENT_URL: string | null; + EVENT_LOCATION: string | null; + EVENT_CITY: string | null; + EVENT_COUNTRY: string | null; + /** Date the visa letter was applied for */ + APPLICATION_DATE: Date | string | null; + /** Visa letter request status. Actual Snowflake values: "Submitted", "Approved", "Denied", "Expired" */ + REQUEST_STATUS: string; + TOTAL_RECORDS: number; +} + +/** + * Visa letter request item for the My Events visa-letters tab + */ +export interface VisaRequest { + /** Unique event identifier */ + id: string; + /** Event display name */ + name: string; + /** External event URL */ + url: string; + /** Human-readable location string (e.g. "Salt Lake City, UT") */ + location: string; + /** Human-readable application date string (e.g. "Jan 15, 2026") */ + applicationDate: string; + /** Visa letter request status (e.g. "Submitted", "Approved", "Denied", "Expired") */ + status: string; +} + +/** + * Paginated API response for visa letter requests + */ +export type VisaRequestsResponse = OffsetPaginatedResponse; + +/** + * Travel fund request item — identical shape to VisaRequest (event name, location, application date, status) + */ +export type TravelFundRequest = VisaRequest; + +/** + * Paginated API response for travel fund requests + */ +export type TravelFundRequestsResponse = OffsetPaginatedResponse; + /** * Valid sort order values for event queries */ export type EventSortOrder = 'ASC' | 'DESC'; +/** + * Server-side options for fetching event requests (visa letters or travel fund) — required pagination/sort fields + */ +export interface GetEventRequestsOptions { + eventId?: string; + projectName?: string; + searchQuery?: string; + status?: string; + sortField?: string; + pageSize: number; + offset: number; + sortOrder: EventSortOrder; +} + /** * Server-side options for fetching user events (required pagination/sort fields) */ @@ -271,10 +380,25 @@ export interface GetMyEventsOptions { pageSize: number; offset: number; sortOrder: EventSortOrder; + /** When true, only events the user has registered for are returned */ + registeredOnly?: boolean; + /** ISO 8601 date string — include only events starting on or after this date */ + startDateFrom?: string; + /** ISO 8601 date string — include only events starting on or before this date */ + startDateTo?: string; + /** Filter events by country (e.g. "United States") */ + country?: string; /** Project slugs from persona detection — scopes upcoming events to affiliated projects */ affiliatedProjectSlugs?: string[]; } +/** + * Response for distinct event countries + */ +export interface GetUpcomingCountriesResponse { + data: string[]; +} + /** * Server-side options for fetching foundation events (required pagination/sort fields) */ @@ -301,3 +425,80 @@ export interface GetEventOrganizationsOptions { /** Project slugs from persona detection — scopes upcoming foundations to affiliated projects */ affiliatedProjectSlugs?: string[]; } + +// --------------------------------------------------------------------------- +// Travel Fund Application +// --------------------------------------------------------------------------- + +export interface TravelFundAboutMe { + firstName: string; + lastName: string; + email: string; + citizenshipCountry: string; + profileLink: string; + company: string; + canReceiveFunds: string; + travelFromCountry: string; + openSourceInvolvement: string; + isLgbtqia: boolean; + isWoman: boolean; + isPersonWithDisability: boolean; + isDiversityOther: boolean; + preferNotToAnswer: boolean; + attendingForCompany: string; + willingToBlog: string; +} + +export interface TravelFundExpenses { + airfareCost: number; + airfareNotes: string; + hotelCost: number; + hotelNotes: string; + groundTransportCost: number; + groundTransportNotes: string; + estimatedTotal: number; +} + +export interface TravelFundApplication { + eventId: string; + eventName: string; + termsAccepted: boolean; + aboutMe: TravelFundAboutMe; + expenses: TravelFundExpenses; +} + +export interface TravelFundApplicationResponse { + success: boolean; + message: string; +} + +// --------------------------------------------------------------------------- +// Visa Request Application +// --------------------------------------------------------------------------- + +export interface VisaRequestApplicantInfo { + firstName: string; + lastName: string; + email: string; + passportNumber: string; + citizenshipCountry: string; + passportExpiryDate: Date | null; + embassyCity: string; + company: string; + mailingAddress: string; +} + +export interface VisaRequestApplication { + eventId: string; + eventName: string; + termsAccepted: boolean; + applicantInfo: VisaRequestApplicantInfo; +} + +export interface VisaRequestApplicationResponse { + success: boolean; + message: string; +} + +export type RequestType = 'visa' | 'travel-fund'; +export type TimeFilterValue = 'any' | 'this-month' | 'next-3-months'; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 177f66094..3207d6216 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -104,9 +104,8 @@ export * from './lens.interface'; // Persona detection interfaces export * from './persona-detection.interface'; -// My Event interfaces -export * from './my-event.interface'; - +// Events interfaces (my events, foundation events, travel fund, visa request) +export * from './events.interface'; // Training interfaces export * from './training.interface';