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
+7 -2
View File
@@ -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
View File
@@ -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 {
+2 -6
View File
@@ -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",
+3
View File
@@ -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) {
+14
View File
@@ -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,
+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"})`,
});