feat(scanner): ean-barcode mit der handykamera scannen

Knopf im Katalog neben "Set anlegen": Strichcode ins Bild halten, und
entweder oeffnet sich das passende Set oder das Formular "Neues Set"
mit ausgefuellter EAN. Dekodiert wird im Browser mit zxing, das nur
beim Oeffnen des Scanners nachgeladen wird - nichts verlaesst das
Geraet, und die Katalogseite bleibt leicht fuer alle, die nie scannen.

Jeder gelesene Code wird gegen seine Pruefziffer geprueft, bevor er
verwendet wird. Ohne das wuerde eine falsch erkannte Ziffer stillschweigend
ein zweites Katalog-Set fuer ein Set anlegen, das es schon gibt; bei
einem Fehlversuch scannt die Kamera einfach weiter.

Die Kamera braucht wie Passkeys einen sicheren Kontext. Fehlt HTTPS oder
wird der Zugriff abgelehnt, steht das als deutscher Hinweis im Dialog
statt einer stummen schwarzen Flaeche.

8 Unit-Tests mit echten EAN-13-, EAN-8- und UPC-A-Beispielen.

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 07:15:41 +02:00
co-authored by Claude Opus 5
parent 4e3cbb81ce
commit baa94bf7e5
9 changed files with 468 additions and 80 deletions
+10 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
@@ -60,12 +61,15 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p>
</div>
<Button asChild size="lg" className="shrink-0">
<Link href="/katalog/neu">
<Plus />
<span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
<div className="flex shrink-0 gap-2">
<BarcodeScannerButton />
<Button asChild size="lg">
<Link href="/katalog/neu">
<Plus />
<span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
</div>
</div>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
+37
View File
@@ -0,0 +1,37 @@
'use server';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { isValidEan } from '@/lib/ean';
import { findSetByEan } from '@/lib/queries/catalog';
import { requireUser } from '@/lib/session';
export type ScanLookup =
| { found: true; setId: string; setNumber: string; name: string }
| { found: false; ean: string };
/**
* Resolves a scanned barcode.
*
* Either the set is already in the catalog - then we jump straight to it - or
* it is not, and the caller opens the "new set" form with the number filled in.
*/
export async function lookupScannedEan(rawEan: unknown): Promise<ActionResult<ScanLookup>> {
await requireUser();
const ean = typeof rawEan === 'string' ? rawEan.replace(/\D/g, '') : '';
if (!isValidEan(ean)) {
return actionError('Der Strichcode wurde nicht sauber erkannt. Bitte noch einmal scannen.');
}
const match = findSetByEan(ean);
if (match) {
return actionData({
found: true as const,
setId: match.id,
setNumber: match.setNumber,
name: match.name,
});
}
return actionData({ found: false as const, ean });
}