Merge lane/testphase in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # prisma/schema.prisma # scripts/test-e2e-tenant-isolation.ts # src/server/backup/topology.ts # src/server/db.ts # src/server/dsgvo/pii-fields.ts
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
let file: { storageKey: string; mimeType: string; fileName: string };
|
||||
try {
|
||||
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write"));
|
||||
const ctx = ctxFromGuard(await moduleGuard("imports", { read: true })("import:write")); // L15: GET download — no trial write lock
|
||||
const { id } = await params;
|
||||
file = await getImportFile(ctx, id);
|
||||
} catch {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { EmergencyWizard } from "@/components/emergency/emergency-wizard";
|
||||
|
||||
/** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */
|
||||
export default async function EmergencyPage() {
|
||||
const ctx = ctxFromGuard(await moduleGuard("emergency")());
|
||||
const ctx = ctxFromGuard(await moduleGuard("emergency", { read: true })()); // L15: read path — no trial write lock
|
||||
const t = await getTranslations("emergency.capture");
|
||||
if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) {
|
||||
return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||
|
||||
@@ -13,6 +13,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; lotseChat: boolean }> {
|
||||
@@ -55,6 +56,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} lotse={time.lotseChat} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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