diff --git a/package.json b/package.json index 49f9650..e24d09e 100644 --- a/package.json +++ b/package.json @@ -1,73 +1,75 @@ -{ - "name": "bricktrack", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "db:studio": "drizzle-kit studio", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "node scripts/reset-e2e.mjs && playwright test", - "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" - }, - "dependencies": { - "@better-auth/passkey": "^1.7.3", - "better-auth": "^1.7.3", - "better-sqlite3": "^13.0.3", - "class-variance-authority": "^0.7.1", - "cn": "^0.2.6", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.41.0", - "next": "15.5.25", - "next-themes": "^0.4.6", - "pdfjs-dist": "^6.3.289", - "radix-ui": "^1.6.7", - "react": "19.1.0", - "react-dom": "19.1.0", - "server-only": "^0.0.1", - "sharp": "^0.35.4", - "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@playwright/test": "^1.63.0", - "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^17.4.2", - "drizzle-kit": "^0.31.10", - "esbuild": "^0.25.12", - "eslint": "^9", - "eslint-config-next": "15.5.25", - "shadcn": "^4.21.0", - "tailwindcss": "^4", - "tsx": "^4.23.13", - "typescript": "^5", - "vitest": "^5.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "sharp", - "unrs-resolver" - ] - } -} +{ + "name": "bricktrack", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "node scripts/reset-e2e.mjs && playwright test", + "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "@zxing/browser": "^0.2.1", + "@zxing/library": "^0.23.0", + "better-auth": "^1.7.3", + "better-sqlite3": "^13.0.3", + "class-variance-authority": "^0.7.1", + "cn": "^0.2.6", + "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.41.0", + "next": "15.5.25", + "next-themes": "^0.4.6", + "pdfjs-dist": "^6.3.289", + "radix-ui": "^1.6.7", + "react": "19.1.0", + "react-dom": "19.1.0", + "server-only": "^0.0.1", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "tw-animate-css": "^1.4.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.63.0", + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "esbuild": "^0.25.12", + "eslint": "^9", + "eslint-config-next": "15.5.25", + "shadcn": "^4.21.0", + "tailwindcss": "^4", + "tsx": "^4.23.13", + "typescript": "^5", + "vitest": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild", + "sharp", + "unrs-resolver" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa513f6..7386ebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@better-auth/passkey': specifier: ^1.7.3 version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) + '@zxing/browser': + specifier: ^0.2.1 + version: 0.2.1(@zxing/library@0.23.0) + '@zxing/library': + specifier: ^0.23.0 + version: 0.23.0 better-auth: specifier: ^1.7.3 version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) @@ -2457,6 +2463,18 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@zxing/browser@0.2.1': + resolution: {integrity: sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==} + peerDependencies: + '@zxing/library': ^0.23.0 + + '@zxing/library@0.23.0': + resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==} + engines: {node: '>= 24.0.0'} + + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4745,6 +4763,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-custom-error@3.3.1: + resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==} + engines: {node: '>=14.0.0'} + ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} @@ -7104,6 +7126,21 @@ snapshots: '@vitest/spy@5.0.0': {} + '@zxing/browser@0.2.1(@zxing/library@0.23.0)': + dependencies: + '@zxing/library': 0.23.0 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + '@zxing/library@0.23.0': + dependencies: + ts-custom-error: 3.3.1 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + '@zxing/text-encoding@0.9.0': + optional: true + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -9555,6 +9592,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-custom-error@3.3.1: {} + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx index 1098964..b3f7f4d 100644 --- a/src/app/(app)/katalog/page.tsx +++ b/src/app/(app)/katalog/page.tsx @@ -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.'}

