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:
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<Record<QrTypeId, ComponentType<QrFormProps>>> = {
|
||||
website: WebsiteForm,
|
||||
vcard: VCardForm,
|
||||
app: AppForm,
|
||||
links: LinksForm,
|
||||
whatsapp: WhatsappForm,
|
||||
email: EmailForm,
|
||||
sms: SmsForm,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user