diff --git a/Dockerfile b/Dockerfile index db78848..7467d91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,26 @@ -FROM node:22-alpine AS api-deps -WORKDIR /app -COPY listify-api/package*.json ./ -RUN npm ci --omit=dev - FROM node:22-alpine AS api-builder WORKDIR /app COPY listify-api/package*.json ./ -RUN npm ci +RUN npm ci --no-audit --no-fund COPY listify-api/ ./ RUN npm run build FROM node:22-alpine AS web-builder WORKDIR /app COPY listify-client/package*.json ./ -RUN npm ci +RUN npm ci --no-audit --no-fund COPY listify-client/ ./ RUN npm run build FROM node:22-alpine AS runtime -RUN apk add --no-cache nginx +RUN apk add --no-cache nginx \ + && rm -rf /usr/local/lib/node_modules/npm \ + && rm -f /usr/local/bin/npm /usr/local/bin/npx ENV NODE_ENV=production ENV PORT=3000 WORKDIR /app -COPY --from=api-deps /app/node_modules ./node_modules COPY --from=api-builder /app/dist ./dist COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html diff --git a/listify-api/nest-cli.json b/listify-api/nest-cli.json index f9aa683..c6e6d93 100644 --- a/listify-api/nest-cli.json +++ b/listify-api/nest-cli.json @@ -3,6 +3,8 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "webpack": true, + "webpackConfigPath": "webpack.config.js" } } diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index 793a8b7..88156c5 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -4,13 +4,18 @@ import { Get, HttpCode, HttpStatus, + Patch, Post, Query, + Req, + UseGuards, } from '@nestjs/common'; +import type { AuthenticatedRequest } from './auth.types'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RegisterDto } from './dto/register.dto'; +import { JwtAuthGuard } from './jwt-auth.guard'; @Controller('auth') export class AuthController { @@ -36,4 +41,22 @@ export class AuthController { refresh(@Body() refreshTokenDto: RefreshTokenDto) { return this.authService.refresh(refreshTokenDto); } + + @Get('me') + @UseGuards(JwtAuthGuard) + me(@Req() request: AuthenticatedRequest) { + return this.authService.getPublicUser(request.user!.sub); + } + + @Patch('me/onboarding') + @UseGuards(JwtAuthGuard) + updateOnboarding( + @Req() request: AuthenticatedRequest, + @Body() body: { completed?: boolean }, + ) { + return this.authService.updateOnboardingCompleted( + request.user!.sub, + body.completed === true, + ); + } } diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index 5eb8eaa..2d786cf 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -193,6 +193,35 @@ export class AuthService { return user.name || user.email; } + async getPublicUser(userId: string): Promise { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new UnauthorizedException('Authenticated user is required.'); + } + + return this.toPublicUser(user); + } + + async updateOnboardingCompleted( + userId: string, + completed: boolean, + ): Promise { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new UnauthorizedException('Authenticated user is required.'); + } + + user.onboardingCompleted = completed; + + return this.toPublicUser(await this.usersRepository.save(user)); + } + private normalizeEmail(email?: string): string { const normalizedEmail = email?.trim().toLowerCase(); @@ -328,6 +357,7 @@ export class AuthService { email: user.email, name: user.name ?? undefined, verified: user.verified, + onboardingCompleted: user.onboardingCompleted === true, }; } } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index fe3f1ae..ee02145 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -7,6 +7,7 @@ export interface AuthUser { passwordHash: string; verificationToken?: string; verified: boolean; + onboardingCompleted: boolean; } export interface AuthTokens { @@ -34,4 +35,5 @@ export interface PublicUser { email: string; name?: string; verified: boolean; + onboardingCompleted: boolean; } diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index 4308cc9..4295a6d 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -33,6 +33,9 @@ export class UserEntity { @Column({ type: 'boolean', default: false }) verified!: boolean; + @Column({ type: 'boolean', default: false }) + onboardingCompleted!: boolean; + @CreateDateColumn({ type: 'datetime', precision: 3, diff --git a/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts b/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts new file mode 100644 index 0000000..d0e4114 --- /dev/null +++ b/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserOnboardingCompleted1781000000000 + implements MigrationInterface +{ + name = 'AddUserOnboardingCompleted1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('users'))) { + return; + } + + if (!(await queryRunner.hasColumn('users', 'onboardingCompleted'))) { + await queryRunner.query( + 'ALTER TABLE `users` ADD `onboardingCompleted` tinyint NOT NULL DEFAULT 0', + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if ( + (await queryRunner.hasTable('users')) && + (await queryRunner.hasColumn('users', 'onboardingCompleted')) + ) { + await queryRunner.query( + 'ALTER TABLE `users` DROP COLUMN `onboardingCompleted`', + ); + } + } +} diff --git a/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts b/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts new file mode 100644 index 0000000..94ffd7f --- /dev/null +++ b/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class GeneratedMigration1781003163444 implements MigrationInterface { + name = 'GeneratedMigration1781003163444' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``); + } + +} diff --git a/listify-api/webpack.config.js b/listify-api/webpack.config.js new file mode 100644 index 0000000..9eaa60e --- /dev/null +++ b/listify-api/webpack.config.js @@ -0,0 +1,4 @@ +module.exports = (options) => ({ + ...options, + externals: [], +}); diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index 1253491..9c50540 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -10,9 +10,29 @@ {{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }} + +
+ + + {{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }} + +
+ + } + + +
+ + Typ + + @for (option of kindOptions; track option.value) { + {{ option.label }} + } + + + + + Sortierung + + @for (option of sortOptions; track option.value) { + {{ option.label }} + } + + +
+ +
+ + Alle + Offen + Erledigt + + + @if (activeFilterCount() > 0 || sortOption() !== 'updated-desc') { + + } +
+ + + @if (!hasVisibleLists()) { + + + +

Keine Treffer

+

Mit den aktuellen Filtern wurde keine Liste gefunden.

+ +
+
+ } @else { +

+ {{ visibleLists().length }} von {{ lists().length }} Listen +

+
- @for (list of lists(); track list.id) { + @for (list of visibleLists(); track list.id) { {{ list.name }} - {{ kindLabel(list.kind) }} + + {{ kindLabel(list.kind) }} - {{ progressLabel(list) }} + @@ -70,7 +158,11 @@ - + Oeffnen @@ -78,6 +170,7 @@ }
+ } } @else { diff --git a/listify-client/src/app/lists/lists.component.scss b/listify-client/src/app/lists/lists.component.scss new file mode 100644 index 0000000..a936972 --- /dev/null +++ b/listify-client/src/app/lists/lists.component.scss @@ -0,0 +1,66 @@ +.list-controls { + display: grid; + gap: 0.75rem; + margin: 0 0 1rem; +} + +.list-controls mat-form-field { + width: 100%; +} + +.search-field { + min-width: 0; +} + +.filter-row { + display: grid; + gap: 0.75rem; +} + +.status-row { + display: grid; + gap: 0.65rem; +} + +.status-row mat-button-toggle-group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + border-radius: 8px; +} + +.status-row mat-button-toggle { + min-width: 0; +} + +.status-row button { + justify-self: start; +} + +.result-count { + margin: 0 0 0.75rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.9rem; +} + +@media (min-width: 701px) { + .list-controls { + grid-template-columns: minmax(260px, 1.2fr) minmax(320px, 1fr); + align-items: start; + gap: 0.85rem 1rem; + margin-bottom: 1.25rem; + } + + .search-field { + grid-row: span 2; + } + + .filter-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .status-row { + grid-template-columns: minmax(300px, 1fr) auto; + align-items: center; + } +} diff --git a/listify-client/src/app/lists/lists.component.ts b/listify-client/src/app/lists/lists.component.ts index 6af6d84..c3eeeb9 100644 --- a/listify-client/src/app/lists/lists.component.ts +++ b/listify-client/src/app/lists/lists.component.ts @@ -2,34 +2,115 @@ import { DatePipe } from '@angular/common'; import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; import { getAuthErrorMessage } from '../auth/error-message'; +import { OnboardingService } from '../onboarding/onboarding.service'; import { ListTemplateKind } from '../templates/templates.models'; import { UserList } from './lists.models'; import { ListsService } from './lists.service'; +type ListStatusFilter = 'all' | 'open' | 'completed'; +type ListSortOption = + | 'updated-desc' + | 'created-desc' + | 'name-asc' + | 'progress-desc' + | 'progress-asc'; +type ListKindFilter = ListTemplateKind | 'all'; + @Component({ selector: 'app-lists', imports: [ DatePipe, RouterLink, MatButtonModule, + MatButtonToggleModule, MatCardModule, + MatFormFieldModule, MatIconModule, + MatInputModule, MatProgressSpinnerModule, + MatSelectModule, ], templateUrl: './lists.component.html', - styleUrl: '../workspace-page.scss', + styleUrls: ['../workspace-page.scss', './lists.component.scss'], }) export class ListsComponent implements OnInit { private readonly listsService = inject(ListsService); + protected readonly onboarding = inject(OnboardingService); protected readonly lists = signal([]); protected readonly loading = signal(true); protected readonly errorMessage = signal(null); + protected readonly searchTerm = signal(''); + protected readonly kindFilter = signal('all'); + protected readonly statusFilter = signal('all'); + protected readonly sortOption = signal('updated-desc'); protected readonly hasLists = computed(() => this.lists().length > 0); + protected readonly visibleLists = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + const kind = this.kindFilter(); + const status = this.statusFilter(); + + return this.lists() + .filter((list) => { + const matchesSearch = + !term || + [ + list.name, + list.description ?? '', + this.kindLabel(list.kind), + ...list.items.flatMap((item) => [item.title, item.notes ?? '']), + ] + .join(' ') + .toLowerCase() + .includes(term); + const matchesKind = kind === 'all' || list.kind === kind; + const matchesStatus = + status === 'all' || + (status === 'completed' + ? this.isCompleted(list) + : !this.isCompleted(list)); + + return matchesSearch && matchesKind && matchesStatus; + }) + .sort((a, b) => this.compareLists(a, b)); + }); + protected readonly hasVisibleLists = computed( + () => this.visibleLists().length > 0, + ); + protected readonly activeFilterCount = computed( + () => + Number(this.searchTerm().trim().length > 0) + + Number(this.kindFilter() !== 'all') + + Number(this.statusFilter() !== 'all'), + ); + protected readonly kindOptions: ReadonlyArray<{ + value: ListKindFilter; + label: string; + }> = [ + { value: 'all', label: 'Alle Typen' }, + { value: 'packing', label: 'Packlisten' }, + { value: 'shopping', label: 'Einkauf' }, + { value: 'todo', label: 'Todo' }, + { value: 'custom', label: 'Eigene Listen' }, + ]; + protected readonly sortOptions: ReadonlyArray<{ + value: ListSortOption; + label: string; + }> = [ + { value: 'updated-desc', label: 'Zuletzt bearbeitet' }, + { value: 'created-desc', label: 'Neueste zuerst' }, + { value: 'name-asc', label: 'Name A-Z' }, + { value: 'progress-desc', label: 'Fortschritt hoch' }, + { value: 'progress-asc', label: 'Fortschritt niedrig' }, + ]; ngOnInit(): void { this.loadLists(); @@ -65,4 +146,49 @@ export class ListsComponent implements OnInit { protected checkedCount(list: UserList): number { return list.items.filter((item) => item.checked).length; } + + protected progressLabel(list: UserList): string { + if (list.items.length === 0) { + return '0%'; + } + + return `${Math.round(this.progress(list) * 100)}%`; + } + + protected resetFilters(): void { + this.searchTerm.set(''); + this.kindFilter.set('all'); + this.statusFilter.set('all'); + this.sortOption.set('updated-desc'); + } + + private compareLists(a: UserList, b: UserList): number { + switch (this.sortOption()) { + case 'created-desc': + return this.dateValue(b.createdAt) - this.dateValue(a.createdAt); + case 'name-asc': + return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }); + case 'progress-desc': + return this.progress(b) - this.progress(a); + case 'progress-asc': + return this.progress(a) - this.progress(b); + case 'updated-desc': + default: + return this.dateValue(b.updatedAt) - this.dateValue(a.updatedAt); + } + } + + private progress(list: UserList): number { + return list.items.length === 0 + ? 0 + : this.checkedCount(list) / list.items.length; + } + + private isCompleted(list: UserList): boolean { + return list.items.length > 0 && this.checkedCount(list) === list.items.length; + } + + private dateValue(value: string): number { + return new Date(value).getTime(); + } } diff --git a/listify-client/src/app/onboarding/onboarding-overlay.component.html b/listify-client/src/app/onboarding/onboarding-overlay.component.html new file mode 100644 index 0000000..265bb20 --- /dev/null +++ b/listify-client/src/app/onboarding/onboarding-overlay.component.html @@ -0,0 +1,42 @@ +@if (onboarding.currentStep(); as step) { +
+ @if (targetBox(); as box) { +
+ } + +
+
+ Schritt {{ onboarding.currentStepNumber() }} von {{ onboarding.totalSteps }} + +
+ +

{{ step.title }}

+

{{ step.body }}

+ +
+ @if (!hasTarget() && step.key !== 'complete') { + + } + + @if (step.key === 'complete') { + + } @else { + + } +
+
+
+} diff --git a/listify-client/src/app/onboarding/onboarding-overlay.component.scss b/listify-client/src/app/onboarding/onboarding-overlay.component.scss new file mode 100644 index 0000000..1321724 --- /dev/null +++ b/listify-client/src/app/onboarding/onboarding-overlay.component.scss @@ -0,0 +1,74 @@ +.onboarding-layer { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; +} + +.onboarding-marker { + position: fixed; + border: 2px solid var(--mat-sys-primary); + border-radius: 12px; + box-shadow: + 0 0 0 9999px rgb(0 0 0 / 32%), + 0 0 0 6px color-mix(in srgb, var(--mat-sys-primary) 22%, transparent); + pointer-events: none; + transition: + top 120ms ease, + left 120ms ease, + width 120ms ease, + height 120ms ease; +} + +.onboarding-panel { + position: fixed; + display: grid; + gap: 0.65rem; + width: min(340px, calc(100vw - 32px)); + padding: 1rem; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; + background: var(--mat-sys-surface); + box-shadow: var(--mat-sys-level4); + pointer-events: auto; +} + +.onboarding-panel.mobile-panel { + right: 0.75rem; + bottom: calc(0.75rem + env(safe-area-inset-bottom)); + left: 0.75rem; + width: auto; +} + +.step-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.8rem; +} + +.step-line button { + flex: 0 0 auto; +} + +.onboarding-panel h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 500; +} + +.onboarding-panel p { + margin: 0; + color: var(--mat-sys-on-surface-variant); + line-height: 1.4; +} + +.onboarding-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.25rem; +} diff --git a/listify-client/src/app/onboarding/onboarding-overlay.component.ts b/listify-client/src/app/onboarding/onboarding-overlay.component.ts new file mode 100644 index 0000000..5974f11 --- /dev/null +++ b/listify-client/src/app/onboarding/onboarding-overlay.component.ts @@ -0,0 +1,144 @@ +import { + AfterViewInit, + Component, + DestroyRef, + OnDestroy, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { auditTime, filter, fromEvent, merge } from 'rxjs'; +import { OnboardingService } from './onboarding.service'; + +interface TargetBox { + top: number; + left: number; + width: number; + height: number; +} + +@Component({ + selector: 'app-onboarding-overlay', + imports: [MatButtonModule, MatIconModule], + templateUrl: './onboarding-overlay.component.html', + styleUrl: './onboarding-overlay.component.scss', +}) +export class OnboardingOverlayComponent implements AfterViewInit, OnDestroy { + protected readonly onboarding = inject(OnboardingService); + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private refreshTimer: number | null = null; + private lastStepKey: string | null = null; + + protected readonly targetBox = signal(null); + protected readonly viewportWidth = signal(this.readViewportWidth()); + protected readonly isMobile = computed(() => this.viewportWidth() < 700); + protected readonly hasTarget = computed(() => Boolean(this.targetBox())); + protected readonly markerStyle = computed(() => { + const box = this.targetBox(); + + if (!box) { + return {}; + } + + return { + top: `${Math.max(8, box.top - 8)}px`, + left: `${Math.max(8, box.left - 8)}px`, + width: `${box.width + 16}px`, + height: `${box.height + 16}px`, + }; + }); + protected readonly panelStyle = computed(() => { + const box = this.targetBox(); + + if (this.isMobile()) { + return {}; + } + + if (!box) { + return { + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }; + } + + const panelWidth = 340; + const left = + box.left + box.width + panelWidth + 24 < this.viewportWidth() + ? box.left + box.width + 16 + : Math.max(16, box.left - panelWidth - 16); + + return { + top: `${Math.max(80, box.top)}px`, + left: `${left}px`, + transform: 'none', + }; + }); + + ngAfterViewInit(): void { + this.refreshTimer = window.setInterval(() => this.updateTarget(), 250); + + merge( + fromEvent(window, 'resize'), + fromEvent(window, 'scroll'), + this.router.events.pipe(filter((event) => event instanceof NavigationEnd)), + ) + .pipe(auditTime(80), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.updateTarget()); + + window.setTimeout(() => this.updateTarget(true)); + } + + ngOnDestroy(): void { + if (this.refreshTimer !== null) { + window.clearInterval(this.refreshTimer); + } + } + + protected goToCurrentStep(): void { + this.onboarding.goToCurrentStep(); + window.setTimeout(() => this.updateTarget(true), 120); + } + + private updateTarget(scrollIntoView = false): void { + this.viewportWidth.set(this.readViewportWidth()); + + const step = this.onboarding.currentStep(); + const selector = step?.targetSelector; + const stepChanged = Boolean(step?.key) && step?.key !== this.lastStepKey; + this.lastStepKey = step?.key ?? null; + + if (!selector) { + this.targetBox.set(null); + return; + } + + const target = document.querySelector(selector); + + if (!target) { + this.targetBox.set(null); + return; + } + + if (scrollIntoView || stepChanged) { + target.scrollIntoView({ block: 'center', inline: 'nearest' }); + } + + const rect = target.getBoundingClientRect(); + this.targetBox.set({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); + } + + private readViewportWidth(): number { + return typeof window === 'undefined' ? 1024 : window.innerWidth; + } +} diff --git a/listify-client/src/app/onboarding/onboarding.service.ts b/listify-client/src/app/onboarding/onboarding.service.ts new file mode 100644 index 0000000..7d1ce45 --- /dev/null +++ b/listify-client/src/app/onboarding/onboarding.service.ts @@ -0,0 +1,267 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { finalize } from 'rxjs'; +import { AuthService } from '../auth/auth.service'; + +export type OnboardingStepKey = + | 'new-template' + | 'template-details' + | 'template-item' + | 'template-copy' + | 'list-open' + | 'complete'; + +export interface OnboardingStep { + key: OnboardingStepKey; + title: string; + body: string; + targetSelector?: string; +} + +interface OnboardingState { + step: OnboardingStepKey; + templateId?: string; + listId?: string; +} + +const STEPS: Record = { + 'new-template': { + key: 'new-template', + title: 'Erstes Template anlegen', + body: 'Starte mit einer Vorlage. Tippe auf "Neues Template".', + targetSelector: '[data-onboarding="new-template"]', + }, + 'template-details': { + key: 'template-details', + title: 'Template benennen', + body: 'Gib deinem Template einen Titel und speichere es. Danach kannst du Items hinzufuegen.', + targetSelector: '[data-onboarding="template-details"]', + }, + 'template-item': { + key: 'template-item', + title: 'Erstes Item hinzufuegen', + body: 'Trage ein Item ein und fuege es hinzu. Items sind spaeter die Punkte deiner Liste.', + targetSelector: '[data-onboarding="template-item"]', + }, + 'template-copy': { + key: 'template-copy', + title: 'Als Liste kopieren', + body: 'Kopiere dein Template als echte Liste, damit du die Items abhaken kannst.', + targetSelector: '[data-onboarding="template-copy"]', + }, + 'list-open': { + key: 'list-open', + title: 'Liste oeffnen', + body: 'Oeffne die neu erstellte Liste. Dort kannst du die Items abhaken.', + targetSelector: '[data-onboarding="open-list"]', + }, + complete: { + key: 'complete', + title: 'Onboarding abgeschlossen', + body: 'Du hast ein Template erstellt, daraus eine Liste gemacht und sie geoeffnet.', + }, +}; + +const STEP_ORDER: OnboardingStepKey[] = [ + 'new-template', + 'template-details', + 'template-item', + 'template-copy', + 'list-open', + 'complete', +]; + +@Injectable({ providedIn: 'root' }) +export class OnboardingService { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + private readonly stateSignal = signal( + this.readStoredState(), + ); + + readonly state = this.stateSignal.asReadonly(); + readonly saving = signal(false); + readonly currentStep = computed(() => { + if (this.auth.user()?.onboardingCompleted) { + return null; + } + + const state = this.stateSignal(); + return state ? STEPS[state.step] : null; + }); + readonly currentStepNumber = computed(() => { + const step = this.stateSignal()?.step; + return step ? STEP_ORDER.indexOf(step) + 1 : 0; + }); + readonly totalSteps = STEP_ORDER.length; + + startForCurrentUser(): boolean { + const user = this.auth.user(); + + if (!user || user.onboardingCompleted) { + return false; + } + + this.setState({ step: 'new-template' }); + void this.router.navigateByUrl('/templates'); + return true; + } + + skip(): void { + this.markCompleted(); + } + + finish(): void { + this.markCompleted(); + } + + resetForCurrentUser(): void { + const userId = this.auth.user()?.id; + + if (!userId || this.saving()) { + return; + } + + this.saving.set(true); + this.auth + .updateOnboardingCompleted(false) + .pipe(finalize(() => this.saving.set(false))) + .subscribe(() => { + this.storage?.removeItem(this.stateKey(userId)); + this.setState({ step: 'new-template' }); + void this.router.navigateByUrl('/templates'); + }); + } + + createTemplateClicked(): void { + if (this.isStep('new-template')) { + this.setState({ step: 'template-details' }); + } + } + + templateCreated(templateId: string): void { + if (this.isStep('template-details')) { + this.setState({ step: 'template-item', templateId }); + } + } + + templateItemAdded(): void { + const state = this.stateSignal(); + + if (state?.step === 'template-item') { + this.setState({ ...state, step: 'template-copy' }); + } + } + + templateCopiedToList(listId: string): void { + const state = this.stateSignal(); + + if (state?.step === 'template-copy') { + this.setState({ ...state, step: 'list-open', listId }); + } + } + + listOpened(listId: string): void { + const state = this.stateSignal(); + + if (state?.step === 'list-open' && (!state.listId || state.listId === listId)) { + this.setState({ ...state, step: 'complete' }); + } + } + + isStep(step: OnboardingStepKey): boolean { + return this.stateSignal()?.step === step; + } + + isListOpenTarget(listId: string): boolean { + const state = this.stateSignal(); + return state?.step === 'list-open' && (!state.listId || state.listId === listId); + } + + routeForCurrentStep(): string | null { + const state = this.stateSignal(); + + if (!state) { + return null; + } + + switch (state.step) { + case 'new-template': + return '/templates'; + case 'template-details': + return state.templateId ? `/templates/${state.templateId}` : '/templates/new'; + case 'template-item': + case 'template-copy': + return state.templateId ? `/templates/${state.templateId}` : '/templates'; + case 'list-open': + return '/lists'; + case 'complete': + return state.listId ? `/lists/${state.listId}` : null; + } + } + + goToCurrentStep(): void { + const route = this.routeForCurrentStep(); + + if (route) { + void this.router.navigateByUrl(route); + } + } + + private setState(state: OnboardingState): void { + this.stateSignal.set(state); + this.storage?.setItem(this.stateKey(), JSON.stringify(state)); + } + + private markCompleted(): void { + const userId = this.auth.user()?.id; + + if (!userId || this.saving()) { + this.stateSignal.set(null); + return; + } + + this.saving.set(true); + this.auth + .updateOnboardingCompleted(true) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.storage?.removeItem(this.stateKey(userId)); + this.stateSignal.set(null); + }, + error: () => { + this.stateSignal.set(null); + }, + }); + } + + private readStoredState(): OnboardingState | null { + const userId = this.auth.user()?.id; + + if (!userId || this.auth.user()?.onboardingCompleted) { + return null; + } + + const rawState = this.storage?.getItem(this.stateKey(userId)); + + if (!rawState) { + return null; + } + + try { + return JSON.parse(rawState) as OnboardingState; + } catch { + this.storage?.removeItem(this.stateKey(userId)); + return null; + } + } + + private stateKey(userId = this.auth.user()?.id ?? 'anonymous'): string { + return `listify.onboarding.state.${userId}`; + } + + private get storage(): Storage | null { + return typeof window === 'undefined' ? null : window.localStorage; + } +} diff --git a/listify-client/src/app/templates/template-detail/template-detail.component.html b/listify-client/src/app/templates/template-detail/template-detail.component.html index 0b8ca50..2c8541d 100644 --- a/listify-client/src/app/templates/template-detail/template-detail.component.html +++ b/listify-client/src/app/templates/template-detail/template-detail.component.html @@ -9,7 +9,13 @@ @if (canEditItems()) {
-
- + Neues Template diff --git a/listify-client/src/app/templates/templates.component.ts b/listify-client/src/app/templates/templates.component.ts index eef1326..56253e7 100644 --- a/listify-client/src/app/templates/templates.component.ts +++ b/listify-client/src/app/templates/templates.component.ts @@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { getAuthErrorMessage } from '../auth/error-message'; +import { OnboardingService } from '../onboarding/onboarding.service'; import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component'; import { ListTemplate, ListTemplateKind } from './templates.models'; import { TemplatesService } from './templates.service'; @@ -33,6 +34,7 @@ export class TemplatesComponent implements OnInit { private readonly dialog = inject(MatDialog); private readonly snackBar = inject(MatSnackBar); private readonly templatesService = inject(TemplatesService); + protected readonly onboarding = inject(OnboardingService); protected readonly templates = signal([]); protected readonly loading = signal(true);