Phase A: acht weitere QR-Typen

E-Mail, SMS, WhatsApp, WLAN, Standort, vCard, Zahlung und App-Link sind
umgesetzt. Damit sind 9 der 15 Typen nutzbar.

Erweiterungen am Typ-Vertrag, die dafuer noetig waren:

- supportsStatic: nicht jeder Typ kann statisch. Beim App-Link steht das
  Ziel erst beim Scannen fest, also gibt es ihn nur dynamisch.
- RedirectContext: die Weiterleitung reicht den User-Agent durch, damit der
  App-Link iOS, Android und alles Uebrige unterscheiden kann.
- assertDynamicPossible(): supportsDynamic ist eine Eigenschaft des Typs,
  aber nicht jede Eingabe taugt dafuer. Ein GiroCode ist kein Web-Ziel,
  obwohl der Typ 'Zahlung' als Zahlungslink sehr wohl dynamisch sein kann.
  Ohne diese Probe liess sich ein Code speichern, der erst beim Scannen
  aufgefallen waere - also nach dem Drucken. Greift beim Anlegen und beim
  Bearbeiten.

Sorgfalt bei den Formaten, gesammelt in src/lib/qr/encoding.ts:

- WLAN maskiert ; , : \\ und setzt rein hexadezimale Werte in
  Anfuehrungszeichen, sonst deuten Geraete sie als Hexzahl.
- vCard 3.0 mit CRLF, Maskierung und Faltung nach 75 Oktetten, oktettweise
  gezaehlt, damit kein mehrbytiges Zeichen zerrissen wird.
- GiroCode nach EPC069-12 mit fester Feldreihenfolge, IBAN-Pruefsumme nach
  Modulo 97 und der 331-Byte-Grenze.
- Rufnummern werden nach E.164 normalisiert, '(0)' faellt weg.
- Standort nimmt Koordinaten auch aus einem Google-Maps- oder Apple-Link an.

Formular-Bausteine (Select, Textarea, Checkbox, FieldRow, FieldSet) fuer die
mehrfeldrigen Typen ergaenzt.

