Onboarding

This commit is contained in:
Bastian Wagner
2026-06-09 13:07:07 +02:00
parent 537c7cbbee
commit 8f3806d398
29 changed files with 1037 additions and 26 deletions

View File

@@ -10,9 +10,29 @@
<mat-icon aria-hidden="true">verified_user</mat-icon>
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
</div>
<div class="status-row">
<mat-icon aria-hidden="true">school</mat-icon>
<span>
{{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }}
</span>
</div>
</mat-card-content>
<mat-card-actions align="end">
<button
mat-stroked-button
type="button"
[disabled]="onboarding.saving()"
(click)="resetOnboarding()"
>
@if (onboarding.saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">restart_alt</mat-icon>
}
Onboarding neu starten
</button>
<button mat-stroked-button type="button" (click)="logout()">
<mat-icon aria-hidden="true">logout</mat-icon>
Logout

View File

@@ -22,6 +22,16 @@
color: var(--mat-sys-primary);
}
mat-card-actions {
flex-wrap: wrap;
gap: 0.5rem;
}
mat-card-actions mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
@media (min-width: 600px) {
.account-page {
place-items: center;

View File

@@ -3,18 +3,25 @@ import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../auth/auth.service';
import { OnboardingService } from '../onboarding/onboarding.service';
@Component({
selector: 'app-account',
imports: [MatButtonModule, MatCardModule, MatIconModule],
imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
templateUrl: './account.component.html',
styleUrl: './account.component.scss',
})
export class AccountComponent {
protected readonly auth = inject(AuthService);
protected readonly onboarding = inject(OnboardingService);
private readonly router = inject(Router);
resetOnboarding(): void {
this.onboarding.resetForCurrentUser();
}
logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');

View File

@@ -123,6 +123,8 @@
<span>Account</span>
</a>
</nav>
<app-onboarding-overlay />
} @else {
<main class="app-main auth-main">
<router-outlet />

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal } from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@@ -9,6 +9,7 @@ import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { map } from 'rxjs';
import { AuthService } from './auth/auth.service';
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
@Component({
selector: 'app-root',
@@ -21,11 +22,12 @@ import { AuthService } from './auth/auth.service';
MatListModule,
MatSidenavModule,
MatToolbarModule,
OnboardingOverlayComponent,
],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
export class App implements OnInit {
protected readonly auth = inject(AuthService);
private readonly breakpointObserver = inject(BreakpointObserver);
@@ -35,6 +37,12 @@ export class App {
);
protected readonly sidebarOpened = signal(false);
ngOnInit(): void {
if (this.auth.isAuthenticated()) {
this.auth.loadCurrentUser().subscribe({ error: () => undefined });
}
}
protected toggleSidebar(): void {
this.sidebarOpened.update((opened) => !opened);
}

View File

@@ -3,6 +3,7 @@ export interface PublicUser {
email: string;
name?: string;
verified: boolean;
onboardingCompleted: boolean;
}
export interface AuthTokenResponse {

View File

@@ -39,6 +39,18 @@ export class AuthService {
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
}
loadCurrentUser(): Observable<PublicUser> {
return this.http
.get<PublicUser>(`${this.apiUrl}/me`)
.pipe(tap((user) => this.storeUser(user)));
}
updateOnboardingCompleted(completed: boolean): Observable<PublicUser> {
return this.http
.patch<PublicUser>(`${this.apiUrl}/me/onboarding`, { completed })
.pipe(tap((user) => this.storeUser(user)));
}
accessToken(): string | null {
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
}
@@ -77,8 +89,12 @@ export class AuthService {
private storeSession(response: AuthTokenResponse): void {
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
this.storage?.setItem(USER_KEY, JSON.stringify(response.user));
this.userSignal.set(response.user);
this.storeUser(response.user);
}
private storeUser(user: PublicUser): void {
this.storage?.setItem(USER_KEY, JSON.stringify(user));
this.userSignal.set(user);
}
private readStoredUser(): PublicUser | null {

View File

@@ -11,6 +11,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
import { OnboardingService } from '../../onboarding/onboarding.service';
@Component({
selector: 'app-login',
@@ -33,6 +34,7 @@ export class LoginComponent {
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly onboarding = inject(OnboardingService);
protected readonly form = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
@@ -54,7 +56,9 @@ export class LoginComponent {
.subscribe({
next: () => {
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
void this.router.navigateByUrl('/account');
if (!this.onboarding.startForCurrentUser()) {
void this.router.navigateByUrl('/account');
}
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });

View File

@@ -12,6 +12,7 @@ import { MatInputModule } from '@angular/material/input';
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 { UserList, UserListItem } from '../lists.models';
import { ListsService } from '../lists.service';
@@ -39,6 +40,7 @@ export class ListDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly onboarding = inject(OnboardingService);
protected readonly list = signal<UserList | null>(null);
protected readonly isCreateMode = signal(false);
@@ -68,6 +70,12 @@ export class ListDetailComponent implements OnInit {
return;
}
const listId = this.listId();
if (listId) {
this.onboarding.listOpened(listId);
}
this.loadList();
}

View File

@@ -31,12 +31,100 @@
</mat-card-content>
</mat-card>
} @else if (hasLists()) {
<section class="list-controls" aria-label="Listen filtern und sortieren">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Listen suchen</mat-label>
<mat-icon matPrefix aria-hidden="true">search</mat-icon>
<input
matInput
type="search"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
@if (searchTerm()) {
<button
mat-icon-button
matSuffix
type="button"
aria-label="Suche loeschen"
(click)="searchTerm.set('')"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
}
</mat-form-field>
<div class="filter-row">
<mat-form-field appearance="outline">
<mat-label>Typ</mat-label>
<mat-select
[value]="kindFilter()"
(selectionChange)="kindFilter.set($event.value)"
>
@for (option of kindOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sortierung</mat-label>
<mat-select
[value]="sortOption()"
(selectionChange)="sortOption.set($event.value)"
>
@for (option of sortOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="status-row">
<mat-button-toggle-group
[value]="statusFilter()"
(change)="statusFilter.set($event.value)"
aria-label="Statusfilter"
>
<mat-button-toggle value="all">Alle</mat-button-toggle>
<mat-button-toggle value="open">Offen</mat-button-toggle>
<mat-button-toggle value="completed">Erledigt</mat-button-toggle>
</mat-button-toggle-group>
@if (activeFilterCount() > 0 || sortOption() !== 'updated-desc') {
<button mat-button type="button" (click)="resetFilters()">
<mat-icon aria-hidden="true">restart_alt</mat-icon>
Zuruecksetzen
</button>
}
</div>
</section>
@if (!hasVisibleLists()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">filter_alt_off</mat-icon>
<h2>Keine Treffer</h2>
<p>Mit den aktuellen Filtern wurde keine Liste gefunden.</p>
<button mat-stroked-button type="button" (click)="resetFilters()">
<mat-icon aria-hidden="true">restart_alt</mat-icon>
Filter zuruecksetzen
</button>
</mat-card-content>
</mat-card>
} @else {
<p class="result-count">
{{ visibleLists().length }} von {{ lists().length }} Listen
</p>
<div class="template-grid">
@for (list of lists(); track list.id) {
@for (list of visibleLists(); track list.id) {
<mat-card class="template-card" appearance="outlined">
<mat-card-header>
<mat-card-title>{{ list.name }}</mat-card-title>
<mat-card-subtitle>{{ kindLabel(list.kind) }}</mat-card-subtitle>
<mat-card-subtitle>
{{ kindLabel(list.kind) }} - {{ progressLabel(list) }}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@@ -70,7 +158,11 @@
</mat-card-content>
<mat-card-actions align="end">
<a mat-button [routerLink]="['/lists', list.id]">
<a
mat-button
[routerLink]="['/lists', list.id]"
[attr.data-onboarding]="onboarding.isListOpenTarget(list.id) ? 'open-list' : null"
>
<mat-icon aria-hidden="true">open_in_new</mat-icon>
Oeffnen
</a>
@@ -78,6 +170,7 @@
</mat-card>
}
</div>
}
} @else {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>

View File

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

View File

@@ -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<UserList[]>([]);
protected readonly loading = signal(true);
protected readonly errorMessage = signal<string | null>(null);
protected readonly searchTerm = signal('');
protected readonly kindFilter = signal<ListKindFilter>('all');
protected readonly statusFilter = signal<ListStatusFilter>('all');
protected readonly sortOption = signal<ListSortOption>('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();
}
}

View File

@@ -0,0 +1,42 @@
@if (onboarding.currentStep(); as step) {
<div class="onboarding-layer">
@if (targetBox(); as box) {
<div class="onboarding-marker" [style]="markerStyle()"></div>
}
<section
class="onboarding-panel"
[class.mobile-panel]="isMobile()"
[style]="panelStyle()"
aria-live="polite"
>
<div class="step-line">
<span>Schritt {{ onboarding.currentStepNumber() }} von {{ onboarding.totalSteps }}</span>
<button mat-icon-button type="button" aria-label="Onboarding schliessen" (click)="onboarding.skip()">
<mat-icon aria-hidden="true">close</mat-icon>
</button>
</div>
<h2>{{ step.title }}</h2>
<p>{{ step.body }}</p>
<div class="onboarding-actions">
@if (!hasTarget() && step.key !== 'complete') {
<button mat-stroked-button type="button" (click)="goToCurrentStep()">
<mat-icon aria-hidden="true">my_location</mat-icon>
Zum Schritt
</button>
}
@if (step.key === 'complete') {
<button mat-flat-button type="button" (click)="onboarding.finish()">
<mat-icon aria-hidden="true">check</mat-icon>
Fertig
</button>
} @else {
<button mat-button type="button" (click)="onboarding.skip()">Ueberspringen</button>
}
</div>
</section>
</div>
}

View File

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

View File

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

View File

@@ -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<OnboardingStepKey, OnboardingStep> = {
'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<OnboardingState | null>(
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;
}
}

View File

@@ -9,7 +9,13 @@
</div>
@if (canEditItems()) {
<div class="detail-actions">
<button mat-stroked-button type="button" [disabled]="copyingTemplate()" (click)="copyTemplateToList()">
<button
mat-stroked-button
type="button"
data-onboarding="template-copy"
[disabled]="copyingTemplate()"
(click)="copyTemplateToList()"
>
@if (copyingTemplate()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
@@ -48,7 +54,7 @@
</mat-card-content>
</mat-card>
} @else {
<mat-card class="editor-card" appearance="outlined">
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-details">
<mat-card-header>
<mat-card-title>Details</mat-card-title>
</mat-card-header>
@@ -80,7 +86,7 @@
</mat-card-content>
</mat-card>
<mat-card class="editor-card" appearance="outlined">
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-item">
<mat-card-header>
<mat-card-title>Items</mat-card-title>
<mat-card-subtitle>

View File

@@ -13,6 +13,7 @@ import { MatInputModule } from '@angular/material/input';
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 } from '../templates.models';
import { TemplatesService } from '../templates.service';
@@ -43,6 +44,7 @@ export class TemplateDetailComponent implements OnInit {
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly templatesService = inject(TemplatesService);
protected readonly onboarding = inject(OnboardingService);
protected readonly template = signal<ListTemplate | null>(null);
protected readonly isCreateMode = signal(false);
@@ -104,6 +106,7 @@ export class TemplateDetailComponent implements OnInit {
protected saveTemplate(): void {
const templateId = this.templateId();
const creatingTemplate = this.isCreateMode() || !templateId;
if (this.templateForm.invalid) {
this.templateForm.markAllAsTouched();
@@ -127,11 +130,12 @@ export class TemplateDetailComponent implements OnInit {
.subscribe({
next: (template) => {
this.setTemplate(template);
if (this.isCreateMode()) {
if (creatingTemplate) {
this.isCreateMode.set(false);
void this.router.navigate(['/templates', template.id], {
replaceUrl: true,
});
this.onboarding.templateCreated(template.id);
}
this.snackBar.open('Template gespeichert.', 'OK', { duration: 2500 });
},
@@ -162,6 +166,7 @@ export class TemplateDetailComponent implements OnInit {
next: (template) => {
this.setTemplate(template);
this.itemForm.reset({ title: '', required: true });
this.onboarding.templateItemAdded();
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
@@ -245,10 +250,11 @@ export class TemplateDetailComponent implements OnInit {
.createListFromTemplate(templateId)
.pipe(finalize(() => this.copyingTemplate.set(false)))
.subscribe({
next: () => {
next: (list) => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
this.onboarding.templateCopiedToList(list.id);
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {

View File

@@ -5,7 +5,12 @@
<p>Vorlagen fuer wiederkehrende Listen.</p>
</div>
<a mat-flat-button routerLink="/templates/new">
<a
mat-flat-button
routerLink="/templates/new"
data-onboarding="new-template"
(click)="onboarding.createTemplateClicked()"
>
<mat-icon aria-hidden="true">add</mat-icon>
Neues Template
</a>

View File

@@ -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<ListTemplate[]>([]);
protected readonly loading = signal(true);