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