Feature: Create Event - First Step Frontend (#17)

Reviewed-on: #17
Co-authored-by: Igor Propisnov <info@igor-propisnov.com>
Co-committed-by: Igor Propisnov <info@igor-propisnov.com>
This commit is contained in:
Igor Hrenowitsch Propisnov 2024-08-22 14:58:34 +02:00 committed by Igor Hrenowitsch Propisnov
parent 2447019b11
commit 07da5a199a
27 changed files with 1419 additions and 27 deletions

View File

@ -22,6 +22,21 @@ export class SessionRepository {
.getMany();
}
public async getUserIdBySessionId(sessionId: string): Promise<string | null> {
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<Session | null> {

View File

@ -20,6 +20,10 @@ export class SessionService {
return this.sessionRepository.findSessionsByUserId(userId);
}
public async getUserIdBySessionId(sessionId: string): Promise<string | null> {
return this.sessionRepository.getUserIdBySessionId(sessionId);
}
public async isSessioExpired(session: Session): Promise<boolean> {
return this.sessionRepository.isSessionExpired(session);
}

View File

@ -21,10 +21,37 @@ export class UserDataRepository {
return this.repository.save(userData);
}
// public async updateEmailVerificationStatus(userId: string): Promise<void> {
// await this.repository.update(
// { user: { id: userId } },
// { isEmailConfirmed: true }
// );
// }
public async updateEmailVerificationStatus(userId: string): Promise<boolean> {
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<boolean> {
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;
}
}
}

View File

@ -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<boolean> {
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<boolean> {
return this.emailVerificationService.isEmailVerified(request.sessionID);
}
}

View File

@ -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<boolean> {
const userId = await this.sessionService.getUserIdBySessionId(sessionID);
if (!userId) {
return false;
}
const isVerfiied =
await this.userDataRepository.isEmailConfirmedByUserId(userId);
if (isVerfiied) {
return true;
}
return false;
}

View File

@ -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]),
],

View File

@ -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
),
},
],
},
];

View File

