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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user