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
+2
View File
@@ -26,6 +26,7 @@ import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started";
type SP = Record<string, string | string[] | undefined>;
@@ -65,6 +66,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
<GettingStarted ctx={ctx} welcome={sp.welcome === "1"} /> {/* L15 Testphase */}
{sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")}
+2
View File
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { storeFile } from "@/server/services/documents/store";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* Multipart upload for the backoffice document tabs (customer, site, /documents).
@@ -19,6 +20,7 @@ export async function POST(req: Request) {
try {
assertSameOrigin(req);
const ctx = await requireApiContext("documents");
await assertTenantWritable(ctx.tenantId); // L15: expired trial → read-only
let form: FormData;
try {
+3
View File
@@ -14,6 +14,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { NotificationBell } from "@/components/notifications/bell";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BackofficeFrame } from "@/components/backoffice-frame";
import { TrialBanner } from "@/components/trial/trial-banner";
export default async function AppLayout({
children,
@@ -111,6 +112,8 @@ export default async function AppLayout({
</form>
</>
}>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={session.user.tenantId} variant="backoffice" canExport={(session.user.permissions ?? []).includes("tenant:manage")} />
{children}
</BackofficeFrame>
);
@@ -0,0 +1,27 @@
import { requireApiContext } from "@/server/api/context";
import { toErrorResponse } from "@/server/api/respond";
import { openTenantExport } from "@/server/services/trial/export";
/**
* GET /settings/export/<id> — download of a finished tenant data export (L15 Testphase).
* Session + DB-authoritative `tenant:manage`, tenant-bound row; a read, therefore also allowed for
* an expired (read-only) trial tenant.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await requireApiContext(null, "tenant:manage");
const { id } = await params;
const { bytes, fileName } = await openTenantExport(ctx, id);
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fileName.replace(/[^\w.\-]/g, "_")}"`,
"Content-Length": String(bytes.length),
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (err) {
return toErrorResponse(err);
}
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { Download, PackageOpen } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { formatInstantDate } from "@/lib/trial/dates";
import { requestExportAction } from "@/server/actions/trial-tenant";
import { listTenantExports } from "@/server/services/trial/export";
const TONE = { queued: "warn", running: "warn", done: "ok", failed: "mut", expired: "mut" } as const;
/** L15 Testphase: data export for tenant administrators (also available in the read-only state). */
export default async function DataExportPage({ searchParams }: { searchParams: Promise<{ requested?: string; error?: string }> }) {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const sp = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.export"), getLocale()]);
const ctx = { db: dbForTenant(session.user.tenantId), tenantId: session.user.tenantId, userId: session.user.id, permissions: new Set(session.user.permissions ?? []) };
const exports = await listTenantExports(ctx);
const errorKey = sp.error === "export_running" ? "export_running" : "failed";
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex flex-wrap items-start gap-4">
<PackageOpen className="size-6 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm">{t("contents")}</p>
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("readOnlyHint")}</p>
</div>
<form action={requestExportAction}>
<Button type="submit" className="h-11">{t("request")}</Button>
</form>
</div>
{sp.requested && <p role="status" className="mt-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-sm text-[var(--ok)]">{t("requested")}</p>}
{sp.error && <p role="alert" className="mt-3 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(`errors.${errorKey}`)}</p>}
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
<h2 className="font-heading text-[15px] font-semibold">{t("listTitle")}</h2>
{exports.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="mt-2 divide-y">
{exports.map((e) => {
const status = e.expired ? "expired" : (e.status as keyof typeof TONE);
return (
<li key={e.id} className="flex flex-wrap items-center gap-3 py-2.5 text-sm">
<Pill tone={TONE[status] ?? "mut"}>{t(`status.${status}`)}</Pill>
<span className="text-muted-foreground">{t("created")}: {e.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { timeZone: "Europe/Berlin" })}</span>
{e.bytes != null && <span className="text-muted-foreground">{(e.bytes / 1024 / 1024).toFixed(1)} MB</span>}
{e.status === "failed" && e.error && <span className="text-[12.5px] text-[var(--risk)]">{e.error}</span>}
{e.status === "done" && !e.expired && (
<Link href={`/settings/export/${e.id}`} prefetch={false} className="ml-auto inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 font-semibold text-[var(--ui-primary)] hover:bg-muted">
<Download className="size-4" aria-hidden /> {t("download")}
{e.expiresAt && <span className="font-normal text-muted-foreground">({t("expires", { date: formatInstantDate(e.expiresAt, locale) })})</span>}
</Link>
)}
</li>
);
})}
</ul>
)}
</div>
</main>
);
}
+3
View File
@@ -12,6 +12,7 @@ import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge";
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime";
import { TrialBanner } from "@/components/trial/trial-banner";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
@@ -53,6 +54,8 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<OnlineBadge />
</div>
</header>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} />
+11 -1
View File
@@ -19,6 +19,7 @@ import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -27,7 +28,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -216,6 +217,15 @@ export default async function AdminTenantPage({
</div>
<div className="space-y-5">
{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */}
<TrialAdminCard
tenant={tenant}
isFullAdmin={isFullAdmin}
base={base}
op={sp.trial === "created" ? undefined : sp.trial}
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/>
{/* Lebenszyklus */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
+27 -1
View File
@@ -16,16 +16,22 @@ import {
import { createTenant } from "@/server/actions/admin";
import { getMailStatus } from "@/server/actions/mail";
import { MailStatusPanel } from "@/components/mail-status-panel";
import { getTranslations } from "next-intl/server";
import { TrialBadge } from "@/components/trial/trial-badge";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const params = await searchParams;
// L15 Testphase: Filter Test/Voll
const tt = await getTranslations("trial.platform");
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
const [tenants, mailStatus] = await Promise.all([
prisma.tenant.findMany({
where: planFilter ? { plan: planFilter } : undefined,
include: { _count: { select: { users: true } }, modules: true },
orderBy: { createdAt: "asc" },
}),
@@ -42,6 +48,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
actions={
<span className="flex items-center gap-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
<Plus className="size-4" /> Neuer Kunde
</Button>
@@ -92,6 +99,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</div>
)}
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
{[
{ key: null, href: "/admin", label: tt("filterAll") },
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
].map((f) => (
<Link
key={f.href}
href={f.href}
aria-current={planFilter === f.key ? "page" : undefined}
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
>
{f.label}
</Link>
))}
</nav>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
@@ -99,6 +123,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableHead>Kunde</TableHead>
<TableHead>Kürzel</TableHead>
<TableHead>Status</TableHead>
<TableHead>{tt("colPlan")}</TableHead>
<TableHead>Nutzer</TableHead>
<TableHead>Aktive Module</TableHead>
</TableRow>
@@ -114,6 +139,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</TableCell>
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
<TableCell><TrialBadge tenant={t} /></TableCell>
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
</TableRow>
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { PlatformTrialCreateForm } from "@/components/trial/platform-forms";
import { addDaysToKey, todayKey } from "@/lib/trial/dates";
import { PLATFORM_TRIAL_MAX_DAYS, TRIAL_DEFAULT_DAYS } from "@/server/services/trial/config";
export const dynamic = "force-dynamic";
/** L15 Testphase: platform wizard "Testmandant anlegen" (access: (platform)/layout.tsx; action: full admins). */
export default async function PlatformTrialWizardPage() {
const t = await getTranslations("trial.platform.wizard");
const ta = await getTranslations("admin");
const today = todayKey();
return (
<main className="flex-1 p-6">
<Link href="/admin" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> {ta("backToOverview")}
</Link>
<div className="mt-2 max-w-3xl">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<PlatformTrialCreateForm defaultEnd={addDaysToKey(today, TRIAL_DEFAULT_DAYS)} min={today} max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)} />
</div>
</main>
);
}
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
@@ -17,6 +18,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
try {
assertSameOrigin(req);
const ctx = await requireApiContext("work_orders", "document:write");
await assertTenantWritable(ctx.tenantId); // L15: not wrapped in withApi → explicit trial write lock
const form = await readFormData(req);
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { TrialConfirmForm } from "@/components/trial/confirm-form";
import { formatDateKey } from "@/lib/trial/dates";
import { peekTrialSignup } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("confirmTitle") };
}
export const dynamic = "force-dynamic";
/**
* L15 Testphase: landing page of the confirmation link. Shows the pending signup (link is only
* checked, not consumed); provisioning happens on the POST of the button (TrialConfirmForm).
*/
export default async function TrialConfirmPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
const { token = "" } = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.confirm"), getLocale()]);
const signup = await peekTrialSignup(token);
return (
<div className="shadow-card mt-4 w-full max-w-md self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{t("title")}</h1>
{signup ? (
<>
<p className="mt-1 text-sm text-muted-foreground">{t("intro")}</p>
<dl className="mt-4 divide-y rounded-lg border text-sm">
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("company")}</dt><dd className="text-right font-medium">{signup.companyName}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("until")}</dt><dd className="font-medium">{formatDateKey(signup.trialEndDate, locale)}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("sampleData")}</dt><dd className="font-medium">{signup.sampleData ? t("yes") : t("no")}</dd></div>
</dl>
<TrialConfirmForm token={token} />
</>
) : (
<>
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("invalid")}</p>
<div className="mt-4 flex flex-col gap-1 text-center">
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
<Link href="/login" className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">{t("toLogin")}</Link>
</div>
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("privacyTitle") };
}
/** L15 Testphase: placeholder privacy notice (operator replaces the text before go-live). */
export default async function TrialPrivacyPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.privacyTitle")} paragraphs={[t("legal.privacyBody1"), t("legal.privacyBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/** L15 Testphase: public shell of /testen (no session; see PUBLIC_PATHS in src/proxy.ts). */
export default async function TrialPublicLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const t = await getTranslations("trial.public");
return (
<div className="flex flex-1 flex-col bg-background">
<header className="flex items-center justify-between gap-3 px-4 py-4 sm:px-8">
<Link href="/testen" className="flex min-h-11 items-center" aria-label="Craftvia">
<CraftviaLogo variant="horizontal" height={32} />
</Link>
<p className="text-[13px] text-muted-foreground">
<span className="hidden sm:inline">{t("haveAccount")} </span>
<Link href="/login" className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)]">{t("login")}</Link>
</p>
</header>
<main className="flex flex-1 justify-center px-4 pb-10 sm:px-8">{children}</main>
<footer className="flex flex-wrap justify-center gap-4 border-t px-4 py-3 text-[12.5px] text-muted-foreground">
<Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center hover:text-foreground">{t("terms")}</Link>
<Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center hover:text-foreground">{t("privacy")}</Link>
</footer>
</div>
);
}
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("termsTitle") };
}
/** L15 Testphase: placeholder terms of use (operator replaces the text before go-live). */
export default async function TrialTermsPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.termsTitle")} paragraphs={[t("legal.termsBody1"), t("legal.termsBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { getLocale, getTranslations } from "next-intl/server";
import { CheckCircle2 } from "lucide-react";
import { TrialSignupWizard } from "@/components/trial/signup-wizard";
import { MODULES } from "@/lib/modules";
import { DEFAULT_PASSWORD_POLICY, describePasswordPolicy } from "@/lib/password-policy";
import { currentTrialBounds } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("title") };
}
// the date bounds depend on "today" — never prerender
export const dynamic = "force-dynamic";
/** L15 Testphase: public wizard "Kostenlos testen". */
export default async function TrialSignupPage() {
const [t, tm, locale] = await Promise.all([getTranslations("trial.public"), getTranslations("modules"), getLocale()]);
const modules = MODULES.map((m) => ({ key: m.key, label: tm(m.nav) }));
return (
<div className="grid w-full max-w-5xl gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<section className="pt-2 lg:pt-10">
<h1 className="font-heading text-[28px] leading-tight font-semibold sm:text-[34px]">{t("heading")}</h1>
<p className="mt-3 text-[15px] text-muted-foreground">{t("intro")}</p>
<ul className="mt-6 space-y-3">
{(["benefit1", "benefit2", "benefit3"] as const).map((k) => (
<li key={k} className="flex items-start gap-2.5 text-[14.5px]">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--ok)]" aria-hidden />
{t(k)}
</li>
))}
</ul>
</section>
<section className="shadow-card relative rounded-2xl border bg-card p-5 sm:p-8">
<TrialSignupWizard bounds={currentTrialBounds()} modules={modules} passwordPolicy={describePasswordPolicy(DEFAULT_PASSWORD_POLICY)} locale={locale} />
</section>
</div>
);
}