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:
+101
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user