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
+5
View File
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
@@ -68,6 +69,10 @@ export async function PATCH(
if (!validated.ok) {
return jsonError(validated.message);
}
// Auch beim Bearbeiten: die neuen Daten müssen ein Weiterleitungsziel
// hergeben. Sonst könnte man einen laufenden Code durch eine Änderung
// stillschweigend unbrauchbar machen.
assertDynamicPossible(qrType, validated.data);
update.data = toJsonInput(validated.data);
}
+4 -5
View File
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug";
@@ -51,17 +52,15 @@ export async function POST(request: NextRequest): Promise<Response> {
const { type, design, label } = parsed.data;
const qrType = await requireAvailableQrType(type);
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data);
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
+1 -1
View File
@@ -57,7 +57,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<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 · Version 1 — QR-Typ „Website/URL“
QR-Code-Generator · statische und dynamische QR-Codes
</div>
</footer>
</body>
+6 -2
View File
@@ -36,7 +36,7 @@ function notFound(message: string): Response {
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
context: Context,
): Promise<Response> {
const { slug } = await context.params;
@@ -59,7 +59,11 @@ export async function GET(
let target: string;
try {
target = qrType.redirectTarget(code.data);
// 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.");
}
+21 -3
View File
@@ -1,18 +1,36 @@
import type { ComponentType } from "react";
import { WebsiteForm } from "@/components/qr-forms/website-form";
import { PaymentForm } from "@/components/qr-forms/payment-form";
import {
AppForm,
EmailForm,
LocationForm,
SmsForm,
WhatsappForm,
WifiForm,
} from "@/components/qr-forms/simple-forms";
import type { QrFormProps } from "@/components/qr-forms/types";
import { VCardForm } from "@/components/qr-forms/vcard-form";
import { WebsiteForm } from "@/components/qr-forms/website-form";
import type { QrTypeId } from "@/lib/qr/type-module";
/**
* Zuordnung QR-Typ → Formular-Komponente.
*
* Gegenstück zur Typ-Registry in `src/lib/qr/registry.ts`: dort liegt die
* Logik (Validierung, Kodierung), hier die Eingabemaske. Ein V2-Typ braucht
* genau einen zusätzlichen Eintrag an beiden Stellen.
* Logik (Validierung, Kodierung), hier die Eingabemaske. Ein neuer Typ
* braucht genau einen zusätzlichen Eintrag an beiden Stellen.
*/
export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
website: WebsiteForm,
vcard: VCardForm,
app: AppForm,
whatsapp: WhatsappForm,
email: EmailForm,
sms: SmsForm,
location: LocationForm,
payment: PaymentForm,
wifi: WifiForm,
};
export type { QrFormProps };
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { Field, FieldRow, Notice, Select, TextInput } from "@/components/ui";
import type { QrFormProps } from "@/components/qr-forms/types";
import { formatIban } from "@/lib/qr/encoding";
import {
PAYMENT_VARIANTS,
PAYMENT_VARIANT_LABELS,
type PaymentVariant,
} from "@/lib/qr/types/payment";
function text(value: Record<string, unknown>, key: string): string {
const raw = value[key];
return typeof raw === "string" ? raw : "";
}
export function PaymentForm({ value, onChange, error, disabled }: QrFormProps) {
const variante = (
PAYMENT_VARIANTS.includes(value.variante as PaymentVariant)
? value.variante
: "girocode"
) as PaymentVariant;
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field label="Zahlungsart" htmlFor="pay-variante">
<Select
id="pay-variante"
value={variante}
disabled={disabled}
onChange={(event) => set("variante", event.target.value)}
>
{PAYMENT_VARIANTS.map((option) => (
<option key={option} value={option}>
{PAYMENT_VARIANT_LABELS[option]}
</option>
))}
</Select>
</Field>
{variante === "girocode" ? (
<>
<Field label="Empfänger" htmlFor="pay-empfaenger" error={error}>
<TextInput
id="pay-empfaenger"
placeholder="Name des Kontoinhabers"
value={text(value, "empfaenger")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("empfaenger", event.target.value)}
/>
</Field>
<Field
label="IBAN"
htmlFor="pay-iban"
hint="Wird beim Verlassen des Feldes in Vierergruppen dargestellt."
>
<TextInput
id="pay-iban"
spellCheck={false}
placeholder="DE02 1203 0000 0000 2020 51"
className="font-mono"
value={text(value, "iban")}
disabled={disabled}
onChange={(event) => set("iban", event.target.value.toUpperCase())}
onBlur={(event) => set("iban", formatIban(event.target.value))}
/>
</Field>
<FieldRow>
<Field label="BIC (optional)" htmlFor="pay-bic">
<TextInput
id="pay-bic"
spellCheck={false}
className="font-mono uppercase"
placeholder="BYLADEM1001"
value={text(value, "bic")}
disabled={disabled}
onChange={(event) => set("bic", event.target.value.toUpperCase())}
/>
</Field>
<Field
label="Betrag in Euro (optional)"
htmlFor="pay-betrag"
hint="Leer lassen, damit der Zahlende ihn selbst einträgt."
>
<TextInput
id="pay-betrag"
inputMode="decimal"
placeholder="49,90"
value={text(value, "betrag")}
disabled={disabled}
onChange={(event) => set("betrag", event.target.value)}
/>
</Field>
</FieldRow>
<Field label="Verwendungszweck (optional)" htmlFor="pay-zweck">
<TextInput
id="pay-zweck"
maxLength={140}
placeholder="Rechnung 2026-0815"
value={text(value, "verwendungszweck")}
disabled={disabled}
onChange={(event) => set("verwendungszweck", event.target.value)}
/>
</Field>
<Notice tone="info">
Der GiroCode wird von Banking-Apps gelesen und füllt die
Überweisung vor. Er funktioniert nur als statischer Code — für
einen dynamischen Code die Variante „Zahlungslink“ wählen.
</Notice>
</>
) : (
<Field
label="Zahlungsadresse"
htmlFor="pay-link"
error={error}
hint="Zum Beispiel PayPal.Me, ein Stripe-Zahlungslink oder die Zahlungsseite deines Shops."
>
<TextInput
id="pay-link"
placeholder="https://paypal.me/name/49.90"
value={text(value, "zahlungslink")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("zahlungslink", event.target.value)}
/>
</Field>
)}
</div>
);
}
+353
View File
@@ -0,0 +1,353 @@
"use client";
import {
Checkbox,
Field,
FieldRow,
Notice,
Select,
TextArea,
TextInput,
} from "@/components/ui";
import type { QrFormProps } from "@/components/qr-forms/types";
import { parseCoordinates } from "@/lib/qr/encoding";
import {
WIFI_SECURITY,
WIFI_SECURITY_LABELS,
type WifiSecurity,
} from "@/lib/qr/types/wifi";
/** Liest ein Textfeld aus den generischen Formulardaten. */
function text(value: Record<string, unknown>, key: string): string {
const raw = value[key];
return typeof raw === "string" ? raw : "";
}
/* ------------------------------------------------------------------ E-Mail */
export function EmailForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field label="Empfänger" htmlFor="email-empfaenger" error={error}>
<TextInput
id="email-empfaenger"
type="email"
inputMode="email"
autoComplete="email"
placeholder="name@example.com"
value={text(value, "empfaenger")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("empfaenger", event.target.value)}
/>
</Field>
<Field label="Betreff (optional)" htmlFor="email-betreff">
<TextInput
id="email-betreff"
value={text(value, "betreff")}
disabled={disabled}
onChange={(event) => set("betreff", event.target.value)}
/>
</Field>
<Field
label="Nachricht (optional)"
htmlFor="email-text"
hint="Wird als vorbereiteter Text in die E-Mail übernommen."
>
<TextArea
id="email-text"
value={text(value, "text")}
disabled={disabled}
onChange={(event) => set("text", event.target.value)}
/>
</Field>
</div>
);
}
/* --------------------------------------------------------------------- SMS */
export function SmsForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field
label="Rufnummer"
htmlFor="sms-nummer"
error={error}
hint="International mit Ländervorwahl, z. B. +49 151 23456789"
>
<TextInput
id="sms-nummer"
type="tel"
inputMode="tel"
placeholder="+49 151 23456789"
value={text(value, "nummer")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("nummer", event.target.value)}
/>
</Field>
<Field label="Nachricht (optional)" htmlFor="sms-text">
<TextArea
id="sms-text"
value={text(value, "text")}
disabled={disabled}
onChange={(event) => set("text", event.target.value)}
/>
</Field>
</div>
);
}
/* ---------------------------------------------------------------- WhatsApp */
export function WhatsappForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field
label="WhatsApp-Nummer"
htmlFor="wa-nummer"
error={error}
hint="Mit Ländervorwahl und ohne führende Null, z. B. +49 151 23456789"
>
<TextInput
id="wa-nummer"
type="tel"
inputMode="tel"
placeholder="+49 151 23456789"
value={text(value, "nummer")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("nummer", event.target.value)}
/>
</Field>
<Field
label="Vorbereitete Nachricht (optional)"
htmlFor="wa-text"
hint="Steht beim Öffnen des Chats bereits im Eingabefeld."
>
<TextArea
id="wa-text"
value={text(value, "text")}
disabled={disabled}
onChange={(event) => set("text", event.target.value)}
/>
</Field>
</div>
);
}
/* -------------------------------------------------------------------- WLAN */
export function WifiForm({ value, onChange, error, disabled }: QrFormProps) {
const security = (
WIFI_SECURITY.includes(value.verschluesselung as WifiSecurity)
? value.verschluesselung
: "WPA"
) as WifiSecurity;
return (
<div className="flex flex-col gap-4">
<Field label="Netzwerkname (SSID)" htmlFor="wifi-ssid" error={error}>
<TextInput
id="wifi-ssid"
autoComplete="off"
spellCheck={false}
placeholder="Gaeste-WLAN"
value={text(value, "ssid")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => onChange({ ...value, ssid: event.target.value })}
/>
</Field>
<Field label="Verschlüsselung" htmlFor="wifi-security">
<Select
id="wifi-security"
value={security}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, verschluesselung: event.target.value })
}
>
{WIFI_SECURITY.map((option) => (
<option key={option} value={option}>
{WIFI_SECURITY_LABELS[option]}
</option>
))}
</Select>
</Field>
{security !== "nopass" ? (
<Field label="Passwort" htmlFor="wifi-password">
<TextInput
id="wifi-password"
autoComplete="off"
spellCheck={false}
value={text(value, "passwort")}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, passwort: event.target.value })
}
/>
</Field>
) : null}
<Checkbox
id="wifi-hidden"
label="Netzwerk ist versteckt"
hint="Nur ankreuzen, wenn die SSID nicht gesendet wird."
checked={value.versteckt === true}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, versteckt: event.target.checked })
}
/>
<Notice tone="info">
Der Code enthält das Passwort im Klartext — wer ihn scannt oder
abfotografiert, kennt es. Für Gästenetze ist das gewollt, für das
eigene Netz eher nicht.
</Notice>
</div>
);
}
/* ---------------------------------------------------------------- Standort */
export function LocationForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
/** Erlaubt es, einen Kartenlink einzufügen, statt Zahlen abzutippen. */
const handlePaste = (raw: string) => {
const parsed = parseCoordinates(raw);
if (parsed) {
onChange({
...value,
breitengrad: String(parsed.latitude),
laengengrad: String(parsed.longitude),
});
return true;
}
return false;
};
return (
<div className="flex flex-col gap-4">
<Field
label="Kartenlink einfügen (optional)"
htmlFor="geo-paste"
hint="Adresse aus Google Maps, Apple Karten oder OpenStreetMap — die Koordinaten werden übernommen."
>
<TextInput
id="geo-paste"
placeholder="https://www.google.com/maps/@52.516,13.377,15z"
disabled={disabled}
onChange={(event) => {
if (handlePaste(event.target.value)) {
event.target.value = "";
}
}}
/>
</Field>
<FieldRow>
<Field label="Breitengrad" htmlFor="geo-lat" error={error}>
<TextInput
id="geo-lat"
inputMode="decimal"
placeholder="52.516"
value={text(value, "breitengrad")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("breitengrad", event.target.value)}
/>
</Field>
<Field label="Längengrad" htmlFor="geo-lon">
<TextInput
id="geo-lon"
inputMode="decimal"
placeholder="13.377"
value={text(value, "laengengrad")}
disabled={disabled}
onChange={(event) => set("laengengrad", event.target.value)}
/>
</Field>
</FieldRow>
</div>
);
}
/* ---------------------------------------------------------------- App-Link */
export function AppForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Notice tone="info">
Der App-Link gibt es nur als dynamischen Code: erst beim Scannen steht
fest, welches Gerät ihn öffnet — und damit, wohin es geht.
</Notice>
<Field
label="iOS — App Store"
htmlFor="app-ios"
error={error}
hint="https://apps.apple.com/…"
>
<TextInput
id="app-ios"
placeholder="https://apps.apple.com/de/app/…"
value={text(value, "ios")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("ios", event.target.value)}
/>
</Field>
<Field
label="Android — Google Play"
htmlFor="app-android"
hint="https://play.google.com/store/apps/…"
>
<TextInput
id="app-android"
placeholder="https://play.google.com/store/apps/details?id=…"
value={text(value, "android")}
disabled={disabled}
onChange={(event) => set("android", event.target.value)}
/>
</Field>
<Field
label="Alle übrigen Geräte"
htmlFor="app-fallback"
hint="Wird auf Rechnern und unbekannten Geräten geöffnet — meist die Website der App."
>
<TextInput
id="app-fallback"
placeholder="https://example.com"
value={text(value, "fallback")}
disabled={disabled}
onChange={(event) => set("fallback", event.target.value)}
/>
</Field>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import {
Field,
FieldRow,
FieldSet,
TextArea,
TextInput,
} from "@/components/ui";
import type { QrFormProps } from "@/components/qr-forms/types";
function text(value: Record<string, unknown>, key: string): string {
const raw = value[key];
return typeof raw === "string" ? raw : "";
}
export function VCardForm({ value, onChange, error, disabled }: QrFormProps) {
const field = (key: string) => ({
value: text(value, key),
disabled,
onChange: (event: { target: { value: string } }) =>
onChange({ ...value, [key]: event.target.value }),
});
return (
<div className="flex flex-col gap-5">
<FieldRow>
<Field label="Vorname" htmlFor="vc-vorname">
<TextInput id="vc-vorname" autoComplete="given-name" {...field("vorname")} />
</Field>
<Field label="Nachname" htmlFor="vc-nachname" error={error}>
<TextInput
id="vc-nachname"
autoComplete="family-name"
invalid={Boolean(error)}
{...field("nachname")}
/>
</Field>
</FieldRow>
<FieldSet legend="Firma">
<FieldRow>
<Field label="Organisation" htmlFor="vc-org">
<TextInput id="vc-org" autoComplete="organization" {...field("organisation")} />
</Field>
<Field label="Position" htmlFor="vc-position">
<TextInput
id="vc-position"
autoComplete="organization-title"
{...field("position")}
/>
</Field>
</FieldRow>
</FieldSet>
<FieldSet legend="Kontakt">
<FieldRow>
<Field label="Telefon" htmlFor="vc-tel">
<TextInput id="vc-tel" type="tel" inputMode="tel" {...field("telefon")} />
</Field>
<Field label="Mobil" htmlFor="vc-mobil">
<TextInput id="vc-mobil" type="tel" inputMode="tel" {...field("mobil")} />
</Field>
</FieldRow>
<Field label="E-Mail" htmlFor="vc-email">
<TextInput
id="vc-email"
type="email"
inputMode="email"
autoComplete="email"
{...field("email")}
/>
</Field>
<Field label="Webseite" htmlFor="vc-web">
<TextInput id="vc-web" placeholder="https://example.com" {...field("webseite")} />
</Field>
</FieldSet>
<FieldSet legend="Anschrift">
<Field label="Straße und Hausnummer" htmlFor="vc-strasse">
<TextInput id="vc-strasse" autoComplete="street-address" {...field("strasse")} />
</Field>
<FieldRow>
<Field label="Postleitzahl" htmlFor="vc-plz">
<TextInput id="vc-plz" autoComplete="postal-code" {...field("plz")} />
</Field>
<Field label="Ort" htmlFor="vc-ort">
<TextInput id="vc-ort" autoComplete="address-level2" {...field("ort")} />
</Field>
</FieldRow>
<Field label="Land" htmlFor="vc-land">
<TextInput id="vc-land" autoComplete="country-name" {...field("land")} />
</Field>
</FieldSet>
<FieldSet legend="Sonstiges">
<Field
label="Notiz"
htmlFor="vc-notiz"
hint="Landet im Notizfeld des Kontakts."
>
<TextArea id="vc-notiz" {...field("notiz")} />
</Field>
</FieldSet>
</div>
);
}
+10 -4
View File
@@ -48,7 +48,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const [data, setData] = useState<QrFormData>(qrType.defaultData);
const [design, setDesign] = useState<QrDesign>(defaultDesign);
const [isDynamic, setIsDynamic] = useState(false);
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null);
@@ -283,9 +284,10 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<button
type="button"
aria-pressed={!isDynamic}
disabled={!qrType.supportsStatic}
onClick={() => setIsDynamic(false)}
className={cx(
"rounded-xl border p-4 text-left transition-colors",
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic
? "border-indigo-600 bg-indigo-50/60"
: "border-slate-300 bg-white hover:bg-slate-50",
@@ -296,7 +298,9 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
Statisch
</span>
<span className="mt-1 block text-sm text-slate-600">
Funktioniert ohne diesen Server, dafür unveränderlich.
{qrType.supportsStatic
? "Funktioniert ohne diesen Server, dafür unveränderlich."
: `Für „${qrType.title}“ nicht möglich.`}
</span>
</button>
@@ -318,7 +322,9 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Badge tone="neutral">mit Statistik</Badge>
</span>
<span className="mt-1 block text-sm text-slate-600">
Ziel jederzeit änderbar, Scans werden gezählt.
{qrType.supportsDynamic
? "Ziel jederzeit änderbar, Scans werden gezählt."
: `Für „${qrType.title}“ nicht möglich — die Daten stecken im Code selbst.`}
</span>
</button>
</div>
+101 -1
View File
@@ -1,4 +1,10 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from "react";
export function cx(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
@@ -129,6 +135,100 @@ export function TextInput({
);
}
const CONTROL_BASE =
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
export function TextArea({
invalid,
className,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }) {
return (
<textarea
rows={3}
className={cx(
CONTROL_BASE,
"resize-y",
invalid ? "border-red-400" : "border-slate-300",
className,
)}
{...props}
/>
);
}
export function Select({
invalid,
className,
children,
...props
}: SelectHTMLAttributes<HTMLSelectElement> & { invalid?: boolean }) {
return (
<select
className={cx(
CONTROL_BASE,
"cursor-pointer appearance-none bg-[length:1.1rem] bg-[right_0.7rem_center] bg-no-repeat pr-9",
invalid ? "border-red-400" : "border-slate-300",
className,
)}
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")",
}}
{...props}
>
{children}
</select>
);
}
export function Checkbox({
label,
hint,
id,
...props
}: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: string }) {
return (
<div className="flex items-start gap-2.5">
<input
id={id}
type="checkbox"
className="mt-0.5 size-4 shrink-0 cursor-pointer rounded border-slate-300 accent-indigo-600"
{...props}
/>
<label htmlFor={id} className="cursor-pointer text-sm text-slate-700">
{label}
{hint ? <span className="block text-slate-500">{hint}</span> : null}
</label>
</div>
);
}
/** Zwei Felder nebeneinander, ab „sm“ — für Paare wie Vor- und Nachname. */
export function FieldRow({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>
);
}
/** Abschnitt innerhalb eines Formulars mit eigener Überschrift. */
export function FieldSet({
legend,
children,
}: {
legend: string;
children: ReactNode;
}) {
return (
<fieldset className="border-t border-slate-200 pt-4">
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
{legend}
</legend>
<div className="mt-3 flex flex-col gap-4">{children}</div>
</fieldset>
);
}
/* ------------------------------------------------------------------- Badge */
export function Badge({
+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})`,
});