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
+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>
);
}