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 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 (
|
||||
<html lang="de" className="h-full">
|
||||
<body 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>
|
||||
</body>
|
||||
<body className="min-h-full">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 { 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>
|
||||
);
|
||||
}
|
||||
+7
-2
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import type { Prisma, QrCode } from "@/generated/prisma/client";
|
||||
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 { getQrType } from "@/lib/qr/registry";
|
||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||
@@ -41,7 +41,12 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
||||
let summary = "";
|
||||
if (qrType) {
|
||||
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);
|
||||
} catch {
|
||||
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
|
||||
|
||||
+22
-2
@@ -24,7 +24,27 @@ export function getBaseUrl(): string {
|
||||
* beendet.
|
||||
*/
|
||||
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";
|
||||
try {
|
||||
@@ -32,7 +52,7 @@ export function shortUrl(slug: string, hostname?: string): string {
|
||||
} catch {
|
||||
// Unbrauchbare BASE_URL — http ist die verträglichere Annahme.
|
||||
}
|
||||
return `${scheme}://${hostname}/r/${slug}`;
|
||||
return `${scheme}://${hostname}${path}`;
|
||||
}
|
||||
|
||||
export function getDatabaseUrl(): string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/lib/qr/type-module";
|
||||
import { appType } from "@/lib/qr/types/app";
|
||||
import { emailType } from "@/lib/qr/types/email";
|
||||
import { linksType } from "@/lib/qr/types/links";
|
||||
import { locationType } from "@/lib/qr/types/location";
|
||||
import { paymentType } from "@/lib/qr/types/payment";
|
||||
import { smsType } from "@/lib/qr/types/sms";
|
||||
@@ -28,12 +29,7 @@ const MODULES: readonly QrTypeModule[] = [
|
||||
websiteType,
|
||||
vcardType,
|
||||
appType,
|
||||
defineComingSoonType({
|
||||
id: "links",
|
||||
title: "Mehrere Links",
|
||||
description: "Eine Landingpage mit mehreren Zielen.",
|
||||
icon: "list",
|
||||
}),
|
||||
linksType,
|
||||
defineComingSoonType({
|
||||
id: "social",
|
||||
title: "Social Media",
|
||||
|
||||
@@ -63,6 +63,9 @@ export function assertDynamicPossible(
|
||||
`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 {
|
||||
qrType.redirectTarget(data, {});
|
||||
} catch (error) {
|
||||
|
||||
@@ -109,6 +109,16 @@ export interface QrTypeModule {
|
||||
readonly supportsStatic: boolean;
|
||||
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
|
||||
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. */
|
||||
readonly defaultData: QrFormData;
|
||||
/** Prüft Eingaben und liefert im Fehlerfall eine deutsche Meldung. */
|
||||
@@ -130,6 +140,8 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
|
||||
/** Voreinstellung: true. */
|
||||
supportsStatic?: boolean;
|
||||
supportsDynamic: boolean;
|
||||
/** Voreinstellung: false. Siehe `QrTypeModule.hasLandingPage`. */
|
||||
hasLandingPage?: boolean;
|
||||
schema: TSchema;
|
||||
defaultData: QrFormData;
|
||||
/**
|
||||
@@ -180,6 +192,7 @@ export function defineQrType<TSchema extends z.ZodType>(
|
||||
implemented: spec.implemented,
|
||||
supportsStatic: spec.supportsStatic ?? true,
|
||||
supportsDynamic: spec.supportsDynamic,
|
||||
hasLandingPage: spec.hasLandingPage ?? false,
|
||||
defaultData: spec.defaultData,
|
||||
validate(input) {
|
||||
const result = spec.schema.safeParse(input);
|
||||
@@ -221,6 +234,7 @@ export function defineComingSoonType(spec: {
|
||||
implemented: false,
|
||||
supportsStatic: false,
|
||||
supportsDynamic: false,
|
||||
hasLandingPage: false,
|
||||
defaultData: {},
|
||||
validate: () => ({
|
||||
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