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
+116
View File
@@ -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 (
<div className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title={`Konto von ${actor.user.email}`}
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
{actorLabel(actor).slice(0, 2)}
</span>
<span className="hidden truncate sm:inline">
{actorLabel(actor)}
</span>
</Link>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
{!isAuthEnabled() ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
Ohne Anmeldung
</Link>
</>
) : null}
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes
</div>
</footer>
</div>
);
}
+10 -106
View File
@@ -1,10 +1,5 @@
import type { Metadata } from "next"; 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"; import "./globals.css";
export const metadata: Metadata = { 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.", "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. * Wurzel-Layout — bewusst nur `html` und `body`.
export const dynamic = "force-dynamic"; *
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
const NAV_LINK = * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
"rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"; * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
* „Meine Codes“ nichts zu suchen.
export default async function RootLayout({ children }: LayoutProps<"/">) { */
const actor = await getActor(); export default function RootLayout({ children }: LayoutProps<"/">) {
const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage");
return ( return (
<html lang="de" className="h-full"> <html lang="de" className="h-full">
<body className="flex min-h-full flex-col"> <body className="min-h-full">{children}</body>
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title={`Konto von ${actor.user.email}`}
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
{actorLabel(actor).slice(0, 2)}
</span>
<span className="hidden truncate sm:inline">
{actorLabel(actor)}
</span>
</Link>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
{!isAuthEnabled() ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
Ohne Anmeldung
</Link>
</>
) : null}
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes
</div>
</footer>
</body>
</html> </html>
); );
} }
+7
View File
@@ -81,6 +81,12 @@ export async function GET(
} }
let target: string; let target: string;
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 { try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
// wohin die Weiterleitung führt. // wohin die Weiterleitung führt.
@@ -90,6 +96,7 @@ export async function GET(
} catch { } catch {
return notFound("Das Ziel dieses Codes ist nicht mehr gültig."); return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
} }
}
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren. // Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
try { try {
+102
View File
@@ -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<Metadata> {
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 (
<LandingNotice title="Diese Seite gibt es nicht">
Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an
denjenigen, der ihn erstellt hat.
</LandingNotice>
);
}
const qrType = getQrType(code.type);
const Landing = qrType?.hasLandingPage ? qrLandings[qrType.id] : undefined;
if (!qrType || !Landing) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
// 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 (
<LandingNotice title="Diese Seite ist gerade nicht verfügbar">
Die hinterlegten Inhalte sind unvollständig. Bitte wende dich an
denjenigen, der den Code erstellt hat.
</LandingNotice>
);
}
return (
<Landing
data={code.data}
label={code.label}
slug={code.slug}
codeId={code.id}
/>
);
}
+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;
};
+2
View File
@@ -1,5 +1,6 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { LinksForm } from "@/components/qr-forms/links-form";
import { PaymentForm } from "@/components/qr-forms/payment-form"; import { PaymentForm } from "@/components/qr-forms/payment-form";
import { import {
AppForm, AppForm,
@@ -25,6 +26,7 @@ export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
website: WebsiteForm, website: WebsiteForm,
vcard: VCardForm, vcard: VCardForm,
app: AppForm, app: AppForm,
links: LinksForm,
whatsapp: WhatsappForm, whatsapp: WhatsappForm,
email: EmailForm, email: EmailForm,
sms: SmsForm, sms: SmsForm,
+177
View File
@@ -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<string, unknown>;
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<LinkEntry>) =>
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 (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="links-title"
hint="Steht groß über der Linkliste, z. B. der Projektname."
>
<TextInput
id="links-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Projekt Nordwind"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="links-description"
hint="Eine Zeile zur Einordnung — was findet man hier?"
>
<TextArea
id="links-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
placeholder="Alle Unterlagen zum Projekt auf einen Blick."
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Links</span>
<span className="text-xs text-slate-500">
{links.length} von {MAX_LINKS}
</span>
</div>
<ul className="space-y-3">
{links.map((link, index) => (
<li
key={index}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<TextInput
aria-label={`Beschriftung von Link ${index + 1}`}
value={link.label}
disabled={disabled}
maxLength={80}
placeholder="Beschriftung, z. B. Preisliste"
onChange={(event) =>
updateLink(index, { label: event.target.value })
}
/>
<TextInput
aria-label={`Adresse von Link ${index + 1}`}
inputMode="url"
spellCheck={false}
value={link.url}
disabled={disabled}
maxLength={2048}
placeholder="https://example.com/preisliste.pdf"
onChange={(event) =>
updateLink(index, { url: event.target.value })
}
/>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Link ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 rotate-90" />
</button>
<button
type="button"
disabled={disabled || index === links.length - 1}
onClick={() => move(index, 1)}
aria-label={`Link ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 -rotate-90" />
</button>
<button
type="button"
disabled={disabled || links.length === 1}
onClick={() => setLinks(links.filter((_, i) => i !== index))}
aria-label={`Link ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</div>
</li>
))}
</ul>
{links.length < MAX_LINKS ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={() => setLinks([...links, { label: "", url: "" }])}
className="mt-3"
>
Link hinzufügen
</Button>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : null}
</div>
</div>
);
}
+7 -2
View File
@@ -2,7 +2,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client"; import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { shortUrl } from "@/lib/env"; import { landingUrl, shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design"; import type { EcLevel } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -41,7 +41,12 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
let summary = ""; let summary = "";
if (qrType) { if (qrType) {
try { try {
target = qrType.redirectTarget(data); // Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
// sich anzusehen.
target = qrType.hasLandingPage
? landingUrl(code.slug, code.domain?.hostname)
: qrType.redirectTarget(data);
summary = qrType.summary(data); summary = qrType.summary(data);
} catch { } catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem // Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
+22 -2
View File
@@ -24,7 +24,27 @@ export function getBaseUrl(): string {
* beendet. * beendet.
*/ */
export function shortUrl(slug: string, hostname?: string): string { export function shortUrl(slug: string, hostname?: string): string {
if (!hostname) return `${getBaseUrl()}/r/${slug}`; return publicUrl(`/r/${slug}`, hostname);
}
/**
* Adresse der Landingpage eines Codes — also der Seite, die diese Anwendung
* selbst ausliefert, statt auf ein fremdes Ziel weiterzuleiten.
*/
export function landingUrl(slug: string, hostname?: string): string {
return publicUrl(`/s/${slug}`, hostname);
}
/**
* Öffentliche Adresse eines Pfades.
*
* Ohne Hostname gilt BASE_URL. Mit Hostname — dem, dem der Code zugeordnet
* ist — wird dessen Domain verwendet; das Schema (http/https) kommt weiter aus
* BASE_URL, weil die Anwendung selbst nicht weiß, ob ein Proxy davor TLS
* beendet.
*/
export function publicUrl(path: string, hostname?: string): string {
if (!hostname) return `${getBaseUrl()}${path}`;
let scheme = "http"; let scheme = "http";
try { try {
@@ -32,7 +52,7 @@ export function shortUrl(slug: string, hostname?: string): string {
} catch { } catch {
// Unbrauchbare BASE_URL — http ist die verträglichere Annahme. // Unbrauchbare BASE_URL — http ist die verträglichere Annahme.
} }
return `${scheme}://${hostname}/r/${slug}`; return `${scheme}://${hostname}${path}`;
} }
export function getDatabaseUrl(): string { export function getDatabaseUrl(): string {
+2 -6
View File
@@ -7,6 +7,7 @@ import {
} from "@/lib/qr/type-module"; } from "@/lib/qr/type-module";
import { appType } from "@/lib/qr/types/app"; import { appType } from "@/lib/qr/types/app";
import { emailType } from "@/lib/qr/types/email"; import { emailType } from "@/lib/qr/types/email";
import { linksType } from "@/lib/qr/types/links";
import { locationType } from "@/lib/qr/types/location"; import { locationType } from "@/lib/qr/types/location";
import { paymentType } from "@/lib/qr/types/payment"; import { paymentType } from "@/lib/qr/types/payment";
import { smsType } from "@/lib/qr/types/sms"; import { smsType } from "@/lib/qr/types/sms";
@@ -28,12 +29,7 @@ const MODULES: readonly QrTypeModule[] = [
websiteType, websiteType,
vcardType, vcardType,
appType, appType,
defineComingSoonType({ linksType,
id: "links",
title: "Mehrere Links",
description: "Eine Landingpage mit mehreren Zielen.",
icon: "list",
}),
defineComingSoonType({ defineComingSoonType({
id: "social", id: "social",
title: "Social Media", title: "Social Media",
+3
View File
@@ -63,6 +63,9 @@ export function assertDynamicPossible(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`, `Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
); );
} }
// Typen mit eigener Landingpage haben kein fremdes Ziel, das scheitern
// könnte — die Probe wäre gegenstandslos.
if (qrType.hasLandingPage) return;
try { try {
qrType.redirectTarget(data, {}); qrType.redirectTarget(data, {});
} catch (error) { } catch (error) {
+14
View File
@@ -109,6 +109,16 @@ export interface QrTypeModule {
readonly supportsStatic: boolean; readonly supportsStatic: boolean;
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */ /** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
readonly supportsDynamic: boolean; readonly supportsDynamic: boolean;
/**
* Führt ein dynamischer Code dieses Typs auf eine Seite, die diese
* Anwendung selbst ausliefert?
*
* Solche Typen — eine Linksammlung, ein hinterlegtes PDF, ein
* Feedback-Formular — haben kein fremdes Ziel, auf das man weiterleiten
* könnte. Statt `redirectTarget` zu befragen, schickt `/r/[slug]` sie auf
* die Landingpage unter `/s/[slug]`.
*/
readonly hasLandingPage: boolean;
/** Startwerte für ein leeres Formular. */ /** Startwerte für ein leeres Formular. */
readonly defaultData: QrFormData; readonly defaultData: QrFormData;
/** Prüft Eingaben und liefert im Fehlerfall eine deutsche Meldung. */ /** Prüft Eingaben und liefert im Fehlerfall eine deutsche Meldung. */
@@ -130,6 +140,8 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
/** Voreinstellung: true. */ /** Voreinstellung: true. */
supportsStatic?: boolean; supportsStatic?: boolean;
supportsDynamic: boolean; supportsDynamic: boolean;
/** Voreinstellung: false. Siehe `QrTypeModule.hasLandingPage`. */
hasLandingPage?: boolean;
schema: TSchema; schema: TSchema;
defaultData: QrFormData; defaultData: QrFormData;
/** /**
@@ -180,6 +192,7 @@ export function defineQrType<TSchema extends z.ZodType>(
implemented: spec.implemented, implemented: spec.implemented,
supportsStatic: spec.supportsStatic ?? true, supportsStatic: spec.supportsStatic ?? true,
supportsDynamic: spec.supportsDynamic, supportsDynamic: spec.supportsDynamic,
hasLandingPage: spec.hasLandingPage ?? false,
defaultData: spec.defaultData, defaultData: spec.defaultData,
validate(input) { validate(input) {
const result = spec.schema.safeParse(input); const result = spec.schema.safeParse(input);
@@ -221,6 +234,7 @@ export function defineComingSoonType(spec: {
implemented: false, implemented: false,
supportsStatic: false, supportsStatic: false,
supportsDynamic: false, supportsDynamic: false,
hasLandingPage: false,
defaultData: {}, defaultData: {},
validate: () => ({ validate: () => ({
ok: false, ok: false,
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { isHttpUrl } from "@/lib/qr/encoding";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
/** Mehr passt auf eine Handy-Seite ohnehin nicht sinnvoll. */
export const MAX_LINKS = 20;
export const linkEntrySchema = z.object({
label: z
.string()
.trim()
.min(1, { error: "Bitte gib jedem Link eine Beschriftung." })
.max(80, { error: "Die Beschriftung ist zu lang (höchstens 80 Zeichen)." }),
url: z
.string()
.trim()
.min(1, { error: "Bitte gib zu jeder Beschriftung auch eine Adresse an." })
.max(2048, { error: "Die Adresse ist zu lang." })
.refine(isHttpUrl, {
error:
"Jede Adresse muss mit http:// oder https:// beginnen, z. B. https://example.com",
}),
});
export const linksDataSchema = z.object({
title: z
.string()
.trim()
.min(1, { error: "Bitte gib der Seite eine Überschrift." })
.max(80, { error: "Die Überschrift ist zu lang (höchstens 80 Zeichen)." }),
description: z
.string()
.trim()
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
.default(""),
links: z
.array(linkEntrySchema)
.min(1, { error: "Bitte lege mindestens einen Link an." })
.max(MAX_LINKS, {
error: `Mehr als ${MAX_LINKS} Links sind nicht vorgesehen.`,
}),
});
export type LinksData = z.infer<typeof linksDataSchema>;
export type LinkEntry = z.infer<typeof linkEntrySchema>;
export const linksType = defineQrType({
id: "links",
title: "Mehrere Links",
description: "Eine Landingpage mit mehreren Zielen.",
icon: "list",
implemented: true,
// Eine Linksammlung braucht eine Seite, die sie anzeigt. In einen statischen
// Code passt immer nur eine einzige Adresse.
supportsStatic: false,
supportsDynamic: true,
hasLandingPage: true,
schema: linksDataSchema,
defaultData: {
title: "",
description: "",
links: [
{ label: "", url: "" },
{ label: "", url: "" },
],
},
encode: () => {
throw new QrTypeError(
"Eine Linksammlung funktioniert nur als dynamischer Code — sie braucht eine Seite, die die Links anzeigt.",
);
},
summary: (data) =>
`${data.title} (${data.links.length} ${data.links.length === 1 ? "Link" : "Links"})`,
});