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:
@@ -3,9 +3,9 @@
|
|||||||
Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus
|
Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus
|
||||||
zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.
|
zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.
|
||||||
|
|
||||||
**Version 1** unterstützt den QR-Typ **Website/URL**, statisch wie dynamisch.
|
Umgesetzt sind **9 der 15 QR-Typen**. Die übrigen sechs sind in der Oberfläche
|
||||||
Die übrigen 14 Typen sind bereits in der Oberfläche sichtbar und für Version 2
|
sichtbar, als Platzhalter markiert und lassen sich über die Verwaltung
|
||||||
vorbereitet.
|
ausblenden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -137,9 +137,41 @@ werden — der Hostname darin bleibt `db` (der Servicename aus
|
|||||||
|
|
||||||
### 1. Typ wählen
|
### 1. Typ wählen
|
||||||
|
|
||||||
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid aller
|
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der
|
||||||
15 QR-Typen. In Version 1 ist **Website** aktiv (Badge „Beliebt“); die übrigen
|
freigegebenen QR-Typen. Umgesetzte Typen sind anklickbar, die übrigen tragen
|
||||||
Kacheln sind sichtbar, aber deaktiviert (Badge „Bald verfügbar“).
|
das Badge „Bald verfügbar“.
|
||||||
|
|
||||||
|
| Typ | Kodiert als | Statisch | Dynamisch |
|
||||||
|
| --- | --- | :-: | :-: |
|
||||||
|
| Website | die URL selbst | ✓ | ✓ |
|
||||||
|
| E-Mail | `mailto:` mit Betreff und Text | ✓ | ✓ |
|
||||||
|
| SMS | `SMSTO:` | ✓ | ✓ |
|
||||||
|
| WhatsApp | `https://wa.me/…` | ✓ | ✓ |
|
||||||
|
| Standort | `geo:` — dynamisch über OpenStreetMap | ✓ | ✓ |
|
||||||
|
| vCard | vCard 3.0 | ✓ | — |
|
||||||
|
| WLAN | `WIFI:` | ✓ | — |
|
||||||
|
| App-Link | Weiterleitung je nach Gerät | — | ✓ |
|
||||||
|
| Zahlung | GiroCode (EPC069-12) oder Zahlungslink | ✓ | nur Link |
|
||||||
|
|
||||||
|
Warum manche Typen nur eine Betriebsart können:
|
||||||
|
|
||||||
|
- **vCard und WLAN** werden vom Gerät direkt ausgewertet — die Kamera legt
|
||||||
|
einen Kontakt an oder richtet das Netz ein. Über eine Kurz-URL würde
|
||||||
|
stattdessen nur ein Browser aufgehen.
|
||||||
|
- **App-Link** kann nicht statisch sein: welches Ziel richtig ist, steht erst
|
||||||
|
beim Scannen fest. Die Weiterleitung wertet den User-Agent aus und schickt
|
||||||
|
iOS in den App Store, Android zu Google Play und alles Übrige auf die
|
||||||
|
Ausweichadresse.
|
||||||
|
- **GiroCode** ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als
|
||||||
|
dynamischer Code funktioniert daher nur die Variante „Zahlungslink“ — die
|
||||||
|
Anwendung weist das beim Speichern ausdrücklich ab, statt einen Code
|
||||||
|
entstehen zu lassen, der beim Scannen ins Leere führt.
|
||||||
|
|
||||||
|
> **Zum WLAN-Code:** Das Passwort steht im Klartext im Code — wer ihn scannt
|
||||||
|
> oder abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht ein
|
||||||
|
> Netzwerkname oder Passwort ausschließlich aus Hex-Ziffern (etwa `abc123`),
|
||||||
|
> setzt die Anwendung ihn wie in der Spezifikation vorgesehen in
|
||||||
|
> Anführungszeichen — sonst würden manche Geräte den Wert als Hexzahl deuten.
|
||||||
|
|
||||||
### 2. Statisch oder dynamisch
|
### 2. Statisch oder dynamisch
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -44,3 +44,4 @@
|
|||||||
"unrs-resolver": true
|
"unrs-resolver": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
|
|||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -68,6 +69,10 @@ export async function PATCH(
|
|||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return jsonError(validated.message);
|
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);
|
update.data = toJsonInput(validated.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
|
|||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||||
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
||||||
import { generateSlug } from "@/lib/slug";
|
import { generateSlug } from "@/lib/slug";
|
||||||
|
|
||||||
@@ -51,17 +52,15 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
|
|
||||||
const { type, design, label } = parsed.data;
|
const { type, design, label } = parsed.data;
|
||||||
const qrType = await requireAvailableQrType(type);
|
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);
|
const validated = qrType.validate(parsed.data.data);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return jsonError(validated.message);
|
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.
|
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
|
|
||||||
<footer className="border-t border-slate-200 bg-white">
|
<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">
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function notFound(message: string): Response {
|
|||||||
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: Context,
|
context: Context,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { slug } = await context.params;
|
const { slug } = await context.params;
|
||||||
@@ -59,7 +59,11 @@ export async function GET(
|
|||||||
|
|
||||||
let target: string;
|
let target: string;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import type { ComponentType } from "react";
|
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 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";
|
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zuordnung QR-Typ → Formular-Komponente.
|
* Zuordnung QR-Typ → Formular-Komponente.
|
||||||
*
|
*
|
||||||
* Gegenstück zur Typ-Registry in `src/lib/qr/registry.ts`: dort liegt die
|
* Gegenstück zur Typ-Registry in `src/lib/qr/registry.ts`: dort liegt die
|
||||||
* Logik (Validierung, Kodierung), hier die Eingabemaske. Ein V2-Typ braucht
|
* Logik (Validierung, Kodierung), hier die Eingabemaske. Ein neuer Typ
|
||||||
* genau einen zusätzlichen Eintrag an beiden Stellen.
|
* braucht genau einen zusätzlichen Eintrag an beiden Stellen.
|
||||||
*/
|
*/
|
||||||
export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
|
export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
|
||||||
website: WebsiteForm,
|
website: WebsiteForm,
|
||||||
|
vcard: VCardForm,
|
||||||
|
app: AppForm,
|
||||||
|
whatsapp: WhatsappForm,
|
||||||
|
email: EmailForm,
|
||||||
|
sms: SmsForm,
|
||||||
|
location: LocationForm,
|
||||||
|
payment: PaymentForm,
|
||||||
|
wifi: WifiForm,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { QrFormProps };
|
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 [data, setData] = useState<QrFormData>(qrType.defaultData);
|
||||||
const [design, setDesign] = useState<QrDesign>(defaultDesign);
|
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 [label, setLabel] = useState("");
|
||||||
|
|
||||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||||
@@ -283,9 +284,10 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={!isDynamic}
|
aria-pressed={!isDynamic}
|
||||||
|
disabled={!qrType.supportsStatic}
|
||||||
onClick={() => setIsDynamic(false)}
|
onClick={() => setIsDynamic(false)}
|
||||||
className={cx(
|
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
|
!isDynamic
|
||||||
? "border-indigo-600 bg-indigo-50/60"
|
? "border-indigo-600 bg-indigo-50/60"
|
||||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||||
@@ -296,7 +298,9 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
Statisch
|
Statisch
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-sm text-slate-600">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -318,7 +322,9 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
<Badge tone="neutral">mit Statistik</Badge>
|
<Badge tone="neutral">mit Statistik</Badge>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-sm text-slate-600">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 {
|
export function cx(...classes: Array<string | false | null | undefined>): string {
|
||||||
return classes.filter(Boolean).join(" ");
|
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 */
|
/* ------------------------------------------------------------------- Badge */
|
||||||
|
|
||||||
export function 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 QrTypeId,
|
||||||
type QrTypeModule,
|
type QrTypeModule,
|
||||||
} from "@/lib/qr/type-module";
|
} 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 { 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
|
* 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[] = [
|
const MODULES: readonly QrTypeModule[] = [
|
||||||
websiteType,
|
websiteType,
|
||||||
defineComingSoonType({
|
vcardType,
|
||||||
id: "vcard",
|
appType,
|
||||||
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",
|
|
||||||
}),
|
|
||||||
defineComingSoonType({
|
defineComingSoonType({
|
||||||
id: "links",
|
id: "links",
|
||||||
title: "Mehrere Links",
|
title: "Mehrere Links",
|
||||||
@@ -42,12 +40,7 @@ const MODULES: readonly QrTypeModule[] = [
|
|||||||
description: "Bündle deine Profile in einem Code.",
|
description: "Bündle deine Profile in einem Code.",
|
||||||
icon: "share",
|
icon: "share",
|
||||||
}),
|
}),
|
||||||
defineComingSoonType({
|
whatsappType,
|
||||||
id: "whatsapp",
|
|
||||||
title: "WhatsApp",
|
|
||||||
description: "Starte einen Chat mit vorbereiteter Nachricht.",
|
|
||||||
icon: "chat",
|
|
||||||
}),
|
|
||||||
defineComingSoonType({
|
defineComingSoonType({
|
||||||
id: "pdf",
|
id: "pdf",
|
||||||
title: "PDF",
|
title: "PDF",
|
||||||
@@ -66,36 +59,11 @@ const MODULES: readonly QrTypeModule[] = [
|
|||||||
description: "Verlinke ein Video oder einen Stream.",
|
description: "Verlinke ein Video oder einen Stream.",
|
||||||
icon: "video",
|
icon: "video",
|
||||||
}),
|
}),
|
||||||
defineComingSoonType({
|
emailType,
|
||||||
id: "email",
|
smsType,
|
||||||
title: "E-Mail",
|
locationType,
|
||||||
description: "Öffne eine vorbereitete E-Mail.",
|
paymentType,
|
||||||
icon: "mail",
|
wifiType,
|
||||||
}),
|
|
||||||
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",
|
|
||||||
}),
|
|
||||||
defineComingSoonType({
|
defineComingSoonType({
|
||||||
id: "feedback",
|
id: "feedback",
|
||||||
title: "Feedback",
|
title: "Feedback",
|
||||||
|
|||||||
+37
-5
@@ -7,6 +7,7 @@ import { designSchema } from "@/lib/qr/design";
|
|||||||
import {
|
import {
|
||||||
QR_TYPE_IDS,
|
QR_TYPE_IDS,
|
||||||
QrRequestError,
|
QrRequestError,
|
||||||
|
QrTypeError,
|
||||||
type QrTypeModule,
|
type QrTypeModule,
|
||||||
} from "@/lib/qr/type-module";
|
} from "@/lib/qr/type-module";
|
||||||
import { shortUrl } from "@/lib/env";
|
import { shortUrl } from "@/lib/env";
|
||||||
@@ -40,6 +41,35 @@ export type QrRequest = z.infer<typeof qrRequestSchema>;
|
|||||||
// dass ein Ringschluss zwischen den beiden Modulen entsteht.
|
// dass ein Ringschluss zwischen den beiden Modulen entsteht.
|
||||||
export { QrRequestError };
|
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 = {
|
export type ResolvedQrRequest = {
|
||||||
qrType: QrTypeModule;
|
qrType: QrTypeModule;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
@@ -62,11 +92,7 @@ export async function resolveQrRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.mode === "dynamic") {
|
if (request.mode === "dynamic") {
|
||||||
if (!qrType.supportsDynamic) {
|
assertDynamicPossible(qrType, validated.data);
|
||||||
throw new QrRequestError(
|
|
||||||
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const slug = request.slug;
|
const slug = request.slug;
|
||||||
if (slug !== undefined && !isValidSlug(slug)) {
|
if (slug !== undefined && !isValidSlug(slug)) {
|
||||||
throw new QrRequestError("Ungültige Kurz-URL.");
|
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 {
|
return {
|
||||||
qrType,
|
qrType,
|
||||||
data: validated.data,
|
data: validated.data,
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export type ValidationResult =
|
|||||||
*/
|
*/
|
||||||
export class QrTypeError extends Error {}
|
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
|
* Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus
|
||||||
* dem passenden HTTP-Status.
|
* dem passenden HTTP-Status.
|
||||||
@@ -78,6 +86,12 @@ export interface QrTypeModule {
|
|||||||
* `src/lib/qr/availability.ts`.
|
* `src/lib/qr/availability.ts`.
|
||||||
*/
|
*/
|
||||||
readonly implemented: boolean;
|
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)? */
|
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
|
||||||
readonly supportsDynamic: boolean;
|
readonly supportsDynamic: boolean;
|
||||||
/** Startwerte für ein leeres Formular. */
|
/** 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. */
|
/** Inhalt, der bei einem statischen Code in den QR-Code kodiert wird. */
|
||||||
encode(data: unknown): string;
|
encode(data: unknown): string;
|
||||||
/** Ziel, auf das ein dynamischer Code weiterleitet. */
|
/** Ziel, auf das ein dynamischer Code weiterleitet. */
|
||||||
redirectTarget(data: unknown): string;
|
redirectTarget(data: unknown, context?: RedirectContext): string;
|
||||||
/** Einzeilige Zusammenfassung für Listen und Übersichten. */
|
/** Einzeilige Zusammenfassung für Listen und Übersichten. */
|
||||||
summary(data: unknown): string;
|
summary(data: unknown): string;
|
||||||
}
|
}
|
||||||
@@ -98,16 +112,25 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
|
/** Voreinstellung: true. */
|
||||||
|
supportsStatic?: boolean;
|
||||||
supportsDynamic: boolean;
|
supportsDynamic: boolean;
|
||||||
schema: TSchema;
|
schema: TSchema;
|
||||||
defaultData: QrFormData;
|
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;
|
encode: (data: z.output<TSchema>) => string;
|
||||||
/**
|
/**
|
||||||
* Weiterleitungsziel eines dynamischen Codes. Fällt auf `encode` zurück —
|
* 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;
|
summary: (data: z.output<TSchema>) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,6 +163,7 @@ export function defineQrType<TSchema extends z.ZodType>(
|
|||||||
description: spec.description,
|
description: spec.description,
|
||||||
icon: spec.icon,
|
icon: spec.icon,
|
||||||
implemented: spec.implemented,
|
implemented: spec.implemented,
|
||||||
|
supportsStatic: spec.supportsStatic ?? true,
|
||||||
supportsDynamic: spec.supportsDynamic,
|
supportsDynamic: spec.supportsDynamic,
|
||||||
defaultData: spec.defaultData,
|
defaultData: spec.defaultData,
|
||||||
validate(input) {
|
validate(input) {
|
||||||
@@ -149,7 +173,10 @@ export function defineQrType<TSchema extends z.ZodType>(
|
|||||||
: { ok: false, message: firstIssueMessage(result.error) };
|
: { ok: false, message: firstIssueMessage(result.error) };
|
||||||
},
|
},
|
||||||
encode: (data) => spec.encode(parse(data)),
|
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)),
|
summary: (data) => spec.summary(parse(data)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -177,6 +204,7 @@ export function defineComingSoonType(spec: {
|
|||||||
description: spec.description,
|
description: spec.description,
|
||||||
icon: spec.icon,
|
icon: spec.icon,
|
||||||
implemented: false,
|
implemented: false,
|
||||||
|
supportsStatic: false,
|
||||||
supportsDynamic: false,
|
supportsDynamic: false,
|
||||||
defaultData: {},
|
defaultData: {},
|
||||||
validate: () => ({
|
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