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,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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"})`,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user