L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job

- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt,
  Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status
- /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung),
  Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot,
  Enumeration-Schutz, Slug-Kollisionen
- Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern,
  umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit)
- Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi),
  Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil
- Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand
- Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding
- Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:01:47 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit d9290a187c
79 changed files with 4576 additions and 19 deletions
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { confirmTrialSignupAction, type TrialConfirmState } from "@/server/actions/trial-signup";
/** L15 Testphase: confirmation button (POST) — the GET link alone never provisions anything. */
export function TrialConfirmForm({ token }: { token: string }) {
const t = useTranslations("trial.confirm");
const [state, action, pending] = useActionState<TrialConfirmState, FormData>(confirmTrialSignupAction, { status: "idle" });
if (state.status === "invalid" || state.status === "expired") {
return (
<div className="mt-4 space-y-3">
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(state.status)}</p>
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
</div>
);
}
return (
<form action={action} className="mt-5 space-y-3">
<input type="hidden" name="token" value={token} />
{state.status === "rate_limited" && (
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("rate_limited")}</p>
)}
<Button type="submit" className="h-11 w-full" disabled={pending}>
{pending ? t("pending") : t("button")}
</Button>
</form>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CheckCircle2, Circle } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import { getOnboardingChecklist } from "@/server/services/trial/onboarding";
import { getTrialState } from "@/server/services/trial/state";
import { hideOnboardingAction, toggleOnboardingItemAction } from "@/server/actions/trial-tenant";
/** L15 Testphase: "Erste Schritte" card on the dashboard (tenant admins of trial-origin tenants). */
export async function GettingStarted({ ctx, welcome = false }: { ctx: ServiceCtx; welcome?: boolean }) {
const checklist = await getOnboardingChecklist(ctx);
if (!checklist) return null;
const [t, trial] = await Promise.all([getTranslations("trial.onboarding"), getTrialState(ctx.tenantId)]);
const writable = !trial.readOnly;
const percent = Math.round((checklist.completed / checklist.total) * 100);
return (
<section aria-labelledby="getting-started-title" className="shadow-card mb-4 rounded-xl border bg-card p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
{welcome && <p className="mb-1 text-sm font-semibold text-[var(--ok)]">{t("welcome")}</p>}
<h2 id="getting-started-title" className="font-heading text-[15px] font-semibold">{t("title")}</h2>
<p className="text-[12.5px] text-muted-foreground">{t("progress", { done: checklist.completed, total: checklist.total })}</p>
</div>
{writable && (
<form action={hideOnboardingAction}>
<button type="submit" className="min-h-11 rounded-md px-3 text-[13px] font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">{t("hide")}</button>
</form>
)}
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={checklist.total} aria-valuenow={checklist.completed} aria-label={t("progress", { done: checklist.completed, total: checklist.total })}>
<div className="h-full rounded-full bg-[var(--ok)]" style={{ width: `${percent}%` }} />
</div>
<ul className="mt-3 divide-y">
{checklist.items.map((item) => (
<li key={item.key} className="flex flex-wrap items-center gap-3 py-2.5">
{item.done ? <CheckCircle2 className="size-5 shrink-0 text-[var(--ok)]" aria-hidden /> : <Circle className="size-5 shrink-0 text-muted-foreground" aria-hidden />}
<div className="min-w-0 flex-1">
<p className={item.done ? "text-sm font-semibold text-muted-foreground line-through" : "text-sm font-semibold"}>
<span className="sr-only">{item.done ? t("doneLabel") : t("openLabel")}: </span>
{t(`items.${item.key}.title`)}
</p>
<p className="text-[12.5px] text-muted-foreground">{t(`items.${item.key}.text`)}{item.auto ? ` · ${t("auto")}` : ""}</p>
</div>
<Link href={item.href} className="inline-flex min-h-11 items-center rounded-md px-3 text-[13px] font-semibold text-[var(--ui-primary)] hover:bg-muted">{t("open")}</Link>
{writable && !item.auto && (
<form action={toggleOnboardingItemAction}>
<input type="hidden" name="key" value={item.key} />
<input type="hidden" name="done" value={item.done ? "0" : "1"} />
<button type="submit" className="min-h-11 rounded-md border px-3 text-[13px] hover:bg-muted">{item.done ? t("markOpen") : t("markDone")}</button>
</form>
)}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,15 @@
import Link from "next/link";
/** L15 Testphase: simple legal text page (terms/privacy placeholders under /testen). */
export function LegalPlaceholder({ title, paragraphs, placeholder, back }: { title: string; paragraphs: string[]; placeholder: string; back: string }) {
return (
<article className="shadow-card mt-4 w-full max-w-2xl self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{title}</h1>
<p className="mt-2 rounded-lg bg-muted px-3 py-2 text-[13px] text-muted-foreground">{placeholder}</p>
{paragraphs.map((p) => (
<p key={p} className="mt-3 text-sm leading-relaxed">{p}</p>
))}
<Link href="/testen" className="mt-4 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--ui-primary)]">← {back}</Link>
</article>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTrialTenantAction, type PlatformTrialState } from "@/server/actions/trial-platform";
const ERR = "rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]";
function ErrorText({ state }: { state: PlatformTrialState }) {
const t = useTranslations("trial.platform.errors");
if (state.status !== "error") return null;
return <p role="alert" className={ERR}>{t.has(state.code) ? t(state.code) : t("failed")}</p>;
}
/** L15 Testphase: platform wizard "Testmandant anlegen" (one page, four sections). */
export function PlatformTrialCreateForm({ defaultEnd, min, max }: { defaultEnd: string; min: string; max: string }) {
const t = useTranslations("trial.platform.wizard");
const [state, action, pending] = useActionState<PlatformTrialState, FormData>(createTrialTenantAction, { status: "idle" });
const section = "shadow-card rounded-xl border bg-card p-5";
return (
<form action={action} className="grid gap-4">
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionCompany")}</legend>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="companyName">{t("companyName")} *</Label>
<Input id="companyName" name="companyName" required minLength={2} maxLength={120} className="mt-1 h-11" />
</div>
<div>
<Label htmlFor="sector">{t("sector")}</Label>
<Input id="sector" name="sector" maxLength={80} className="mt-1 h-11" />
</div>
</div>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionAdmin")}</legend>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="adminName">{t("adminName")} *</Label>
<Input id="adminName" name="adminName" required minLength={2} maxLength={120} className="mt-1 h-11" />
</div>
<div>
<Label htmlFor="adminEmail">{t("adminEmail")} *</Label>
<Input id="adminEmail" name="adminEmail" type="email" required maxLength={200} className="mt-1 h-11" />
</div>
</div>
<p className="mt-2 text-[12px] text-muted-foreground">{t("adminHint")}</p>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionPeriod")}</legend>
<div className="mt-3 max-w-xs">
<Label htmlFor="endDate">{t("endDate")} *</Label>
<Input id="endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
<p className="mt-1 text-[12px] text-muted-foreground">{t("endDateHint")}</p>
</div>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionSetup")}</legend>
<label className="mt-3 flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="sampleData" defaultChecked className="size-5" /> {t("sampleData")}
</label>
</fieldset>
<ErrorText state={state} />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href="/admin" />}>{t("cancel")}</Button>
</div>
</form>
);
}
/** L15 Testphase: confirmation form of one lifecycle operation (bound server action). */
export function TrialLifecycleForm({
action,
withDate,
defaultEnd,
min,
max,
closeHref,
destructive,
}: {
action: (prev: PlatformTrialState, fd: FormData) => Promise<PlatformTrialState>;
withDate: boolean;
defaultEnd?: string;
min?: string;
max?: string;
closeHref: string;
destructive?: boolean;
}) {
const t = useTranslations("trial.platform.ops");
const tw = useTranslations("trial.platform.wizard");
const [state, formAction, pending] = useActionState<PlatformTrialState, FormData>(action, { status: "idle" });
return (
<form action={formAction} className="space-y-3">
{withDate && (
<div className="max-w-xs">
<Label htmlFor="op-endDate">{tw("endDate")} *</Label>
<Input id="op-endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
</div>
)}
<label className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
</label>
<ErrorText state={state} />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" variant={destructive ? "destructive" : "default"} disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>{t("close")}</Button>
</div>
</form>
);
}
+341
View File
@@ -0,0 +1,341 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Check, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { diffDayKeys, formatDateKey, type TrialBounds } from "@/lib/trial/dates";
import {
TRIAL_COMPANY_SIZES,
TRIAL_SECTOR_KEYS,
TRIAL_STEPS,
validateTrialSignup,
validateTrialStep,
type FieldErrors,
type TrialSignupValues,
type TrialStep,
} from "@/lib/trial/signup";
import { checkTrialStepAction, submitTrialSignupAction } from "@/server/actions/trial-signup";
/**
* L15 Testphase: public 5-step wizard. All values live in one state object, so "Zurück" never loses
* input. Each step is validated in the browser (same rules as the server) and then by the server
* (`checkTrialStepAction`); the final submit validates everything again.
*/
type Props = {
bounds: TrialBounds;
modules: { key: string; label: string }[];
passwordPolicy: string;
locale: string;
};
const STEP_FIELDS: Record<TrialStep, (keyof TrialSignupValues)[]> = {
company: ["companyName", "sector", "companySize"],
account: ["adminName", "email", "password"],
period: ["trialEndDate"],
setup: ["sampleData", "modules"],
summary: ["acceptTerms", "acceptPrivacy"],
};
const fieldCls = "mt-1 h-11 text-base sm:text-sm";
const selectCls = "mt-1 h-11 w-full rounded-md border border-input bg-background px-3 text-base sm:text-sm";
const errorCls = "mt-1 text-[13px] text-[var(--risk)]";
export function TrialSignupWizard({ bounds, modules, passwordPolicy, locale }: Props) {
const t = useTranslations("trial");
const [step, setStep] = useState(0);
const [sectorKey, setSectorKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [values, setValues] = useState<TrialSignupValues>({
companyName: "",
sector: "",
companySize: "",
adminName: "",
email: "",
password: "",
trialEndDate: bounds.defaultEnd,
sampleData: true,
modules: modules.map((m) => m.key),
acceptTerms: false,
acceptPrivacy: false,
website: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
const [sent, setSent] = useState(false);
const [pending, startTransition] = useTransition();
const current = TRIAL_STEPS[step];
const set = <K extends keyof TrialSignupValues>(key: K, value: TrialSignupValues[K]) => {
setValues((v) => ({ ...v, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined, _form: undefined }));
};
const days = useMemo(() => (/^\d{4}-\d{2}-\d{2}$/.test(values.trialEndDate) ? diffDayKeys(bounds.today, values.trialEndDate) : null), [values.trialEndDate, bounds.today]);
const err = (key: keyof FieldErrors) => (errors[key] ? t(`errors.${errors[key]}`) : null);
function firstStepWithError(e: FieldErrors): number {
const idx = TRIAL_STEPS.findIndex((s) => STEP_FIELDS[s].some((f) => e[f]));
return idx === -1 ? step : idx;
}
function next() {
const local = validateTrialStep(current, values, bounds);
if (Object.keys(local).length) return setErrors(local);
startTransition(async () => {
const server = await checkTrialStepAction(current, values);
if (Object.keys(server).length) return setErrors(server);
setErrors({});
setStep((s) => Math.min(s + 1, TRIAL_STEPS.length - 1));
});
}
function submit() {
const local = validateTrialSignup(values, bounds);
if (Object.keys(local).length) {
setErrors(local);
return setStep(firstStepWithError(local));
}
startTransition(async () => {
const res = await submitTrialSignupAction(values);
if (res.status === "sent") return setSent(true);
setErrors(res.errors);
setStep(firstStepWithError(res.errors));
});
}
if (sent) {
return (
<div role="status" className="space-y-3 text-center">
<MailCheck className="mx-auto size-10 text-[var(--ok)]" aria-hidden />
<h2 className="font-heading text-xl font-semibold">{t("sent.title")}</h2>
<p className="text-sm">{t("sent.body")}</p>
<p className="text-[13px] text-muted-foreground">{t("sent.hint")}</p>
<Button variant="outline" className="h-11" onClick={() => { setSent(false); setStep(0); }}>
{t("sent.again")}
</Button>
</div>
);
}
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (current === "summary") submit();
else next();
}}
>
{/* Progress */}
<nav aria-label={t("progressLabel")}>
<p className="text-[13px] font-medium text-muted-foreground">{t("progress", { current: step + 1, total: TRIAL_STEPS.length })}</p>
<ol className="mt-2 grid grid-cols-5 gap-1.5">
{TRIAL_STEPS.map((s, i) => (
<li key={s} aria-current={i === step ? "step" : undefined}>
<button
type="button"
disabled={i > step || pending}
onClick={() => setStep(i)}
className="flex w-full flex-col items-start gap-1 text-left disabled:cursor-default"
>
<span className={cn("h-1.5 w-full rounded-full", i <= step ? "bg-[var(--ui-primary)]" : "bg-muted")} />
<span className={cn("hidden text-[11.5px] sm:inline", i === step ? "font-semibold text-foreground" : "text-muted-foreground")}>
{i < step && <Check className="mr-0.5 inline size-3" aria-hidden />}
{t(`steps.${s}`)}
</span>
</button>
</li>
))}
</ol>
</nav>
<h2 className="mt-5 font-heading text-lg font-semibold">{t(`steps.${current}`)}</h2>
{/* Honeypot: invisible for people, not announced to screen readers */}
<div aria-hidden className="absolute -left-[10000px] top-auto size-px overflow-hidden">
<label htmlFor="website">{t("fields.website")}</label>
<input id="website" name="website" tabIndex={-1} autoComplete="off" value={values.website} onChange={(e) => set("website", e.target.value)} />
</div>
<div className="mt-4 space-y-4">
{current === "company" && (
<>
<div>
<Label htmlFor="companyName">{t("fields.companyName")} *</Label>
<Input id="companyName" className={fieldCls} autoComplete="organization" value={values.companyName} onChange={(e) => set("companyName", e.target.value)} aria-invalid={!!errors.companyName} aria-describedby={errors.companyName ? "companyName-error" : undefined} />
{err("companyName") && <p id="companyName-error" className={errorCls}>{err("companyName")}</p>}
</div>
<div>
<Label htmlFor="sector">{t("fields.sector")}</Label>
<select
id="sector"
className={selectCls}
value={sectorKey}
onChange={(e) => {
const key = e.target.value;
setSectorKey(key);
set("sector", key && key !== "other" ? t(`sectors.${key}`) : "");
}}
>
<option value="">{t("fields.sectorChoose")}</option>
{TRIAL_SECTOR_KEYS.map((k) => (
<option key={k} value={k}>{t(`sectors.${k}`)}</option>
))}
</select>
{sectorKey === "other" && (
<>
<Label htmlFor="sectorOther" className="mt-3 block">{t("fields.sectorOther")}</Label>
<Input id="sectorOther" className={fieldCls} value={values.sector} onChange={(e) => set("sector", e.target.value)} />
</>
)}
{err("sector") && <p className={errorCls}>{err("sector")}</p>}
</div>
<div>
<Label htmlFor="companySize">{t("fields.companySize")}</Label>
<select id="companySize" className={selectCls} value={values.companySize} onChange={(e) => set("companySize", e.target.value)}>
<option value="">{t("fields.companySizeNone")}</option>
{TRIAL_COMPANY_SIZES.map((s) => (
<option key={s} value={s}>{t(`sizes.${s.replace(/[^0-9]/g, "_")}`)}</option>
))}
</select>
{err("companySize") && <p className={errorCls}>{err("companySize")}</p>}
</div>
</>
)}
{current === "account" && (
<>
<div>
<Label htmlFor="adminName">{t("fields.adminName")} *</Label>
<Input id="adminName" className={fieldCls} autoComplete="name" value={values.adminName} onChange={(e) => set("adminName", e.target.value)} aria-invalid={!!errors.adminName} />
{err("adminName") && <p className={errorCls}>{err("adminName")}</p>}
</div>
<div>
<Label htmlFor="email">{t("fields.email")} *</Label>
<Input id="email" type="email" inputMode="email" className={fieldCls} autoComplete="email" value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} aria-describedby="email-hint" />
<p id="email-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.emailHint")}</p>
{err("email") && <p className={errorCls}>{err("email")}</p>}
</div>
<div>
<Label htmlFor="password">{t("fields.password")} *</Label>
<Input id="password" type={showPassword ? "text" : "password"} className={fieldCls} autoComplete="new-password" value={values.password} onChange={(e) => set("password", e.target.value)} aria-invalid={!!errors.password} aria-describedby="password-hint" />
<p id="password-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.passwordHint", { policy: passwordPolicy })}</p>
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" className="size-5" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} />
{t("fields.showPassword")}
</label>
{err("password") && <p className={errorCls}>{err("password")}</p>}
</div>
</>
)}
{current === "period" && (
<div>
<Label htmlFor="trialEndDate">{t("fields.trialEndDate")} *</Label>
<Input id="trialEndDate" type="date" className={fieldCls} min={bounds.min} max={bounds.max} value={values.trialEndDate} onChange={(e) => set("trialEndDate", e.target.value)} aria-invalid={!!errors.trialEndDate} aria-describedby="period-range" />
<p id="period-range" className="mt-1 text-[12.5px] text-muted-foreground">
{t("period.range", { min: formatDateKey(bounds.min, locale), max: formatDateKey(bounds.max, locale) })}
</p>
{err("trialEndDate") ? (
<p className={errorCls}>{err("trialEndDate")}</p>
) : (
days !== null && days > 0 && (
<p className="mt-3 rounded-lg bg-[var(--ui-primary-soft)] px-3 py-2 text-sm font-semibold" aria-live="polite">
{t("period.until", { date: formatDateKey(values.trialEndDate, locale), days })}
</p>
)
)}
<p className="mt-3 text-[13px] text-muted-foreground">{t("period.afterEnd")}</p>
</div>
)}
{current === "setup" && (
<>
<label className="flex min-h-11 items-start gap-3 rounded-lg border p-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.sampleData} onChange={(e) => set("sampleData", e.target.checked)} />
<span>
<span className="block text-sm font-semibold">{t("fields.sampleData")}</span>
<span className="block text-[12.5px] text-muted-foreground">{t("fields.sampleDataHint")}</span>
</span>
</label>
<fieldset>
<legend className="text-sm font-semibold">{t("fields.modules")}</legend>
<p className="text-[12.5px] text-muted-foreground">{t("fields.modulesHint")}</p>
<div className="mt-2 grid gap-1 sm:grid-cols-2">
{modules.map((m) => (
<label key={m.key} className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted">
<input
type="checkbox"
className="size-5"
checked={values.modules.includes(m.key)}
onChange={(e) => set("modules", e.target.checked ? [...values.modules, m.key] : values.modules.filter((k) => k !== m.key))}
/>
{m.label}
</label>
))}
</div>
{err("modules") && <p className={errorCls}>{err("modules")}</p>}
</fieldset>
</>
)}
{current === "summary" && (
<>
<p className="text-sm text-muted-foreground">{t("summary.intro")}</p>
<dl className="divide-y rounded-lg border text-sm">
{[
{ step: 0, label: t("steps.company"), value: `${values.companyName} · ${values.sector || t("summary.noSector")}` },
{ step: 1, label: t("steps.account"), value: `${values.adminName} · ${values.email}` },
{ step: 2, label: t("steps.period"), value: days !== null ? t("period.until", { date: formatDateKey(values.trialEndDate, locale), days }) : values.trialEndDate },
{ step: 3, label: t("steps.setup"), value: `${values.sampleData ? t("summary.sampleYes") : t("summary.sampleNo")} · ${t("summary.modulesCount", { count: values.modules.length, total: modules.length })}` },
].map((row) => (
<div key={row.step} className="flex items-start justify-between gap-3 px-3 py-2.5">
<div className="min-w-0">
<dt className="text-[12px] text-muted-foreground">{row.label}</dt>
<dd className="break-words">{row.value}</dd>
</div>
<button type="button" className="min-h-11 shrink-0 px-2 text-[13px] font-semibold text-[var(--ui-primary)]" onClick={() => setStep(row.step)}>
{t("summary.edit")}
</button>
</div>
))}
</dl>
<label className="flex min-h-11 items-start gap-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptTerms} onChange={(e) => set("acceptTerms", e.target.checked)} aria-invalid={!!errors.acceptTerms} />
<span className="text-sm">
{t.rich("fields.acceptTerms", { link: () => <Link href="/testen/nutzungsbedingungen" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.terms")}</Link> })}
</span>
</label>
{err("acceptTerms") && <p className={errorCls}>{err("acceptTerms")}</p>}
<label className="flex min-h-11 items-start gap-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptPrivacy} onChange={(e) => set("acceptPrivacy", e.target.checked)} aria-invalid={!!errors.acceptPrivacy} />
<span className="text-sm">
{t.rich("fields.acceptPrivacy", { link: () => <Link href="/testen/datenschutz" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.privacy")}</Link> })}
</span>
</label>
{err("acceptPrivacy") && <p className={errorCls}>{err("acceptPrivacy")}</p>}
</>
)}
</div>
{err("_form") && (
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
{err("_form")}
</p>
)}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="outline" className="h-11 min-w-24" disabled={step === 0 || pending} onClick={() => { setErrors({}); setStep((s) => Math.max(0, s - 1)); }}>
{t("actions.back")}
</Button>
<Button type="submit" className="h-11 min-w-32" disabled={pending}>
{current === "summary" ? (pending ? t("actions.submitting") : t("actions.submit")) : pending ? t("actions.checking") : t("actions.next")}
</Button>
</div>
</form>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
import { addDaysToKey, formatDateKey, formatInstantDate, todayKey } from "@/lib/trial/dates";
import { trialLifecycleAction } from "@/server/actions/trial-platform";
import { PLATFORM_TRIAL_MAX_DAYS } from "@/server/services/trial/config";
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
import { TrialLifecycleForm } from "./platform-forms";
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
type Op = (typeof OPS)[number];
/**
* L15 Testphase: trial card on the platform tenant detail page with lifecycle actions (full admins).
* Each action opens a confirmation popup (?trial=<op>, existing Modal pattern).
*/
export async function TrialAdminCard({
tenant,
isFullAdmin,
base,
op,
notice,
}: {
tenant: TenantTrialRow & { id: string; trialSource: string | null; status: string };
isFullAdmin: boolean;
base: string;
op?: string;
notice?: "created" | "invited" | "done" | null;
}) {
if (!tenant.trialStartedAt) return null;
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
const state = computeTrialState(tenant);
const deleted = !!tenant.trialDeletedAt;
const isTrial = tenant.plan === "TRIAL" && !deleted;
const today = todayKey();
const activeOp = isFullAdmin && isTrial && (OPS as readonly string[]).includes(op ?? "") ? (op as Op) : null;
const stateText = deleted ? t("card.stateDeleted") : tenant.plan === "FULL" ? t("card.stateConverted") : state.expired ? t("card.stateExpired") : t("card.stateActive");
const actions: { op: Op; show: boolean }[] = [
{ op: "extend", show: true },
{ op: "convert", show: true },
{ op: "end", show: !state.expired },
{ op: "schedule", show: !tenant.deletionDueAt },
{ op: "cancel", show: !!tenant.deletionDueAt },
];
return (
<div className="shadow-card rounded-xl border bg-card p-5" data-trial-card>
<p className="mb-2 font-heading text-sm font-semibold">{t("card.title")}</p>
{notice && <p role="status" className="mb-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t(`card.${notice}`)}</p>}
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.state")}</dt><dd><Pill tone={deleted ? "mut" : tenant.plan === "FULL" ? "ok" : "warn"}>{stateText}</Pill></dd></div>
{state.endDateKey && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.until")}</dt><dd>{formatDateKey(state.endDateKey, locale)}</dd></div>}
{isTrial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.deletion")}</dt><dd>{tenant.deletionDueAt ? formatInstantDate(tenant.deletionDueAt, locale) : t("card.deletionNone")}</dd></div>}
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.source")}</dt><dd>{tenant.trialSource === "platform" ? t("card.sourcePlatform") : t("card.sourceSelf")}</dd></div>
</dl>
{isFullAdmin && isTrial && (
<div className="mt-3 space-y-2">
{actions.filter((a) => a.show).map((a) => (
<Link key={a.op} href={`${base}?trial=${a.op}`} scroll={false} className="block">
<Button variant="outline" size="sm" className={a.op === "end" || a.op === "schedule" ? "min-h-11 w-full justify-center border-destructive/40 text-destructive" : "min-h-11 w-full justify-center"}>
{t(`card.${a.op}`)}
</Button>
</Link>
))}
</div>
)}
{activeOp && (
<Modal title={t(`ops.${activeOp}Title`)} closeHref={base} closeLabel={t("ops.close")}>
<div className="space-y-3 p-5">
<p className="text-sm">{t(`ops.${activeOp}Text`)}</p>
<TrialLifecycleForm
action={trialLifecycleAction.bind(null, tenant.id, activeOp)}
withDate={activeOp === "extend"}
defaultEnd={state.endDateKey && state.endDateKey >= today ? addDaysToKey(state.endDateKey, 14) : addDaysToKey(today, 14)}
min={today}
max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)}
closeHref={base}
destructive={activeOp === "end" || activeOp === "schedule"}
/>
</div>
</Modal>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { getLocale, getTranslations } from "next-intl/server";
import { Pill } from "@/components/mockup-ui";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
/** L15 Testphase: plan badge(s) in the platform tenant list ("Test bis …", "abgelaufen – nur lesen", "Löschung am …"). */
export async function TrialBadge({ tenant }: { tenant: TenantTrialRow }) {
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
if (tenant.trialDeletedAt) return <Pill tone="mut">{t("badgeDeleted")}</Pill>;
const state = computeTrialState(tenant);
if (!state.isTrial) return <Pill tone="ok">{t("badgeFull")}</Pill>;
return (
<span className="flex flex-wrap gap-1.5">
{state.expired ? <Pill tone="warn">{t("badgeExpired")}</Pill> : <Pill tone="warn">{t("badgeUntil", { date: formatDateKey(state.endDateKey!, locale) })}</Pill>}
{state.deletionDueAt && <Pill tone="mut">{t("badgeDeletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</Pill>}
</span>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Hourglass, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { trialContactEmail } from "@/server/services/trial/config";
import { getTrialState } from "@/server/services/trial/state";
/**
* L15 Testphase: banner in the backoffice and mobile shell — countdown from 7 days before the end,
* read-only notice (+ deletion date, contact, export link) after expiry. Status is carried by text
* and icon, colour only supports it.
*/
export async function TrialBanner({ tenantId, variant, canExport = false }: { tenantId: string; variant: "backoffice" | "mobile"; canExport?: boolean }) {
const state = await getTrialState(tenantId);
if (!state.isTrial || (!state.expired && !state.showCountdown)) return null;
const [t, locale] = await Promise.all([getTranslations("trial.banner"), getLocale()]);
const contact = trialContactEmail();
const Icon = state.expired ? Lock : Hourglass;
return (
<div
role="status"
data-trial-banner={state.expired ? "expired" : "countdown"}
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 text-[13px]",
state.expired ? "bg-[rgba(198,66,66,0.08)] text-foreground" : "bg-[rgba(200,121,18,0.10)] text-foreground",
variant === "mobile" && "text-[14px]",
)}
>
<Icon className={cn("size-4 shrink-0", state.expired ? "text-[var(--risk)]" : "text-[var(--warn)]")} aria-hidden />
<span className="font-semibold">
{state.expired ? t("expired") : t("endsIn", { days: Math.max(0, state.daysLeft ?? 0) })}
</span>
{!state.expired && state.endDateKey && <span>{t("endsOn", { date: formatDateKey(state.endDateKey, locale) })}</span>}
{state.expired && state.deletionDueAt && <span>{t("deletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</span>}
<span className="text-muted-foreground">
{contact ? (
<>
{t("contact", { email: "" })}
<a href={`mailto:${contact}`} className="font-semibold text-[var(--ui-primary)] underline">{contact}</a>
</>
) : (
t("contactGeneric")
)}
</span>
{state.expired && canExport && (
<Link href="/settings/export" className="ml-auto inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] underline">
{t("export")}
</Link>
)}
</div>
);
}