Geprueft: 225 Pruefungen, davon 80 neu - Dekodierung je Typ aus dem fertigen
PNG, Validierung, Betriebsarten, geraeteabhaengige Weiterleitung und die
Formulare im Browser.
This commit is contained in:
Florian Wilde
2026-08-14 11:30:09 +02:00
parent c6da20f419
commit 7f7cb6fa20
24 changed files with 1693 additions and 81 deletions
+208
View File
@@ -0,0 +1,208 @@
/**
* Bausteine für die Kodierung der QR-Nutzdaten.
*
* Die Formate stammen aus unterschiedlichen Welten und maskieren jeweils
* anders. Diese Datei sammelt die Regeln an einer Stelle, damit sie nicht in
* jedem Typ-Modul neu — und leicht falsch — entstehen.
*/
/* -------------------------------------------------------------- Rufnummern */
/**
* Bringt eine Rufnummer in die Form, die `tel:`, `SMSTO:` und wa.me erwarten:
* nur Ziffern, führendes Pluszeichen erhalten.
*
* `(0)` in Schreibweisen wie „+49 (0)30 …“ wird entfernt — die Null gehört
* dort ausdrücklich nicht in die internationale Nummer.
*/
export function normalizePhone(value: string): string {
const cleaned = value.trim().replace(/\(0\)/g, "");
const digits = cleaned.replace(/[^\d+]/g, "");
const plus = digits.startsWith("+");
return (plus ? "+" : "") + digits.replace(/\+/g, "");
}
/** Nur Ziffern, ohne Pluszeichen — das erwartet wa.me. */
export function phoneDigits(value: string): string {
return normalizePhone(value).replace(/^\+/, "");
}
/**
* Eine international eindeutige Nummer im Format E.164: führendes Plus,
* Ländervorwahl, insgesamt 8 bis 15 Ziffern.
*/
export function isE164(value: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(normalizePhone(value));
}
/* -------------------------------------------------------------------- URLs */
export function isHttpUrl(value: string): boolean {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
parsed.hostname.length > 0
);
}
/** Baut eine Abfragezeichenfolge; leere Werte fallen weg. */
export function queryString(
params: Record<string, string | undefined>,
): string {
const parts = Object.entries(params)
.filter(([, value]) => value !== undefined && value.length > 0)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`,
);
return parts.length > 0 ? `?${parts.join("&")}` : "";
}
/* -------------------------------------------------------------------- WLAN */
/**
* Maskierung für das WIFI-Format. Sonderzeichen müssen mit einem
* Rückwärtsstrich versehen werden, sonst bricht das Feld ab — mit Abstand die
* häufigste Fehlerquelle bei WLAN-Codes.
*/
export function escapeWifi(value: string): string {
return value.replace(/([\\;,:"])/g, "\\$1");
}
/**
* Besteht ein Wert nur aus Hexadezimalziffern, deuten manche Geräte ihn als
* Hexzahl statt als Text. Anführungszeichen erzwingen die Textlesart.
*/
export function quoteWifiIfHex(value: string): string {
const escaped = escapeWifi(value);
return /^[0-9a-fA-F]+$/.test(value) && value.length > 0
? `"${escaped}"`
: escaped;
}
/* ------------------------------------------------------------------ vCard */
/** Maskierung für vCard-Werte nach RFC 6350. */
export function escapeVCard(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
/**
* Faltet eine vCard-Zeile nach 75 Oktetten, wie es die Spezifikation
* verlangt: Umbruch, dann ein führendes Leerzeichen in der Folgezeile.
*
* Gezählt werden Oktette, nicht Zeichen — ein Umbruch mitten in einem
* mehrbytigen Zeichen würde die Datei zerstören.
*/
export function foldVCardLine(line: string, limit = 75): string {
const encoder = new TextEncoder();
if (encoder.encode(line).length <= limit) return line;
const out: string[] = [];
let current = "";
let currentBytes = 0;
// Ab der zweiten Zeile kostet das führende Leerzeichen ein Oktett.
let budget = limit;
for (const char of line) {
const size = encoder.encode(char).length;
if (currentBytes + size > budget) {
out.push(current);
current = char;
currentBytes = size;
budget = limit - 1;
} else {
current += char;
currentBytes += size;
}
}
out.push(current);
return out.join("\r\n ");
}
/** Setzt vCard-Zeilen zusammen: gefaltet und mit CRLF getrennt. */
export function joinVCardLines(lines: string[]): string {
return lines.map((line) => foldVCardLine(line)).join("\r\n");
}
/* ------------------------------------------------------------------- IBAN */
/** Prüft eine IBAN über die Modulo-97-Prüfsumme nach ISO 13616. */
export function isValidIban(value: string): boolean {
const iban = value.replace(/\s+/g, "").toUpperCase();
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) return false;
// Die ersten vier Zeichen ans Ende, Buchstaben zu Zahlen (A=10 … Z=35).
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (char) =>
String(char.charCodeAt(0) - 55),
);
// Stückweise rechnen — die Zahl wäre für einen JS-Number zu groß.
let remainder = 0;
for (const digit of numeric) {
remainder = (remainder * 10 + Number(digit)) % 97;
}
return remainder === 1;
}
export function formatIban(value: string): string {
return (
value
.replace(/\s+/g, "")
.toUpperCase()
.match(/.{1,4}/g)
?.join(" ") ?? ""
);
}
/* ------------------------------------------------------------- Geokoordinaten */
/**
* Zieht Koordinaten aus einem Kartenlink oder einer Texteingabe.
* Erkannt werden unter anderem:
* https://www.google.com/maps/@52.5163,13.3777,15z
* https://www.google.com/maps?q=52.5163,13.3777
* https://maps.apple.com/?ll=52.5163,13.3777
* 52.5163, 13.3777
*/
export function parseCoordinates(
input: string,
): { latitude: number; longitude: number } | null {
const patterns = [
/[@!]\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/,
/(?:^|[?&])(?:q|ll|daddr|sll)=(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/,
/^\s*(-?\d+(?:\.\d+)?)\s*[,;]\s*(-?\d+(?:\.\d+)?)\s*$/,
];
for (const pattern of patterns) {
const match = pattern.exec(input);
if (!match) continue;
const latitude = Number(match[1]);
const longitude = Number(match[2]);
if (
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
Math.abs(latitude) <= 90 &&
Math.abs(longitude) <= 180
) {
return { latitude, longitude };
}
}
return null;
}
/** Koordinate ohne unnötige Nullen, auf sechs Nachkommastellen begrenzt. */
export function formatCoordinate(value: number): string {
return String(Number(value.toFixed(6)));
}
+16 -48
View File
@@ -5,7 +5,15 @@ import {
type QrTypeId,
type QrTypeModule,
} from "@/lib/qr/type-module";
import { appType } from "@/lib/qr/types/app";
import { emailType } from "@/lib/qr/types/email";
import { locationType } from "@/lib/qr/types/location";
import { paymentType } from "@/lib/qr/types/payment";
import { smsType } from "@/lib/qr/types/sms";
import { vcardType } from "@/lib/qr/types/vcard";
import { websiteType } from "@/lib/qr/types/website";
import { whatsappType } from "@/lib/qr/types/whatsapp";
import { wifiType } from "@/lib/qr/types/wifi";
/**
* Zentrale Registry aller QR-Typen. Die Reihenfolge bestimmt die Reihenfolge
@@ -18,18 +26,8 @@ import { websiteType } from "@/lib/qr/types/website";
*/
const MODULES: readonly QrTypeModule[] = [
websiteType,
defineComingSoonType({
id: "vcard",
title: "vCard",
description: "Digitale Visitenkarte mit Kontaktdaten.",
icon: "contact",
}),
defineComingSoonType({
id: "app",
title: "App-Link",
description: "Leite je nach Gerät in den passenden App-Store.",
icon: "smartphone",
}),
vcardType,
appType,
defineComingSoonType({
id: "links",
title: "Mehrere Links",
@@ -42,12 +40,7 @@ const MODULES: readonly QrTypeModule[] = [
description: "Bündle deine Profile in einem Code.",
icon: "share",
}),
defineComingSoonType({
id: "whatsapp",
title: "WhatsApp",
description: "Starte einen Chat mit vorbereiteter Nachricht.",
icon: "chat",
}),
whatsappType,
defineComingSoonType({
id: "pdf",
title: "PDF",
@@ -66,36 +59,11 @@ const MODULES: readonly QrTypeModule[] = [
description: "Verlinke ein Video oder einen Stream.",
icon: "video",
}),
defineComingSoonType({
id: "email",
title: "E-Mail",
description: "Öffne eine vorbereitete E-Mail.",
icon: "mail",
}),
defineComingSoonType({
id: "sms",
title: "SMS",
description: "Öffne eine vorbereitete Kurznachricht.",
icon: "message",
}),
defineComingSoonType({
id: "location",
title: "Standort",
description: "Zeige einen Ort auf der Karte.",
icon: "pin",
}),
defineComingSoonType({
id: "payment",
title: "Zahlung",
description: "Fordere eine Zahlung per QR-Code an.",
icon: "card",
}),
defineComingSoonType({
id: "wifi",
title: "WLAN",
description: "Verbinde Gäste ohne Passworteingabe.",
icon: "wifi",
}),
emailType,
smsType,
locationType,
paymentType,
wifiType,
defineComingSoonType({
id: "feedback",
title: "Feedback",
+37 -5
View File
@@ -7,6 +7,7 @@ import { designSchema } from "@/lib/qr/design";
import {
QR_TYPE_IDS,
QrRequestError,
QrTypeError,
type QrTypeModule,
} from "@/lib/qr/type-module";
import { shortUrl } from "@/lib/env";
@@ -40,6 +41,35 @@ export type QrRequest = z.infer<typeof qrRequestSchema>;
// dass ein Ringschluss zwischen den beiden Modulen entsteht.
export { QrRequestError };
/**
* Prüft, ob sich aus diesen Daten überhaupt ein Weiterleitungsziel bilden
* lässt — und zwar, indem es einmal gebildet wird.
*
* `supportsDynamic` ist eine Eigenschaft des Typs, aber nicht jede Eingabe
* eines dynamikfähigen Typs taugt dafür: ein GiroCode etwa ist kein Web-Ziel,
* obwohl der Typ „Zahlung“ in seiner Link-Variante sehr wohl dynamisch sein
* kann. Ohne diese Probe entstünde ein gespeicherter Code, der erst beim
* Scannen auffällt — also genau dann, wenn er schon gedruckt ist.
*/
export function assertDynamicPossible(
qrType: QrTypeModule,
data: Record<string, unknown>,
): void {
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
try {
qrType.redirectTarget(data, {});
} catch (error) {
if (error instanceof QrTypeError) {
throw new QrRequestError(error.message);
}
throw error;
}
}
export type ResolvedQrRequest = {
qrType: QrTypeModule;
data: Record<string, unknown>;
@@ -62,11 +92,7 @@ export async function resolveQrRequest(
}
if (request.mode === "dynamic") {
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
assertDynamicPossible(qrType, validated.data);
const slug = request.slug;
if (slug !== undefined && !isValidSlug(slug)) {
throw new QrRequestError("Ungültige Kurz-URL.");
@@ -78,6 +104,12 @@ export async function resolveQrRequest(
};
}
if (!qrType.supportsStatic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
);
}
return {
qrType,
data: validated.data,
+33 -5
View File
@@ -46,6 +46,14 @@ export type ValidationResult =
*/
export class QrTypeError extends Error {}
/**
* Was über den Zugriff bekannt ist, der die Weiterleitung ausgelöst hat.
* Typen wie der App-Link entscheiden daran, wohin es geht.
*/
export type RedirectContext = {
userAgent?: string;
};
/**
* Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus
* dem passenden HTTP-Status.
@@ -78,6 +86,12 @@ export interface QrTypeModule {
* `src/lib/qr/availability.ts`.
*/
readonly implemented: boolean;
/**
* Unterstützt der Typ statische Codes? Für Typen, deren Ziel erst beim
* Scannen feststeht — etwa der App-Link, der je nach Gerät woandershin
* führt — ist das nicht möglich.
*/
readonly supportsStatic: boolean;
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
readonly supportsDynamic: boolean;
/** Startwerte für ein leeres Formular. */
@@ -87,7 +101,7 @@ export interface QrTypeModule {
/** Inhalt, der bei einem statischen Code in den QR-Code kodiert wird. */
encode(data: unknown): string;
/** Ziel, auf das ein dynamischer Code weiterleitet. */
redirectTarget(data: unknown): string;
redirectTarget(data: unknown, context?: RedirectContext): string;
/** Einzeilige Zusammenfassung für Listen und Übersichten. */
summary(data: unknown): string;
}
@@ -98,16 +112,25 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
description: string;
icon: IconName;
implemented: boolean;
/** Voreinstellung: true. */
supportsStatic?: boolean;
supportsDynamic: boolean;
schema: TSchema;
defaultData: QrFormData;
/** Inhalt des statischen QR-Codes. */
/**
* Inhalt des statischen QR-Codes. Typen ohne statische Unterstützung
* werfen hier — sie werden nur über `redirectTarget` ausgeliefert.
*/
encode: (data: z.output<TSchema>) => string;
/**
* Weiterleitungsziel eines dynamischen Codes. Fällt auf `encode` zurück —
* für URL-basierte Typen ist beides identisch.
* für URL-basierte Typen ist beides identisch. Der Kontext enthält, was
* über den scannenden Zugriff bekannt ist.
*/
redirectTarget?: (data: z.output<TSchema>) => string;
redirectTarget?: (
data: z.output<TSchema>,
context: RedirectContext,
) => string;
summary: (data: z.output<TSchema>) => string;
};
@@ -140,6 +163,7 @@ export function defineQrType<TSchema extends z.ZodType>(
description: spec.description,
icon: spec.icon,
implemented: spec.implemented,
supportsStatic: spec.supportsStatic ?? true,
supportsDynamic: spec.supportsDynamic,
defaultData: spec.defaultData,
validate(input) {
@@ -149,7 +173,10 @@ export function defineQrType<TSchema extends z.ZodType>(
: { ok: false, message: firstIssueMessage(result.error) };
},
encode: (data) => spec.encode(parse(data)),
redirectTarget: (data) => (spec.redirectTarget ?? spec.encode)(parse(data)),
redirectTarget: (data, context = {}) =>
spec.redirectTarget
? spec.redirectTarget(parse(data), context)
: spec.encode(parse(data)),
summary: (data) => spec.summary(parse(data)),
};
}
@@ -177,6 +204,7 @@ export function defineComingSoonType(spec: {
description: spec.description,
icon: spec.icon,
implemented: false,
supportsStatic: false,
supportsDynamic: false,
defaultData: {},
validate: () => ({
+94
View File
@@ -0,0 +1,94 @@
import { z } from "zod";
import { isHttpUrl } from "@/lib/qr/encoding";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
const storeUrl = (feld: string) =>
z
.string()
.trim()
.max(2048, { error: `${feld} ist zu lang.` })
.default("")
.refine((value) => value.length === 0 || isHttpUrl(value), {
error: `${feld} muss mit http:// oder https:// beginnen.`,
});
export const appDataSchema = z
.object({
ios: storeUrl("Die iOS-Adresse"),
android: storeUrl("Die Android-Adresse"),
fallback: z
.string()
.trim()
.min(1, { error: "Bitte gib eine Adresse für alle übrigen Geräte ein." })
.refine(isHttpUrl, {
error:
"Die Ausweichadresse muss mit http:// oder https:// beginnen, z. B. https://example.com",
}),
})
.refine((data) => data.ios.length > 0 || data.android.length > 0, {
error:
"Bitte gib mindestens eine Store-Adresse an — sonst landen alle auf der Ausweichadresse.",
path: ["ios"],
});
export type AppData = z.infer<typeof appDataSchema>;
/**
* Erkennt das Betriebssystem am User-Agent.
*
* Bewusst grob gehalten: es geht nur um die Frage „iOS, Android oder etwas
* anderes“, und im Zweifel gewinnt die Ausweichadresse. iPadOS meldet sich
* seit Version 13 als Macintosh, lässt sich aber am Berührungsbildschirm
* erkennen — das steht im User-Agent allerdings nicht, weshalb iPads auf dem
* Desktop-Weg landen können. Für einen App-Store-Link ist das hinnehmbar.
*/
export function detectPlatform(
userAgent: string | undefined,
): "ios" | "android" | "other" {
if (!userAgent) return "other";
const ua = userAgent.toLowerCase();
if (/android/.test(ua)) return "android";
if (/iphone|ipad|ipod/.test(ua)) return "ios";
// iPadOS ab 13 gibt sich als Macintosh aus und ergänzt "mobile".
if (/macintosh/.test(ua) && /mobile/.test(ua)) return "ios";
return "other";
}
export function resolveAppTarget(
data: AppData,
userAgent: string | undefined,
): string {
const platform = detectPlatform(userAgent);
if (platform === "ios" && data.ios) return data.ios;
if (platform === "android" && data.android) return data.android;
return data.fallback;
}
export const appType = defineQrType({
id: "app",
title: "App-Link",
description: "Leite je nach Gerät in den passenden App-Store.",
icon: "smartphone",
implemented: true,
// Das Ziel steht erst beim Scannen fest — je nach Gerät ein anderes.
// Ein statischer Code könnte immer nur eine der Adressen enthalten.
supportsStatic: false,
supportsDynamic: true,
schema: appDataSchema,
defaultData: { ios: "", android: "", fallback: "" },
encode: () => {
throw new QrTypeError(
"Ein App-Link funktioniert nur als dynamischer Code — erst beim Scannen steht fest, welches Gerät ihn öffnet.",
);
},
redirectTarget: (data, context) => resolveAppTarget(data, context.userAgent),
summary: (data) => {
const stores = [data.ios && "iOS", data.android && "Android"].filter(
Boolean,
);
return stores.length > 0
? `App-Link (${stores.join(" + ")})`
: "App-Link";
},
});
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
import { queryString } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
export const emailDataSchema = z.object({
empfaenger: z
.string()
.trim()
.min(1, { error: "Bitte gib eine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse, z. B. name@example.com",
}),
betreff: z
.string()
.trim()
.max(200, { error: "Der Betreff darf höchstens 200 Zeichen lang sein." })
.default(""),
text: z
.string()
.max(1000, { error: "Der Text darf höchstens 1000 Zeichen lang sein." })
.default(""),
});
export type EmailData = z.infer<typeof emailDataSchema>;
export const emailType = defineQrType({
id: "email",
title: "E-Mail",
description: "Öffne eine vorbereitete E-Mail.",
icon: "mail",
implemented: true,
supportsDynamic: true,
schema: emailDataSchema,
defaultData: { empfaenger: "", betreff: "", text: "" },
encode: (data) =>
`mailto:${data.empfaenger}${queryString({
subject: data.betreff,
body: data.text,
})}`,
summary: (data) =>
data.betreff ? `${data.empfaenger} — ${data.betreff}` : data.empfaenger,
});
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
import { formatCoordinate } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
const coordinate = (
max: number,
achse: "Breitengrad" | "Längengrad",
) =>
z.coerce
.number({ error: `Bitte gib den ${achse} als Zahl ein.` })
.refine((value) => Number.isFinite(value) && Math.abs(value) <= max, {
error: `Der ${achse} muss zwischen −${max} und ${max} liegen.`,
});
export const locationDataSchema = z.object({
breitengrad: coordinate(90, "Breitengrad"),
laengengrad: coordinate(180, "Längengrad"),
});
export type LocationData = z.infer<typeof locationDataSchema>;
export const locationType = defineQrType({
id: "location",
title: "Standort",
description: "Zeige einen Ort auf der Karte.",
icon: "pin",
implemented: true,
supportsDynamic: true,
schema: locationDataSchema,
defaultData: { breitengrad: "", laengengrad: "" },
// geo: ist das standardisierte Format (RFC 5870) und öffnet auf allen
// Geräten die jeweils eingerichtete Kartenanwendung.
encode: (data) =>
`geo:${formatCoordinate(data.breitengrad)},${formatCoordinate(data.laengengrad)}`,
// Für die Weiterleitung braucht es eine Webadresse — ein geo:-Ziel würde
// ein Browser nicht verstehen.
redirectTarget: (data) =>
`https://www.openstreetmap.org/?mlat=${formatCoordinate(data.breitengrad)}&mlon=${formatCoordinate(data.laengengrad)}#map=17/${formatCoordinate(data.breitengrad)}/${formatCoordinate(data.laengengrad)}`,
summary: (data) =>
`${formatCoordinate(data.breitengrad)}, ${formatCoordinate(data.laengengrad)}`,
});
+178
View File
@@ -0,0 +1,178 @@
import { z } from "zod";
import { isHttpUrl, isValidIban } from "@/lib/qr/encoding";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
/**
* Zwei Wege, eine Zahlung anzustoßen:
*
* - **GiroCode** nach EPC069-12 — der europäische Standard für
* SEPA-Überweisungen. Banking-Apps füllen daraus das Überweisungsformular.
* - **Zahlungslink** — eine Adresse bei einem Anbieter wie PayPal.
*/
export const PAYMENT_VARIANTS = ["girocode", "link"] as const;
export type PaymentVariant = (typeof PAYMENT_VARIANTS)[number];
export const PAYMENT_VARIANT_LABELS: Record<PaymentVariant, string> = {
girocode: "GiroCode — SEPA-Überweisung",
link: "Zahlungslink — PayPal, Stripe und andere",
};
/** Höchstlänge des EPC-Datensatzes laut Spezifikation. */
export const EPC_MAX_BYTES = 331;
const betrag = z
.string()
.trim()
.default("")
.refine(
(value) => value.length === 0 || /^\d{1,9}([.,]\d{1,2})?$/.test(value),
{ error: "Der Betrag muss eine Zahl sein, z. B. 49,90" },
)
.refine(
(value) => value.length === 0 || Number(value.replace(",", ".")) >= 0.01,
{ error: "Der Betrag muss mindestens 0,01 € betragen." },
);
export const paymentDataSchema = z
.object({
variante: z.enum(PAYMENT_VARIANTS, {
error: "Bitte wähle eine Zahlungsart.",
}),
// GiroCode
empfaenger: z
.string()
.trim()
.max(70, { error: "Der Empfänger darf höchstens 70 Zeichen lang sein." })
.default(""),
iban: z.string().trim().toUpperCase().default(""),
bic: z
.string()
.trim()
.toUpperCase()
.max(11, { error: "Eine BIC ist 8 oder 11 Zeichen lang." })
.default(""),
betrag,
verwendungszweck: z
.string()
.trim()
.max(140, {
error: "Der Verwendungszweck darf höchstens 140 Zeichen lang sein.",
})
.default(""),
// Zahlungslink
zahlungslink: z.string().trim().default(""),
})
.superRefine((data, ctx) => {
if (data.variante === "girocode") {
if (data.empfaenger.length === 0) {
ctx.addIssue({
code: "custom",
path: ["empfaenger"],
message: "Bitte gib den Namen des Empfängers ein.",
});
}
if (data.iban.length === 0) {
ctx.addIssue({
code: "custom",
path: ["iban"],
message: "Bitte gib die IBAN des Empfängers ein.",
});
} else if (!isValidIban(data.iban)) {
ctx.addIssue({
code: "custom",
path: ["iban"],
message:
"Diese IBAN ist nicht gültig — bitte prüfe sie auf Zahlendreher.",
});
}
if (data.bic.length > 0 && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(data.bic)) {
ctx.addIssue({
code: "custom",
path: ["bic"],
message: "Das ist keine gültige BIC.",
});
}
} else if (!isHttpUrl(data.zahlungslink)) {
ctx.addIssue({
code: "custom",
path: ["zahlungslink"],
message:
"Bitte gib die vollständige Adresse an, z. B. https://paypal.me/name",
});
}
});
export type PaymentData = z.infer<typeof paymentDataSchema>;
/** Betrag im EPC-Format: „EUR“ plus Punktschreibweise, z. B. EUR49.90 */
function formatEpcAmount(value: string): string {
if (value.length === 0) return "";
return `EUR${Number(value.replace(",", ".")).toFixed(2)}`;
}
export function buildGiroCode(data: PaymentData): string {
// Die Reihenfolge der Zeilen ist durch EPC069-12 fest vorgegeben;
// Zeilen dürfen leer sein, aber nicht fehlen.
const lines = [
"BCD", // Service-Kennung
"002", // Version — erlaubt eine leere BIC
"1", // Zeichensatz 1 = UTF-8
"SCT", // SEPA Credit Transfer
data.bic,
data.empfaenger,
data.iban.replace(/\s+/g, ""),
formatEpcAmount(data.betrag),
"", // Zweckcode (optional)
"", // strukturierte Referenz — schließt freien Text aus
data.verwendungszweck,
"", // Hinweis an den Zahlenden
];
// Nachlaufende Leerzeilen sind erlaubt und werden weggelassen.
while (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
const payload = lines.join("\n");
const bytes = new TextEncoder().encode(payload).length;
if (bytes > EPC_MAX_BYTES) {
throw new QrTypeError(
`Der GiroCode ist mit ${bytes} Bytes zu lang (erlaubt sind ${EPC_MAX_BYTES}). Bitte kürze den Verwendungszweck.`,
);
}
return payload;
}
export const paymentType = defineQrType({
id: "payment",
title: "Zahlung",
description: "Fordere eine Zahlung per QR-Code an.",
icon: "card",
implemented: true,
supportsDynamic: true,
schema: paymentDataSchema,
defaultData: {
variante: "girocode",
empfaenger: "",
iban: "",
bic: "",
betrag: "",
verwendungszweck: "",
zahlungslink: "",
},
encode: (data) =>
data.variante === "girocode" ? buildGiroCode(data) : data.zahlungslink,
redirectTarget: (data) => {
if (data.variante === "link") return data.zahlungslink;
// Ein GiroCode ist kein Web-Ziel — als dynamischer Code ergibt nur die
// Link-Variante Sinn.
throw new QrTypeError(
"Ein GiroCode lässt sich nicht als dynamischer Code verwenden. Bitte wähle die Variante „Zahlungslink“ oder einen statischen Code.",
);
},
summary: (data) =>
data.variante === "girocode"
? `${data.empfaenger}${data.betrag ? ` · ${data.betrag} €` : ""}`
: data.zahlungslink,
});
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod";
import { isE164, normalizePhone } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
export const smsDataSchema = z.object({
nummer: z
.string()
.trim()
.min(1, { error: "Bitte gib eine Rufnummer ein." })
.transform(normalizePhone)
.refine(isE164, {
error:
"Bitte die Rufnummer international angeben, mit Ländervorwahl — z. B. +49 151 23456789",
}),
text: z
.string()
.max(500, { error: "Der Text darf höchstens 500 Zeichen lang sein." })
.default(""),
});
export type SmsData = z.infer<typeof smsDataSchema>;
export const smsType = defineQrType({
id: "sms",
title: "SMS",
description: "Öffne eine vorbereitete Kurznachricht.",
icon: "message",
implemented: true,
supportsDynamic: true,
schema: smsDataSchema,
defaultData: { nummer: "", text: "" },
// SMSTO ist das Format, das von Scanner-Apps am breitesten unterstützt
// wird — breiter als das naheliegendere "sms:", das je nach Betriebssystem
// unterschiedlich aufgebaut sein müsste.
encode: (data) => `SMSTO:${data.nummer}:${data.text}`,
summary: (data) => data.nummer,
});
+105
View File
@@ -0,0 +1,105 @@
import { z } from "zod";
import { escapeVCard, isHttpUrl, joinVCardLines } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
const optionalText = (max: number, feld: string) =>
z
.string()
.trim()
.max(max, { error: `${feld} darf höchstens ${max} Zeichen lang sein.` })
.default("");
export const vcardDataSchema = z
.object({
vorname: optionalText(50, "Der Vorname"),
nachname: optionalText(50, "Der Nachname"),
organisation: optionalText(100, "Die Firma"),
position: optionalText(100, "Die Position"),
telefon: optionalText(40, "Die Telefonnummer"),
mobil: optionalText(40, "Die Mobilnummer"),
email: optionalText(254, "Die E-Mail-Adresse"),
webseite: optionalText(2048, "Die Webadresse"),
strasse: optionalText(100, "Die Straße"),
plz: optionalText(20, "Die Postleitzahl"),
ort: optionalText(80, "Der Ort"),
land: optionalText(80, "Das Land"),
notiz: optionalText(300, "Die Notiz"),
})
.refine((data) => data.vorname.length > 0 || data.nachname.length > 0, {
error: "Bitte gib mindestens einen Vor- oder Nachnamen ein.",
path: ["nachname"],
})
.refine(
(data) => data.email.length === 0 || /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(data.email),
{ error: "Das ist keine gültige E-Mail-Adresse.", path: ["email"] },
)
.refine((data) => data.webseite.length === 0 || isHttpUrl(data.webseite), {
error: "Die Webadresse muss mit http:// oder https:// beginnen.",
path: ["webseite"],
});
export type VCardData = z.infer<typeof vcardDataSchema>;
function buildVCard(data: VCardData): string {
const e = escapeVCard;
const fullName = [data.vorname, data.nachname].filter(Boolean).join(" ");
const lines = ["BEGIN:VCARD", "VERSION:3.0"];
// N ist Pflicht und hat feste Feldreihenfolge:
// Nachname;Vorname;weitere;Präfix;Suffix
lines.push(`N:${e(data.nachname)};${e(data.vorname)};;;`);
lines.push(`FN:${e(fullName)}`);
if (data.organisation) lines.push(`ORG:${e(data.organisation)}`);
if (data.position) lines.push(`TITLE:${e(data.position)}`);
if (data.telefon) lines.push(`TEL;TYPE=WORK,VOICE:${e(data.telefon)}`);
if (data.mobil) lines.push(`TEL;TYPE=CELL:${e(data.mobil)}`);
if (data.email) lines.push(`EMAIL;TYPE=INTERNET:${e(data.email)}`);
if (data.webseite) lines.push(`URL:${e(data.webseite)}`);
// ADR: Postfach;Zusatz;Straße;Ort;Region;PLZ;Land
if (data.strasse || data.plz || data.ort || data.land) {
lines.push(
`ADR;TYPE=WORK:;;${e(data.strasse)};${e(data.ort)};;${e(data.plz)};${e(data.land)}`,
);
}
if (data.notiz) lines.push(`NOTE:${e(data.notiz)}`);
lines.push("END:VCARD");
return joinVCardLines(lines);
}
export const vcardType = defineQrType({
id: "vcard",
title: "vCard",
description: "Digitale Visitenkarte mit Kontaktdaten.",
icon: "contact",
implemented: true,
// Die Kontaktdaten stecken im Code selbst; das Gerät legt sie direkt an.
// Über eine Weiterleitung ginge das nicht.
supportsDynamic: false,
schema: vcardDataSchema,
defaultData: {
vorname: "",
nachname: "",
organisation: "",
position: "",
telefon: "",
mobil: "",
email: "",
webseite: "",
strasse: "",
plz: "",
ort: "",
land: "",
notiz: "",
},
encode: buildVCard,
summary: (data) => {
const name = [data.vorname, data.nachname].filter(Boolean).join(" ");
return data.organisation ? `${name} · ${data.organisation}` : name;
},
});
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import { isE164, phoneDigits, queryString } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
export const whatsappDataSchema = z.object({
nummer: z
.string()
.trim()
.min(1, { error: "Bitte gib eine Rufnummer ein." })
.refine(isE164, {
error:
"WhatsApp braucht die Nummer international, mit Ländervorwahl und ohne führende Null — z. B. +49 151 23456789",
}),
text: z
.string()
.max(1000, { error: "Der Text darf höchstens 1000 Zeichen lang sein." })
.default(""),
});
export type WhatsappData = z.infer<typeof whatsappDataSchema>;
export const whatsappType = defineQrType({
id: "whatsapp",
title: "WhatsApp",
description: "Starte einen Chat mit vorbereiteter Nachricht.",
icon: "chat",
implemented: true,
supportsDynamic: true,
schema: whatsappDataSchema,
defaultData: { nummer: "", text: "" },
// wa.me erwartet die Nummer ohne Pluszeichen und ohne Trennzeichen.
encode: (data) =>
`https://wa.me/${phoneDigits(data.nummer)}${queryString({ text: data.text })}`,
summary: (data) => `WhatsApp ${data.nummer}`,
});
+76
View File
@@ -0,0 +1,76 @@
import { z } from "zod";
import { quoteWifiIfHex } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
export const WIFI_SECURITY = ["WPA", "WEP", "nopass"] as const;
export type WifiSecurity = (typeof WIFI_SECURITY)[number];
export const WIFI_SECURITY_LABELS: Record<WifiSecurity, string> = {
WPA: "WPA/WPA2/WPA3 (üblich)",
WEP: "WEP (veraltet)",
nopass: "Offen, ohne Passwort",
};
export const wifiDataSchema = z
.object({
ssid: z
.string()
.trim()
.min(1, { error: "Bitte gib den Netzwerknamen (SSID) ein." })
.max(32, { error: "Ein Netzwerkname ist höchstens 32 Zeichen lang." }),
verschluesselung: z.enum(WIFI_SECURITY, {
error: "Bitte wähle eine Verschlüsselung.",
}),
passwort: z
.string()
.max(63, { error: "Ein WLAN-Passwort ist höchstens 63 Zeichen lang." })
.default(""),
versteckt: z.boolean().default(false),
})
.refine(
(data) => data.verschluesselung === "nopass" || data.passwort.length > 0,
{
error: "Für ein verschlüsseltes Netz wird ein Passwort benötigt.",
path: ["passwort"],
},
);
export type WifiData = z.infer<typeof wifiDataSchema>;
export const wifiType = defineQrType({
id: "wifi",
title: "WLAN",
description: "Verbinde Gäste ohne Passworteingabe.",
icon: "wifi",
implemented: true,
// Ein WLAN-Code enthält Zugangsdaten und wird vom Gerät direkt ausgewertet.
// Über eine Kurz-URL ginge das nicht — die Kamera-App würde nur eine
// Webseite öffnen, statt das Netz einzurichten.
supportsDynamic: false,
schema: wifiDataSchema,
defaultData: {
ssid: "",
verschluesselung: "WPA",
passwort: "",
versteckt: false,
},
encode: (data) => {
const parts = [
`T:${data.verschluesselung}`,
`S:${quoteWifiIfHex(data.ssid)}`,
];
if (data.verschluesselung !== "nopass") {
parts.push(`P:${quoteWifiIfHex(data.passwort)}`);
}
if (data.versteckt) {
parts.push("H:true");
}
// Der abschließende doppelte Strichpunkt gehört zum Format.
return `WIFI:${parts.join(";")};;`;
},
summary: (data) =>
data.verschluesselung === "nopass"
? `${data.ssid} (offen)`
: `${data.ssid} (${data.verschluesselung})`,
});