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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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)}`,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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}`,
|
||||
});
|
||||
@@ -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})`,
|
||||
});
|
||||
Reference in New Issue
Block a user