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:
parent
2447019b11
commit
07da5a199a
|
@ -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> {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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]),
|
||||
],
|
||||
|
|
|
@ -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
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "18.17.1"
|
||||
},
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
Loading…
Reference in New Issue