Feature: Create Event - First Step Frontend #17
|
@ -22,6 +22,21 @@ export class SessionRepository {
|
||||||
.getMany();
|
.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(
|
public async findSessionBySessionId(
|
||||||
sessionId: string
|
sessionId: string
|
||||||
): Promise<Session | null> {
|
): Promise<Session | null> {
|
||||||
|
|
|
@ -20,6 +20,10 @@ export class SessionService {
|
||||||
return this.sessionRepository.findSessionsByUserId(userId);
|
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> {
|
public async isSessioExpired(session: Session): Promise<boolean> {
|
||||||
return this.sessionRepository.isSessionExpired(session);
|
return this.sessionRepository.isSessionExpired(session);
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,10 +21,37 @@ export class UserDataRepository {
|
||||||
return this.repository.save(userData);
|
return this.repository.save(userData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// public async updateEmailVerificationStatus(userId: string): Promise<void> {
|
public async updateEmailVerificationStatus(userId: string): Promise<boolean> {
|
||||||
// await this.repository.update(
|
try {
|
||||||
// { user: { id: userId } },
|
const result = await this.repository.update(
|
||||||
// { isEmailConfirmed: true }
|
{ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { ApiCreatedResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { SessionGuard } from 'src/modules/session/guard';
|
||||||
import { Public } from 'src/shared/decorator';
|
import { Public } from 'src/shared/decorator';
|
||||||
|
|
||||||
import { EmailVerificationService } from '../services/email-verification.service';
|
import { EmailVerificationService } from '../services/email-verification.service';
|
||||||
|
@ -12,15 +23,26 @@ export class VerifyController {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiCreatedResponse({
|
@ApiCreatedResponse({
|
||||||
description: 'Email verified successfully',
|
description: 'Verify email',
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
})
|
})
|
||||||
@Public()
|
@Public()
|
||||||
@Get()
|
@Post()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
public async verifyEmail(
|
public async verifyEmail(
|
||||||
@Query('token') tokenToVerify: string
|
@Query('token') tokenToVerify: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return this.emailVerificationService.verifyEmail(tokenToVerify);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { randomBytes } from 'crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { EmailVerification } from 'src/entities';
|
import { EmailVerification } from 'src/entities';
|
||||||
|
import { SessionService } from 'src/modules/session/services/session.service';
|
||||||
import { UriEncoderService } from 'src/shared';
|
import { UriEncoderService } from 'src/shared';
|
||||||
|
|
||||||
import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
|
import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
|
||||||
|
@ -13,6 +14,7 @@ export class EmailVerificationService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly emailVerifyRepository: EmailVerifyRepository,
|
private readonly emailVerifyRepository: EmailVerifyRepository,
|
||||||
private readonly userDataRepository: UserDataRepository,
|
private readonly userDataRepository: UserDataRepository,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -42,14 +44,32 @@ export class EmailVerificationService {
|
||||||
await this.deleteEmailVerificationToken(tokenToVerify);
|
await this.deleteEmailVerificationToken(tokenToVerify);
|
||||||
|
|
||||||
if (emailVerification && emailVerification.user) {
|
if (emailVerification && emailVerification.user) {
|
||||||
// await this.userDataRepository.updateEmailVerificationStatus(
|
const isStatusUpdated =
|
||||||
// emailVerification.user.id
|
await this.userDataRepository.updateEmailVerificationStatus(
|
||||||
// );
|
emailVerification.user.id
|
||||||
return true;
|
);
|
||||||
} else {
|
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { EmailVerification } from 'src/entities';
|
import { EmailVerification } from 'src/entities';
|
||||||
|
|
||||||
|
import { SessionModule } from '../session/session.module';
|
||||||
import { UserModule } from '../user-module/user.module';
|
import { UserModule } from '../user-module/user.module';
|
||||||
|
|
||||||
import { VerifyController } from './controller/verify.controller';
|
import { VerifyController } from './controller/verify.controller';
|
||||||
|
@ -12,6 +13,7 @@ import { EmailVerificationService } from './services/email-verification.service'
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
SessionModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
TypeOrmModule.forFeature([EmailVerification]),
|
TypeOrmModule.forFeature([EmailVerification]),
|
||||||
],
|
],
|
||||||
|
|
|
@ -26,7 +26,25 @@ const protectedRoutes: Routes = [
|
||||||
import('./pages/dashboard-root/dashboard-root.component').then(
|
import('./pages/dashboard-root/dashboard-root.component').then(
|
||||||
(m) => m.DashboardRootComponent
|
(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
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -3,18 +3,18 @@
|
||||||
[ngStyle]="navigation"
|
[ngStyle]="navigation"
|
||||||
[class]="
|
[class]="
|
||||||
isCollapsed
|
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
|
: 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
|
: isDesktopCollapsed
|
||||||
? 'bg-primary w-48 md:w-14 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'
|
: '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="transform h-full z-20 overflow-y-auto fixed md:relative flex flex-col">
|
||||||
<div
|
<div
|
||||||
[ngClass]="showMobileMenu ? 'justify-center' : 'justify-between'"
|
[ngClass]="showMobileMenu ? 'justify-center' : 'justify-between'"
|
||||||
[ngStyle]="navigation"
|
[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 justify-center h-full w-full">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@if (!isCollapsed && !isDesktopCollapsed) {
|
@if (!isCollapsed && !isDesktopCollapsed) {
|
||||||
|
@ -60,7 +60,8 @@
|
||||||
class="cursor-pointer rounded-btn mt-2"
|
class="cursor-pointer rounded-btn mt-2"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'bg-base-100 text-primary': item.active,
|
'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)"
|
(click)="setActive(item)"
|
||||||
(keydown.enter)="setActive(item)"
|
(keydown.enter)="setActive(item)"
|
||||||
|
@ -114,7 +115,7 @@
|
||||||
<div class="flex flex-col flex-grow">
|
<div class="flex flex-col flex-grow">
|
||||||
<header
|
<header
|
||||||
[ngStyle]="navigation"
|
[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">
|
<div class="w-10 flex items-center justify-center md:hidden">
|
||||||
<label class="btn btn-ghost swap swap-rotate">
|
<label class="btn btn-ghost swap swap-rotate">
|
||||||
<input
|
<input
|
||||||
|
@ -143,7 +144,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -38,7 +38,6 @@ export class LayoutComponent implements OnInit {
|
||||||
public isCollapsed: boolean = false;
|
public isCollapsed: boolean = false;
|
||||||
public isDesktopCollapsed: boolean = false;
|
public isDesktopCollapsed: boolean = false;
|
||||||
public showMobileMenu: boolean = false;
|
public showMobileMenu: boolean = false;
|
||||||
public userHasInteracted: boolean = false;
|
|
||||||
public menuItems: TopMenuItem[] = [
|
public menuItems: TopMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: 'Dashboard',
|
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" />
|
<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>`),
|
</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[] = [
|
public bottomMenuItems: BottomMenuItem[] = [
|
||||||
{
|
{
|
||||||
|
@ -134,7 +141,6 @@ export class LayoutComponent implements OnInit {
|
||||||
} else {
|
} else {
|
||||||
this.isDesktopCollapsed = !this.isDesktopCollapsed;
|
this.isDesktopCollapsed = !this.isDesktopCollapsed;
|
||||||
}
|
}
|
||||||
this.userHasInteracted = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleDesktopSidebar(): void {
|
public toggleDesktopSidebar(): void {
|
||||||
|
@ -145,7 +151,10 @@ export class LayoutComponent implements OnInit {
|
||||||
this.menuItems.forEach((menu: TopMenuItem) => {
|
this.menuItems.forEach((menu: TopMenuItem) => {
|
||||||
menu.active = false;
|
menu.active = false;
|
||||||
});
|
});
|
||||||
item.active = true;
|
this.router.navigate([item.route]);
|
||||||
|
if (!this.isCollapsed && this.showMobileMenu) {
|
||||||
|
this.toggleSidebar();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setActiveItemBasedOnRoute(): void {
|
private setActiveItemBasedOnRoute(): void {
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>Hello World</h1>
|
|
@ -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() {}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<app-event-empty-state></app-event-empty-state>
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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 = () => {};
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './dropdown/dropdown.component';
|
||||||
|
export * from './stepper-indicator/stepper-indicator.component';
|
|
@ -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>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "mvp-ticket",
|
"name": "mvp-ticket",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "18.17.1"
|
||||||
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
Loading…
Reference in New Issue