added onboarding wizard for manager and employees

This commit is contained in:
Rajpreet0 2026-09-10 13:39:25 +02:00
parent e4aaaff7ba
commit b15d37548a
25 changed files with 1813 additions and 39 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Business" ADD COLUMN "dashboardDismissedRecommendations" JSONB;

View file

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

View file

@ -65,6 +65,7 @@ model Employee {
onboardingToken String? @unique onboardingToken String? @unique
onboardingTokenExpiry DateTime? onboardingTokenExpiry DateTime?
isOnboarded Boolean @default(false) isOnboarded Boolean @default(false)
firstStepsDismissedAt DateTime? // "Erste Schritte"-Karte im Dashboard weggeklickt
createdBy String? // Manager ID who created the employee createdBy String? // Manager ID who created the employee
trainings UserTraining[] trainings UserTraining[]
createdAt DateTime? @default(now()) createdAt DateTime? @default(now())
@ -97,28 +98,40 @@ model Manager {
} }
model Business { model Business {
id String @id @default(uuid()) id String @id @default(uuid())
managerId String managerId String
name String name String
address String address String
email String email String
title String title String
ustId String? ustId String?
banking String? banking String?
plan String? plan String?
subscription Json? subscription Json?
slug String? @unique slug String? @unique
isPublic Boolean @default(false) isPublic Boolean @default(false)
stripeAccountId String? @unique // Stripe Connect Account ID stripeAccountId String? @unique // Stripe Connect Account ID
stripeAccountStatus String? // "pending" | "active" | "restricted" stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. 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). 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). 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]) // Weggeklickte Dashboard-Empfehlungen: [{ id: "add-session:<courseId>", until: ISO-Datum }].
courses Course[] // until = Schlummer-Ende, danach taucht die Empfehlung wieder auf. Die zugehoerige
createdAt DateTime @default(now()) // Migration (20260828164044) war nur in der Datenbank, nicht im Repo - das Feld wird
updatedAt DateTime @updatedAt // 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]) @@index([managerId])
} }

View file

@ -1,11 +1,21 @@
import { Suspense } from "react"; 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 DashboardView from "@/src/modules/dashboard/ui/views/dashboard-view";
import { shouldStartOnboarding } from "@/src/modules/onboarding/actions/onboarding-actions";
import DashboardLoading from "./loading"; import DashboardLoading from "./loading";
export default async function DashboardPage() { export default async function DashboardPage() {
const userData = await requireAuthWithData(); 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 ( return (
<Suspense fallback={<DashboardLoading />}> <Suspense fallback={<DashboardLoading />}>
<DashboardView userData={userData} /> <DashboardView userData={userData} />

View file

@ -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 (
<OnboardingWizardView
businessId={progress.businessId}
businessName={progress.businessName}
managerFirstName={manager.firstName}
done={done}
branding={brandingResult.branding}
policy={policyResult.policy}
isPublic={business.isPublic}
slug={business.slug}
stripeAccountStatus={business.stripeAccountStatus}
initialStep={initialStep}
/>
);
}
export const metadata = {
title: "Einrichtung",
};

View file

@ -1,21 +1,27 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions"; 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. * Refresh-URL für Stripe Connect Onboarding.
* Wird aufgerufen wenn der Onboarding-Link abgelaufen ist - wir generieren einen neuen. * 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) { export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get("businessId"); const businessId = request.nextUrl.searchParams.get("businessId");
const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get("returnTo"));
const target = returnTo ?? "/settings/payments";
if (!businessId) { 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) { 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); return NextResponse.redirect(result.url);

View file

@ -1,21 +1,28 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { syncConnectAccountStatus } from "@/src/modules/settings/actions/stripe-connect-actions"; 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. * Return-URL nach erfolgreichem (oder abgeschlossenem) Stripe Connect Onboarding.
* Stripe leitet den Manager hierher zurück. Wir synchronisieren dann den Account-Status. * 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) { export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get("businessId"); const businessId = request.nextUrl.searchParams.get("businessId");
const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get("returnTo"));
const target = returnTo ?? "/settings/payments";
if (!businessId) { 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); const result = await syncConnectAccountStatus(businessId);
if (!result.success) { 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));
} }

View file

