From 07da5a199a4d8dcd8a4bdfd1a02bbc61c4074be7 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Thu, 22 Aug 2024 14:58:34 +0200 Subject: [PATCH] Feature: Create Event - First Step Frontend (#17) Reviewed-on: https://gitea.admiral.access.ly/igorpropisnov/mvp-ticket/pulls/17 Co-authored-by: Igor Propisnov Co-committed-by: Igor Propisnov --- .../session/repository/session.repository.ts | 15 + .../session/services/session.service.ts | 4 + .../repositories/user-data.repository.ts | 39 ++- .../controller/verify.controller.ts | 28 +- .../services/email-verification.service.ts | 32 +- .../modules/verify-module/verify.module.ts | 2 + frontend/src/app/app.routes.ts | 20 +- .../layout/main-layout/layout.component.html | 17 +- .../layout/main-layout/layout.component.ts | 15 +- .../create-event/create-event.component.html | 54 +++ .../create-event/create-event.component.ts | 161 +++++++++ .../create-location.dialog.component.html | 100 ++++++ .../create-location.dialog.component.ts | 85 +++++ .../steps/basic-step.component.html | 80 +++++ .../steps/basic-step.component.ts | 131 ++++++++ .../steps/tickets-step.component.html | 1 + .../steps/tickets-step.component.ts | 13 + .../event-empty-state.component.html | 49 +++ .../event-empty-state.component.ts | 48 +++ .../event-root/event-root.component.html | 1 + .../pages/event-root/event-root.component.ts | 14 + .../dropdown/dropdown.component.html | 127 +++++++ .../components/dropdown/dropdown.component.ts | 318 ++++++++++++++++++ frontend/src/app/shared/components/index.ts | 2 + .../stepper-indicator.component.html | 63 ++++ .../stepper-indicator.component.ts | 24 ++ package.json | 3 + 27 files changed, 1419 insertions(+), 27 deletions(-) create mode 100644 frontend/src/app/pages/event-root/create-event/create-event.component.html create mode 100644 frontend/src/app/pages/event-root/create-event/create-event.component.ts create mode 100644 frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.html create mode 100644 frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.ts create mode 100644 frontend/src/app/pages/event-root/create-event/steps/basic-step.component.html create mode 100644 frontend/src/app/pages/event-root/create-event/steps/basic-step.component.ts create mode 100644 frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.html create mode 100644 frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.ts create mode 100644 frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.html create mode 100644 frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts create mode 100644 frontend/src/app/pages/event-root/event-root.component.html create mode 100644 frontend/src/app/pages/event-root/event-root.component.ts create mode 100644 frontend/src/app/shared/components/dropdown/dropdown.component.html create mode 100644 frontend/src/app/shared/components/dropdown/dropdown.component.ts create mode 100644 frontend/src/app/shared/components/index.ts create mode 100644 frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.html create mode 100644 frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.ts diff --git a/backend/src/modules/session/repository/session.repository.ts b/backend/src/modules/session/repository/session.repository.ts index 8c93c52..be5e85d 100644 --- a/backend/src/modules/session/repository/session.repository.ts +++ b/backend/src/modules/session/repository/session.repository.ts @@ -22,6 +22,21 @@ export class SessionRepository { .getMany(); } + public async getUserIdBySessionId(sessionId: string): Promise { + const session = await this.findSessionBySessionId(sessionId); + + if (!session || !session.json) { + return null; + } + + const sessionData = + typeof session.json === 'string' + ? JSON.parse(session.json) + : session.json; + + return sessionData?.passport?.user?.id || null; + } + public async findSessionBySessionId( sessionId: string ): Promise { diff --git a/backend/src/modules/session/services/session.service.ts b/backend/src/modules/session/services/session.service.ts index 0fd0b56..061e83f 100644 --- a/backend/src/modules/session/services/session.service.ts +++ b/backend/src/modules/session/services/session.service.ts @@ -20,6 +20,10 @@ export class SessionService { return this.sessionRepository.findSessionsByUserId(userId); } + public async getUserIdBySessionId(sessionId: string): Promise { + return this.sessionRepository.getUserIdBySessionId(sessionId); + } + public async isSessioExpired(session: Session): Promise { return this.sessionRepository.isSessionExpired(session); } diff --git a/backend/src/modules/user-module/repositories/user-data.repository.ts b/backend/src/modules/user-module/repositories/user-data.repository.ts index 11056eb..e1f45c0 100644 --- a/backend/src/modules/user-module/repositories/user-data.repository.ts +++ b/backend/src/modules/user-module/repositories/user-data.repository.ts @@ -21,10 +21,37 @@ export class UserDataRepository { return this.repository.save(userData); } - // public async updateEmailVerificationStatus(userId: string): Promise { - // await this.repository.update( - // { user: { id: userId } }, - // { isEmailConfirmed: true } - // ); - // } + public async updateEmailVerificationStatus(userId: string): Promise { + try { + const result = await this.repository.update( + { userCredentials: { id: userId } }, + { isEmailConfirmed: true } + ); + + return result.affected > 0; + } catch (error) { + console.error('Error updating email verification status:', error); + return false; + } + } + + public async isEmailConfirmedByUserId(userId: string): Promise { + try { + const userData = await this.repository.findOne({ + where: { + userCredentials: { id: userId }, + }, + relations: ['userCredentials'], + }); + + if (userData) { + return userData.isEmailConfirmed; + } + + return false; + } catch (error) { + console.error('Error checking email confirmation status:', error); + return false; + } + } } diff --git a/backend/src/modules/verify-module/controller/verify.controller.ts b/backend/src/modules/verify-module/controller/verify.controller.ts index 76f9180..d10f070 100644 --- a/backend/src/modules/verify-module/controller/verify.controller.ts +++ b/backend/src/modules/verify-module/controller/verify.controller.ts @@ -1,5 +1,16 @@ -import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { + Controller, + Get, + Req, + HttpCode, + HttpStatus, + Query, + UseGuards, + Post, +} from '@nestjs/common'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { SessionGuard } from 'src/modules/session/guard'; import { Public } from 'src/shared/decorator'; import { EmailVerificationService } from '../services/email-verification.service'; @@ -12,15 +23,26 @@ export class VerifyController { ) {} @ApiCreatedResponse({ - description: 'Email verified successfully', + description: 'Verify email', type: Boolean, }) @Public() - @Get() + @Post() @HttpCode(HttpStatus.OK) public async verifyEmail( @Query('token') tokenToVerify: string ): Promise { return this.emailVerificationService.verifyEmail(tokenToVerify); } + + @ApiCreatedResponse({ + description: 'Check if email is verified', + type: Boolean, + }) + @Get('check') + @HttpCode(HttpStatus.OK) + @UseGuards(SessionGuard) + public async isEmailVerified(@Req() request: Request): Promise { + return this.emailVerificationService.isEmailVerified(request.sessionID); + } } diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index 288d2ef..3f07e61 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'crypto'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EmailVerification } from 'src/entities'; +import { SessionService } from 'src/modules/session/services/session.service'; import { UriEncoderService } from 'src/shared'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; @@ -13,6 +14,7 @@ export class EmailVerificationService { public constructor( private readonly emailVerifyRepository: EmailVerifyRepository, private readonly userDataRepository: UserDataRepository, + private readonly sessionService: SessionService, private readonly configService: ConfigService ) {} @@ -42,14 +44,32 @@ export class EmailVerificationService { await this.deleteEmailVerificationToken(tokenToVerify); if (emailVerification && emailVerification.user) { - // await this.userDataRepository.updateEmailVerificationStatus( - // emailVerification.user.id - // ); - return true; - } else { - return false; + const isStatusUpdated = + await this.userDataRepository.updateEmailVerificationStatus( + emailVerification.user.id + ); + + return isStatusUpdated; } } + + return false; + } + + public async isEmailVerified(sessionID: string): Promise { + const userId = await this.sessionService.getUserIdBySessionId(sessionID); + + if (!userId) { + return false; + } + + const isVerfiied = + await this.userDataRepository.isEmailConfirmedByUserId(userId); + + if (isVerfiied) { + return true; + } + return false; } diff --git a/backend/src/modules/verify-module/verify.module.ts b/backend/src/modules/verify-module/verify.module.ts index 8c90fc8..f2ea20d 100644 --- a/backend/src/modules/verify-module/verify.module.ts +++ b/backend/src/modules/verify-module/verify.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EmailVerification } from 'src/entities'; +import { SessionModule } from '../session/session.module'; import { UserModule } from '../user-module/user.module'; import { VerifyController } from './controller/verify.controller'; @@ -12,6 +13,7 @@ import { EmailVerificationService } from './services/email-verification.service' @Module({ imports: [ ConfigModule, + SessionModule, UserModule, TypeOrmModule.forFeature([EmailVerification]), ], diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index a9754fc..f0d58b1 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -26,7 +26,25 @@ const protectedRoutes: Routes = [ import('./pages/dashboard-root/dashboard-root.component').then( (m) => m.DashboardRootComponent ), - canActivate: [], + }, + { + path: 'event', + children: [ + { + path: '', + loadComponent: () => + import('./pages/event-root/event-root.component').then( + (m) => m.EventRootComponent + ), + }, + { + path: 'create', + loadComponent: () => + import('./pages/event-root/create-event/create-event.component').then( + (m) => m.CreateEventComponent + ), + }, + ], }, ]; diff --git a/frontend/src/app/layout/main-layout/layout.component.html b/frontend/src/app/layout/main-layout/layout.component.html index 1ec156f..2ba8ef2 100644 --- a/frontend/src/app/layout/main-layout/layout.component.html +++ b/frontend/src/app/layout/main-layout/layout.component.html @@ -3,18 +3,18 @@ [ngStyle]="navigation" [class]=" isCollapsed - ? 'bg-primary w-0 md:w-20 transition-all duration-300 ease-in-out' + ? 'bg-primary w-0 md:w-20 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]' : showMobileMenu - ? 'bg-primary w-64 transition-all duration-300 ease-in-out' + ? 'bg-primary w-64 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]' : isDesktopCollapsed - ? 'bg-primary w-48 md:w-14 transition-all duration-300 ease-in-out' - : 'bg-primary w-48 md:w-48 transition-all duration-300 ease-in-out' + ? 'bg-primary w-48 md:w-14 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]' + : 'bg-primary w-48 md:w-48 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]' " class="transform h-full z-20 overflow-y-auto fixed md:relative flex flex-col">
+ class="p-1 w-full h-16 z-50 bg-base-100 flex items-center relative">
@if (!isCollapsed && !isDesktopCollapsed) { @@ -60,7 +60,8 @@ class="cursor-pointer rounded-btn mt-2" [ngClass]="{ 'bg-base-100 text-primary': item.active, - 'text-primary-content hover:text-base-content': !item.active + 'text-primary-content hover:text-accent-content hover:bg-accent': + !item.active }" (click)="setActive(item)" (keydown.enter)="setActive(item)" @@ -114,7 +115,7 @@
+ class="p-4 z-0 md:z-20 relative bg-primary text-primary-content flex items-center h-16 shadow-[0_5px_20px_rgba(0,0,0,0.5)]">
-
+
diff --git a/frontend/src/app/layout/main-layout/layout.component.ts b/frontend/src/app/layout/main-layout/layout.component.ts index c711d32..080481c 100644 --- a/frontend/src/app/layout/main-layout/layout.component.ts +++ b/frontend/src/app/layout/main-layout/layout.component.ts @@ -38,7 +38,6 @@ export class LayoutComponent implements OnInit { public isCollapsed: boolean = false; public isDesktopCollapsed: boolean = false; public showMobileMenu: boolean = false; - public userHasInteracted: boolean = false; public menuItems: TopMenuItem[] = [ { name: 'Dashboard', @@ -48,6 +47,14 @@ export class LayoutComponent implements OnInit { `), }, + { + name: 'Event', + route: '/event', + icon: this.sanitizer + .bypassSecurityTrustHtml(` + + `), + }, ]; public bottomMenuItems: BottomMenuItem[] = [ { @@ -134,7 +141,6 @@ export class LayoutComponent implements OnInit { } else { this.isDesktopCollapsed = !this.isDesktopCollapsed; } - this.userHasInteracted = true; } public toggleDesktopSidebar(): void { @@ -145,7 +151,10 @@ export class LayoutComponent implements OnInit { this.menuItems.forEach((menu: TopMenuItem) => { menu.active = false; }); - item.active = true; + this.router.navigate([item.route]); + if (!this.isCollapsed && this.showMobileMenu) { + this.toggleSidebar(); + } } private setActiveItemBasedOnRoute(): void { diff --git a/frontend/src/app/pages/event-root/create-event/create-event.component.html b/frontend/src/app/pages/event-root/create-event/create-event.component.html new file mode 100644 index 0000000..be43da3 --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/create-event.component.html @@ -0,0 +1,54 @@ +
+
+
+ +
+
+ + + +
+
+ @if (currentStep() === 0) { + + } + @if (currentStep() === 1) { + + } +
+
+ +
+
+
+ +
+
+ @if (currentStep() < steps.length - 1) { + + } +
+
+
+
diff --git a/frontend/src/app/pages/event-root/create-event/create-event.component.ts b/frontend/src/app/pages/event-root/create-event/create-event.component.ts new file mode 100644 index 0000000..85d6e32 --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/create-event.component.ts @@ -0,0 +1,161 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + ChangeDetectionStrategy, + signal, + WritableSignal, + effect, + ViewChild, +} from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { StepperIndicatorComponent } from '../../../shared/components'; + +import { BasicStepComponent } from './steps/basic-step.component'; +import { TicketsStepComponent } from './steps/tickets-step.component'; + +@Component({ + selector: 'app-create-event', + standalone: true, + providers: [], + imports: [ + CommonModule, + BasicStepComponent, + TicketsStepComponent, + StepperIndicatorComponent, + ], + templateUrl: './create-event.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateEventComponent { + @ViewChild(BasicStepComponent) public basicStep!: BasicStepComponent; + public currentStep: WritableSignal = signal(0); + public readonly steps: string[] = ['Basic', 'Tickets', 'Review']; + public form!: FormGroup; + public isCurrentStepValid: WritableSignal = signal(false); + public hasAttemptedNextStep: WritableSignal = signal(false); + + public constructor(private readonly formBuilder: FormBuilder) { + this.form = this.formBuilder.group({ + eventTitle: ['', [Validators.required, Validators.minLength(3)]], + eventLocation: ['', Validators.required], + eventDate: ['', Validators.required], + eventTime: ['', Validators.required], + }); + + effect(() => { + this.isCurrentStepValid.set(this.isStepValid(this.currentStep())); + this.hasAttemptedNextStep.set(false); // Reset when step changes + }); + + this.form.valueChanges.subscribe(() => { + this.isCurrentStepValid.set(this.isStepValid(this.currentStep())); + if (this.isCurrentStepValid()) { + this.hasAttemptedNextStep.set(false); // Reset when form becomes valid + } + }); + } + + public getStepContent(index: number): string { + if (index < this.currentStep()) { + return this.isStepValid(index) ? '✓' : '?'; + } else { + return (index + 1).toString(); + } + } + + public goToStep(stepIndex: number): void { + if (stepIndex < this.currentStep()) { + this.currentStep.set(stepIndex); + } else if (this.canAdvanceToStep(stepIndex)) { + this.currentStep.set(stepIndex); + } + } + + public canAdvanceToStep(stepIndex: number): boolean { + for (let i = this.currentStep(); i < stepIndex; i++) { + if (!this.isStepValid(i)) { + return false; + } + } + return true; + } + + public isStepValid(stepIndex: number): boolean { + switch (stepIndex) { + case 0: + return this.form.valid; + case 1: + // TODO: Implement validation for Tickets step + return true; + case 2: + // TODO: Implement validation for Review step + return true; + default: + return false; + } + } + + public nextStep(): void { + if (this.currentStep() < this.steps.length - 1) { + if (this.isCurrentStepValid()) { + this.currentStep.set(this.currentStep() + 1); + this.hasAttemptedNextStep.set(false); + } else { + this.hasAttemptedNextStep.set(true); + this.markCurrentStepAsTouched(); + } + } + } + + public prevStep(): void { + if (this.currentStep() > 0) { + this.currentStep.set(this.currentStep() - 1); + } + } + + public getNextButtonClass(): string { + return this.hasAttemptedNextStep() && !this.isCurrentStepValid() + ? 'btn btn-outline btn-error' + : 'btn btn-primary btn-outline'; + } + + public getNextButtonText(): string { + if (this.hasAttemptedNextStep() && !this.isCurrentStepValid()) { + return 'Required Fields Need Attention'; + } else { + return `Next to ${this.steps[this.currentStep() + 1]}`; + } + } + + public getNextButtonAriaLabel(): string { + if (this.hasAttemptedNextStep() && !this.isCurrentStepValid()) { + return 'Required Fields Need Attention'; + } else { + return `Next to ${this.steps[this.currentStep() + 1]}`; + } + } + + public getBackButtonText(): string { + if (this.currentStep() === 0) { + return 'Back'; + } else { + return `Back to ${this.steps[this.currentStep() - 1]}`; + } + } + + public getBackButtonAriaLabel(): string { + if (this.currentStep() === 0) { + return 'Back (disabled)'; + } else { + return `Back to ${this.steps[this.currentStep() - 1]}`; + } + } + + private markCurrentStepAsTouched(): void { + if (this.currentStep() === 0 && this.basicStep) { + this.basicStep.markAllAsTouched(); + } + // step 1 and 2 missing + } +} diff --git a/frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.html b/frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.html new file mode 100644 index 0000000..7604b4a --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.html @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.ts b/frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.ts new file mode 100644 index 0000000..42e2524 --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/dialog/create-location.dialog.component.ts @@ -0,0 +1,85 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + output, + OutputEmitterRef, + signal, + WritableSignal, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; + +export interface EventLocation { + plz: string; + stadt: string; + strasse: string; + name: string; +} + +@Component({ + selector: 'app-location-dialog', + templateUrl: './create-location.dialog.component.html', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], +}) +export class LocationDialogComponent { + public locationCreated: OutputEmitterRef = + output(); + public locationForm: FormGroup = new FormGroup({}); + public formSubmitted: WritableSignal = signal(false); + + public constructor(private readonly formBuilder: FormBuilder) { + this.locationForm = this.formBuilder.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + postalCode: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]], + city: ['', [Validators.required, Validators.minLength(2)]], + street: ['', [Validators.required, Validators.minLength(3)]], + houseNumber: [ + '', + [Validators.required, Validators.pattern(/^[0-9]+[a-zA-Z]?$/)], + ], + }); + } + + public getInputClass(controlName: string): string { + const control = this.locationForm.get(controlName); + + if ((control?.dirty || this.formSubmitted()) && control?.touched) { + return control.valid ? 'input-success' : 'input-error'; + } + return ''; + } + + public createLocation(): void { + this.formSubmitted.set(true); + this.locationForm.markAllAsTouched(); + if (this.locationForm.valid) { + this.locationCreated.emit(this.locationForm.value); + this.closeModal(); + } + } + + public openModal(): void { + const modal = document.getElementById( + 'location_modal' + ) as HTMLDialogElement; + + this.locationForm.reset(); + this.locationForm.markAsUntouched(); + this.locationForm.markAsPristine(); + this.formSubmitted.set(false); + modal.showModal(); + } + + public closeModal(): void { + const modal = document.getElementById( + 'location_modal' + ) as HTMLDialogElement; + + modal.close(); + } +} diff --git a/frontend/src/app/pages/event-root/create-event/steps/basic-step.component.html b/frontend/src/app/pages/event-root/create-event/steps/basic-step.component.html new file mode 100644 index 0000000..c32aad6 --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/steps/basic-step.component.html @@ -0,0 +1,80 @@ +
+

Event Basic Information

+ + + +
+ +
+ +
+ + + +
+
+ + diff --git a/frontend/src/app/pages/event-root/create-event/steps/basic-step.component.ts b/frontend/src/app/pages/event-root/create-event/steps/basic-step.component.ts new file mode 100644 index 0000000..3f95edc --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/steps/basic-step.component.ts @@ -0,0 +1,131 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + InputSignal, + ViewChild, + input, +} from '@angular/core'; +import { + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, +} from '@angular/forms'; + +import { DropdownComponent } from '../../../../shared/components/dropdown/dropdown.component'; +import { + EventLocation, + LocationDialogComponent, +} from '../dialog/create-location.dialog.component'; + +@Component({ + selector: 'app-basic-step', + standalone: true, + templateUrl: './basic-step.component.html', + providers: [], + imports: [ + CommonModule, + FormsModule, + DropdownComponent, + LocationDialogComponent, + ReactiveFormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasicStepComponent { + @ViewChild(LocationDialogComponent) + public locationModal!: LocationDialogComponent; + @ViewChild(DropdownComponent) + public dropdownComponent!: DropdownComponent; + public items: string[] = ['Nachtigal Köln']; + public form: InputSignal = input.required(); + + public constructor() {} + + public onDropdownSubmit(): void { + this.openLocationModal(); + } + + public getInputClass(controlName: string): string { + const control = this.form().get(controlName); + + if (control?.touched) { + return control.valid ? 'input-success' : 'input-error'; + } + return ''; + } + + public onLocationSubmit(location: EventLocation): void { + //TODO: save location + } + + public markAllAsTouched(): void { + Object.values(this.form().controls).forEach((control) => { + control.markAsTouched(); + }); + if (this.dropdownComponent) { + this.dropdownComponent.markAsTouched(); + } + } + + public getErrorMessage(controlName: string): string { + const control = this.form().get(controlName); + + if (control && control.touched && control.errors) { + return this.getErrorMessageFromValidationErrors( + control.errors, + controlName + ); + } + return ''; + } + + private getErrorMessageFromValidationErrors( + errors: ValidationErrors, + controlName: string + ): string { + switch (controlName) { + case 'eventTitle': + if (errors['required']) { + return 'Event title is required to uniquely identify your event in our system and for attendees.'; + } + if (errors['minlength']) { + return `Please provide a more descriptive title (minimum ${errors['minlength'].requiredLength} characters) to improve discoverability.`; + } + break; + case 'eventLocation': + if (errors['required']) { + return 'Location is crucial for attendees and for our logistics planning. Please select or add a venue.'; + } + if (errors['invalidOption']) { + return 'Please select a valid location from the list or add a new one to ensure accurate event information.'; + } + break; + case 'eventDate': + if (errors['required']) { + return 'Event date is essential for scheduling and marketing. Please select a date for your event.'; + } + // You might add a custom validator for dates in the past + if (errors['pastDate']) { + return 'Please select a future date. Our platform is designed for upcoming events.'; + } + break; + case 'eventTime': + if (errors['required']) { + return 'Start time helps attendees plan their schedule. Please specify when your event begins.'; + } + break; + } + + // Generic messages for any other errors + if (errors['required']) { + return 'This information is required to ensure a complete and accurate event listing.'; + } + return 'Please check this field to ensure all event details are correct and complete.'; + } + + private openLocationModal(): void { + this.locationModal.openModal(); + } +} diff --git a/frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.html b/frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.html new file mode 100644 index 0000000..f3e333e --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.html @@ -0,0 +1 @@ +

Hello World

diff --git a/frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.ts b/frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.ts new file mode 100644 index 0000000..ad1b58f --- /dev/null +++ b/frontend/src/app/pages/event-root/create-event/steps/tickets-step.component.ts @@ -0,0 +1,13 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'app-tickets-step', + templateUrl: './tickets-step.component.html', + standalone: true, + providers: [], + imports: [], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TicketsStepComponent { + public constructor() {} +} diff --git a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.html b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.html new file mode 100644 index 0000000..aed332d --- /dev/null +++ b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.html @@ -0,0 +1,49 @@ +
+
+
+

No Events Yet

+ +
+

+ Every great event starts with a single step. Take yours today! +

+
+ +

+ Create your first event and start +
+ selling tickets today! +

+ +
+
+
+ + + + diff --git a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts new file mode 100644 index 0000000..59f79bc --- /dev/null +++ b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts @@ -0,0 +1,48 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + ViewChild, +} from '@angular/core'; +import { Router } from '@angular/router'; + +import { VerifyApiService } from '../../../api'; + +@Component({ + selector: 'app-event-empty-state', + standalone: true, + templateUrl: './event-empty-state.component.html', + providers: [], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventEmptyStateComponent { + @ViewChild('emailVerificationModal') + public emailVerificationModal!: ElementRef; + + public constructor( + private readonly router: Router, + private readonly verifyApi: VerifyApiService + ) {} + + public navigateToCreateEvent(): void { + this.verifyApi + .verifyControllerIsEmailVerified() + .subscribe((isVerified: boolean) => { + if (!isVerified) { + this.openEmailVerificationModal(); + } else { + this.router.navigate(['/event/create']); + } + }); + } + + public closeEmailVerificationModal(): void { + (this.emailVerificationModal.nativeElement as HTMLDialogElement).close(); + } + + private openEmailVerificationModal(): void { + ( + this.emailVerificationModal.nativeElement as HTMLDialogElement + ).showModal(); + } +} diff --git a/frontend/src/app/pages/event-root/event-root.component.html b/frontend/src/app/pages/event-root/event-root.component.html new file mode 100644 index 0000000..5ca8bb2 --- /dev/null +++ b/frontend/src/app/pages/event-root/event-root.component.html @@ -0,0 +1 @@ + diff --git a/frontend/src/app/pages/event-root/event-root.component.ts b/frontend/src/app/pages/event-root/event-root.component.ts new file mode 100644 index 0000000..10d2fe9 --- /dev/null +++ b/frontend/src/app/pages/event-root/event-root.component.ts @@ -0,0 +1,14 @@ +import { CommonModule } from '@angular/common'; +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +import { EventEmptyStateComponent } from './event-empty-state/event-empty-state.component'; + +@Component({ + selector: 'app-event-root', + standalone: true, + imports: [CommonModule, EventEmptyStateComponent], + providers: [], + templateUrl: './event-root.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventRootComponent {} diff --git a/frontend/src/app/shared/components/dropdown/dropdown.component.html b/frontend/src/app/shared/components/dropdown/dropdown.component.html new file mode 100644 index 0000000..d97f7d5 --- /dev/null +++ b/frontend/src/app/shared/components/dropdown/dropdown.component.html @@ -0,0 +1,127 @@ + diff --git a/frontend/src/app/shared/components/dropdown/dropdown.component.ts b/frontend/src/app/shared/components/dropdown/dropdown.component.ts new file mode 100644 index 0000000..08869e8 --- /dev/null +++ b/frontend/src/app/shared/components/dropdown/dropdown.component.ts @@ -0,0 +1,318 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + forwardRef, + input, + InputSignal, + output, + OutputEmitterRef, + signal, + WritableSignal, + OnInit, + HostListener, + ElementRef, +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormsModule, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; + +@Component({ + selector: 'app-dropdown', + templateUrl: './dropdown.component.html', + standalone: true, + imports: [CommonModule, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DropdownComponent), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DropdownComponent), + multi: true, + }, + ], +}) +export class DropdownComponent + implements ControlValueAccessor, Validator, OnInit +{ + // Input properties + /** + * The label text for the dropdown. + */ + public label: InputSignal = input.required(); + /** + * The error message to display when the dropdown selection is invalid. + */ + public errorMessage: InputSignal = input.required(); + /** + * The placeholder text for the dropdown input. + */ + public placeholder: InputSignal = input.required(); + /** + * The list of items to display in the dropdown. + */ + public items: InputSignal = input.required(); + /** + * The text to display when the dropdown list is empty. + */ + public emptyStateText: InputSignal = input(''); + /** + * The text to display as a divider in the dropdown list. + */ + public dividerText: InputSignal = input(''); + /** + * The text to display for adding a new item to the dropdown list. + */ + public newItemText: InputSignal = input(''); + /** + * Whether the dropdown selection is required. + */ + public required: InputSignal = input(false); + /** + * Hint text to display in the top right of the dropdown. + */ + public hintTopRight: InputSignal = input(''); + /** + * Hint text to display in the bottom left of the dropdown. + */ + public hintBottomLeft: InputSignal = input(''); + /** + * Hint text to display in the bottom right of the dropdown. + */ + public hintBottomRight: InputSignal = input(''); + // Output properties + /** + * Event emitted when an item is selected from the dropdown. + */ + public itemSelected: OutputEmitterRef = output(); + /** + * Event emitted when a new item is submitted to be added to the dropdown. + */ + public submitNewItems: OutputEmitterRef = output(); + /** + * Event emitted when an item is selected for editing. + */ + public itemEdit: OutputEmitterRef = output(); + // Internal state + public searchTerm: WritableSignal = signal(''); + public showDropdown: WritableSignal = signal(false); + public filteredItems: WritableSignal = signal([]); + public selectedItem: WritableSignal = signal( + null + ); + public touched: WritableSignal = signal(false); + private isMouseInDropdown: WritableSignal = signal(false); + private internalValue: WritableSignal = signal( + null + ); + + public constructor(private readonly elementRef: ElementRef) {} + + @HostListener('document:click', ['$event']) + public onDocumentClick(event: MouseEvent): void { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.closeDropdown(); + } + } + + public ngOnInit(): void { + this.updateStateFromInternalValue(); + } + + public writeValue(value: string | null): void { + this.internalValue.set(value); + this.updateStateFromInternalValue(); + } + + public registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void): void { + this.onTouched = (): void => { + this.touched.set(true); + fn(); + }; + } + + public validate(control: AbstractControl): ValidationErrors | null { + const value = control.value; + + if (this.required() && (!value || value.trim().length === 0)) { + return { required: true }; + } + if (value && value.trim().length > 0 && !this.isValidOption(value)) { + return { invalidOption: true }; + } + return null; + } + + public toggleDropdown(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + const newDropdownState = !this.showDropdown(); + + this.showDropdown.set(newDropdownState); + + if (newDropdownState) { + this.updateFilteredItems(); + } else { + (event.target as HTMLElement).blur(); + } + } + + public onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + + this.searchTerm.set(value); + this.updateFilteredItems(); + + const exactMatch = this.items().find( + (item) => item.toLowerCase() === value.toLowerCase() + ); + + if (exactMatch) { + this.selectedItem.set(exactMatch); + this.internalValue.set(exactMatch); + this.onChange(exactMatch); + this.itemSelected.emit(exactMatch); + } else { + this.selectedItem.set(null); + this.internalValue.set(null); + this.onChange(''); + this.itemSelected.emit(''); + } + + this.showDropdown.set(true); + this.markAsTouched(); + } + + public selectItem(item: string): void { + if (this.isValidOption(item)) { + this.searchTerm.set(item); + this.selectedItem.set(item); + this.internalValue.set(item); + this.closeDropdown(); + this.itemSelected.emit(item); + this.onChange(item); + this.markAsTouched(); + } + } + + public submitNewItem(): void { + this.closeDropdown(); + this.submitNewItems.emit(true); + } + + public onInputClear(): void { + this.searchTerm.set(''); + this.selectedItem.set(null); + this.internalValue.set(null); + this.onChange(''); + this.markAsTouched(); + } + + public markAsTouched(): void { + if (!this.touched()) { + this.touched.set(true); + this.onTouched(); + } + } + + public editItem(item: string, event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.itemEdit.emit(item); + } + + public getInputClass(): string { + if (!this.touched() && !this.hasValidValue()) { + return ''; + } + + const validationResult = this.validate({ + value: this.internalValue(), + } as AbstractControl); + + if (validationResult === null) { + return 'input-success'; + } else if ( + validationResult['required'] || + validationResult['invalidOption'] + ) { + return 'input-error'; + } + return ''; + } + + public onFocus(): void { + this.showDropdown.set(true); + this.updateFilteredItems(); + this.markAsTouched(); + } + + public onBlur(): void { + setTimeout(() => { + if (!this.isMouseInDropdown()) { + this.closeDropdown(); + this.markAsTouched(); + } + }, 1); + } + + public onDropdownMouseEnter(): void { + this.isMouseInDropdown.set(true); + } + + public onDropdownMouseLeave(): void { + this.isMouseInDropdown.set(false); + } + + private isValidOption(value: string): boolean { + return this.items().includes(value); + } + + private updateFilteredItems(): void { + const searchTerm = this.searchTerm().toLowerCase(); + const matchingItems = this.items().filter((item) => + item.toLowerCase().includes(searchTerm) + ); + + this.filteredItems.set(matchingItems); + } + + private updateStateFromInternalValue(): void { + const value = this.internalValue(); + + if (value && this.isValidOption(value)) { + this.searchTerm.set(value); + this.selectedItem.set(value); + } else { + this.searchTerm.set(''); + this.selectedItem.set(null); + } + this.updateFilteredItems(); + } + + private hasValidValue(): boolean { + const value = this.internalValue(); + + return value !== null && this.isValidOption(value); + } + + private closeDropdown(): void { + this.showDropdown.set(false); + this.isMouseInDropdown.set(false); + } + + private onChange: (value: string) => void = () => {}; + private onTouched: () => void = () => {}; +} diff --git a/frontend/src/app/shared/components/index.ts b/frontend/src/app/shared/components/index.ts new file mode 100644 index 0000000..3990296 --- /dev/null +++ b/frontend/src/app/shared/components/index.ts @@ -0,0 +1,2 @@ +export * from './dropdown/dropdown.component'; +export * from './stepper-indicator/stepper-indicator.component'; diff --git a/frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.html b/frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.html new file mode 100644 index 0000000..ab9e7a7 --- /dev/null +++ b/frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.html @@ -0,0 +1,63 @@ +
+
+ +
+ @for (step of steps; track $index) { + @if ($index < steps.length - 1) { +
+ } + } +
+ + + @for (step of steps; track $index) { +
+ + + {{ step }} + +
+ } +
+
diff --git a/frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.ts b/frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.ts new file mode 100644 index 0000000..b1a9498 --- /dev/null +++ b/frontend/src/app/shared/components/stepper-indicator/stepper-indicator.component.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'app-stepper-indicator', + standalone: true, + imports: [CommonModule], + templateUrl: './stepper-indicator.component.html', + styles: [], +}) +export class StepperIndicatorComponent { + @Output() public stepChange: EventEmitter = + new EventEmitter(); + @Input() public steps: string[] = []; + @Input() public currentStep: number = 0; + @Input() public isStepValid: (index: number) => boolean = () => true; + @Input() public canAdvanceToStep: (index: number) => boolean = () => true; + + public goToStep(index: number): void { + if (index <= this.currentStep || this.canAdvanceToStep(index)) { + this.stepChange.emit(index); + } + } +} diff --git a/package.json b/package.json index f932772..d308e65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "mvp-ticket", "version": "1.0.0", + "engines": { + "node": "18.17.1" + }, "description": "", "main": "index.js", "scripts": {