mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
Merge pull request #161 from Since-3/feature/onboarding-wizard
added onboarding wizard for manager and employees
This commit is contained in:
commit
e22735509b
25 changed files with 1813 additions and 39 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Business" ADD COLUMN "dashboardDismissedRecommendations" JSONB;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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:<courseId>", 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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Suspense fallback={<DashboardLoading />}>
|
||||
<DashboardView userData={userData} />
|
||||
|
|
|
|||
79
src/app/(main)/onboarding/page.tsx
Normal file
79
src/app/(main)/onboarding/page.tsx
Normal 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",
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome header */}
|
||||
|
|
@ -48,6 +67,15 @@ const DashboardView = async ({ userData }: DashboardViewProps) => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup-Checkliste, solange das Onboarding offen ist */}
|
||||
{onboarding && (
|
||||
<OnboardingChecklist
|
||||
steps={onboarding.steps}
|
||||
completedCount={onboarding.completedCount}
|
||||
totalCount={onboarding.totalCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats tiles */}
|
||||
{stats && <DashboardStatsGrid stats={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) => {
|
|||
</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">
|
||||
<DashboardTodaySchedule courses={todayCourses} />
|
||||
|
||||
|
|
|
|||
239
src/modules/onboarding/actions/onboarding-actions.ts
Normal file
239
src/modules/onboarding/actions/onboarding-actions.ts
Normal 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." };
|
||||
}
|
||||
}
|
||||
190
src/modules/onboarding/lib/onboarding-steps.ts
Normal file
190
src/modules/onboarding/lib/onboarding-steps.ts
Normal 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;
|
||||
}
|
||||
118
src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx
Normal file
118
src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx
Normal 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;
|
||||
128
src/modules/onboarding/ui/components/OnboardingChecklist.tsx
Normal file
128
src/modules/onboarding/ui/components/OnboardingChecklist.tsx
Normal 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;
|
||||
41
src/modules/onboarding/ui/components/OnboardingStepIcon.tsx
Normal file
41
src/modules/onboarding/ui/components/OnboardingStepIcon.tsx
Normal 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;
|
||||
115
src/modules/onboarding/ui/components/OnboardingStepShell.tsx
Normal file
115
src/modules/onboarding/ui/components/OnboardingStepShell.tsx
Normal 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;
|
||||
51
src/modules/onboarding/ui/components/steps/StepBranding.tsx
Normal file
51
src/modules/onboarding/ui/components/steps/StepBranding.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
85
src/modules/onboarding/ui/components/steps/StepPayments.tsx
Normal file
85
src/modules/onboarding/ui/components/steps/StepPayments.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
83
src/modules/onboarding/ui/components/steps/StepRoom.tsx
Normal file
83
src/modules/onboarding/ui/components/steps/StepRoom.tsx
Normal 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;
|
||||
271
src/modules/onboarding/ui/views/onboarding-wizard-view.tsx
Normal file
271
src/modules/onboarding/ui/views/onboarding-wizard-view.tsx
Normal 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'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;
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
18
src/modules/settings/lib/return-to.ts
Normal file
18
src/modules/settings/lib/return-to.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
155
tests/onboarding/onboarding-steps.test.ts
Normal file
155
tests/onboarding/onboarding-steps.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
tests/settings/return-to.test.ts
Normal file
27
tests/settings/return-to.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue