diff --git a/README.md b/README.md index 4aae7e1..d7b16ce 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank. -**Version 1** unterstützt den QR-Typ **Website/URL**, statisch wie dynamisch. -Die übrigen 14 Typen sind bereits in der Oberfläche sichtbar und für Version 2 -vorbereitet. +Umgesetzt sind **9 der 15 QR-Typen**. Die übrigen sechs sind in der Oberfläche +sichtbar, als Platzhalter markiert und lassen sich über die Verwaltung +ausblenden. --- @@ -137,9 +137,41 @@ werden — der Hostname darin bleibt `db` (der Servicename aus ### 1. Typ wählen -Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid aller -15 QR-Typen. In Version 1 ist **Website** aktiv (Badge „Beliebt“); die übrigen -Kacheln sind sichtbar, aber deaktiviert (Badge „Bald verfügbar“). +Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der +freigegebenen QR-Typen. Umgesetzte Typen sind anklickbar, die übrigen tragen +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 diff --git a/package.json b/package.json index e498a2c..64a20cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qrcode-generator", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", @@ -44,3 +44,4 @@ "unrs-resolver": true } } + diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 5199d75..1f22bc8 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -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); } diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index ed2e64a..7a88a4c 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -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 { 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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3ef8824..0954bf1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -57,7 +57,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[slug]/route.ts index 79d7bf3..40f1629 100644 --- a/src/app/r/[slug]/route.ts +++ b/src/app/r/[slug]/route.ts @@ -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 { 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."); } diff --git a/src/components/qr-forms/index.ts b/src/components/qr-forms/index.ts index fb10342..df4a4d9 100644 --- a/src/components/qr-forms/index.ts +++ b/src/components/qr-forms/index.ts @@ -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>> = { website: WebsiteForm, + vcard: VCardForm, + app: AppForm, + whatsapp: WhatsappForm, + email: EmailForm, + sms: SmsForm, + location: LocationForm, + payment: PaymentForm, + wifi: WifiForm, }; export type { QrFormProps }; diff --git a/src/components/qr-forms/payment-form.tsx b/src/components/qr-forms/payment-form.tsx new file mode 100644 index 0000000..b9d83db --- /dev/null +++ b/src/components/qr-forms/payment-form.tsx @@ -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, 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 ( +
+ + + + + {variante === "girocode" ? ( + <> + + set("empfaenger", event.target.value)} + /> + + + + set("iban", event.target.value.toUpperCase())} + onBlur={(event) => set("iban", formatIban(event.target.value))} + /> + + + + + set("bic", event.target.value.toUpperCase())} + /> + + + set("betrag", event.target.value)} + /> + + + + + set("verwendungszweck", event.target.value)} + /> + + + + 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. + + + ) : ( + + set("zahlungslink", event.target.value)} + /> + + )} +
+ ); +} diff --git a/src/components/qr-forms/simple-forms.tsx b/src/components/qr-forms/simple-forms.tsx new file mode 100644 index 0000000..fb11d68 --- /dev/null +++ b/src/components/qr-forms/simple-forms.tsx @@ -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, 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 ( +
+ + set("empfaenger", event.target.value)} + /> + + + + set("betreff", event.target.value)} + /> + + + +