diff --git a/src/app/anmelden/page.tsx b/src/app/(app)/anmelden/page.tsx
similarity index 100%
rename from src/app/anmelden/page.tsx
rename to src/app/(app)/anmelden/page.tsx
diff --git a/src/app/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx
similarity index 100%
rename from src/app/erstellen/[type]/page.tsx
rename to src/app/(app)/erstellen/[type]/page.tsx
diff --git a/src/app/kein-zugriff/page.tsx b/src/app/(app)/kein-zugriff/page.tsx
similarity index 100%
rename from src/app/kein-zugriff/page.tsx
rename to src/app/(app)/kein-zugriff/page.tsx
diff --git a/src/app/konto/page.tsx b/src/app/(app)/konto/page.tsx
similarity index 100%
rename from src/app/konto/page.tsx
rename to src/app/(app)/konto/page.tsx
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
new file mode 100644
index 0000000..a66a5c5
--- /dev/null
+++ b/src/app/(app)/layout.tsx
@@ -0,0 +1,116 @@
+import Link from "next/link";
+
+import { LogoutButton } from "@/components/auth-forms";
+import { Icon } from "@/components/icons";
+import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
+import { can } from "@/lib/auth/policy";
+
+// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
+// werden.
+export const dynamic = "force-dynamic";
+
+const NAV_LINK =
+ "rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
+
+/**
+ * Rahmen der eigentlichen Anwendung: Kopfzeile mit Navigation, Inhalt,
+ * Fußzeile. Die öffentlichen Landingpages liegen bewusst außerhalb dieser
+ * Gruppe und bekommen davon nichts ab.
+ */
+export default async function AppLayout({ children }: LayoutProps<"/">) {
+ const actor = await getActor();
+ const signedIn = actor.kind !== "anonymous";
+ const showAdmin = can(actor, "instance:manage");
+
+ return (
+
+
+
+
+
+
+
+ {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
+ Navigation nicht mehr in eine Zeile. */}
+ QR-Code-Generator
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx
similarity index 100%
rename from src/app/meine-codes/page.tsx
rename to src/app/(app)/meine-codes/page.tsx
diff --git a/src/app/page.tsx b/src/app/(app)/page.tsx
similarity index 100%
rename from src/app/page.tsx
rename to src/app/(app)/page.tsx
diff --git a/src/app/verwaltung/benutzer/page.tsx b/src/app/(app)/verwaltung/benutzer/page.tsx
similarity index 100%
rename from src/app/verwaltung/benutzer/page.tsx
rename to src/app/(app)/verwaltung/benutzer/page.tsx
diff --git a/src/app/verwaltung/domains/page.tsx b/src/app/(app)/verwaltung/domains/page.tsx
similarity index 100%
rename from src/app/verwaltung/domains/page.tsx
rename to src/app/(app)/verwaltung/domains/page.tsx
diff --git a/src/app/verwaltung/gruppen/page.tsx b/src/app/(app)/verwaltung/gruppen/page.tsx
similarity index 100%
rename from src/app/verwaltung/gruppen/page.tsx
rename to src/app/(app)/verwaltung/gruppen/page.tsx
diff --git a/src/app/verwaltung/page.tsx b/src/app/(app)/verwaltung/page.tsx
similarity index 100%
rename from src/app/verwaltung/page.tsx
rename to src/app/(app)/verwaltung/page.tsx
diff --git a/src/app/verwaltung/typen/page.tsx b/src/app/(app)/verwaltung/typen/page.tsx
similarity index 100%
rename from src/app/verwaltung/typen/page.tsx
rename to src/app/(app)/verwaltung/typen/page.tsx
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1fb47f2..7dfff57 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,10 +1,5 @@
import type { Metadata } from "next";
-import Link from "next/link";
-import { LogoutButton } from "@/components/auth-forms";
-import { Icon } from "@/components/icons";
-import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
-import { can } from "@/lib/auth/policy";
import "./globals.css";
export const metadata: Metadata = {
@@ -16,109 +11,18 @@ export const metadata: Metadata = {
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
};
-// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
-// werden.
-export const dynamic = "force-dynamic";
-
-const NAV_LINK =
- "rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
-
-export default async function RootLayout({ children }: LayoutProps<"/">) {
- const actor = await getActor();
- const signedIn = actor.kind !== "anonymous";
- const showAdmin = can(actor, "instance:manage");
-
+/**
+ * Wurzel-Layout — bewusst nur `html` und `body`.
+ *
+ * Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
+ * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
+ * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
+ * „Meine Codes“ nichts zu suchen.
+ */
+export default function RootLayout({ children }: LayoutProps<"/">) {
return (
-
-
-
-
-
-
-
- {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
- Navigation nicht mehr in eine Zeile. */}
- QR-Code-Generator
-
-
-
-
-
-
- {children}
-
-
-
+ {children}
);
}
diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[slug]/route.ts
index ad197c5..b5283bc 100644
--- a/src/app/r/[slug]/route.ts
+++ b/src/app/r/[slug]/route.ts
@@ -81,14 +81,21 @@ export async function GET(
}
let target: string;
- try {
- // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
- // wohin die Weiterleitung führt.
- target = qrType.redirectTarget(code.data, {
- userAgent: request.headers.get("user-agent") ?? undefined,
- });
- } catch {
- return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
+ if (qrType.hasLandingPage) {
+ // Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
+ // damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
+ // wenn das eine nicht registrierte ist, etwa eine IP im LAN).
+ target = `/s/${slug}`;
+ } else {
+ try {
+ // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
+ // wohin die Weiterleitung führt.
+ target = qrType.redirectTarget(code.data, {
+ userAgent: request.headers.get("user-agent") ?? undefined,
+ });
+ } catch {
+ return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
+ }
}
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
diff --git a/src/app/s/[slug]/page.tsx b/src/app/s/[slug]/page.tsx
new file mode 100644
index 0000000..69fabaa
--- /dev/null
+++ b/src/app/s/[slug]/page.tsx
@@ -0,0 +1,102 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+
+import { qrLandings } from "@/components/landing";
+import { LandingNotice } from "@/components/landing/shell";
+import { prisma } from "@/lib/db";
+import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
+import { getQrType } from "@/lib/qr/registry";
+import { isValidSlug } from "@/lib/slug";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ slug: string }> };
+
+/**
+ * Öffentliche Landingpage eines dynamischen Codes.
+ *
+ * Hierher schickt `/r/[slug]` alle Typen, die keine fremde Adresse haben,
+ * sondern eine Seite: Linksammlungen, hinterlegte Dateien, Feedback.
+ *
+ * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
+ * Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
+ */
+async function loadCode(slug: string) {
+ if (!isValidSlug(slug)) return null;
+
+ const matched = await resolveRequestDomain(await headers());
+ const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
+ if (!domain.isActive) return null;
+
+ return prisma.qrCode.findUnique({
+ where: { domainId_slug: { domainId: domain.id, slug } },
+ });
+}
+
+export async function generateMetadata({
+ params,
+}: PageProps): Promise {
+ const { slug } = await params;
+ const code = await loadCode(slug);
+ const qrType = code ? getQrType(code.type) : undefined;
+
+ if (!code || !qrType?.hasLandingPage) {
+ return { title: "Nicht gefunden" };
+ }
+
+ let title = qrType.title;
+ try {
+ title = qrType.summary(code.data);
+ } catch {
+ // Daten passen nicht mehr zum Schema — der Typname tut es auch.
+ }
+ // Absolut, damit der Titel nicht mit dem Anwendungsnamen ergänzt wird:
+ // Wer hier landet, hat mit dem Generator nichts zu tun.
+ return { title: { absolute: title } };
+}
+
+export default async function LandingPage({ params }: PageProps) {
+ const { slug } = await params;
+ const code = await loadCode(slug);
+
+ if (!code) {
+ return (
+
+ Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an
+ denjenigen, der ihn erstellt hat.
+
+ );
+ }
+
+ const qrType = getQrType(code.type);
+ const Landing = qrType?.hasLandingPage ? qrLandings[qrType.id] : undefined;
+
+ if (!qrType || !Landing) {
+ return (
+
+ Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
+ der ihn erstellt hat.
+
+ );
+ }
+
+ // Alte Datensätze können nach einer Schema-Änderung unbrauchbar sein. Das
+ // ist kein Grund, den Besuchern einen Absturz zu zeigen.
+ if (!qrType.validate(code.data).ok) {
+ return (
+
+ Die hinterlegten Inhalte sind unvollständig. Bitte wende dich an
+ denjenigen, der den Code erstellt hat.
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/landing/index.ts b/src/components/landing/index.ts
new file mode 100644
index 0000000..d1f9fc1
--- /dev/null
+++ b/src/components/landing/index.ts
@@ -0,0 +1,19 @@
+import type { ComponentType } from "react";
+
+import { LinksLanding } from "@/components/landing/links-landing";
+import type { LandingProps } from "@/components/landing/types";
+import type { QrTypeId } from "@/lib/qr/type-module";
+
+/**
+ * Zuordnung QR-Typ → öffentliche Landingpage.
+ *
+ * Dritter Teil des Musters aus Typ-Registry (`src/lib/qr/registry.ts`) und
+ * Formular-Registry (`src/components/qr-forms/index.ts`): Ein Typ, der
+ * `hasLandingPage` meldet, braucht hier genau einen Eintrag.
+ */
+export const qrLandings: Partial>> =
+ {
+ links: LinksLanding,
+ };
+
+export type { LandingProps };
diff --git a/src/components/landing/links-landing.tsx b/src/components/landing/links-landing.tsx
new file mode 100644
index 0000000..1d41043
--- /dev/null
+++ b/src/components/landing/links-landing.tsx
@@ -0,0 +1,34 @@
+import { Icon } from "@/components/icons";
+import { LandingShell } from "@/components/landing/shell";
+import type { LandingProps } from "@/components/landing/types";
+import { linksDataSchema } from "@/lib/qr/types/links";
+
+export function LinksLanding({ data }: LandingProps) {
+ const { title, description, links } = linksDataSchema.parse(data);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/landing/shell.tsx b/src/components/landing/shell.tsx
new file mode 100644
index 0000000..37070a0
--- /dev/null
+++ b/src/components/landing/shell.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from "react";
+
+/**
+ * Rahmen aller öffentlichen Landingpages.
+ *
+ * Diese Seiten sehen Leute, die einen Code gescannt haben — meist auf dem
+ * Handy, oft unterwegs, ohne Bezug zu dieser Anwendung. Deshalb: eine Spalte,
+ * große Schrift, keine Navigation, kein Hinweis auf die Verwaltung.
+ */
+export function LandingShell({
+ title,
+ subtitle,
+ children,
+ footer,
+}: {
+ title: string;
+ subtitle?: string | null;
+ children: ReactNode;
+ footer?: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Ein Fehlerbild im selben Gewand — etwa wenn ein Code gelöscht wurde.
+ * Bewusst ohne Schuldzuweisung: wer hier landet, kann nichts dafür.
+ */
+export function LandingNotice({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/components/landing/types.ts b/src/components/landing/types.ts
new file mode 100644
index 0000000..2c625cb
--- /dev/null
+++ b/src/components/landing/types.ts
@@ -0,0 +1,14 @@
+/**
+ * Was eine Landingpage über den Code weiß, den jemand gescannt hat.
+ *
+ * `data` ist bereits vom Typ-Modul geprüft; die Komponente parst es nur noch
+ * einmal mit ihrem eigenen Schema, um typisiert damit zu arbeiten.
+ */
+export type LandingProps = {
+ data: unknown;
+ /** Interner Name des Codes — nicht öffentlich, nur als Rückfallebene. */
+ label: string | null;
+ slug: string;
+ /** Datenbank-Id des Codes, für Landingpages mit Rückmeldung. */
+ codeId: string;
+};
diff --git a/src/components/qr-forms/index.ts b/src/components/qr-forms/index.ts
index df4a4d9..78ca41e 100644
--- a/src/components/qr-forms/index.ts
+++ b/src/components/qr-forms/index.ts
@@ -1,5 +1,6 @@
import type { ComponentType } from "react";
+import { LinksForm } from "@/components/qr-forms/links-form";
import { PaymentForm } from "@/components/qr-forms/payment-form";
import {
AppForm,
@@ -25,6 +26,7 @@ export const qrForms: Partial>> = {
website: WebsiteForm,
vcard: VCardForm,
app: AppForm,
+ links: LinksForm,
whatsapp: WhatsappForm,
email: EmailForm,
sms: SmsForm,
diff --git a/src/components/qr-forms/links-form.tsx b/src/components/qr-forms/links-form.tsx
new file mode 100644
index 0000000..62c1d1d
--- /dev/null
+++ b/src/components/qr-forms/links-form.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { Icon } from "@/components/icons";
+import type { QrFormProps } from "@/components/qr-forms/types";
+import { Button, Field, TextArea, TextInput } from "@/components/ui";
+import { MAX_LINKS, type LinkEntry } from "@/lib/qr/types/links";
+
+/**
+ * Liest die Linkliste aus den Formulardaten heraus.
+ *
+ * Die Daten kommen als `QrFormData` — ein flaches Objekt ohne Garantien.
+ * Beim Bearbeiten eines gespeicherten Codes stammen sie aus der Datenbank und
+ * können nach einer Schema-Änderung unvollständig sein; deshalb hier prüfen
+ * statt annehmen.
+ */
+function readLinks(value: unknown): LinkEntry[] {
+ if (!Array.isArray(value)) return [{ label: "", url: "" }];
+ const entries = value.map((entry) => {
+ const record = (entry ?? {}) as Record;
+ return {
+ label: typeof record.label === "string" ? record.label : "",
+ url: typeof record.url === "string" ? record.url : "",
+ };
+ });
+ return entries.length > 0 ? entries : [{ label: "", url: "" }];
+}
+
+export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
+ const title = typeof value.title === "string" ? value.title : "";
+ const description =
+ typeof value.description === "string" ? value.description : "";
+ const links = readLinks(value.links);
+
+ const setLinks = (next: LinkEntry[]) => onChange({ ...value, links: next });
+
+ const updateLink = (index: number, patch: Partial) =>
+ setLinks(links.map((link, i) => (i === index ? { ...link, ...patch } : link)));
+
+ const move = (index: number, direction: -1 | 1) => {
+ const target = index + direction;
+ if (target < 0 || target >= links.length) return;
+ const next = [...links];
+ [next[index], next[target]] = [next[target], next[index]];
+ setLinks(next);
+ };
+
+ return (
+