@ -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">
<div
[ngClass]="showMobileMenu ? 'justify-center' : 'justify-between'"
[ngStyle]="navigation"
class="p-1 w-full h-16 bg-base-100 flex items-center relative">
class="p-1 w-full h-16 z-50 bg-base-100 flex items-center relative">
<div class="flex items-center justify-center h-full w-full">
<div class="flex items-center space-x-4">
@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 @@
<div class="flex flex-col flex-grow">
<header
[ngStyle]="navigation"
class="p-4 bg-primary text-primary-content flex items-center h-16">
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)]">
<div class="w-10 flex items-center justify-center md:hidden">
<label class="btn btn-ghost swap swap-rotate">
<input
@ -143,7 +144,7 @@
</div>
</header>
<div [ngStyle]="mainContent" class="px-8 py-4 flex-grow text-2xl p-4">
<div [ngStyle]="mainContent" class="overflow-y-auto h-screen">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -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 {
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>`),
},
{
name: 'Event',
route: '/event',
icon: this.sanitizer
.bypassSecurityTrustHtml(`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z" />
</svg>`),
},
];
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 {

View File

@ -0,0 +1,54 @@
<div class="flex flex-col h-full">
<div
class="w-full bg-base-100 max-w-full sticky top-0 z-10 pt-4 px-4 sm:px-8">
<div class="w-full max-w-4xl mx-auto">
<app-stepper-indicator
[steps]="steps"
[currentStep]="currentStep()"
[isStepValid]="isStepValid.bind(this)"
[canAdvanceToStep]="canAdvanceToStep.bind(this)"
(stepChange)="goToStep($event)"></app-stepper-indicator>
</div>
</div>
<!-- Rest of the component remains the same -->
<div class="flex-grow overflow-y-auto px-4 sm:px-8 py-8">
<div class="w-full max-w-4xl mx-auto">
@if (currentStep() === 0) {
<app-basic-step [form]="form"></app-basic-step>
}
@if (currentStep() === 1) {
<app-tickets-step></app-tickets-step>
}
</div>
</div>
<div
class="w-full bg-base-100 max-w-full sticky bottom-0 z-10 px-4 sm:px-8 py-4">
<div class="flex justify-between max-w-4xl mx-auto">
<div>
<button
type="button"
class="btn btn-primary btn-outline"
(click)="prevStep()"
[disabled]="currentStep() === 0"
[attr.aria-label]="getBackButtonAriaLabel()"
[class.opacity-50]="currentStep() === 0">
<span [innerHTML]="getBackButtonText()"></span>
</button>
</div>
<div>
@if (currentStep() < steps.length - 1) {
<button
type="button"
[class]="getNextButtonClass()"
(click)="nextStep()"
[attr.aria-label]="getNextButtonAriaLabel()">
<span [innerHTML]="getNextButtonText()"></span>
</button>
}
</div>
</div>
</div>
</div>

View File

@ -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<number> = signal(0);
public readonly steps: string[] = ['Basic', 'Tickets', 'Review'];
public form!: FormGroup;
public isCurrentStepValid: WritableSignal<boolean> = signal(false);
public hasAttemptedNextStep: WritableSignal<boolean> = 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 <span class="font-bold">${this.steps[this.currentStep() + 1]}</span>`;
}
}
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 <span class="font-bold">${this.steps[this.currentStep() - 1]}</span>`;
}
}
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
}
}

View File

@ -0,0 +1,100 @@
<dialog id="location_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">Create New Location</h3>
<form [formGroup]="locationForm">
<label class="form-control w-full">
<div class="label">
<span class="label-text">Location Name</span>
<span class="label-text-alt">Name of the location</span>
</div>
<input
[ngClass]="getInputClass('name')"
formControlName="name"
type="text"
class="input input-bordered w-full"
placeholder="" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt"></span>
</div>
</label>
<div class="grid grid-cols-4 gap-4">
<label class="form-control col-span-1">
<div class="label">
<span class="label-text">Postal Code</span>
<span class="label-text-alt"></span>
</div>
<input
[ngClass]="getInputClass('postalCode')"
type="text"
formControlName="postalCode"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt"></span>
</div>
</label>
<label class="form-control col-span-3">
<div class="label">
<span class="label-text">City</span>
<span class="label-text-alt">Name of the city</span>
</div>
<input
[ngClass]="getInputClass('city')"
type="text"
formControlName="city"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt"></span>
</div>
</label>
</div>
<div class="grid grid-cols-4 gap-4">
<label class="form-control col-span-3">
<div class="label">
<span class="label-text">Street</span>
<span class="label-text-alt">Name of the street</span>
</div>
<input
[ngClass]="getInputClass('street')"
type="text"
formControlName="street"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt"></span>
</div>
</label>
<label class="form-control col-span-1">
<div class="label">
<span class="label-text">Number</span>
<span class="label-text-alt"></span>
</div>
<input
[ngClass]="getInputClass('houseNumber')"
type="text"
formControlName="houseNumber"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt"></span>
</div>
</label>
</div>
</form>
<div class="modal-action flex justify-between">
<button class="btn" (click)="closeModal()">Cancel</button>
<button
class="btn btn-primary"
(click)="createLocation()"
[disabled]="locationForm.invalid">
Create Location
</button>
</div>
</div>
</dialog>

View File

@ -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<EventLocation> =
output<EventLocation>();
public locationForm: FormGroup = new FormGroup({});
public formSubmitted: WritableSignal<boolean> = signal<boolean>(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();
}
}

View File

@ -0,0 +1,80 @@
<form [formGroup]="form()" class="flex flex-col">
<h1 class="text-2xl font-bold mb-6">Event Basic Information</h1>
<label class="form-control w-full mb-4">
<div class="label">
<span class="label-text">Event Title</span>
<span class="label-text-alt">Make it catchy and descriptive</span>
</div>
<input
formControlName="eventTitle"
[ngClass]="getInputClass('eventTitle')"
type="text"
placeholder="Enter your event title"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt text-error">
{{ getErrorMessage('eventTitle') }}
</span>
</div>
</label>
<div class="form-control w-full mb-4">
<app-dropdown
formControlName="eventLocation"
label="Event Location"
placeholder="Search for a Location"
hintTopRight="Select or create a new Location by using the dropdown"
dividerText="Or add a new Location"
newItemText="Create new Location"
emptyStateText="No matching Locations found"
[items]="items"
[required]="true"
[errorMessage]="getErrorMessage('eventLocation')"
(submitNewItems)="onDropdownSubmit()"></app-dropdown>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<label class="form-control w-full">
<div class="label">
<span class="label-text">Date</span>
<span class="label-text-alt">Select the event date</span>
</div>
<input
formControlName="eventDate"
[ngClass]="getInputClass('eventDate')"
type="date"
id="event-date"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt text-error">
{{ getErrorMessage('eventDate') }}
</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Time</span>
<span class="label-text-alt">Choose the event time</span>
</div>
<input
formControlName="eventTime"
[ngClass]="getInputClass('eventTime')"
type="time"
id="event-time"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt text-error">
{{ getErrorMessage('eventTime') }}
</span>
</div>
</label>
</div>
</form>
<app-location-dialog
(locationCreated)="onLocationSubmit($event)"></app-location-dialog>

View File

@ -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<FormGroup> = input.required<FormGroup>();
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();
}
}

View File

@ -0,0 +1 @@
<h1>Hello World</h1>

View File

@ -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() {}
}

View File

@ -0,0 +1,49 @@
<div class="flex justify-center w-full h-full items-center">
<div class="hero-content text-center">
<div class="max-w-xl">
<h1 class="text-5xl font-bold">No Events Yet</h1>
<blockquote class="py-2">
<p class="text-sm font-semibold">
Every great event starts with a single step. Take yours today!
</p>
</blockquote>
<p class="pt-4 pb-8">
Create your first event and start
<br />
selling tickets today!
</p>
<button
(click)="navigateToCreateEvent()"
class="btn btn-primary animate-shake animate-once animate-duration-300 animate-delay-[2000ms] animate-ease-linear animate-fill-both">
<p>Create Your First Event</p>
</button>
</div>
</div>
</div>
<dialog #emailVerificationModal class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Email Verification Required</h3>
<p class="py-4">
To ensure the security and integrity of our platform, we require email
verification before you can create an event. Verifying your email helps
us:
</p>
<ul class="list-disc list-inside mb-4">
<li>Protect your account and event information</li>
<li>Ensure reliable communication with attendees</li>
<li>Maintain the trust and quality of our event community</li>
</ul>
<p class="mb-4">
Would you like to verify your email now to unlock full access to our event
creation tools?
</p>
<div class="modal-action">
<button class="btn btn-primary" (click)="closeEmailVerificationModal()">
I'll Do This Later
</button>
</div>
</div>
</dialog>

View File

@ -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();
}
}

View File

@ -0,0 +1 @@
<app-event-empty-state></app-event-empty-state>

View File

@ -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 {}

View File

@ -0,0 +1,127 @@
<label class="form-control w-full mb-4">
<div class="label">
<span class="label-text">{{ label() }}</span>
<span class="label-text-alt">
{{ hintTopRight() }}
</span>
</div>
<div class="relative">
<label
[ngClass]="getInputClass()"
class="input input-bordered flex items-center gap-2">
<input
type="text"
[placeholder]="placeholder()"
class="grow"
(focus)="onFocus()"
(blur)="onBlur()"
(input)="onInput($event)"
(keyup.backspace)="onInputClear()"
[(ngModel)]="searchTerm" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 cursor-pointer"
(click)="toggleDropdown($event)"
[class.rotate-180]="showDropdown()">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</label>
@if (showDropdown()) {
<ul
(mouseenter)="onDropdownMouseEnter()"
(mouseleave)="onDropdownMouseLeave()"
class="menu bg-base-100 w-full mt-1 shadow-lg rounded-box absolute top-full left-0 z-10">
@if (filteredItems().length > 0) {
@for (item of filteredItems(); track item) {
<li>
<div class="flex justify-between items-center w-full">
<span
(mousedown)="selectItem(item)"
class="flex-grow cursor-pointer"
[class.font-bold]="item === selectedItem()"
[class.text-primary]="item === selectedItem()">
{{ item }}
</span>
@if (item === selectedItem()) {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 text-primary">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m4.5 12.75 6 6 9-13.5" />
</svg>
} @else {
<div
class="tooltip tooltip-primary tooltip-left"
data-tip="Edit this location's details. You can modify the name, address, or any other relevant information associated with this place.">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 cursor-pointer"
(mousedown)="editItem(item, $event)">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</div>
}
</div>
</li>
}
<div class="divider">{{ dividerText() }}</div>
} @else if (searchTerm()) {
<li
(mousedown)="submitNewItem()"
class="text-center py-2 text-gray-500">
{{ emptyStateText() }}
</li>
}
<li>
<a
(mousedown)="submitNewItem()"
class="flex items-center text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 mr-2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ newItemText() }}
</a>
</li>
</ul>
}
</div>
<div class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt text-error">
{{ errorMessage() }}
</span>
</div>
<div class="label">
<span class="label-text-alt">{{ hintBottomLeft() }}</span>
<span class="label-text-alt">{{ hintBottomRight() }}</span>
</div>
</label>

View File

@ -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<string> = input.required<string>();
/**
* The error message to display when the dropdown selection is invalid.
*/
public errorMessage: InputSignal<string> = input.required<string>();
/**
* The placeholder text for the dropdown input.
*/
public placeholder: InputSignal<string> = input.required<string>();
/**
* The list of items to display in the dropdown.
*/
public items: InputSignal<string[]> = input.required<string[]>();
/**
* The text to display when the dropdown list is empty.
*/
public emptyStateText: InputSignal<string> = input<string>('');
/**
* The text to display as a divider in the dropdown list.
*/
public dividerText: InputSignal<string> = input<string>('');
/**
* The text to display for adding a new item to the dropdown list.
*/
public newItemText: InputSignal<string> = input<string>('');
/**
* Whether the dropdown selection is required.
*/
public required: InputSignal<boolean> = input<boolean>(false);
/**
* Hint text to display in the top right of the dropdown.
*/
public hintTopRight: InputSignal<string> = input<string>('');
/**
* Hint text to display in the bottom left of the dropdown.
*/
public hintBottomLeft: InputSignal<string> = input<string>('');
/**
* Hint text to display in the bottom right of the dropdown.
*/
public hintBottomRight: InputSignal<string> = input<string>('');
// Output properties
/**
* Event emitted when an item is selected from the dropdown.
*/
public itemSelected: OutputEmitterRef<string> = output<string>();
/**
* Event emitted when a new item is submitted to be added to the dropdown.
*/
public submitNewItems: OutputEmitterRef<boolean> = output<boolean>();
/**
* Event emitted when an item is selected for editing.
*/
public itemEdit: OutputEmitterRef<string> = output<string>();
// Internal state
public searchTerm: WritableSignal<string> = signal('');
public showDropdown: WritableSignal<boolean> = signal<boolean>(false);
public filteredItems: WritableSignal<string[]> = signal<string[]>([]);
public selectedItem: WritableSignal<string | null> = signal<string | null>(
null
);
public touched: WritableSignal<boolean> = signal(false);
private isMouseInDropdown: WritableSignal<boolean> = signal<boolean>(false);
private internalValue: WritableSignal<string | null> = signal<string | null>(
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 = () => {};
}

View File

@ -0,0 +1,2 @@
export * from './dropdown/dropdown.component';
export * from './stepper-indicator/stepper-indicator.component';

View File

@ -0,0 +1,63 @@
<div class="w-full max-w-4xl mx-auto">
<div class="relative flex justify-between w-full mb-4">
<!-- Connecting lines -->
<div class="absolute top-5 left-0 right-0 h-1 flex">
@for (step of steps; track $index) {
@if ($index < steps.length - 1) {
<div
class="flex-1 mx-2 transition-all duration-300"
[class.bg-primary]="$index < currentStep"
[class.bg-warning]="
$index < currentStep - 1 &&
(!isStepValid($index) || !isStepValid($index + 1))
"
[class.bg-base-300]="$index >= currentStep"></div>
}
}
</div>
<!-- Steps -->
@for (step of steps; track $index) {
<div class="flex flex-col items-center z-10">
<button
[class.bg-primary]="$index <= currentStep"
[class.bg-warning]="$index < currentStep && !isStepValid($index)"
[class.bg-base-300]="$index > currentStep"
class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-base-100 transition-all duration-300 mb-2 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
[class.cursor-pointer]="
$index <= currentStep || canAdvanceToStep($index)
"
[attr.aria-label]="'Go to step ' + ($index + 1)"
[attr.tabindex]="
$index <= currentStep || canAdvanceToStep($index) ? 0 : -1
"
(click)="goToStep($index)"
(keydown.enter)="goToStep($index)"
(keydown.space)="goToStep($index); $event.preventDefault()">
@if ($index < currentStep && isStepValid($index)) {
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7" />
</svg>
} @else {
{{ $index + 1 }}
}
</button>
<span
class="text-center text-sm"
[class.font-bold]="$index === currentStep">
{{ step }}
</span>
</div>
}
</div>
</div>

View File

@ -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<number> =
new EventEmitter<number>();
@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);
}
}
}

View File

@ -1,6 +1,9 @@
{
"name": "mvp-ticket",
"version": "1.0.0",
"engines": {
"node": "18.17.1"
},
"description": "",
"main": "index.js",
"scripts": {