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
+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({