From b15d37548a4bd1e13b355e9db3542009c184cdfd Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Thu, 10 Sep 2026 13:39:25 +0200 Subject: [PATCH] added onboarding wizard for manager and employees --- .../migration.sql | 2 + .../migration.sql | 13 + prisma/schema.prisma | 57 ++-- src/app/(main)/dashboard/page.tsx | 12 +- src/app/(main)/onboarding/page.tsx | 79 +++++ src/app/api/stripe/connect/refresh/route.ts | 12 +- src/app/api/stripe/connect/return/route.ts | 13 +- .../dashboard/ui/views/dashboard-view.tsx | 48 +++- .../onboarding/actions/onboarding-actions.ts | 239 +++++++++++++++ .../onboarding/lib/onboarding-steps.ts | 190 ++++++++++++ .../ui/components/EmployeeFirstSteps.tsx | 118 ++++++++ .../ui/components/OnboardingChecklist.tsx | 128 +++++++++ .../ui/components/OnboardingStepIcon.tsx | 41 +++ .../ui/components/OnboardingStepShell.tsx | 115 ++++++++ .../ui/components/steps/StepBranding.tsx | 51 ++++ .../ui/components/steps/StepCancellation.tsx | 46 +++ .../ui/components/steps/StepPayments.tsx | 85 ++++++ .../ui/components/steps/StepPublicPage.tsx | 34 +++ .../ui/components/steps/StepRoom.tsx | 83 ++++++ .../ui/views/onboarding-wizard-view.tsx | 271 ++++++++++++++++++ .../actions/stripe-connect-actions.ts | 11 +- src/modules/settings/lib/return-to.ts | 18 ++ .../ui/components/BusinessPublicToggle.tsx | 4 + tests/onboarding/onboarding-steps.test.ts | 155 ++++++++++ tests/settings/return-to.test.ts | 27 ++ 25 files changed, 1813 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/20260828164044_add_dashboard_dismissed_recommendations/migration.sql create mode 100644 prisma/migrations/20260910000000_add_onboarding_state/migration.sql create mode 100644 src/app/(main)/onboarding/page.tsx create mode 100644 src/modules/onboarding/actions/onboarding-actions.ts create mode 100644 src/modules/onboarding/lib/onboarding-steps.ts create mode 100644 src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx create mode 100644 src/modules/onboarding/ui/components/OnboardingChecklist.tsx create mode 100644 src/modules/onboarding/ui/components/OnboardingStepIcon.tsx create mode 100644 src/modules/onboarding/ui/components/OnboardingStepShell.tsx create mode 100644 src/modules/onboarding/ui/components/steps/StepBranding.tsx create mode 100644 src/modules/onboarding/ui/components/steps/StepCancellation.tsx create mode 100644 src/modules/onboarding/ui/components/steps/StepPayments.tsx create mode 100644 src/modules/onboarding/ui/components/steps/StepPublicPage.tsx create mode 100644 src/modules/onboarding/ui/components/steps/StepRoom.tsx create mode 100644 src/modules/onboarding/ui/views/onboarding-wizard-view.tsx create mode 100644 src/modules/settings/lib/return-to.ts create mode 100644 tests/onboarding/onboarding-steps.test.ts create mode 100644 tests/settings/return-to.test.ts diff --git a/prisma/migrations/20260828164044_add_dashboard_dismissed_recommendations/migration.sql b/prisma/migrations/20260828164044_add_dashboard_dismissed_recommendations/migration.sql new file mode 100644 index 0000000..f4352ae --- /dev/null +++ b/prisma/migrations/20260828164044_add_dashboard_dismissed_recommendations/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Business" ADD COLUMN "dashboardDismissedRecommendations" JSONB; diff --git a/prisma/migrations/20260910000000_add_onboarding_state/migration.sql b/prisma/migrations/20260910000000_add_onboarding_state/migration.sql new file mode 100644 index 0000000..ebc03b0 --- /dev/null +++ b/prisma/migrations/20260910000000_add_onboarding_state/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "Business" ADD COLUMN "onboardingStartedAt" TIMESTAMP(3), +ADD COLUMN "onboardingDismissedAt" TIMESTAMP(3), +ADD COLUMN "onboardingCompletedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Employee" ADD COLUMN "firstStepsDismissedAt" TIMESTAMP(3); + +-- Backfill: bestehende Studios haben den Wizard nie gesehen, sollen aber auch nicht +-- nachtraeglich dorthin umgeleitet werden. onboardingStartedAt = createdAt markiert sie +-- als "gesehen"; die Setup-Checkliste im Dashboard erscheint trotzdem, und der Wizard +-- bleibt manuell ueber /onboarding erreichbar. +UPDATE "Business" SET "onboardingStartedAt" = "createdAt" WHERE "onboardingStartedAt" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fb0d1f7..f7e9be1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,7 @@ model Employee { onboardingToken String? @unique onboardingTokenExpiry DateTime? isOnboarded Boolean @default(false) + firstStepsDismissedAt DateTime? // "Erste Schritte"-Karte im Dashboard weggeklickt createdBy String? // Manager ID who created the employee trainings UserTraining[] createdAt DateTime? @default(now()) @@ -97,28 +98,40 @@ model Manager { } model Business { - id String @id @default(uuid()) - managerId String - name String - address String - email String - title String - ustId String? - banking String? - plan String? - subscription Json? - slug String? @unique - isPublic Boolean @default(false) - stripeAccountId String? @unique // Stripe Connect Account ID - stripeAccountStatus String? // "pending" | "active" | "restricted" - invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern - cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. - branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING). - notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES). - manager Manager @relation(fields: [managerId], references: [id]) - courses Course[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + managerId String + name String + address String + email String + title String + ustId String? + banking String? + plan String? + subscription Json? + slug String? @unique + isPublic Boolean @default(false) + stripeAccountId String? @unique // Stripe Connect Account ID + stripeAccountStatus String? // "pending" | "active" | "restricted" + invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern + cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. + branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING). + notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES). + // Weggeklickte Dashboard-Empfehlungen: [{ id: "add-session:", until: ISO-Datum }]. + // until = Schlummer-Ende, danach taucht die Empfehlung wieder auf. Die zugehoerige + // Migration (20260828164044) war nur in der Datenbank, nicht im Repo - das Feld wird + // aktuell von keinem Code auf dev benutzt, enthaelt aber echte Daten. + dashboardDismissedRecommendations Json? + + // Onboarding-Status des Studios. Bewusst nur Meta-Zustand: der Fortschritt der + // einzelnen Schritte wird aus den vorhandenen Daten abgeleitet (siehe + // src/modules/onboarding/lib/onboarding-steps.ts), damit er nicht driften kann. + onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr + onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet + onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren + manager Manager @relation(fields: [managerId], references: [id]) + courses Course[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([managerId]) } diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index d3add4c..64c1605 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -1,11 +1,21 @@ import { Suspense } from "react"; -import { requireAuthWithData } from "@/src/lib/auth/getUser"; +import { redirect } from "next/navigation"; +import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser"; import DashboardView from "@/src/modules/dashboard/ui/views/dashboard-view"; +import { shouldStartOnboarding } from "@/src/modules/onboarding/actions/onboarding-actions"; import DashboardLoading from "./loading"; export default async function DashboardPage() { const userData = await requireAuthWithData(); + // Weiches Gate: frisch registrierte Manager landen genau EINMAL im Setup-Wizard. + // Sobald er geöffnet wurde (onboardingStartedAt), führt der Weg nur noch über die + // Checkliste - der Rest der App bleibt jederzeit erreichbar, weil die Prüfung + // hier und nicht im Layout sitzt. + if (isManager(userData) && (await shouldStartOnboarding(userData.id))) { + redirect("/onboarding"); + } + return ( }> diff --git a/src/app/(main)/onboarding/page.tsx b/src/app/(main)/onboarding/page.tsx new file mode 100644 index 0000000..fb69c34 --- /dev/null +++ b/src/app/(main)/onboarding/page.tsx @@ -0,0 +1,79 @@ +import { redirect } from "next/navigation"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { prisma } from "@/src/lib/prisma"; +import { getBusinessBranding } from "@/src/modules/settings/actions/business-branding-actions"; +import { getCancellationPolicy } from "@/src/modules/settings/actions/cancellation-settings-actions"; +import { + getOnboardingProgress, + markOnboardingStarted, +} from "@/src/modules/onboarding/actions/onboarding-actions"; +import { + WIZARD_STEP_IDS, + type OnboardingStepId, + type WizardStepId, +} from "@/src/modules/onboarding/lib/onboarding-steps"; +import OnboardingWizardView from "@/src/modules/onboarding/ui/views/onboarding-wizard-view"; + +interface OnboardingPageProps { + searchParams: Promise<{ step?: string }>; +} + +const isWizardStepId = (value: string): value is WizardStepId => + (WIZARD_STEP_IDS as string[]).includes(value); + +export default async function OnboardingPage({ searchParams }: OnboardingPageProps) { + const manager = await requireManager(); + + // Markiert den Wizard als gesehen - ab jetzt leitet das Dashboard nicht mehr + // automatisch hierher um, auch wenn der Manager gleich wieder abbricht. + await markOnboardingStarted(); + + const progressResult = await getOnboardingProgress(); + if (!progressResult.success) { + redirect("/dashboard"); + } + const { progress } = progressResult; + + const [business, brandingResult, policyResult] = await Promise.all([ + prisma.business.findUnique({ + where: { id: progress.businessId }, + select: { isPublic: true, slug: true, stripeAccountStatus: true }, + }), + getBusinessBranding(progress.businessId), + getCancellationPolicy(progress.businessId), + ]); + + // Branding und Storno-Policy sind Pflicht-Eingaben der jeweiligen Editoren. + // Lässt sich eines von beidem nicht laden, wäre der Wizard nur halb bedienbar - + // dann lieber zurück aufs Dashboard, wo die Checkliste in die Einstellungen führt. + if (!business || !brandingResult.success || !policyResult.success) { + redirect("/dashboard"); + } + + const { step } = await searchParams; + const initialStep = step && isWizardStepId(step) ? step : null; + + const done = Object.fromEntries(progress.steps.map(({ id, done }) => [id, done])) as Record< + OnboardingStepId, + boolean + >; + + return ( + + ); +} + +export const metadata = { + title: "Einrichtung", +}; diff --git a/src/app/api/stripe/connect/refresh/route.ts b/src/app/api/stripe/connect/refresh/route.ts index 9efb79b..dbcf31f 100644 --- a/src/app/api/stripe/connect/refresh/route.ts +++ b/src/app/api/stripe/connect/refresh/route.ts @@ -1,21 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions"; +import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to"; /** * Refresh-URL für Stripe Connect Onboarding. * Wird aufgerufen wenn der Onboarding-Link abgelaufen ist - wir generieren einen neuen. + * + * returnTo wird dabei weitergereicht, damit auch ein neu erzeugter Link den Manager + * wieder an seinen Ausgangspunkt (z.B. den Setup-Wizard) zurückbringt. */ export async function GET(request: NextRequest) { const businessId = request.nextUrl.searchParams.get("businessId"); + const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get("returnTo")); + const target = returnTo ?? "/settings/payments"; if (!businessId) { - return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url)); + return NextResponse.redirect(new URL(`${target}?error=missing-business`, request.url)); } - const result = await createConnectOnboardingLink(businessId); + const result = await createConnectOnboardingLink(businessId, returnTo ?? undefined); if (!result.success || !result.url) { - return NextResponse.redirect(new URL("/settings/payments?error=refresh-failed", request.url)); + return NextResponse.redirect(new URL(`${target}?error=refresh-failed`, request.url)); } return NextResponse.redirect(result.url); diff --git a/src/app/api/stripe/connect/return/route.ts b/src/app/api/stripe/connect/return/route.ts index cd27b1b..3d2831f 100644 --- a/src/app/api/stripe/connect/return/route.ts +++ b/src/app/api/stripe/connect/return/route.ts @@ -1,21 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; import { syncConnectAccountStatus } from "@/src/modules/settings/actions/stripe-connect-actions"; +import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to"; /** * Return-URL nach erfolgreichem (oder abgeschlossenem) Stripe Connect Onboarding. * Stripe leitet den Manager hierher zurück. Wir synchronisieren dann den Account-Status. + * + * returnTo erlaubt es dem Setup-Wizard, den Manager wieder bei sich landen zu lassen + * statt in den Zahlungseinstellungen. Der Wert wird gegen offene Redirects geprüft; + * fällt die Prüfung durch, gilt der bisherige Standardpfad. */ export async function GET(request: NextRequest) { const businessId = request.nextUrl.searchParams.get("businessId"); + const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get("returnTo")); + const target = returnTo ?? "/settings/payments"; if (!businessId) { - return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url)); + return NextResponse.redirect(new URL(`${target}?error=missing-business`, request.url)); } const result = await syncConnectAccountStatus(businessId); if (!result.success) { - return NextResponse.redirect(new URL("/settings/payments?error=sync-failed", request.url)); + return NextResponse.redirect(new URL(`${target}?error=sync-failed`, request.url)); } - return NextResponse.redirect(new URL("/settings/payments?onboarding=success", request.url)); + return NextResponse.redirect(new URL(`${target}?onboarding=success`, request.url)); } diff --git a/src/modules/dashboard/ui/views/dashboard-view.tsx b/src/modules/dashboard/ui/views/dashboard-view.tsx index 7995370..e8bc15d 100644 --- a/src/modules/dashboard/ui/views/dashboard-view.tsx +++ b/src/modules/dashboard/ui/views/dashboard-view.tsx @@ -3,6 +3,12 @@ import DashboardStatsGrid from "../components/DashboardStatsGrid"; import DashboardRevenueChart from "../components/DashboardRevenueChart"; import DashboardTodaySchedule from "../components/DashboardTodaySchedule"; import DashboardRecentPayments from "../components/DashboardRecentPayments"; +import OnboardingChecklist from "@/src/modules/onboarding/ui/components/OnboardingChecklist"; +import EmployeeFirstSteps from "@/src/modules/onboarding/ui/components/EmployeeFirstSteps"; +import { + getOnboardingProgress, + isEmployeeFirstStepsVisible, +} from "@/src/modules/onboarding/actions/onboarding-actions"; import { getDashboardStats, getDashboardRevenueChart, @@ -22,18 +28,31 @@ const DashboardView = async ({ userData }: DashboardViewProps) => { : userData.name || userData.email.split("@")[0]; if (isManager(userData)) { - const [statsResult, chartResult, todayResult, bookingsResult] = await Promise.all([ - getDashboardStats(), - getDashboardRevenueChart(), - getDashboardTodayCourses(), - getDashboardRecentBookings(), - ]); + const [statsResult, chartResult, todayResult, bookingsResult, onboardingResult] = + await Promise.all([ + getDashboardStats(), + getDashboardRevenueChart(), + getDashboardTodayCourses(), + getDashboardRecentBookings(), + getOnboardingProgress(), + ]); const stats = statsResult.success ? statsResult.stats : null; const chartData = chartResult.success ? chartResult.data : []; const todayCourses = todayResult.success ? todayResult.courses : []; const recentBookings = bookingsResult.success ? bookingsResult.bookings : []; + // Checkliste nur solange etwas offen und sie nicht weggeklickt ist. Der + // Fortschritt ist abgeleitet: bricht ein Schritt später weg (z.B. Stripe wird + // "restricted"), taucht die Karte von selbst wieder auf - es sei denn, der + // Manager hat sie bewusst ausgeblendet. + const onboarding = + onboardingResult.success && + !onboardingResult.progress.isComplete && + onboardingResult.progress.dismissedAt === null + ? onboardingResult.progress + : null; + return (
{/* Welcome header */} @@ -48,6 +67,15 @@ const DashboardView = async ({ userData }: DashboardViewProps) => {

+ {/* Setup-Checkliste, solange das Onboarding offen ist */} + {onboarding && ( + + )} + {/* Stats tiles */} {stats && } @@ -64,7 +92,10 @@ const DashboardView = async ({ userData }: DashboardViewProps) => { } if (isEmployee(userData)) { - const todayResult = await getDashboardTodayCourses(); + const [todayResult, showFirstSteps] = await Promise.all([ + getDashboardTodayCourses(), + isEmployeeFirstStepsVisible(userData.id), + ]); const todayCourses = todayResult.success ? todayResult.courses : []; return ( @@ -82,6 +113,9 @@ const DashboardView = async ({ userData }: DashboardViewProps) => { + {/* Erste Schritte für frisch eingeladene Mitarbeiter */} + {showFirstSteps && } +
diff --git a/src/modules/onboarding/actions/onboarding-actions.ts b/src/modules/onboarding/actions/onboarding-actions.ts new file mode 100644 index 0000000..78632f7 --- /dev/null +++ b/src/modules/onboarding/actions/onboarding-actions.ts @@ -0,0 +1,239 @@ +/** + * Onboarding Actions - Fortschritt und Meta-Zustand des Studio-Onboardings. + * + * Der Fortschritt wird bei jedem Aufruf frisch aus den Fachdaten abgeleitet + * (siehe onboarding-steps.ts) und bewusst NICHT gecacht: die Zahlen sind ein + * findFirst plus drei count-Queries, und ein gecachter Wert würde nach jedem + * erledigten Schritt für die Cache-Dauer weiter "offen" anzeigen. + */ + +"use server"; + +import { prisma } from "@/src/lib/prisma"; +import { requireManager, requireAuthWithData, isEmployee } from "@/src/lib/auth/getUser"; +import { revalidatePath } from "next/cache"; +import { + countCompletedSteps, + getStepStates, + isOnboardingComplete, + type OnboardingContext, + type OnboardingStepId, +} from "../lib/onboarding-steps"; + +export interface OnboardingProgress { + businessId: string; + businessName: string; + steps: Array<{ id: OnboardingStepId; done: boolean }>; + completedCount: number; + totalCount: number; + isComplete: boolean; + startedAt: Date | null; + dismissedAt: Date | null; + completedAt: Date | null; +} + +/** + * Ermittelt das primäre Business des Managers. Das Schema erlaubt mehrere + * Businesses pro Manager, die Registrierung legt aber genau eines an - das + * älteste ist damit das des laufenden Onboardings. + */ +async function getPrimaryBusiness(managerId: string) { + return prisma.business.findFirst({ + where: { managerId }, + orderBy: { createdAt: "asc" }, + select: { + id: true, + name: true, + slug: true, + isPublic: true, + branding: true, + cancellationPolicy: true, + stripeAccountStatus: true, + onboardingStartedAt: true, + onboardingDismissedAt: true, + onboardingCompletedAt: true, + }, + }); +} + +export async function getOnboardingProgress(): Promise< + { success: true; progress: OnboardingProgress } | { success: false; error: string } +> { + // requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect + // aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen. + const manager = await requireManager(); + + try { + const business = await getPrimaryBusiness(manager.id); + if (!business) { + return { success: false, error: "Kein Business gefunden." }; + } + + const [roomCount, employeeCount, courseCount] = await Promise.all([ + prisma.room.count({ where: { createdBy: manager.id } }), + prisma.employee.count({ where: { createdBy: manager.id } }), + prisma.course.count({ where: { businessId: business.id } }), + ]); + + const ctx: OnboardingContext = { + stripeAccountStatus: business.stripeAccountStatus, + slug: business.slug, + isPublic: business.isPublic, + hasBranding: business.branding !== null, + hasCancellationPolicy: business.cancellationPolicy !== null, + roomCount, + employeeCount, + courseCount, + }; + + const states = getStepStates(ctx); + const complete = isOnboardingComplete(ctx); + + // completedAt einmalig festschreiben, sobald alle Pflichtschritte erledigt + // sind. Der Zeitstempel ist reine Historie (Erfolgsscreen, später Auswertung); + // sichtbar bleibt die Checkliste weiterhin über die abgeleiteten Schritte, + // damit ein später wegbrechender Schritt wieder auftaucht. + let completedAt = business.onboardingCompletedAt; + if (complete && completedAt === null) { + completedAt = new Date(); + await prisma.business.update({ + where: { id: business.id }, + data: { onboardingCompletedAt: completedAt }, + }); + } + + return { + success: true, + progress: { + businessId: business.id, + businessName: business.name, + steps: states.map(({ id, done }) => ({ id, done })), + completedCount: countCompletedSteps(ctx), + totalCount: states.length, + isComplete: complete, + startedAt: business.onboardingStartedAt, + dismissedAt: business.onboardingDismissedAt, + completedAt, + }, + }; + } catch (error) { + console.error("Error fetching onboarding progress:", error); + return { success: false, error: "Onboarding-Fortschritt konnte nicht geladen werden." }; + } +} + +/** + * Weiches Gate fürs Dashboard: true nur, solange der Manager den Wizard noch nie + * geöffnet hat. Bewusst eine eigene, schlanke Abfrage statt getOnboardingProgress() - + * sie läuft bei JEDEM Dashboard-Aufruf und darf keine counts auslösen. + * + * Bestehende Studios wurden in der Migration auf onboardingStartedAt = createdAt + * gesetzt und werden deshalb nie umgeleitet. + */ +export async function shouldStartOnboarding(managerId: string): Promise { + try { + const business = await prisma.business.findFirst({ + where: { managerId }, + orderBy: { createdAt: "asc" }, + select: { onboardingStartedAt: true }, + }); + return business !== null && business.onboardingStartedAt === null; + } catch (error) { + // Ein Fehler hier darf das Dashboard nicht blockieren - im Zweifel nicht umleiten. + console.error("Error checking onboarding start state:", error); + return false; + } +} + +/** + * Markiert den Wizard als gesehen. Ab dann leitet das Dashboard nicht mehr + * automatisch dorthin um - der Manager erreicht ihn nur noch über die Checkliste. + */ +export async function markOnboardingStarted(): Promise< + { success: true } | { success: false; error: string } +> { + const manager = await requireManager(); + + try { + // updateMany + startedAt: null statt update: setzt den Zeitstempel nur beim + // allerersten Öffnen und überschreibt ihn bei jedem weiteren Besuch nicht. + await prisma.business.updateMany({ + where: { managerId: manager.id, onboardingStartedAt: null }, + data: { onboardingStartedAt: new Date() }, + }); + return { success: true }; + } catch (error) { + console.error("Error marking onboarding as started:", error); + return { success: false, error: "Onboarding-Status konnte nicht gespeichert werden." }; + } +} + +/** Blendet die Setup-Checkliste im Dashboard aus. */ +export async function dismissOnboardingChecklist(): Promise< + { success: true } | { success: false; error: string } +> { + const manager = await requireManager(); + + try { + const business = await getPrimaryBusiness(manager.id); + if (!business) { + return { success: false, error: "Kein Business gefunden." }; + } + + await prisma.business.update({ + where: { id: business.id }, + data: { onboardingDismissedAt: new Date() }, + }); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + console.error("Error dismissing onboarding checklist:", error); + return { success: false, error: "Checkliste konnte nicht ausgeblendet werden." }; + } +} + +/** + * Ob die "Erste Schritte"-Karte für den eingeloggten Mitarbeiter noch sichtbar ist. + * + * Bewusst eine eigene Abfrage statt eines Feldes in getUserData(): dessen Ergebnis + * liegt 60s im Data Cache, das Wegklicken würde also bis zu eine Minute lang wieder + * zurückspringen. + */ +export async function isEmployeeFirstStepsVisible(employeeId: string): Promise { + try { + const employee = await prisma.employee.findUnique({ + where: { id: employeeId }, + select: { firstStepsDismissedAt: true }, + }); + return employee !== null && employee.firstStepsDismissedAt === null; + } catch (error) { + // Ein Fehler hier darf das Dashboard nicht blockieren - im Zweifel nichts anzeigen. + console.error("Error checking employee first steps state:", error); + return false; + } +} + +/** Blendet die "Erste Schritte"-Karte eines Mitarbeiters aus. */ +export async function dismissEmployeeFirstSteps(): Promise< + { success: true } | { success: false; error: string } +> { + const userData = await requireAuthWithData(); + + if (!isEmployee(userData)) { + return { success: false, error: "Nur für Mitarbeiter verfügbar." }; + } + + try { + await prisma.employee.update({ + where: { id: userData.id }, + data: { firstStepsDismissedAt: new Date() }, + }); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + console.error("Error dismissing employee first steps:", error); + return { success: false, error: "Karte konnte nicht ausgeblendet werden." }; + } +} diff --git a/src/modules/onboarding/lib/onboarding-steps.ts b/src/modules/onboarding/lib/onboarding-steps.ts new file mode 100644 index 0000000..8bc9f27 --- /dev/null +++ b/src/modules/onboarding/lib/onboarding-steps.ts @@ -0,0 +1,190 @@ +/** + * Registry aller Onboarding-Schritte für Studios (Manager). + * + * Single Source of Truth für: + * - welche Schritte es gibt und in welcher Reihenfolge + * - Label, Beschreibung, Icon und Ziel-Link + * - required: ob der Schritt für "Onboarding abgeschlossen" zählt + * - mode: "wizard" = eigener Screen unter /onboarding, "link" = nur Punkt in der Checkliste + * - isDone: wie sich der Status aus den vorhandenen Daten ableitet + * + * Bewusst KEINE eigenen DB-Spalten pro Schritt: jeder Status steht bereits in den + * Fachdaten (Stripe-Status, Slug, Räume, Kurse ...). Würde man ihn zusätzlich als + * Flag speichern, driften beide auseinander, sobald sich die Fachdaten ändern - + * z.B. wenn Stripe einen Account nachträglich auf "restricted" setzt oder der + * letzte Raum gelöscht wird. Ein neuer Schritt wird ausschließlich hier ergänzt. + */ + +/** Zustand, aus dem sich der Fortschritt ableitet (siehe getOnboardingProgress). */ +export interface OnboardingContext { + stripeAccountStatus: string | null; + slug: string | null; + isPublic: boolean; + hasBranding: boolean; + hasCancellationPolicy: boolean; + roomCount: number; + employeeCount: number; + courseCount: number; +} + +export interface OnboardingStepDef { + label: string; + description: string; + /** lucide-react Icon-Komponentenname, gemappt in OnboardingStepIcon */ + icon: string; + /** Zählt der Schritt für "Onboarding abgeschlossen"? */ + required: boolean; + /** "wizard" = eigener Screen im Wizard, "link" = nur Checklisten-Punkt mit Deep-Link */ + mode: "wizard" | "link"; + /** Ziel für den Checklisten-Link bzw. den Absprung aus dem Wizard */ + href: string; + /** Beschriftung des Buttons, der zu href führt */ + cta: string; + isDone: (ctx: OnboardingContext) => boolean; +} + +export const ONBOARDING_STEPS = { + branding: { + label: "Branding festlegen", + description: + "Logo und Farben erscheinen auf deiner öffentlichen Seite, in E-Mails und auf Rechnungen.", + icon: "Megaphone", + required: false, + mode: "wizard", + href: "/settings/branding", + cta: "Branding bearbeiten", + isDone: (ctx) => ctx.hasBranding, + }, + payments: { + label: "Zahlungen aktivieren", + description: + "Verbinde dein Stripe-Konto. Ohne aktives Konto können deine Kunden keine Kurse bezahlen.", + icon: "CreditCard", + required: true, + mode: "wizard", + href: "/settings/payments", + cta: "Stripe verbinden", + isDone: (ctx) => ctx.stripeAccountStatus === "active", + }, + cancellation: { + label: "Stornoregeln festlegen", + description: + "Lege fest, wie viel Prozent deine Kunden je nach Zeitpunkt der Stornierung zurückbekommen.", + icon: "TicketX", + required: false, + mode: "wizard", + href: "/settings/cancellation", + cta: "Regeln festlegen", + isDone: (ctx) => ctx.hasCancellationPolicy, + }, + room: { + label: "Ersten Raum anlegen", + description: "Jeder Kurs braucht einen Raum. Du kannst später beliebig viele ergänzen.", + icon: "DoorOpen", + required: true, + mode: "wizard", + href: "/rooms/create", + cta: "Raum anlegen", + isDone: (ctx) => ctx.roomCount > 0, + }, + publicPage: { + label: "Seite veröffentlichen", + description: + "Schalte deine öffentliche Kursseite frei, damit Kunden deine Kurse finden und buchen können.", + icon: "PanelTop", + required: true, + mode: "wizard", + href: "/settings/public-page", + cta: "Seite veröffentlichen", + isDone: (ctx) => ctx.isPublic && ctx.slug !== null, + }, + team: { + label: "Team einladen", + description: + "Lade Trainer und Mitarbeiter ein. Sie bekommen eine E-Mail und richten ihr Konto selbst ein.", + icon: "Users", + required: false, + mode: "link", + href: "/employee/create", + cta: "Mitarbeiter einladen", + isDone: (ctx) => ctx.employeeCount > 0, + }, + course: { + label: "Ersten Kurs erstellen", + description: "Der eigentliche Start: leg deinen ersten Kurs an und veröffentliche ihn.", + icon: "CalendarPlus", + required: true, + mode: "link", + href: "/courses/create", + cta: "Kurs erstellen", + isDone: (ctx) => ctx.courseCount > 0, + }, +} as const satisfies Record; + +export type OnboardingStepId = keyof typeof ONBOARDING_STEPS; + +/** Reihenfolge in Checkliste und Wizard = Reihenfolge der Registry-Einträge. */ +export const ONBOARDING_STEP_IDS = Object.keys(ONBOARDING_STEPS) as OnboardingStepId[]; + +/** + * IDs nach mode aufgeteilt - als abgeleitete Typen, damit z.B. der Wizard beim + * Rendern seiner Screens vom Compiler auf Vollständigkeit geprüft wird und ein + * neuer Registry-Eintrag nicht stillschweigend ohne Screen bleibt. + */ +type StepIdsWithMode = { + [K in OnboardingStepId]: (typeof ONBOARDING_STEPS)[K]["mode"] extends M ? K : never; +}[OnboardingStepId]; + +export type WizardStepId = StepIdsWithMode<"wizard">; +export type LinkStepId = StepIdsWithMode<"link">; + +/** Nur die Schritte, die einen eigenen Screen im Wizard haben. */ +export const WIZARD_STEP_IDS = ONBOARDING_STEP_IDS.filter( + (id): id is WizardStepId => ONBOARDING_STEPS[id].mode === "wizard" +); + +/** Schritte, die der Wizard nur verlinkt und die in der Checkliste stehen bleiben. */ +export const LINK_STEP_IDS = ONBOARDING_STEP_IDS.filter( + (id): id is LinkStepId => ONBOARDING_STEPS[id].mode === "link" +); + +export function isOnboardingStepId(value: string): value is OnboardingStepId { + // Object.hasOwn statt `in`: schließt geerbte Namen wie "__proto__" oder + // "constructor" aus, die sonst als gültige Schritt-IDs durchrutschen würden. + return Object.hasOwn(ONBOARDING_STEPS, value); +} + +export interface OnboardingStepState { + id: OnboardingStepId; + def: OnboardingStepDef; + done: boolean; +} + +export function getStepStates(ctx: OnboardingContext): OnboardingStepState[] { + return ONBOARDING_STEP_IDS.map((id) => ({ + id, + def: ONBOARDING_STEPS[id], + done: ONBOARDING_STEPS[id].isDone(ctx), + })); +} + +/** + * Abgeschlossen ist das Onboarding, sobald alle PFLICHT-Schritte erledigt sind. + * Optionale Schritte (Branding, Storno, Team) haben brauchbare Defaults und + * dürfen den Abschluss nicht blockieren. + */ +export function isOnboardingComplete(ctx: OnboardingContext): boolean { + return ONBOARDING_STEP_IDS.every( + (id) => !ONBOARDING_STEPS[id].required || ONBOARDING_STEPS[id].isDone(ctx) + ); +} + +/** Anzahl erledigter Schritte über ALLE Schritte (für "3 von 7 erledigt"). */ +export function countCompletedSteps(ctx: OnboardingContext): number { + return ONBOARDING_STEP_IDS.filter((id) => ONBOARDING_STEPS[id].isDone(ctx)).length; +} + +/** Erster noch offener Wizard-Schritt, sonst null (= Wizard durchgelaufen). */ +export function firstOpenWizardStep(ctx: OnboardingContext): OnboardingStepId | null { + return WIZARD_STEP_IDS.find((id) => !ONBOARDING_STEPS[id].isDone(ctx)) ?? null; +} diff --git a/src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx b/src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx new file mode 100644 index 0000000..05f3054 --- /dev/null +++ b/src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { ArrowRight, CalendarDays, DoorOpen, User, Users, X } from "lucide-react"; +import { toast } from "sonner"; +import type { EmployeeData } from "@/src/lib/auth/getUser"; +import { dismissEmployeeFirstSteps } from "../../actions/onboarding-actions"; + +interface EmployeeFirstStepsProps { + permissions: EmployeeData["permissions"]; +} + +/** + * "Erste Schritte" für frisch eingeladene Mitarbeiter. Bewusst kein Wizard und kein + * Gate: der Invite-Flow (Token + Profil) ist bereits abgeschlossen, hier geht es nur + * noch um Orientierung. Gezeigt werden ausschließlich Punkte, für die der Mitarbeiter + * auch Rechte hat - sonst verlinkt die Karte auf Seiten, die ihn abweisen. + */ +const EmployeeFirstSteps = ({ permissions }: EmployeeFirstStepsProps) => { + const [isHidden, setIsHidden] = useState(false); + const [isPending, startTransition] = useTransition(); + + // Icons als fertige Elemente statt als Komponenten-Referenz: wertet + // der React-Compiler-Linter als "Komponente während Render erzeugt". + const items = [ + { + show: true, + href: "/settings/personal", + icon: , + label: "Profil vervollständigen", + description: "Profilbild und Kontaktdaten ergänzen.", + }, + { + show: permissions.courses.view, + href: "/courses", + icon: , + label: "Kurse ansehen", + description: "Verschaff dir einen Überblick über den Kursplan.", + }, + { + show: permissions.rooms.view, + href: "/rooms", + icon: , + label: "Räume ansehen", + description: "Sieh dir an, welche Räume zur Verfügung stehen.", + }, + { + show: permissions.employees.view, + href: "/employee", + icon: , + label: "Team kennenlernen", + description: "Wer arbeitet sonst noch im Studio?", + }, + // Bewusst KEIN Link auf /settings/notifications: die Seite ist über + // requireManager() manager-only und würde Mitarbeiter aufs Dashboard zurückwerfen. + ].filter((item) => item.show); + + const handleDismiss = () => { + setIsHidden(true); + startTransition(async () => { + const result = await dismissEmployeeFirstSteps(); + if (!result.success) { + setIsHidden(false); + toast.error(result.error); + } + }); + }; + + if (isHidden) return null; + + return ( +
+
+ +
+
+
+

+ Willkommen +

+

Deine ersten Schritte

+
+ +
+ +
+ {items.map((item) => ( + +
+ {item.icon} +
+
+

{item.label}

+

{item.description}

+
+ + + ))} +
+
+
+ ); +}; + +export default EmployeeFirstSteps; diff --git a/src/modules/onboarding/ui/components/OnboardingChecklist.tsx b/src/modules/onboarding/ui/components/OnboardingChecklist.tsx new file mode 100644 index 0000000..ab785ed --- /dev/null +++ b/src/modules/onboarding/ui/components/OnboardingChecklist.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { ArrowRight, Check, Rocket, X } from "lucide-react"; +import { toast } from "sonner"; +import { Progress } from "@/src/components/ui/progress"; +import { Button } from "@/src/components/ui/button"; +import { dismissOnboardingChecklist } from "../../actions/onboarding-actions"; +import { ONBOARDING_STEPS, type OnboardingStepId } from "../../lib/onboarding-steps"; +import OnboardingStepIcon from "./OnboardingStepIcon"; + +interface OnboardingChecklistProps { + steps: Array<{ id: OnboardingStepId; done: boolean }>; + completedCount: number; + totalCount: number; +} + +const OnboardingChecklist = ({ steps, completedCount, totalCount }: OnboardingChecklistProps) => { + // Optimistisch ausblenden, damit die Karte nicht bis zum Ende der Server + // Action stehen bleibt; bei Fehler kommt sie mit einem Toast zurück. + const [isHidden, setIsHidden] = useState(false); + const [isPending, startTransition] = useTransition(); + + const percent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + const hasOpenWizardStep = steps.some( + ({ id, done }) => !done && ONBOARDING_STEPS[id].mode === "wizard" + ); + + const handleDismiss = () => { + setIsHidden(true); + startTransition(async () => { + const result = await dismissOnboardingChecklist(); + if (!result.success) { + setIsHidden(false); + toast.error(result.error); + } + }); + }; + + if (isHidden) return null; + + return ( +
+
+ +
+
+
+

+ Einrichtung +

+

Richte dein Studio ein

+

+ {completedCount} von {totalCount} Schritten erledigt +

+
+ +
+ + + +
+ {steps.map(({ id, done }) => { + const step = ONBOARDING_STEPS[id]; + + if (done) { + return ( +
+
+ +
+

{step.label}

+
+ ); + } + + return ( + +
+ +
+
+

+ {step.label} + {!step.required && ( + + optional + + )} +

+

{step.description}

+
+ + + ); + })} +
+ + {hasOpenWizardStep && ( + + )} +
+
+ ); +}; + +export default OnboardingChecklist; diff --git a/src/modules/onboarding/ui/components/OnboardingStepIcon.tsx b/src/modules/onboarding/ui/components/OnboardingStepIcon.tsx new file mode 100644 index 0000000..380749f --- /dev/null +++ b/src/modules/onboarding/ui/components/OnboardingStepIcon.tsx @@ -0,0 +1,41 @@ +import { + CalendarPlus, + Circle, + CreditCard, + DoorOpen, + Megaphone, + PanelTop, + TicketX, + Users, + type LucideProps, +} from "lucide-react"; +import { ONBOARDING_STEPS, type OnboardingStepId } from "../../lib/onboarding-steps"; + +/** + * Rendert das zu einem Onboarding-Schritt gehörende lucide-Icon. + * + * Bewusst als eigene Komponente statt `const Icon = lookup(id)` im Aufrufer: + * Letzteres wertet der React-Compiler-Linter als "Komponente während Render + * erzeugt". Hier sind alle Icon-Referenzen statisch. + */ +const ICONS = { + CalendarPlus, + CreditCard, + DoorOpen, + Megaphone, + PanelTop, + TicketX, + Users, +} as const; + +interface OnboardingStepIconProps extends LucideProps { + step: OnboardingStepId; +} + +const OnboardingStepIcon = ({ step, ...props }: OnboardingStepIconProps) => { + const name = ONBOARDING_STEPS[step]?.icon as keyof typeof ICONS | undefined; + const Icon = (name && ICONS[name]) || Circle; + return ; +}; + +export default OnboardingStepIcon; diff --git a/src/modules/onboarding/ui/components/OnboardingStepShell.tsx b/src/modules/onboarding/ui/components/OnboardingStepShell.tsx new file mode 100644 index 0000000..25a0fe0 --- /dev/null +++ b/src/modules/onboarding/ui/components/OnboardingStepShell.tsx @@ -0,0 +1,115 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ArrowRight, Check } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import { Progress } from "@/src/components/ui/progress"; +import { cn } from "@/src/lib/utils"; + +interface OnboardingStepShellProps { + eyebrow: string; + title: string; + description: string; + /** 1-basiert, nur für die Anzeige "Schritt 2 von 5" */ + stepNumber: number; + totalSteps: number; + /** Punkte-Navigation: erledigte Schritte bekommen einen Haken */ + markers: Array<{ id: string; label: string; done: boolean; active: boolean }>; + onMarkerClick: (index: number) => void; + onBack?: () => void; + onNext: () => void; + nextLabel: string; + children: React.ReactNode; +} + +const OnboardingStepShell = ({ + eyebrow, + title, + description, + stepNumber, + totalSteps, + markers, + onMarkerClick, + onBack, + onNext, + nextLabel, + children, +}: OnboardingStepShellProps) => { + const percent = totalSteps > 0 ? Math.round(((stepNumber - 1) / totalSteps) * 100) : 0; + + return ( +
+
+
+ +
+
+

+ {eyebrow} +

+

+ Schritt {stepNumber} von {totalSteps} +

+
+ + + + {/* Punkte-Navigation: erlaubt das Springen zwischen bereits gesehenen Schritten */} +
+ {markers.map((marker, index) => ( + + ))} +
+ +
+

{title}

+

{description}

+
+
+
+ +
{children}
+ +
+ {onBack ? ( + + ) : ( + + )} + +
+ + Später erledigen + + +
+
+
+ ); +}; + +export default OnboardingStepShell; diff --git a/src/modules/onboarding/ui/components/steps/StepBranding.tsx b/src/modules/onboarding/ui/components/steps/StepBranding.tsx new file mode 100644 index 0000000..b0a8948 --- /dev/null +++ b/src/modules/onboarding/ui/components/steps/StepBranding.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import BusinessBrandingEditor from "@/src/modules/settings/ui/components/BusinessBrandingEditor"; +import { saveBusinessBranding } from "@/src/modules/settings/actions/business-branding-actions"; +import type { BusinessBranding } from "@/src/modules/settings/schemas/business-branding-schema"; + +interface StepBrandingProps { + businessId: string; + initialBranding: BusinessBranding; + onSaved: () => void; +} + +/** Nutzt denselben Editor wie /settings/branding - nur die Speicher-Rückmeldung ist anders. */ +const StepBranding = ({ businessId, initialBranding, onSaved }: StepBrandingProps) => { + const [branding] = useState(initialBranding); + const [isSaving, startSaving] = useTransition(); + + const handleSave = (newBranding: BusinessBranding): Promise => { + return new Promise((resolve) => { + startSaving(async () => { + try { + const result = await saveBusinessBranding(businessId, newBranding); + if (result.success) { + toast.success("Branding gespeichert."); + onSaved(); + resolve(true); + } else { + toast.error(result.error); + resolve(false); + } + } catch { + toast.error("Branding konnte nicht gespeichert werden."); + resolve(false); + } + }); + }); + }; + + return ( + + ); +}; + +export default StepBranding; diff --git a/src/modules/onboarding/ui/components/steps/StepCancellation.tsx b/src/modules/onboarding/ui/components/steps/StepCancellation.tsx new file mode 100644 index 0000000..2f8a86b --- /dev/null +++ b/src/modules/onboarding/ui/components/steps/StepCancellation.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import CancellationPolicyEditor from "@/src/modules/settings/ui/components/CancellationPolicyEditor"; +import { saveCancellationPolicy } from "@/src/modules/settings/actions/cancellation-settings-actions"; +import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema"; + +interface StepCancellationProps { + businessId: string; + initialPolicy: CancellationPolicy; + onSaved: () => void; +} + +/** Nutzt denselben Editor wie /settings/cancellation. */ +const StepCancellation = ({ businessId, initialPolicy, onSaved }: StepCancellationProps) => { + const [policy] = useState(initialPolicy); + const [isSaving, startSaving] = useTransition(); + + const handleSave = (newPolicy: CancellationPolicy): Promise => { + return new Promise((resolve) => { + startSaving(async () => { + try { + const result = await saveCancellationPolicy(businessId, newPolicy); + if (result.success) { + toast.success("Stornierungsregeln gespeichert."); + onSaved(); + resolve(true); + } else { + toast.error(result.error); + resolve(false); + } + } catch { + toast.error("Stornierungsregeln konnten nicht gespeichert werden."); + resolve(false); + } + }); + }); + }; + + return ( + + ); +}; + +export default StepCancellation; diff --git a/src/modules/onboarding/ui/components/steps/StepPayments.tsx b/src/modules/onboarding/ui/components/steps/StepPayments.tsx new file mode 100644 index 0000000..30b7d4a --- /dev/null +++ b/src/modules/onboarding/ui/components/steps/StepPayments.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { AlertCircle, CheckCircle2, CreditCard, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/src/components/ui/button"; +import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions"; + +interface StepPaymentsProps { + businessId: string; + stripeAccountStatus: string | null; +} + +/** + * Stripe-Connect-Schritt. Der Manager verlässt hier die App und kommt über + * /api/stripe/connect/return zurück - returnTo sorgt dafür, dass er wieder im + * Wizard und nicht in den Zahlungseinstellungen landet. + */ +const StepPayments = ({ businessId, stripeAccountStatus }: StepPaymentsProps) => { + const [isPending, setIsPending] = useState(false); + + const handleConnect = async () => { + setIsPending(true); + try { + const result = await createConnectOnboardingLink(businessId, "/onboarding?step=payments"); + if (result.success && result.url) { + window.location.assign(result.url); + return; + } + toast.error(result.error || "Fehler beim Verbinden"); + } catch { + toast.error("Fehler beim Verbinden"); + } finally { + setIsPending(false); + } + }; + + if (stripeAccountStatus === "active") { + return ( +
+ +
+

Zahlungen sind aktiv

+

+ Dein Stripe-Konto ist verbunden. Buchungen werden direkt an dich ausgezahlt. +

+
+
+ ); + } + + return ( +
+ {stripeAccountStatus === "pending" && ( +
+ +
+

Prüfung läuft noch

+

+ Stripe hat noch nicht alle Angaben. Setz das Konto-Onboarding fort, um Zahlungen + freizuschalten. +

+
+
+ )} + +
+

+ Kumasy wickelt Zahlungen über Stripe ab. Du legst in wenigen Minuten ein Konto an, danach + gehen die Kursgebühren direkt auf dein Bankkonto — abzüglich der Servicegebühr. +

+ +
+
+ ); +}; + +export default StepPayments; diff --git a/src/modules/onboarding/ui/components/steps/StepPublicPage.tsx b/src/modules/onboarding/ui/components/steps/StepPublicPage.tsx new file mode 100644 index 0000000..8f4f315 --- /dev/null +++ b/src/modules/onboarding/ui/components/steps/StepPublicPage.tsx @@ -0,0 +1,34 @@ +"use client"; + +import BusinessPublicToggle from "@/src/modules/settings/ui/components/BusinessPublicToggle"; + +interface StepPublicPageProps { + businessId: string; + businessName: string; + initialIsPublic: boolean; + initialSlug: string | null; + onPublicChange: (isPublic: boolean) => void; +} + +/** + * Letzter Wizard-Schritt: das "Live gehen". Der Toggle ist derselbe wie unter + * /settings/public-page und meldet den neuen Zustand per Callback zurück, damit + * der Wizard den Schritt sofort abhaken kann. + */ +const StepPublicPage = ({ + businessId, + businessName, + initialIsPublic, + initialSlug, + onPublicChange, +}: StepPublicPageProps) => ( + +); + +export default StepPublicPage; diff --git a/src/modules/onboarding/ui/components/steps/StepRoom.tsx b/src/modules/onboarding/ui/components/steps/StepRoom.tsx new file mode 100644 index 0000000..f5358f8 --- /dev/null +++ b/src/modules/onboarding/ui/components/steps/StepRoom.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { CheckCircle2, Loader2, Plus } from "lucide-react"; +import { toast } from "sonner"; +import InputComponent from "@/src/components/layout/InputComponent"; +import { Button } from "@/src/components/ui/button"; +import { createRoom } from "@/src/modules/rooms/actions/room-actions"; + +interface StepRoomProps { + hasRoom: boolean; + onCreated: () => void; +} + +/** + * Legt den ersten Raum an. Bewusst ein eigenes Mini-Formular statt RoomsCreateView: + * die View bringt eine eigene Seitenüberschrift, den Löschen-Dialog und ein festes + * router.push("/rooms") mit - im Wizard wäre das mehr Umbau als Wiederverwendung. + */ +const StepRoom = ({ hasRoom, onCreated }: StepRoomProps) => { + const [name, setName] = useState(""); + const [error, setError] = useState(); + const [isPending, setIsPending] = useState(false); + + const handleCreate = async () => { + if (!name.trim()) { + setError("Raumname ist erforderlich"); + return; + } + + setError(undefined); + setIsPending(true); + try { + const result = await createRoom({ name: name.trim() }); + if (result.success) { + toast.success(result.message ?? "Raum angelegt"); + setName(""); + onCreated(); + } else { + toast.error(result.error || "Raum konnte nicht angelegt werden"); + } + } catch { + toast.error("Raum konnte nicht angelegt werden"); + } finally { + setIsPending(false); + } + }; + + return ( +
+ {hasRoom && ( +
+ +
+

Raum angelegt

+

+ Du kannst hier weitere ergänzen oder später unter „Räume“ verwalten. +

+
+
+ )} + +
+ setName(e.target.value)} + placeholder="z.B. Studio 1" + error={error} + /> + +
+
+ ); +}; + +export default StepRoom; diff --git a/src/modules/onboarding/ui/views/onboarding-wizard-view.tsx b/src/modules/onboarding/ui/views/onboarding-wizard-view.tsx new file mode 100644 index 0000000..2635b16 --- /dev/null +++ b/src/modules/onboarding/ui/views/onboarding-wizard-view.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowRight, PartyPopper, Rocket } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import type { BusinessBranding } from "@/src/modules/settings/schemas/business-branding-schema"; +import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema"; +import { + LINK_STEP_IDS, + ONBOARDING_STEPS, + WIZARD_STEP_IDS, + type OnboardingStepId, + type WizardStepId, +} from "../../lib/onboarding-steps"; +import OnboardingStepShell from "../components/OnboardingStepShell"; +import OnboardingStepIcon from "../components/OnboardingStepIcon"; +import StepBranding from "../components/steps/StepBranding"; +import StepCancellation from "../components/steps/StepCancellation"; +import StepPayments from "../components/steps/StepPayments"; +import StepPublicPage from "../components/steps/StepPublicPage"; +import StepRoom from "../components/steps/StepRoom"; + +interface OnboardingWizardViewProps { + businessId: string; + businessName: string; + managerFirstName: string; + done: Record; + branding: BusinessBranding; + policy: CancellationPolicy; + isPublic: boolean; + slug: string | null; + stripeAccountStatus: string | null; + /** Aus ?step=... - z.B. beim Rücksprung von Stripe */ + initialStep: WizardStepId | null; +} + +// Screen-Index: 0 = Willkommen, 1..n = Wizard-Schritte, n+1 = Abschluss +const WELCOME_INDEX = 0; +const FIRST_STEP_INDEX = 1; +const DONE_INDEX = WIZARD_STEP_IDS.length + 1; + +const OnboardingWizardView = ({ + businessId, + businessName, + managerFirstName, + done: initialDone, + branding, + policy, + isPublic: initialIsPublic, + slug, + stripeAccountStatus, + initialStep, +}: OnboardingWizardViewProps) => { + const router = useRouter(); + // Lokale Kopie des Fortschritts: erledigte Schritte sollen sofort abgehakt + // erscheinen, ohne auf ein Neuladen der Server-Daten zu warten. + const [done, setDone] = useState(initialDone); + const [screen, setScreen] = useState(() => + initialStep ? FIRST_STEP_INDEX + WIZARD_STEP_IDS.indexOf(initialStep) : WELCOME_INDEX + ); + + const markStepDone = (id: OnboardingStepId) => setDone((prev) => ({ ...prev, [id]: true })); + + const markers = WIZARD_STEP_IDS.map((id, index) => ({ + id, + label: ONBOARDING_STEPS[id].label, + done: done[id], + active: screen === FIRST_STEP_INDEX + index, + })); + + const goTo = (index: number) => { + setScreen(index); + // Der Wizard scrollt nicht automatisch mit - ohne das startet der nächste + // Schritt je nach Länge des vorherigen mitten im Inhalt. + if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const finish = () => { + // refresh(), damit Dashboard und Checkliste den im Wizard erzeugten + // Fortschritt sofort und nicht erst nach einem harten Reload sehen. + router.refresh(); + router.push("/dashboard"); + }; + + // ---------- Willkommen ---------- + if (screen === WELCOME_INDEX) { + return ( +
+
+
+ +
+
+ +
+

