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:
+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