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,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