+ Willkommen bei Kumasy +

+

Hallo {managerFirstName} 👋

+

+ Lass uns {businessName} in ein paar Minuten startklar machen. Du kannst jeden Schritt + überspringen und später in den Einstellungen nachholen. +

+ +
+ {WIZARD_STEP_IDS.map((id) => ( +
+
+ +
+
+

+ {ONBOARDING_STEPS[id].label} + {!ONBOARDING_STEPS[id].required && ( + + optional + + )} +

+

+ {ONBOARDING_STEPS[id].description} +

+
+
+ ))} +
+ +
+ + + Später erledigen + +
+
+
+
+ ); + } + + // ---------- Abschluss ---------- + if (screen >= DONE_INDEX) { + const openLinkSteps = LINK_STEP_IDS.filter((id) => !done[id]); + + return ( +
+
+
+ +
+
+ +
+

Grundeinrichtung erledigt

+

+ {openLinkSteps.length > 0 + ? "Zwei Dinge fehlen noch, die etwas mehr Zeit brauchen — du findest sie jederzeit in der Checkliste auf deinem Dashboard." + : "Alles steht. Deine Kurse sind buchbar."} +

+ + {openLinkSteps.length > 0 && ( +
+ {openLinkSteps.map((id) => ( + +
+ +
+
+

+ {ONBOARDING_STEPS[id].cta} +

+

+ {ONBOARDING_STEPS[id].description} +

+
+ + + ))} +
+ )} + +
+ + +
+
+
+
+ ); + } + + // ---------- Wizard-Schritte ---------- + const stepIndex = screen - FIRST_STEP_INDEX; + const stepId = WIZARD_STEP_IDS[stepIndex]; + const step = ONBOARDING_STEPS[stepId]; + const isLastStep = screen === DONE_INDEX - 1; + + // Record: fehlt für einen Wizard-Schritt der Screen, schlägt + // der Build fehl, statt zur Laufzeit eine leere Seite zu rendern. + const stepContent: Record = { + branding: ( + markStepDone("branding")} + /> + ), + payments: , + cancellation: ( + markStepDone("cancellation")} + /> + ), + room: markStepDone("room")} />, + publicPage: ( + setDone((prev) => ({ ...prev, publicPage: nowPublic }))} + /> + ), + }; + + return ( + goTo(FIRST_STEP_INDEX + index)} + onBack={() => goTo(screen - 1)} + onNext={() => goTo(screen + 1)} + nextLabel={ + isLastStep ? "Fertig" : done[stepId] ? "Weiter" : step.required ? "Überspringen" : "Weiter" + } + > + {stepContent[stepId]} + + ); +}; + +export default OnboardingWizardView; diff --git a/src/modules/settings/actions/stripe-connect-actions.ts b/src/modules/settings/actions/stripe-connect-actions.ts index 86b3a7c..6d8ce2b 100644 --- a/src/modules/settings/actions/stripe-connect-actions.ts +++ b/src/modules/settings/actions/stripe-connect-actions.ts @@ -11,6 +11,7 @@ import { prisma } from "@/src/lib/prisma"; import { stripe } from "@/src/lib/stripe"; import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser"; import { revalidatePath } from "next/cache"; +import { sanitizeReturnTo } from "../lib/return-to"; /** * Erstellt einen Stripe Connect Express Account für ein Business und liefert @@ -18,9 +19,11 @@ import { revalidatePath } from "next/cache"; * ein neuer Onboarding-Link generiert. * * @param businessId - Die ID des Business + * @param returnTo - App-interner Pfad, auf dem der Manager nach dem Stripe-Onboarding + * landen soll (z.B. "/onboarding"). Ohne Angabe: /settings/payments. * @returns Onboarding-URL oder Fehler */ -export async function createConnectOnboardingLink(businessId: string) { +export async function createConnectOnboardingLink(businessId: string, returnTo?: string) { try { const userData = await requireAuthWithData(); @@ -74,10 +77,12 @@ export async function createConnectOnboardingLink(businessId: string) { } const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const safeReturnTo = sanitizeReturnTo(returnTo); + const returnToParam = safeReturnTo ? `&returnTo=${encodeURIComponent(safeReturnTo)}` : ""; const accountLink = await stripe.accountLinks.create({ account: stripeAccountId, - refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}`, - return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}`, + refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}${returnToParam}`, + return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}${returnToParam}`, type: "account_onboarding", }); diff --git a/src/modules/settings/lib/return-to.ts b/src/modules/settings/lib/return-to.ts new file mode 100644 index 0000000..6922f82 --- /dev/null +++ b/src/modules/settings/lib/return-to.ts @@ -0,0 +1,18 @@ +/** + * Prüft, ob ein returnTo-Wert ein sicherer, app-interner Pfad ist. + * + * Der Wert landet in einer Stripe-Redirect-URL und damit in einem Location-Header - + * ohne Prüfung wäre das ein offener Redirect. Erlaubt ist deshalb nur ein einzelner + * führender Slash: "//host" und "/\host" interpretieren Browser als protokoll- + * relative URL auf eine fremde Domain. + * + * Bewusst in einer eigenen lib-Datei statt in stripe-connect-actions.ts: dort gilt + * "use server", und aus einer Server-Actions-Datei dürfen nur async Funktionen + * exportiert werden. + */ +export function sanitizeReturnTo(returnTo: string | null | undefined): string | null { + if (!returnTo) return null; + if (!returnTo.startsWith("/")) return null; + if (returnTo.startsWith("//") || returnTo.startsWith("/\\")) return null; + return returnTo; +} diff --git a/src/modules/settings/ui/components/BusinessPublicToggle.tsx b/src/modules/settings/ui/components/BusinessPublicToggle.tsx index de36758..5943002 100644 --- a/src/modules/settings/ui/components/BusinessPublicToggle.tsx +++ b/src/modules/settings/ui/components/BusinessPublicToggle.tsx @@ -12,6 +12,8 @@ interface BusinessPublicToggleProps { businessName: string; initialIsPublic: boolean; initialSlug: string | null; + /** Optional: meldet den neuen Zustand nach oben, z.B. damit der Setup-Wizard den Schritt abhaken kann. */ + onPublicChange?: (isPublic: boolean) => void; } const BusinessPublicToggle = ({ @@ -19,6 +21,7 @@ const BusinessPublicToggle = ({ businessName, initialIsPublic, initialSlug, + onPublicChange, }: BusinessPublicToggleProps) => { const [isPublic, setIsPublic] = useState(initialIsPublic); const [slug, setSlug] = useState(initialSlug); @@ -35,6 +38,7 @@ const BusinessPublicToggle = ({ if (result.success) { setIsPublic(checked); if (result.slug) setSlug(result.slug); + onPublicChange?.(checked); toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt"); } else { toast.error(result.error ?? "Fehler beim Aktualisieren"); diff --git a/tests/onboarding/onboarding-steps.test.ts b/tests/onboarding/onboarding-steps.test.ts new file mode 100644 index 0000000..0b6198a --- /dev/null +++ b/tests/onboarding/onboarding-steps.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { + ONBOARDING_STEPS, + ONBOARDING_STEP_IDS, + WIZARD_STEP_IDS, + LINK_STEP_IDS, + countCompletedSteps, + firstOpenWizardStep, + getStepStates, + isOnboardingComplete, + isOnboardingStepId, + type OnboardingContext, +} from "@/src/modules/onboarding/lib/onboarding-steps"; + +/** Frisch registriertes Studio: nichts eingerichtet. */ +const emptyContext: OnboardingContext = { + stripeAccountStatus: null, + slug: null, + isPublic: false, + hasBranding: false, + hasCancellationPolicy: false, + roomCount: 0, + employeeCount: 0, + courseCount: 0, +}; + +/** Alle Pflichtschritte erledigt, optionale Schritte offen. */ +const requiredDoneContext: OnboardingContext = { + ...emptyContext, + stripeAccountStatus: "active", + slug: "mein-studio", + isPublic: true, + roomCount: 1, + courseCount: 1, +}; + +describe("isOnboardingStepId", () => { + it("erkennt gültige Registry-Keys", () => { + expect(isOnboardingStepId("payments")).toBe(true); + expect(isOnboardingStepId("course")).toBe(true); + }); + + it("lehnt unbekannte Keys ab", () => { + expect(isOnboardingStepId("zahlungen")).toBe(false); + expect(isOnboardingStepId("")).toBe(false); + expect(isOnboardingStepId("__proto__")).toBe(false); + }); +}); + +describe("Registry-Aufteilung", () => { + it("teilt alle Schritte überschneidungsfrei in Wizard- und Link-Schritte", () => { + expect([...WIZARD_STEP_IDS, ...LINK_STEP_IDS].sort()).toEqual([...ONBOARDING_STEP_IDS].sort()); + expect(WIZARD_STEP_IDS.filter((id) => (LINK_STEP_IDS as string[]).includes(id))).toEqual([]); + }); + + it("hat mindestens einen Pflichtschritt", () => { + expect(ONBOARDING_STEP_IDS.some((id) => ONBOARDING_STEPS[id].required)).toBe(true); + }); + + it("verlinkt jeden Schritt auf einen app-internen Pfad", () => { + for (const id of ONBOARDING_STEP_IDS) { + expect(ONBOARDING_STEPS[id].href.startsWith("/")).toBe(true); + expect(ONBOARDING_STEPS[id].href.startsWith("//")).toBe(false); + } + }); +}); + +describe("getStepStates", () => { + it("meldet im leeren Studio jeden Schritt als offen", () => { + const states = getStepStates(emptyContext); + expect(states).toHaveLength(ONBOARDING_STEP_IDS.length); + expect(states.every((s) => !s.done)).toBe(true); + }); + + it("behält die Registry-Reihenfolge bei", () => { + expect(getStepStates(emptyContext).map((s) => s.id)).toEqual(ONBOARDING_STEP_IDS); + }); +}); + +describe("Ableitung der einzelnen Schritte", () => { + it("hakt Zahlungen nur bei aktivem Stripe-Konto ab", () => { + expect( + ONBOARDING_STEPS.payments.isDone({ ...emptyContext, stripeAccountStatus: "active" }) + ).toBe(true); + // "pending" und "restricted" reichen NICHT: in beiden Fällen kann das Studio + // kein Geld empfangen, der Schritt muss also offen bleiben. + expect( + ONBOARDING_STEPS.payments.isDone({ ...emptyContext, stripeAccountStatus: "pending" }) + ).toBe(false); + expect( + ONBOARDING_STEPS.payments.isDone({ ...emptyContext, stripeAccountStatus: "restricted" }) + ).toBe(false); + }); + + it("verlangt für die öffentliche Seite Slug UND Sichtbarkeit", () => { + expect( + ONBOARDING_STEPS.publicPage.isDone({ ...emptyContext, isPublic: true, slug: null }) + ).toBe(false); + expect( + ONBOARDING_STEPS.publicPage.isDone({ ...emptyContext, isPublic: false, slug: "s" }) + ).toBe(false); + expect(ONBOARDING_STEPS.publicPage.isDone({ ...emptyContext, isPublic: true, slug: "s" })).toBe( + true + ); + }); +}); + +describe("isOnboardingComplete", () => { + it("ist im leeren Studio nicht erfüllt", () => { + expect(isOnboardingComplete(emptyContext)).toBe(false); + }); + + it("ist erfüllt, sobald alle Pflichtschritte stehen - auch ohne die optionalen", () => { + expect(isOnboardingComplete(requiredDoneContext)).toBe(true); + expect(requiredDoneContext.hasBranding).toBe(false); + expect(requiredDoneContext.employeeCount).toBe(0); + }); + + it("fällt zurück, wenn ein Pflichtschritt wegbricht (z.B. Stripe wird restricted)", () => { + expect( + isOnboardingComplete({ ...requiredDoneContext, stripeAccountStatus: "restricted" }) + ).toBe(false); + }); +}); + +describe("countCompletedSteps", () => { + it("zählt über ALLE Schritte, nicht nur die Pflichtschritte", () => { + expect(countCompletedSteps(emptyContext)).toBe(0); + expect(countCompletedSteps(requiredDoneContext)).toBe( + ONBOARDING_STEP_IDS.filter((id) => ONBOARDING_STEPS[id].required).length + ); + expect( + countCompletedSteps({ ...requiredDoneContext, hasBranding: true, employeeCount: 2 }) + ).toBe(ONBOARDING_STEP_IDS.length - 1); // nur die Storno-Policy fehlt noch + }); +}); + +describe("firstOpenWizardStep", () => { + it("liefert den ersten offenen Wizard-Schritt in Registry-Reihenfolge", () => { + expect(firstOpenWizardStep(emptyContext)).toBe(WIZARD_STEP_IDS[0]); + }); + + it("überspringt bereits erledigte Schritte", () => { + expect(firstOpenWizardStep({ ...emptyContext, hasBranding: true })).toBe(WIZARD_STEP_IDS[1]); + }); + + it("liefert null, wenn alle Wizard-Schritte erledigt sind", () => { + const allWizardDone: OnboardingContext = { + ...requiredDoneContext, + hasBranding: true, + hasCancellationPolicy: true, + }; + expect(firstOpenWizardStep(allWizardDone)).toBeNull(); + }); +}); diff --git a/tests/settings/return-to.test.ts b/tests/settings/return-to.test.ts new file mode 100644 index 0000000..f5e3b5e --- /dev/null +++ b/tests/settings/return-to.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to"; + +describe("sanitizeReturnTo", () => { + it("lässt app-interne Pfade durch", () => { + expect(sanitizeReturnTo("/onboarding")).toBe("/onboarding"); + expect(sanitizeReturnTo("/onboarding?step=payments")).toBe("/onboarding?step=payments"); + }); + + it("lehnt absolute URLs auf fremde Hosts ab", () => { + expect(sanitizeReturnTo("https://evil.example/pwn")).toBeNull(); + expect(sanitizeReturnTo("http://evil.example")).toBeNull(); + }); + + it("lehnt protokollrelative URLs ab", () => { + // "//host" und "/\host" interpretieren Browser als fremde Domain - das wäre + // ein offener Redirect über den Stripe-Rücksprung. + expect(sanitizeReturnTo("//evil.example")).toBeNull(); + expect(sanitizeReturnTo("/\\evil.example")).toBeNull(); + }); + + it("behandelt leere und fehlende Werte als 'kein returnTo'", () => { + expect(sanitizeReturnTo(null)).toBeNull(); + expect(sanitizeReturnTo(undefined)).toBeNull(); + expect(sanitizeReturnTo("")).toBeNull(); + }); +});