- +
+ + +
diff --git a/src/app/(app)/katalog/scan-actions.ts b/src/app/(app)/katalog/scan-actions.ts new file mode 100644 index 0000000..b07163e --- /dev/null +++ b/src/app/(app)/katalog/scan-actions.ts @@ -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> { + 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 }); +} diff --git a/src/components/katalog/barcode-scanner.tsx b/src/components/katalog/barcode-scanner.tsx new file mode 100644 index 0000000..f5fa78f --- /dev/null +++ b/src/components/katalog/barcode-scanner.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2, ScanLine } from 'lucide-react'; +import { toast } from 'sonner'; +import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions'; +import { formatEan, isValidEan } from '@/lib/ean'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +type Controls = { stop: () => void }; + +/** + * Scans the barcode on a box with the phone camera. + * + * Decoding runs in the browser with zxing, which is bundled with the app and + * loaded only when the scanner is opened - nothing leaves the device, and the + * catalog pages stay light for everyone who never scans. + */ +export function BarcodeScannerButton() { + const router = useRouter(); + const videoRef = useRef(null); + const controlsRef = useRef(null); + const handledRef = useRef(false); + + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting'); + const [message, setMessage] = useState(null); + + const stop = useCallback(() => { + controlsRef.current?.stop(); + controlsRef.current = null; + }, []); + + const onDetected = useCallback( + (raw: string) => { + if (handledRef.current) return; + if (!isValidEan(raw)) return; // keep scanning on a misread + + handledRef.current = true; + stop(); + setStatus('checking'); + + void lookupScannedEan(raw).then((result) => { + if (!result.ok) { + toast.error(result.error); + setOpen(false); + return; + } + + setOpen(false); + + if (result.data.found) { + toast.success(`${result.data.setNumber} · ${result.data.name}`); + router.push(`/katalog/${result.data.setId}`); + } else { + toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`); + router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`); + } + router.refresh(); + }); + }, + [router, stop], + ); + + useEffect(() => { + if (!open) return; + + handledRef.current = false; + setStatus('starting'); + setMessage(null); + + // The camera needs a secure context - same rule as for passkeys. + if (!window.isSecureContext) { + setStatus('error'); + setMessage( + 'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.', + ); + return; + } + + let cancelled = false; + + void (async () => { + try { + const { BrowserMultiFormatReader } = await import('@zxing/browser'); + const reader = new BrowserMultiFormatReader(); + const video = videoRef.current; + if (!video || cancelled) return; + + const controls = await reader.decodeFromConstraints( + // The rear camera is the one pointing at the box. + { video: { facingMode: 'environment' } }, + video, + (result) => { + if (result) onDetected(result.getText()); + }, + ); + + if (cancelled) { + controls.stop(); + return; + } + + controlsRef.current = controls; + setStatus('scanning'); + } catch (error) { + if (cancelled) return; + console.error('[scan] Kamera konnte nicht gestartet werden', error); + setStatus('error'); + + const name = (error as { name?: string })?.name; + setMessage( + name === 'NotAllowedError' + ? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.' + : name === 'NotFoundError' + ? 'Dieses Gerät hat keine nutzbare Kamera.' + : 'Die Kamera lässt sich nicht starten.', + ); + } + })(); + + return () => { + cancelled = true; + stop(); + }; + }, [open, onDetected, stop]); + + return ( + <> + + + { + if (!next) stop(); + setOpen(next); + }} + > + + + Barcode scannen + + Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon, + landest du direkt darauf – sonst im Formular mit ausgefüllter EAN. + + + + {status === 'error' ? ( +

+ {message} +

+ ) : ( +
+
+ + ); +} diff --git a/src/lib/ean.ts b/src/lib/ean.ts new file mode 100644 index 0000000..4dac099 --- /dev/null +++ b/src/lib/ean.ts @@ -0,0 +1,41 @@ +/** + * Barcode helpers. + * + * Kept free of server-only imports so both the scanner in the browser and the + * lookup on the server use the very same rules. + */ + +/** Digits only - scanners and humans disagree about spaces and dashes. */ +export function normalizeEan(input: string): string { + return input.replace(/\D/g, ''); +} + +/** + * Checks an EAN-8, EAN-13 or UPC-A against its check digit. + * + * Worth doing: a camera occasionally reads a digit wrong, and without this a + * misread would silently create a second catalog entry for a set that is + * already there. + */ +export function isValidEan(input: string): boolean { + const digits = normalizeEan(input); + if (![8, 12, 13].includes(digits.length)) return false; + + // From the right: every second digit counts three times. + const checkDigit = Number(digits[digits.length - 1]); + const body = digits.slice(0, -1); + + let sum = 0; + for (let index = body.length - 1, weight = 3; index >= 0; index -= 1, weight = weight === 3 ? 1 : 3) { + sum += Number(body[index]) * weight; + } + + return (10 - (sum % 10)) % 10 === checkDigit; +} + +/** "4005556603985" -> "4 005556 603985" for readable display. */ +export function formatEan(input: string): string { + const digits = normalizeEan(input); + if (digits.length !== 13) return digits; + return `${digits.slice(0, 1)} ${digits.slice(1, 7)} ${digits.slice(7)}`; +} diff --git a/src/lib/queries/catalog.ts b/src/lib/queries/catalog.ts index 9bb2fd2..96b45ff 100644 --- a/src/lib/queries/catalog.ts +++ b/src/lib/queries/catalog.ts @@ -160,6 +160,24 @@ export function getSetById(id: string) { return row ?? null; } +/** + * Lookup by barcode. EANs are stored as typed, so the comparison strips + * everything but digits on both sides - a scanner never produces the spaces + * and dashes people write by hand. + */ +export function findSetByEan(ean: string) { + const digits = ean.replace(/\D/g, ''); + if (digits.length === 0) return null; + + return ( + db + .select({ id: set.id, setNumber: set.setNumber, name: set.name }) + .from(set) + .where(sql`replace(replace(coalesce(${set.ean}, ''), '-', ''), ' ', '') = ${digits}`) + .get() ?? null + ); +} + export function getSetByNumber(manufacturerId: string, setNumber: string) { return ( db diff --git a/tests/setup-env.ts b/tests/setup-env.ts index b19663f..702ce79 100644 --- a/tests/setup-env.ts +++ b/tests/setup-env.ts @@ -10,7 +10,7 @@ * public base path and sets it to "/", which is not a URL. The clash only * exists under Vite-based tooling, never in the app itself. */ -process.env.NODE_ENV ??= 'test'; +// NODE_ENV is typed read-only; vitest already sets it to 'test'. process.env.BASE_URL = 'http://localhost:3000'; process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123'; diff --git a/tests/unit/ean.test.ts b/tests/unit/ean.test.ts new file mode 100644 index 0000000..44ba727 --- /dev/null +++ b/tests/unit/ean.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { formatEan, isValidEan, normalizeEan } from '@/lib/ean'; + +describe('EAN normalisieren', () => { + it('behaelt nur Ziffern', () => { + expect(normalizeEan('4 005556 603985')).toBe('4005556603985'); + expect(normalizeEan('4005556-603985')).toBe('4005556603985'); + }); +}); + +describe('EAN pruefen', () => { + it('akzeptiert gueltige EAN-13', () => { + // Playmobil 70392 und ein Lego-Beispiel. + expect(isValidEan('4008789703927')).toBe(true); + expect(isValidEan('5702017152677')).toBe(true); + }); + + it('akzeptiert gueltige EAN-8 und UPC-A', () => { + expect(isValidEan('96385074')).toBe(true); + expect(isValidEan('036000291452')).toBe(true); + }); + + it('lehnt eine falsche Pruefziffer ab', () => { + // Letzte Ziffer verdreht - genau der Fall, den eine Kamera produziert. + expect(isValidEan('4008789703928')).toBe(false); + expect(isValidEan('5702017152678')).toBe(false); + }); + + it('lehnt falsche Laengen ab', () => { + expect(isValidEan('')).toBe(false); + expect(isValidEan('123')).toBe(false); + expect(isValidEan('40087897039271')).toBe(false); + }); + + it('ignoriert Trennzeichen bei der Pruefung', () => { + expect(isValidEan('4 008789 703927')).toBe(true); + }); +}); + +describe('EAN anzeigen', () => { + it('gruppiert eine EAN-13 lesbar', () => { + expect(formatEan('4008789703927')).toBe('4 008789 703927'); + }); + + it('laesst andere Laengen unveraendert', () => { + expect(formatEan('96385074')).toBe('96385074'); + }); +});