@ -3,6 +3,12 @@ import DashboardStatsGrid from "../components/DashboardStatsGrid";
import DashboardRevenueChart from "../components/DashboardRevenueChart"; import DashboardRevenueChart from "../components/DashboardRevenueChart";
import DashboardTodaySchedule from "../components/DashboardTodaySchedule"; import DashboardTodaySchedule from "../components/DashboardTodaySchedule";
import DashboardRecentPayments from "../components/DashboardRecentPayments"; 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 { import {
getDashboardStats, getDashboardStats,
getDashboardRevenueChart, getDashboardRevenueChart,
@ -22,18 +28,31 @@ const DashboardView = async ({ userData }: DashboardViewProps) => {
: userData.name || userData.email.split("@")[0]; : userData.name || userData.email.split("@")[0];
if (isManager(userData)) { if (isManager(userData)) {
const [statsResult, chartResult, todayResult, bookingsResult] = await Promise.all([ const [statsResult, chartResult, todayResult, bookingsResult, onboardingResult] =
getDashboardStats(), await Promise.all([
getDashboardRevenueChart(), getDashboardStats(),
getDashboardTodayCourses(), getDashboardRevenueChart(),
getDashboardRecentBookings(), getDashboardTodayCourses(),
]); getDashboardRecentBookings(),
getOnboardingProgress(),
]);
const stats = statsResult.success ? statsResult.stats : null; const stats = statsResult.success ? statsResult.stats : null;
const chartData = chartResult.success ? chartResult.data : []; const chartData = chartResult.success ? chartResult.data : [];
const todayCourses = todayResult.success ? todayResult.courses : []; const todayCourses = todayResult.success ? todayResult.courses : [];
const recentBookings = bookingsResult.success ? bookingsResult.bookings : []; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Welcome header */} {/* Welcome header */}
@ -48,6 +67,15 @@ const DashboardView = async ({ userData }: DashboardViewProps) => {
</p> </p>
</div> </div>
{/* Setup-Checkliste, solange das Onboarding offen ist */}
{onboarding && (
<OnboardingChecklist
steps={onboarding.steps}
completedCount={onboarding.completedCount}
totalCount={onboarding.totalCount}
/>
)}
{/* Stats tiles */} {/* Stats tiles */}
{stats && <DashboardStatsGrid stats={stats} />} {stats && <DashboardStatsGrid stats={stats} />}
@ -64,7 +92,10 @@ const DashboardView = async ({ userData }: DashboardViewProps) => {
} }
if (isEmployee(userData)) { if (isEmployee(userData)) {
const todayResult = await getDashboardTodayCourses(); const [todayResult, showFirstSteps] = await Promise.all([
getDashboardTodayCourses(),
isEmployeeFirstStepsVisible(userData.id),
]);
const todayCourses = todayResult.success ? todayResult.courses : []; const todayCourses = todayResult.success ? todayResult.courses : [];
return ( return (
@ -82,6 +113,9 @@ const DashboardView = async ({ userData }: DashboardViewProps) => {
</div> </div>
</div> </div>
{/* Erste Schritte für frisch eingeladene Mitarbeiter */}
{showFirstSteps && <EmployeeFirstSteps permissions={userData.permissions} />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DashboardTodaySchedule courses={todayCourses} /> <DashboardTodaySchedule courses={todayCourses} />

View file

@ -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<boolean> {
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<boolean> {
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." };
}
}

View file

@ -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<string, OnboardingStepDef>;
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<M extends OnboardingStepDef["mode"]> = {
[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;
}

View file

@ -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: <item.icon /> wertet
// der React-Compiler-Linter als "Komponente während Render erzeugt".
const items = [
{
show: true,
href: "/settings/personal",
icon: <User className="size-4 text-gray-500" />,
label: "Profil vervollständigen",
description: "Profilbild und Kontaktdaten ergänzen.",
},
{
show: permissions.courses.view,
href: "/courses",
icon: <CalendarDays className="size-4 text-gray-500" />,
label: "Kurse ansehen",
description: "Verschaff dir einen Überblick über den Kursplan.",
},
{
show: permissions.rooms.view,
href: "/rooms",
icon: <DoorOpen className="size-4 text-gray-500" />,
label: "Räume ansehen",
description: "Sieh dir an, welche Räume zur Verfügung stehen.",
},
{
show: permissions.employees.view,
href: "/employee",
icon: <Users className="size-4 text-gray-500" />,
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 (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_70%_at_0%_0%,oklch(0.88_0.09_85/_0.22)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-0.5">
Willkommen
</p>
<h2 className="text-lg font-bold text-gray-900">Deine ersten Schritte</h2>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={isPending}
aria-label="Erste Schritte ausblenden"
className="text-gray-300 hover:text-gray-500 transition-colors cursor-pointer disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-2">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 p-3 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
{item.icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">{item.label}</p>
<p className="text-xs text-gray-400 font-light truncate">{item.description}</p>
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-[#c9a84c] group-hover:translate-x-0.5 transition-all shrink-0" />
</Link>
))}
</div>
</div>
</div>
);
};
export default EmployeeFirstSteps;

View file

@ -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 (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_70%_at_0%_0%,oklch(0.88_0.09_85/_0.22)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-0.5">
Einrichtung
</p>
<h2 className="text-lg font-bold text-gray-900">Richte dein Studio ein</h2>
<p className="text-sm text-gray-400 font-light mt-0.5">
{completedCount} von {totalCount} Schritten erledigt
</p>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={isPending}
aria-label="Checkliste ausblenden"
className="text-gray-300 hover:text-gray-500 transition-colors cursor-pointer disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<Progress value={percent} className="mb-5" />
<div className="space-y-2">
{steps.map(({ id, done }) => {
const step = ONBOARDING_STEPS[id];
if (done) {
return (
<div
key={id}
className="flex items-center gap-3 p-3 rounded-xl border border-transparent"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-green-100/80 shrink-0">
<Check className="size-4 text-green-600" />
</div>
<p className="text-sm font-medium text-gray-400 line-through">{step.label}</p>
</div>
);
}
return (
<Link
key={id}
href={step.href}
className="flex items-center gap-3 p-3 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
<OnboardingStepIcon step={id} className="size-4 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">
{step.label}
{!step.required && (
<span className="ml-2 text-[10px] font-medium text-gray-400 uppercase tracking-wide">
optional
</span>
)}
</p>
<p className="text-xs text-gray-400 font-light truncate">{step.description}</p>
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-[#c9a84c] group-hover:translate-x-0.5 transition-all shrink-0" />
</Link>
);
})}
</div>
{hasOpenWizardStep && (
<Button asChild size="sm" className="mt-5">
<Link href="/onboarding">
<Rocket className="size-4" />
Einrichtung fortsetzen
</Link>
</Button>
)}
</div>
</div>
);
};
export default OnboardingChecklist;

View file

@ -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 <Icon {...props} />;
};
export default OnboardingStepIcon;

View file

@ -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 (
<div className="mx-auto w-full max-w-3xl space-y-6 py-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_70%_at_100%_0%,oklch(0.88_0.09_85/_0.22)_0%,transparent_70%)] pointer-events-none" />
<div className="relative space-y-5">
<div className="flex items-center justify-between gap-4">
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase">
{eyebrow}
</p>
<p className="text-xs font-medium text-gray-400">
Schritt {stepNumber} von {totalSteps}
</p>
</div>
<Progress value={percent} />
{/* Punkte-Navigation: erlaubt das Springen zwischen bereits gesehenen Schritten */}
<div className="flex flex-wrap items-center gap-2">
{markers.map((marker, index) => (
<button
key={marker.id}
type="button"
onClick={() => onMarkerClick(index)}
className={cn(
"flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer",
marker.active
? "bg-gray-900 text-white"
: marker.done
? "bg-green-100/80 text-green-700 hover:bg-green-100"
: "bg-gray-100/80 text-gray-500 hover:bg-gray-200/80"
)}
>
{marker.done && <Check className="size-3" />}
{marker.label}
</button>
))}
</div>
<div>
<h1 className="text-2xl font-black text-gray-900">{title}</h1>
<p className="text-sm text-gray-400 font-light mt-1">{description}</p>
</div>
</div>
</div>
<div>{children}</div>
<div className="flex items-center justify-between gap-4">
{onBack ? (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="size-4" />
Zurück
</Button>
) : (
<span />
)}
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-sm font-medium text-gray-400 hover:text-gray-700 transition-colors"
>
Später erledigen
</Link>
<Button onClick={onNext}>
{nextLabel}
<ArrowRight className="size-4" />
</Button>
</div>
</div>
</div>
);
};
export default OnboardingStepShell;

View file

@ -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<boolean> => {
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 (
<BusinessBrandingEditor
businessId={businessId}
initialBranding={branding}
onSave={handleSave}
isSaving={isSaving}
/>
);
};
export default StepBranding;

View file

@ -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<boolean> => {
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 (
<CancellationPolicyEditor initialPolicy={policy} onSave={handleSave} isSaving={isSaving} />
);
};
export default StepCancellation;

View file

@ -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 (
<div className="flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4">
<CheckCircle2 className="size-5 text-green-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-green-900">Zahlungen sind aktiv</p>
<p className="text-sm text-green-700 mt-0.5">
Dein Stripe-Konto ist verbunden. Buchungen werden direkt an dich ausgezahlt.
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{stripeAccountStatus === "pending" && (
<div className="flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50 p-4">
<AlertCircle className="size-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-amber-900">Prüfung läuft noch</p>
<p className="text-sm text-amber-700 mt-0.5">
Stripe hat noch nicht alle Angaben. Setz das Konto-Onboarding fort, um Zahlungen
freizuschalten.
</p>
</div>
</div>
)}
<div className="rounded-xl border border-white/60 bg-white/50 p-5">
<p className="text-sm text-gray-600">
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.
</p>
<Button onClick={handleConnect} disabled={isPending} className="mt-4">
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<CreditCard className="size-4" />
)}
{stripeAccountStatus === "pending" ? "Onboarding fortsetzen" : "Mit Stripe verbinden"}
</Button>
</div>
</div>
);
};
export default StepPayments;

View file

@ -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) => (
<BusinessPublicToggle
businessId={businessId}
businessName={businessName}
initialIsPublic={initialIsPublic}
initialSlug={initialSlug}
onPublicChange={onPublicChange}
/>
);
export default StepPublicPage;

View file

@ -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<string | undefined>();
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 (
<div className="space-y-4">
{hasRoom && (
<div className="flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4">
<CheckCircle2 className="size-5 text-green-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-green-900">Raum angelegt</p>
<p className="text-sm text-green-700 mt-0.5">
Du kannst hier weitere ergänzen oder später unter „Räume“ verwalten.
</p>
</div>
</div>
)}
<div className="rounded-xl border border-white/60 bg-white/50 p-5 space-y-4">
<InputComponent
label="Raumname"
isLabel
id="onboarding-room-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Studio 1"
error={error}
/>
<Button onClick={handleCreate} disabled={isPending}>
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Raum anlegen
</Button>
</div>
</div>
);
};
export default StepRoom;

View file

@ -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<OnboardingStepId, boolean>;
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 (
<div className="mx-auto w-full max-w-3xl py-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-center justify-center size-12 rounded-xl bg-gray-900 mb-5">
<Rocket className="size-6 text-white" />
</div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">
Willkommen bei Kumasy
</p>
<h1 className="text-3xl font-black text-gray-900">Hallo {managerFirstName} 👋</h1>
<p className="text-gray-500 mt-2 max-w-xl">
Lass uns {businessName} in ein paar Minuten startklar machen. Du kannst jeden Schritt
überspringen und später in den Einstellungen nachholen.
</p>
<div className="mt-6 space-y-2">
{WIZARD_STEP_IDS.map((id) => (
<div
key={id}
className="flex items-center gap-3 p-3 rounded-xl border border-white/50 bg-white/40"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
<OnboardingStepIcon step={id} className="size-4 text-gray-500" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900">
{ONBOARDING_STEPS[id].label}
{!ONBOARDING_STEPS[id].required && (
<span className="ml-2 text-[10px] font-medium text-gray-400 uppercase tracking-wide">
optional
</span>
)}
</p>
<p className="text-xs text-gray-400 font-light">
{ONBOARDING_STEPS[id].description}
</p>
</div>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-7">
<Button onClick={() => goTo(FIRST_STEP_INDEX)}>
Los geht&apos;s
<ArrowRight className="size-4" />
</Button>
<Link
href="/dashboard"
className="text-sm font-medium text-gray-400 hover:text-gray-700 transition-colors"
>
Später erledigen
</Link>
</div>
</div>
</div>
</div>
);
}
// ---------- Abschluss ----------
if (screen >= DONE_INDEX) {
const openLinkSteps = LINK_STEP_IDS.filter((id) => !done[id]);
return (
<div className="mx-auto w-full max-w-3xl py-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-center justify-center size-12 rounded-xl bg-green-600 mb-5">
<PartyPopper className="size-6 text-white" />
</div>
<h1 className="text-3xl font-black text-gray-900">Grundeinrichtung erledigt</h1>
<p className="text-gray-500 mt-2 max-w-xl">
{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."}
</p>
{openLinkSteps.length > 0 && (
<div className="mt-6 space-y-2">
{openLinkSteps.map((id) => (
<Link
key={id}
href={ONBOARDING_STEPS[id].href}
className="flex items-center gap-3 p-3.5 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
<OnboardingStepIcon step={id} className="size-4 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">
{ONBOARDING_STEPS[id].cta}
</p>
<p className="text-xs text-gray-400 font-light">
{ONBOARDING_STEPS[id].description}
</p>
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-[#c9a84c] group-hover:translate-x-0.5 transition-all shrink-0" />
</Link>
))}
</div>
)}
<div className="flex items-center gap-4 mt-7">
<Button onClick={finish}>
Zum Dashboard
<ArrowRight className="size-4" />
</Button>
<button
type="button"
onClick={() => goTo(DONE_INDEX - 1)}
className="text-sm font-medium text-gray-400 hover:text-gray-700 transition-colors cursor-pointer"
>
Zurück
</button>
</div>
</div>
</div>
</div>
);
}
// ---------- 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<WizardStepId, ...>: fehlt für einen Wizard-Schritt der Screen, schlägt
// der Build fehl, statt zur Laufzeit eine leere Seite zu rendern.
const stepContent: Record<WizardStepId, React.ReactNode> = {
branding: (
<StepBranding
businessId={businessId}
initialBranding={branding}
onSaved={() => markStepDone("branding")}
/>
),
payments: <StepPayments businessId={businessId} stripeAccountStatus={stripeAccountStatus} />,
cancellation: (
<StepCancellation
businessId={businessId}
initialPolicy={policy}
onSaved={() => markStepDone("cancellation")}
/>
),
room: <StepRoom hasRoom={done.room} onCreated={() => markStepDone("room")} />,
publicPage: (
<StepPublicPage
businessId={businessId}
businessName={businessName}
initialIsPublic={initialIsPublic}
initialSlug={slug}
onPublicChange={(nowPublic) => setDone((prev) => ({ ...prev, publicPage: nowPublic }))}
/>
),
};
return (
<OnboardingStepShell
eyebrow="Einrichtung"
title={step.label}
description={step.description}
stepNumber={stepIndex + 1}
totalSteps={WIZARD_STEP_IDS.length}
markers={markers}
onMarkerClick={(index) => 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]}
</OnboardingStepShell>
);
};
export default OnboardingWizardView;

View file

@ -11,6 +11,7 @@ import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe"; import { stripe } from "@/src/lib/stripe";
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser"; import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sanitizeReturnTo } from "../lib/return-to";
/** /**
* Erstellt einen Stripe Connect Express Account für ein Business und liefert * 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. * ein neuer Onboarding-Link generiert.
* *
* @param businessId - Die ID des Business * @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 * @returns Onboarding-URL oder Fehler
*/ */
export async function createConnectOnboardingLink(businessId: string) { export async function createConnectOnboardingLink(businessId: string, returnTo?: string) {
try { try {
const userData = await requireAuthWithData(); 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 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({ const accountLink = await stripe.accountLinks.create({
account: stripeAccountId, account: stripeAccountId,
refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}`, refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}${returnToParam}`,
return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}`, return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}${returnToParam}`,
type: "account_onboarding", type: "account_onboarding",
}); });

View file

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

View file

@ -12,6 +12,8 @@ interface BusinessPublicToggleProps {
businessName: string; businessName: string;
initialIsPublic: boolean; initialIsPublic: boolean;
initialSlug: string | null; 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 = ({ const BusinessPublicToggle = ({
@ -19,6 +21,7 @@ const BusinessPublicToggle = ({
businessName, businessName,
initialIsPublic, initialIsPublic,
initialSlug, initialSlug,
onPublicChange,
}: BusinessPublicToggleProps) => { }: BusinessPublicToggleProps) => {
const [isPublic, setIsPublic] = useState(initialIsPublic); const [isPublic, setIsPublic] = useState(initialIsPublic);
const [slug, setSlug] = useState(initialSlug); const [slug, setSlug] = useState(initialSlug);
@ -35,6 +38,7 @@ const BusinessPublicToggle = ({
if (result.success) { if (result.success) {
setIsPublic(checked); setIsPublic(checked);
if (result.slug) setSlug(result.slug); if (result.slug) setSlug(result.slug);
onPublicChange?.(checked);
toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt"); toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt");
} else { } else {
toast.error(result.error ?? "Fehler beim Aktualisieren"); toast.error(result.error ?? "Fehler beim Aktualisieren");

View file

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

View file

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