Landingpages als eigene Gattung, erster Typ: Mehrere Links

Die sechs noch offenen Typen haben kein fremdes Ziel, auf das man
weiterleiten koennte — sie brauchen eine Seite. Dafuer bekommt der
Typ-Vertrag `hasLandingPage`: /r/[slug] schickt solche Codes auf /s/[slug],
statt `redirectTarget` zu befragen.

Die oeffentlichen Seiten duerfen die Navigation der Anwendung nicht zeigen —
wer einen Code scannt, hat mit „Verwaltung" nichts zu tun. Deshalb liegt das
App-Chrome jetzt in der Routengruppe (app); das Wurzel-Layout traegt nur noch
html und body. Die URLs aendern sich dadurch nicht.
This commit is contained in:
Florian Wilde
2026-08-14 22:27:49 +02:00
parent b3d386b0d2
commit e627f7c372
27 changed files with 678 additions and 124 deletions
+19
View File
@@ -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<Record<QrTypeId, ComponentType<LandingProps>>> =
{
links: LinksLanding,
};
export type { LandingProps };
+34
View File
@@ -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 (
<LandingShell title={title} subtitle={description || null}>
<ul className="space-y-3">
{links.map((link, index) => (
<li key={`${index}-${link.url}`}>
<a
href={link.url}
// Fremde Ziele: kein Zugriff auf dieses Fenster.
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
>
<span className="min-w-0 flex-1 font-medium break-words">
{link.label}
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
</li>
))}
</ul>
</LandingShell>
);
}
+66
View File
@@ -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 (
<div className="flex min-h-full flex-col bg-slate-50">
<main className="mx-auto w-full max-w-lg flex-1 px-4 py-10 sm:px-6 sm:py-14">
<header className="mb-7 text-center">
<h1 className="text-2xl font-bold tracking-tight text-balance text-slate-900 sm:text-3xl">
{title}
</h1>
{subtitle ? (
<p className="mt-2.5 text-pretty text-slate-600">{subtitle}</p>
) : null}
</header>
{children}
</main>
{footer ? (
<footer className="px-4 pb-8 text-center text-xs text-slate-400">
{footer}
</footer>
) : null}
</div>
);
}
/**
* 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 (
<div className="grid min-h-full place-items-center bg-slate-50 px-4 py-16">
<div className="max-w-md text-center">
<h1 className="text-xl font-bold text-slate-900 sm:text-2xl">
{title}
</h1>
<p className="mt-3 leading-relaxed text-pretty text-slate-600">
{children}
</p>
</div>
</div>
);
}
+14
View File
@@ -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;
};