test: unit-tests der kernlogik und playwright-smoke-test

50 Unit-Tests mit Vitest fuer die Stellen, an denen ein Fehler teuer
waere: Besitzstatus, Mehrfachauswahl inklusive Ueberspringen von
Dubletten und bereits besessenen Sets, Token-Erzeugung und -Vergleich,
Cent-Umrechnung und Slugs.

Der Playwright-Smoke-Test laeuft den ganzen Weg: Registrierung des
ersten Nutzers, Hersteller anlegen, Set anlegen, auf eine neue
Wunschliste setzen, Link holen und als Besucher ohne Konto reservieren.
Er nutzt ein eigenes Datenverzeichnis (./data-e2e), das vor jedem Lauf
geleert wird.

Dabei aufgefallen und behoben: eine spontan aus dem Katalog angelegte
Wunschliste war nicht teilbar, weil das Feld isPublic ohne Formular
auf false fiel. Ausserdem ist das Sternchen an Pflichtfeldern jetzt
aria-hidden, damit der zugaengliche Name des Feldes sauber bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 23:31:21 +02:00
co-authored by Claude Opus 5
parent 79b0133083
commit b0152b8667
16 changed files with 1289 additions and 746 deletions
+1
View File
@@ -19,6 +19,7 @@ next-env.d.ts
# runtime data (database, uploads) - never in git, this is the backup target # runtime data (database, uploads) - never in git, this is the backup target
/data /data
/data-e2e
# production # production
*.tsbuildinfo *.tsbuildinfo
+13
View File
@@ -0,0 +1,13 @@
import { execFileSync } from 'node:child_process';
/**
* Migrates the smoke-test database. The directory itself is wiped earlier by
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
* hook runs and the open database file would block the delete on Windows.
*/
export default function globalSetup() {
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
stdio: 'inherit',
env: { ...process.env, DATA_DIR: './data-e2e' },
});
}
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test';
/**
* The one path that has to work: register, put a set into the catalog, and
* share it on a wishlist that a visitor can reserve from without an account.
*/
test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ page, browser }) => {
// --- first run: the very first visitor registers and becomes admin --------
await page.goto('/');
await expect(page).toHaveURL(/\/registrieren/);
// Card titles are plain elements, not headings - hence getByText here.
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
await page.getByLabel('Name').fill('Florian');
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
await page.getByLabel('Passwort').fill('smoketest-passwort');
await page.getByRole('button', { name: 'Konto anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
// --- master data: a set needs a manufacturer -----------------------------
await page.goto('/verwaltung/hersteller');
await page.getByRole('button', { name: 'Hersteller anlegen' }).click();
await page.getByLabel('Name').fill('Lego');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Lego')).toBeVisible();
// --- catalog: create a set ------------------------------------------------
await page.goto('/katalog/neu');
await page.getByLabel('Setnummer').fill('60398');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Familienhaus und Elektroauto');
await page.getByLabel('Jahr').fill('2023');
await page.getByLabel('UVP').fill('49,99');
await page.getByRole('button', { name: 'Set anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
await expect(page.getByText('Fehlt noch')).toBeVisible();
// --- wishlist: put the set on a fresh list --------------------------------
await page.getByRole('button', { name: 'Auf Wunschliste' }).click();
await page.getByRole('combobox', { name: 'Wunschliste' }).selectOption('__neu__');
await page.getByLabel('Titel der neuen Liste').fill('Weihnachten 2026');
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// Wait for the confirmation, not for the button label that is on screen anyway.
await expect(page.getByText('Auf die Wunschliste gesetzt.')).toBeVisible();
// --- sharing: grab the public link ---------------------------------------
await page.goto('/wunschlisten');
// Navigate by href: a toast can still be on screen and would swallow the click.
const listHref = await page
.getByRole('link', { name: /Weihnachten 2026/ })
.getAttribute('href');
expect(listHref).toMatch(/^\/wunschlisten\//);
await page.goto(listHref!);
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
expect(shareUrl).toContain('/w/');
// --- the visitor: no account, no session ---------------------------------
const visitorContext = await browser.newContext();
const visitor = await visitorContext.newPage();
await visitor.goto(shareUrl);
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
await expect(visitor.getByText('Familienhaus und Elektroauto')).toBeVisible();
await visitor.getByRole('button', { name: 'Das schenke ich' }).click();
await visitor.getByLabel('Dein Name').fill('Tante Anne');
await visitor.getByRole('button', { name: 'Reservieren' }).click();
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
await visitor.getByRole('button', { name: 'Alles klar' }).click();
await expect(visitor.getByText('Das schenkst du')).toBeVisible();
// --- back on the owner side: the reservation shows up ---------------------
await page.reload();
await expect(page.getByText('Reserviert von Tante Anne')).toBeVisible();
await visitorContext.close();
});
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
await page.goto('/sammlung');
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
});
test('Ein unbekannter Share-Token liefert 404', async ({ page }) => {
const response = await page.goto('/w/dieser-token-existiert-nicht');
expect(response?.status()).toBe(404);
});
+72 -72
View File
@@ -1,72 +1,72 @@
{ {
"name": "bricktrack", "name": "bricktrack",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
"packageManager": "pnpm@10.34.5", "packageManager": "pnpm@10.34.5",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts", "db:migrate": "tsx scripts/migrate.ts",
"db:seed": "tsx scripts/seed.ts", "db:seed": "tsx scripts/seed.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "node scripts/reset-e2e.mjs && playwright test",
"screenshots": "playwright test --config=playwright.screenshots.config.ts" "screenshots": "playwright test --config=playwright.screenshots.config.ts"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.3", "@better-auth/passkey": "^1.7.3",
"better-auth": "^1.7.3", "better-auth": "^1.7.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.2.6", "cn": "^0.2.6",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"lucide-react": "^1.41.0", "lucide-react": "^1.41.0",
"next": "15.5.25", "next": "15.5.25",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pdfjs-dist": "^6.3.289", "pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.5.4" "zod": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@playwright/test": "^1.63.0", "@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0", "@types/better-sqlite3": "^9.6.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.25", "eslint-config-next": "15.5.25",
"shadcn": "^4.21.0", "shadcn": "^4.21.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "^5", "typescript": "^5",
"vitest": "^5.0.0" "vitest": "^5.0.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"better-sqlite3", "better-sqlite3",
"esbuild", "esbuild",
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ]
} }
} }
+50
View File
@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke test setup.
*
* Runs against its own data directory (./data-e2e) so a test never touches the
* real database. The global setup wipes and migrates it before each run.
*/
const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: false,
workers: 1,
retries: 0,
// Generous: the dev server compiles each route on first visit.
timeout: 240_000,
expect: { timeout: 15_000 },
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
},
projects: [
{
name: 'mobile',
// The app is built for phones first, so the smoke test uses one.
use: { ...devices['Pixel 7'] },
},
],
webServer: {
command: `pnpm exec next dev -p ${PORT}`,
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
NODE_ENV: 'development',
DATA_DIR: './data-e2e',
BASE_URL,
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
MAX_UPLOAD_MB: '25',
},
},
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Wipes the Playwright data directory before the smoke run.
*
* Runs before Playwright starts, because Playwright boots its web server
* before global setup - on Windows the open SQLite file would then block the
* delete.
*/
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-e2e');
fs.rmSync(dataDir, { recursive: true, force: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
+311 -306
View File
@@ -1,306 +1,311 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useRef, useState, useTransition } from 'react'; import { useRef, useState, useTransition } from 'react';
import { CheckCheck, Gift, Loader2, X } from 'lucide-react'; import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from 'cn'; import { cn } from 'cn';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import type { CatalogRow } from '@/lib/queries/catalog'; import type { CatalogRow } from '@/lib/queries/catalog';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { SetCard, SetCardContent } from './set-card'; import { SetCard, SetCardContent } from './set-card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Field } from '@/components/ui/field'; import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select'; import { NativeSelect } from '@/components/ui/native-select';
const LONG_PRESS_MS = 450; const LONG_PRESS_MS = 450;
const NEW_LIST = '__neu__'; const NEW_LIST = '__neu__';
export type WishlistTarget = { id: string; title: string }; export type WishlistTarget = { id: string; title: string };
type Props = { type Props = {
sets: CatalogRow[]; sets: CatalogRow[];
wishlists: WishlistTarget[]; wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */ /** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string; defaultWishlistId?: string;
emptyState?: React.ReactNode; emptyState?: React.ReactNode;
}; };
/** /**
* The catalog grid with its selection mode. * The catalog grid with its selection mode.
* *
* On a phone a long press starts the selection, afterwards a tap toggles. * On a phone a long press starts the selection, afterwards a tap toggles.
* On a desktop the "Auswählen" button does the same job. Sets that are already * On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist. * in the collection cannot be selected - they do not belong on a wishlist.
*/ */
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) { export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
const router = useRouter(); const router = useRouter();
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST); const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2'); const [priority, setPriority] = useState('2');
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false); const longPressFired = useRef(false);
const selectable = sets.filter((item) => item.ownedCopies === 0); const selectable = sets.filter((item) => item.ownedCopies === 0);
function toggle(setId: string) { function toggle(setId: string) {
setSelected((current) => { setSelected((current) => {
const next = new Set(current); const next = new Set(current);
if (next.has(setId)) next.delete(setId); if (next.has(setId)) next.delete(setId);
else next.add(setId); else next.add(setId);
return next; return next;
}); });
} }
function startLongPress(item: CatalogRow) { function startLongPress(item: CatalogRow) {
if (item.ownedCopies > 0) return; if (item.ownedCopies > 0) return;
longPressFired.current = false; longPressFired.current = false;
longPressTimer.current = setTimeout(() => { longPressTimer.current = setTimeout(() => {
longPressFired.current = true; longPressFired.current = true;
setSelecting(true); setSelecting(true);
toggle(item.id); toggle(item.id);
// A short buzz confirms the mode switch where the device supports it. // A short buzz confirms the mode switch where the device supports it.
navigator.vibrate?.(15); navigator.vibrate?.(15);
}, LONG_PRESS_MS); }, LONG_PRESS_MS);
} }
function cancelLongPress() { function cancelLongPress() {
if (longPressTimer.current) clearTimeout(longPressTimer.current); if (longPressTimer.current) clearTimeout(longPressTimer.current);
longPressTimer.current = null; longPressTimer.current = null;
} }
function endSelection() { function endSelection() {
setSelecting(false); setSelecting(false);
setSelected(new Set()); setSelected(new Set());
} }
function submit() { function submit() {
const setIds = [...selected]; const setIds = [...selected];
if (setIds.length === 0) return; if (setIds.length === 0) return;
startTransition(async () => { startTransition(async () => {
let wishlistId = targetId; let wishlistId = targetId;
if (wishlistId === NEW_LIST) { if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' }); // isPublic: a list created on the fly is meant to be shared right away.
if (!created.ok) { const created = await createWishlist({
toast.error(created.error); title: newTitle,
return; occasion: 'other',
} isPublic: 'on',
wishlistId = created.data.id; });
} if (!created.ok) {
toast.error(created.error);
const result = await addSetsToWishlist({ wishlistId, setIds, priority }); return;
if (!result.ok) { }
toast.error(result.error); wishlistId = created.data.id;
return; }
}
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
if (result.data.added === 0) toast.warning(result.data.message); if (!result.ok) {
else toast.success(result.data.message); toast.error(result.error);
return;
setDialogOpen(false); }
endSelection();
router.refresh(); if (result.data.added === 0) toast.warning(result.data.message);
}); else toast.success(result.data.message);
}
setDialogOpen(false);
if (sets.length === 0) { endSelection();
return ( router.refresh();
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> });
{emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'} }
</div>
); if (sets.length === 0) {
} return (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
return ( {emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'}
<div className="space-y-4"> </div>
<div className="flex items-center justify-between gap-2"> );
<p className="text-xs text-muted-foreground"> }
{selecting
? 'Tippe die Sets an, die auf die Wunschliste sollen.' return (
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'} <div className="space-y-4">
</p> <div className="flex items-center justify-between gap-2">
{selecting ? ( <p className="text-xs text-muted-foreground">
<Button variant="ghost" size="sm" onClick={endSelection}> {selecting
<X /> ? 'Tippe die Sets an, die auf die Wunschliste sollen.'
Abbrechen : 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
</Button> </p>
) : ( {selecting ? (
<Button <Button variant="ghost" size="sm" onClick={endSelection}>
variant="outline" <X />
size="sm" Abbrechen
onClick={() => setSelecting(true)} </Button>
disabled={selectable.length === 0} ) : (
> <Button
<CheckCheck /> variant="outline"
Auswählen size="sm"
</Button> onClick={() => setSelecting(true)}
)} disabled={selectable.length === 0}
</div> >
<CheckCheck />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> Auswählen
{sets.map((item) => { </Button>
const owned = item.ownedCopies > 0; )}
const isSelected = selected.has(item.id); </div>
if (!selecting) { <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
return ( {sets.map((item) => {
<div const owned = item.ownedCopies > 0;
key={item.id} const isSelected = selected.has(item.id);
onPointerDown={() => startLongPress(item)}
onPointerUp={cancelLongPress} if (!selecting) {
onPointerLeave={cancelLongPress} return (
onPointerCancel={cancelLongPress} <div
onContextMenu={(event) => { key={item.id}
// The long press must not open the browser context menu. onPointerDown={() => startLongPress(item)}
if (longPressFired.current) event.preventDefault(); onPointerUp={cancelLongPress}
}} onPointerLeave={cancelLongPress}
onClickCapture={(event) => { onPointerCancel={cancelLongPress}
if (longPressFired.current) { onContextMenu={(event) => {
event.preventDefault(); // The long press must not open the browser context menu.
event.stopPropagation(); if (longPressFired.current) event.preventDefault();
longPressFired.current = false; }}
} onClickCapture={(event) => {
}} if (longPressFired.current) {
> event.preventDefault();
<SetCard set={item} className="block h-full" /> event.stopPropagation();
</div> longPressFired.current = false;
); }
} }}
>
return ( <SetCard set={item} className="block h-full" />
<button </div>
key={item.id} );
type="button" }
disabled={owned}
aria-pressed={isSelected} return (
onClick={() => toggle(item.id)} <button
title={owned ? 'Schon in der Sammlung' : undefined} key={item.id}
className={cn( type="button"
'relative rounded-xl text-left outline-none transition-opacity', disabled={owned}
'focus-visible:ring-3 focus-visible:ring-ring/50', aria-pressed={isSelected}
owned && 'cursor-not-allowed opacity-50', onClick={() => toggle(item.id)}
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40', title={owned ? 'Schon in der Sammlung' : undefined}
)} className={cn(
> 'relative rounded-xl text-left outline-none transition-opacity',
<SetCardContent set={item} /> 'focus-visible:ring-3 focus-visible:ring-ring/50',
{!owned ? ( owned && 'cursor-not-allowed opacity-50',
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm"> isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden /> )}
</span> >
) : null} <SetCardContent set={item} />
</button> {!owned ? (
); <span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
})} <Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
</div> </span>
) : null}
{selecting ? ( </button>
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 p-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur"> );
<div className="mx-auto flex max-w-5xl items-center gap-3"> })}
<span className="text-sm font-medium"> </div>
{selected.size} ausgewählt
</span> {selecting ? (
<div className="flex-1" /> <div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 p-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur">
<Button <div className="mx-auto flex max-w-5xl items-center gap-3">
size="lg" <span className="text-sm font-medium">
disabled={selected.size === 0} {selected.size} ausgewählt
onClick={() => setDialogOpen(true)} </span>
> <div className="flex-1" />
<Gift /> <Button
Zur Wunschliste hinzufügen size="lg"
</Button> disabled={selected.size === 0}
</div> onClick={() => setDialogOpen(true)}
</div> >
) : null} <Gift />
Zur Wunschliste hinzufügen
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> </Button>
<DialogContent> </div>
<DialogHeader> </div>
<DialogTitle> ) : null}
{selected.size === 1
? '1 Set auf die Wunschliste' <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
: `${selected.size} Sets auf die Wunschliste`} <DialogContent>
</DialogTitle> <DialogHeader>
<DialogDescription> <DialogTitle>
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden {selected.size === 1
übersprungen. ? '1 Set auf die Wunschliste'
</DialogDescription> : `${selected.size} Sets auf die Wunschliste`}
</DialogHeader> </DialogTitle>
<DialogDescription>
<div className="space-y-4"> Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
<Field label="Wunschliste" htmlFor="target-wishlist" required> übersprungen.
<NativeSelect </DialogDescription>
id="target-wishlist" </DialogHeader>
value={targetId}
onChange={(event) => setTargetId(event.target.value)} <div className="space-y-4">
> <Field label="Wunschliste" htmlFor="target-wishlist" required>
{wishlists.map((list) => ( <NativeSelect
<option key={list.id} value={list.id}> id="target-wishlist"
{list.title} value={targetId}
</option> onChange={(event) => setTargetId(event.target.value)}
))} >
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option> {wishlists.map((list) => (
</NativeSelect> <option key={list.id} value={list.id}>
</Field> {list.title}
</option>
{targetId === NEW_LIST ? ( ))}
<Field label="Titel der neuen Liste" htmlFor="new-title" required> <option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
<Input </NativeSelect>
id="new-title" </Field>
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)} {targetId === NEW_LIST ? (
placeholder="Weihnachten 2026" <Field label="Titel der neuen Liste" htmlFor="new-title" required>
maxLength={80} <Input
/> id="new-title"
</Field> value={newTitle}
) : null} onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
<Field maxLength={80}
label="Priorität" />
htmlFor="priority" </Field>
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern." ) : null}
>
<NativeSelect <Field
id="priority" label="Priorität"
value={priority} htmlFor="priority"
onChange={(event) => setPriority(event.target.value)} hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
> >
{Object.entries(PRIORITY_LABELS).map(([value, label]) => ( <NativeSelect
<option key={value} value={value}> id="priority"
{label} value={priority}
</option> onChange={(event) => setPriority(event.target.value)}
))} >
</NativeSelect> {Object.entries(PRIORITY_LABELS).map(([value, label]) => (
</Field> <option key={value} value={value}>
</div> {label}
</option>
<DialogFooter> ))}
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}> </NativeSelect>
Abbrechen </Field>
</Button> </div>
<Button onClick={submit} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />} <DialogFooter>
Hinzufügen <Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
</Button> Abbrechen
</DialogFooter> </Button>
</DialogContent> <Button onClick={submit} disabled={pending}>
</Dialog> {pending ? <Loader2 className="animate-spin" /> : <Gift />}
</div> Hinzufügen
); </Button>
} </DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+221 -216
View File
@@ -1,216 +1,221 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react'; import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { deleteSet } from '@/app/(app)/katalog/actions'; import { deleteSet } from '@/app/(app)/katalog/actions';
import { addSetToCollection } from '@/app/(app)/sammlung/actions'; import { addSetToCollection } from '@/app/(app)/sammlung/actions';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Field } from '@/components/ui/field'; import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select'; import { NativeSelect } from '@/components/ui/native-select';
const NEW_LIST = '__neu__'; const NEW_LIST = '__neu__';
export function SetActions({ export function SetActions({
setId, setId,
setName, setName,
owned, owned,
wishlists, wishlists,
}: { }: {
setId: string; setId: string;
setName: string; setName: string;
owned: boolean; owned: boolean;
wishlists: Array<{ id: string; title: string }>; wishlists: Array<{ id: string; title: string }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false); const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST); const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2'); const [priority, setPriority] = useState('2');
function addToCollection() { function addToCollection() {
startTransition(async () => { startTransition(async () => {
const result = await addSetToCollection(setId); const result = await addSetToCollection(setId);
if (!result.ok) { if (!result.ok) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success('Zur Sammlung hinzugefügt.'); toast.success('Zur Sammlung hinzugefügt.');
router.refresh(); router.refresh();
}); });
} }
function addToWishlist() { function addToWishlist() {
startTransition(async () => { startTransition(async () => {
let wishlistId = targetId; let wishlistId = targetId;
if (wishlistId === NEW_LIST) { if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' }); // isPublic: a list created on the fly is meant to be shared right away.
if (!created.ok) { const created = await createWishlist({
toast.error(created.error); title: newTitle,
return; occasion: 'other',
} isPublic: 'on',
wishlistId = created.data.id; });
} if (!created.ok) {
toast.error(created.error);
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority }); return;
if (!result.ok) { }
toast.error(result.error); wishlistId = created.data.id;
return; }
}
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
if (result.data.added === 0) toast.warning(result.data.message); if (!result.ok) {
else toast.success('Auf die Wunschliste gesetzt.'); toast.error(result.error);
return;
setWishlistOpen(false); }
router.refresh();
}); if (result.data.added === 0) toast.warning(result.data.message);
} else toast.success('Auf die Wunschliste gesetzt.');
function remove() { setWishlistOpen(false);
startTransition(async () => { router.refresh();
const result = await deleteSet(setId); });
if (!result.ok) { }
toast.error(result.error);
return; function remove() {
} startTransition(async () => {
toast.success('Set gelöscht.'); const result = await deleteSet(setId);
router.push('/katalog'); if (!result.ok) {
router.refresh(); toast.error(result.error);
}); return;
} }
toast.success('Set gelöscht.');
return ( router.push('/katalog');
<div className="flex flex-wrap gap-2"> router.refresh();
<Button size="lg" onClick={addToCollection} disabled={pending}> });
{pending ? <Loader2 className="animate-spin" /> : <Plus />} }
Zur Sammlung hinzufügen
</Button> return (
<div className="flex flex-wrap gap-2">
<Button <Button size="lg" onClick={addToCollection} disabled={pending}>
size="lg" {pending ? <Loader2 className="animate-spin" /> : <Plus />}
variant="outline" Zur Sammlung hinzufügen
onClick={() => setWishlistOpen(true)} </Button>
disabled={pending || owned}
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined} <Button
> size="lg"
<Gift /> variant="outline"
Auf Wunschliste onClick={() => setWishlistOpen(true)}
</Button> disabled={pending || owned}
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}> >
<Trash2 className="text-destructive" /> <Gift />
Löschen Auf Wunschliste
</Button> </Button>
{owned ? ( <Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
<p className="w-full text-xs text-muted-foreground"> <Trash2 className="text-destructive" />
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen. Löschen
</p> </Button>
) : null}
{owned ? (
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}> <p className="w-full text-xs text-muted-foreground">
<DialogContent> Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
<DialogHeader> </p>
<DialogTitle>Auf Wunschliste setzen</DialogTitle> ) : null}
<DialogDescription>{setName}</DialogDescription>
</DialogHeader> <Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent>
<div className="space-y-4"> <DialogHeader>
<Field label="Wunschliste" htmlFor="single-target" required> <DialogTitle>Auf Wunschliste setzen</DialogTitle>
<NativeSelect <DialogDescription>{setName}</DialogDescription>
id="single-target" </DialogHeader>
value={targetId}
onChange={(event) => setTargetId(event.target.value)} <div className="space-y-4">
> <Field label="Wunschliste" htmlFor="single-target" required>
{wishlists.map((list) => ( <NativeSelect
<option key={list.id} value={list.id}> id="single-target"
{list.title} value={targetId}
</option> onChange={(event) => setTargetId(event.target.value)}
))} >
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option> {wishlists.map((list) => (
</NativeSelect> <option key={list.id} value={list.id}>
</Field> {list.title}
</option>
{targetId === NEW_LIST ? ( ))}
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required> <option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
<Input </NativeSelect>
id="single-new-title" </Field>
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)} {targetId === NEW_LIST ? (
placeholder="Weihnachten 2026" <Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
maxLength={80} <Input
/> id="single-new-title"
</Field> value={newTitle}
) : null} onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
<Field label="Priorität" htmlFor="single-priority"> maxLength={80}
<NativeSelect />
id="single-priority" </Field>
value={priority} ) : null}
onChange={(event) => setPriority(event.target.value)}
> <Field label="Priorität" htmlFor="single-priority">
{Object.entries(PRIORITY_LABELS).map(([value, label]) => ( <NativeSelect
<option key={value} value={value}> id="single-priority"
{label} value={priority}
</option> onChange={(event) => setPriority(event.target.value)}
))} >
</NativeSelect> {Object.entries(PRIORITY_LABELS).map(([value, label]) => (
</Field> <option key={value} value={value}>
</div> {label}
</option>
<DialogFooter> ))}
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}> </NativeSelect>
Abbrechen </Field>
</Button> </div>
<Button onClick={addToWishlist} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />} <DialogFooter>
Hinzufügen <Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
</Button> Abbrechen
</DialogFooter> </Button>
</DialogContent> <Button onClick={addToWishlist} disabled={pending}>
</Dialog> {pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> </Button>
<AlertDialogContent> </DialogFooter>
<AlertDialogHeader> </DialogContent>
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle> </Dialog>
<AlertDialogDescription>
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen, <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig <AlertDialogContent>
machen. <AlertDialogHeader>
</AlertDialogDescription> <AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
</AlertDialogHeader> <AlertDialogDescription>
<AlertDialogFooter> „{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
<AlertDialogCancel>Abbrechen</AlertDialogCancel> Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction> machen.
</AlertDialogFooter> </AlertDialogDescription>
</AlertDialogContent> </AlertDialogHeader>
</AlertDialog> <AlertDialogFooter>
</div> <AlertDialogCancel>Abbrechen</AlertDialogCancel>
); <AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
} </AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+36 -30
View File
@@ -1,30 +1,36 @@
import { cn } from 'cn'; import { cn } from 'cn';
import { Label } from './label'; import { Label } from './label';
/** Label + control + optional hint, the layout every form in the app uses. */ /** Label + control + optional hint, the layout every form in the app uses. */
export function Field({ export function Field({
label, label,
htmlFor, htmlFor,
hint, hint,
required, required,
className, className,
children, children,
}: { }: {
label: string; label: string;
htmlFor: string; htmlFor: string;
hint?: string; hint?: string;
required?: boolean; required?: boolean;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2', className)}>
<Label htmlFor={htmlFor}> <Label htmlFor={htmlFor}>
{label} {label}
{required ? <span className="text-destructive"> *</span> : null} {/* aria-hidden keeps the accessible name clean ("Name", not "Name *");
</Label> the required state is already carried by the input itself. */}
{children} {required ? (
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null} <span aria-hidden className="text-destructive">
</div> {' *'}
); </span>
} ) : null}
</Label>
{children}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}
+128 -122
View File
@@ -1,122 +1,128 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { Check, Copy, Link2Off, RefreshCw, Share2 } from 'lucide-react'; import { Check, Copy, Link2Off, RefreshCw, Share2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { rotateShareToken } from '@/app/(app)/wunschlisten/actions'; import { rotateShareToken } from '@/app/(app)/wunschlisten/actions';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
export function ShareCard({ export function ShareCard({
wishlistId, wishlistId,
shareToken, shareToken,
isPublic, isPublic,
title, title,
}: { }: {
wishlistId: string; wishlistId: string;
shareToken: string; shareToken: string;
isPublic: boolean; isPublic: boolean;
title: string; title: string;
}) { }) {
const [token, setToken] = useState(shareToken); const [token, setToken] = useState(shareToken);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [rotateOpen, setRotateOpen] = useState(false); const [rotateOpen, setRotateOpen] = useState(false);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
// Built in the browser so the link always matches the host actually used. // Built in the browser so the link always matches the host actually used.
const url = typeof window === 'undefined' ? '' : `${window.location.origin}/w/${token}`; const url = typeof window === 'undefined' ? '' : `${window.location.origin}/w/${token}`;
async function copy() { async function copy() {
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setCopied(true); setCopied(true);
toast.success('Link kopiert.'); toast.success('Link kopiert.');
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch { } catch {
toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'); toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.');
} }
} }
async function share() { async function share() {
if (!navigator.share) { if (!navigator.share) {
void copy(); void copy();
return; return;
} }
try { try {
await navigator.share({ title, url }); await navigator.share({ title, url });
} catch { } catch {
/* the user cancelled the share sheet */ /* the user cancelled the share sheet */
} }
} }
function rotate() { function rotate() {
startTransition(async () => { startTransition(async () => {
const result = await rotateShareToken(wishlistId); const result = await rotateShareToken(wishlistId);
if (!result.ok) { if (!result.ok) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
setToken(result.data.token); setToken(result.data.token);
setRotateOpen(false); setRotateOpen(false);
toast.success('Neuer Link erzeugt. Der alte funktioniert nicht mehr.'); toast.success('Neuer Link erzeugt. Der alte funktioniert nicht mehr.');
}); });
} }
if (!isPublic) { if (!isPublic) {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground"> <div className="flex items-center gap-2 rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground">
<Link2Off className="size-4 shrink-0" aria-hidden /> <Link2Off className="size-4 shrink-0" aria-hidden />
Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste. Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste.
</div> </div>
); );
} }
return ( return (
<div className="space-y-2 rounded-lg border border-border p-3"> <div className="space-y-2 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Link zum Teilen</p> <p className="text-sm font-medium">Link zum Teilen</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Input readOnly value={url} onFocus={(event) => event.currentTarget.select()} className="font-mono text-xs" /> <Input
<Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren"> readOnly
{copied ? <Check className="text-emerald-600" /> : <Copy />} aria-label="Link zum Teilen"
</Button> value={url}
<Button variant="outline" size="icon" onClick={share} aria-label="Link teilen"> onFocus={(event) => event.currentTarget.select()}
<Share2 /> className="font-mono text-xs"
</Button> />
</div> <Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren">
<p className="text-xs text-muted-foreground"> {copied ? <Check className="text-emerald-600" /> : <Copy />}
Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben. </Button>
</p> <Button variant="outline" size="icon" onClick={share} aria-label="Link teilen">
<Button variant="ghost" size="sm" onClick={() => setRotateOpen(true)} disabled={pending}> <Share2 />
<RefreshCw /> </Button>
Neuen Link erzeugen </div>
</Button> <p className="text-xs text-muted-foreground">
Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben.
<AlertDialog open={rotateOpen} onOpenChange={setRotateOpen}> </p>
<AlertDialogContent> <Button variant="ghost" size="sm" onClick={() => setRotateOpen(true)} disabled={pending}>
<AlertDialogHeader> <RefreshCw />
<AlertDialogTitle>Neuen Link erzeugen?</AlertDialogTitle> Neuen Link erzeugen
<AlertDialogDescription> </Button>
Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene
Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden <AlertDialog open={rotateOpen} onOpenChange={setRotateOpen}>
ungültig. <AlertDialogContent>
</AlertDialogDescription> <AlertDialogHeader>
</AlertDialogHeader> <AlertDialogTitle>Neuen Link erzeugen?</AlertDialogTitle>
<AlertDialogFooter> <AlertDialogDescription>
<AlertDialogCancel>Abbrechen</AlertDialogCancel> Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene
<AlertDialogAction onClick={rotate}>Neuen Link erzeugen</AlertDialogAction> Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden
</AlertDialogFooter> ungültig.
</AlertDialogContent> </AlertDialogDescription>
</AlertDialog> </AlertDialogHeader>
</div> <AlertDialogFooter>
); <AlertDialogCancel>Abbrechen</AlertDialogCancel>
} <AlertDialogAction onClick={rotate}>Neuen Link erzeugen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { centsToInput, formatEuro, parseEuroInput, sumCents } from '@/lib/money';
describe('Preiseingabe nach Cent', () => {
it('versteht das deutsche Komma', () => {
expect(parseEuroInput('12,99')).toBe(1299);
expect(parseEuroInput('0,05')).toBe(5);
});
it('versteht den englischen Punkt', () => {
expect(parseEuroInput('12.99')).toBe(1299);
});
it('versteht Tausendertrennzeichen in beiden Schreibweisen', () => {
expect(parseEuroInput('1.234,56')).toBe(123456);
expect(parseEuroInput('1,234.56')).toBe(123456);
});
it('ignoriert Euro-Zeichen und Leerzeichen', () => {
expect(parseEuroInput(' 89,99 € ')).toBe(8999);
});
it('liefert null fuer leere Eingaben', () => {
expect(parseEuroInput('')).toBeNull();
expect(parseEuroInput(' ')).toBeNull();
expect(parseEuroInput(null)).toBeNull();
expect(parseEuroInput(undefined)).toBeNull();
});
it('liefert null fuer Unsinn und negative Betraege', () => {
expect(parseEuroInput('abc')).toBeNull();
expect(parseEuroInput('-5')).toBeNull();
});
it('rundet auf ganze Cent', () => {
expect(parseEuroInput('9,999')).toBe(1000);
expect(parseEuroInput('9,994')).toBe(999);
});
});
describe('Preisanzeige', () => {
it('formatiert Cent als Euro-Betrag', () => {
// Intl setzt ein schmales geschuetztes Leerzeichen vor das Zeichen.
expect(formatEuro(1299).replace(/ | /g, ' ')).toBe('12,99 €');
});
it('zeigt fuer unbekannte Preise einen Gedankenstrich', () => {
expect(formatEuro(null)).toBe('–');
expect(formatEuro(undefined)).toBe('–');
});
it('macht aus Cent wieder eine Eingabe ohne Waehrungszeichen', () => {
expect(centsToInput(1299)).toBe('12,99');
expect(centsToInput(null)).toBe('');
});
it('ueberlebt den Rundweg Eingabe -> Cent -> Eingabe', () => {
for (const input of ['0,01', '12,99', '449,99', '1.234,56']) {
const cents = parseEuroInput(input);
expect(cents).not.toBeNull();
expect(parseEuroInput(centsToInput(cents))).toBe(cents);
}
});
});
describe('Summen', () => {
it('addiert exakt, ohne Float-Fehler', () => {
// 0,10 + 0,20 waere als Float 0.30000000000000004.
expect(sumCents([10, 20])).toBe(30);
});
it('ignoriert unbekannte Preise', () => {
expect(sumCents([1299, null, undefined, 100])).toBe(1399);
expect(sumCents([])).toBe(0);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { CollectionStatus } from '@/db/types';
import {
countOwnedCopies,
isOwningStatus,
isSetOwned,
OWNED_STATUSES,
parseOwnershipFilter,
} from '@/lib/ownership';
describe('Besitzstatus', () => {
it('zaehlt jeden Status ausser sold als Besitz', () => {
for (const status of OWNED_STATUSES) {
expect(isOwningStatus(status)).toBe(true);
}
expect(isOwningStatus('sold')).toBe(false);
});
it('gilt ohne Sammlungseintrag als nicht vorhanden', () => {
expect(isSetOwned([])).toBe(false);
});
it('gilt als vorhanden, sobald ein Exemplar nicht verkauft ist', () => {
expect(isSetOwned(['owned'])).toBe(true);
expect(isSetOwned(['built'])).toBe(true);
expect(isSetOwned(['disassembled'])).toBe(true);
expect(isSetOwned(['lent_out'])).toBe(true);
});
it('gilt als fehlend, wenn alle Exemplare verkauft sind', () => {
expect(isSetOwned(['sold'])).toBe(false);
expect(isSetOwned(['sold', 'sold'])).toBe(false);
});
it('gilt als vorhanden, wenn ein Exemplar verkauft und eines geblieben ist', () => {
const statuses: CollectionStatus[] = ['sold', 'built'];
expect(isSetOwned(statuses)).toBe(true);
});
it('zaehlt nur die nicht verkauften Exemplare', () => {
expect(countOwnedCopies(['owned', 'owned', 'sold'])).toBe(2);
expect(countOwnedCopies(['sold'])).toBe(0);
expect(countOwnedCopies([])).toBe(0);
});
});
describe('Besitz-Filter aus der URL', () => {
it('erkennt die vier gueltigen Werte', () => {
expect(parseOwnershipFilter('alle')).toBe('alle');
expect(parseOwnershipFilter('sammlung')).toBe('sammlung');
expect(parseOwnershipFilter('fehlt')).toBe('fehlt');
expect(parseOwnershipFilter('wunschliste')).toBe('wunschliste');
});
it('faellt bei Unsinn auf "alle" zurueck', () => {
expect(parseOwnershipFilter('quatsch')).toBe('alle');
expect(parseOwnershipFilter(undefined)).toBe('alle');
expect(parseOwnershipFilter(null)).toBe('alle');
expect(parseOwnershipFilter('')).toBe('alle');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { slugify, uniqueSlug } from '@/lib/slug';
describe('Slugs', () => {
it('schreibt deutsche Umlaute aus', () => {
expect(slugify('Grüne Kiste')).toBe('gruene-kiste');
expect(slugify('Öl')).toBe('oel');
expect(slugify('Straße')).toBe('strasse');
});
it('entfernt uebrige Akzente', () => {
expect(slugify('Café Déjà vu')).toBe('cafe-deja-vu');
});
it('macht aus Sonderzeichen einzelne Trennstriche', () => {
expect(slugify('Lego City --- Feuerwehr!')).toBe('lego-city-feuerwehr');
});
it('laesst keine Trennstriche am Rand stehen', () => {
expect(slugify(' -Playmobil- ')).toBe('playmobil');
});
it('kuerzt sehr lange Namen', () => {
expect(slugify('a'.repeat(200))).toHaveLength(60);
});
});
describe('Eindeutige Slugs', () => {
it('nimmt den Slug direkt, wenn er frei ist', () => {
expect(uniqueSlug('Lego', [])).toBe('lego');
});
it('haengt einen Zaehler an, bis der Slug frei ist', () => {
expect(uniqueSlug('Lego', ['lego'])).toBe('lego-2');
expect(uniqueSlug('Lego', ['lego', 'lego-2'])).toBe('lego-3');
});
it('faellt bei Namen ohne verwertbare Zeichen auf "eintrag" zurueck', () => {
expect(uniqueSlug('###', [])).toBe('eintrag');
expect(uniqueSlug('###', ['eintrag'])).toBe('eintrag-2');
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { createToken, hashToken, tokensMatch, TOKEN_BYTES } from '@/lib/tokens';
describe('Token-Erzeugung', () => {
it('erzeugt 32 Byte Zufall als base64url', () => {
const token = createToken();
expect(TOKEN_BYTES).toBe(32);
expect(Buffer.from(token, 'base64url')).toHaveLength(32);
// base64url darf nicht in URLs escaped werden muessen.
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('erzeugt bei jedem Aufruf einen anderen Token', () => {
const tokens = new Set(Array.from({ length: 500 }, () => createToken()));
expect(tokens.size).toBe(500);
});
});
describe('Token-Hashing', () => {
it('liefert einen stabilen SHA-256-Hex-Hash', () => {
const hash = hashToken('beispiel');
expect(hash).toHaveLength(64);
expect(hash).toMatch(/^[0-9a-f]+$/);
expect(hashToken('beispiel')).toBe(hash);
});
it('speichert den Klartext nirgends im Hash', () => {
expect(hashToken('geheim')).not.toContain('geheim');
});
it('erkennt den passenden Token', () => {
const token = createToken();
expect(tokensMatch(token, hashToken(token))).toBe(true);
});
it('weist einen falschen Token ab', () => {
expect(tokensMatch(createToken(), hashToken(createToken()))).toBe(false);
});
it('weist einen fehlenden Hash ab, statt zu vertrauen', () => {
const token = createToken();
expect(tokensMatch(token, null)).toBe(false);
expect(tokensMatch(token, undefined)).toBe(false);
expect(tokensMatch(token, '')).toBe(false);
});
it('weist einen Hash falscher Laenge ab, ohne zu werfen', () => {
expect(tokensMatch(createToken(), 'abc')).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { describeBulkAdd, planBulkAdd, priorityLabel } from '@/lib/wishlist-logic';
describe('Mehrfachauswahl auf die Wunschliste', () => {
it('uebernimmt alles, was weder drauf noch in der Sammlung ist', () => {
const plan = planBulkAdd(['a', 'b', 'c'], new Set(), new Set());
expect(plan.toAdd).toEqual(['a', 'b', 'c']);
expect(plan.duplicates).toEqual([]);
expect(plan.owned).toEqual([]);
});
it('ueberspringt Sets, die schon auf der Liste stehen', () => {
const plan = planBulkAdd(['a', 'b', 'c'], new Set(['b']), new Set());
expect(plan.toAdd).toEqual(['a', 'c']);
expect(plan.duplicates).toEqual(['b']);
});
it('ueberspringt Sets, die schon in der Sammlung sind', () => {
const plan = planBulkAdd(['a', 'b'], new Set(), new Set(['a']));
expect(plan.toAdd).toEqual(['b']);
expect(plan.owned).toEqual(['a']);
});
it('zaehlt ein Set, das beides ist, nur als Dublette', () => {
const plan = planBulkAdd(['a'], new Set(['a']), new Set(['a']));
expect(plan.toAdd).toEqual([]);
expect(plan.duplicates).toEqual(['a']);
expect(plan.owned).toEqual([]);
});
it('entfernt Mehrfachnennungen aus der Auswahl', () => {
const plan = planBulkAdd(['a', 'a', 'b', 'a'], new Set(), new Set());
expect(plan.toAdd).toEqual(['a', 'b']);
});
it('behaelt die Reihenfolge der Auswahl bei', () => {
const plan = planBulkAdd(['c', 'a', 'b'], new Set(), new Set());
expect(plan.toAdd).toEqual(['c', 'a', 'b']);
});
it('kommt mit einer leeren Auswahl klar', () => {
const plan = planBulkAdd([], new Set(['a']), new Set(['b']));
expect(plan).toEqual({ toAdd: [], duplicates: [], owned: [] });
});
});
describe('Rueckmeldung nach der Mehrfachauswahl', () => {
const names = new Map([
['a', '60398 Familienhaus'],
['b', '71427 Lloyds Drache'],
['c', '42172 McLaren P1'],
['d', '70392 Novelmore'],
['e', '2-30500 Mustang'],
]);
it('nennt die Zahl der hinzugefuegten Sets im Singular', () => {
const message = describeBulkAdd({ toAdd: ['a'], duplicates: [], owned: [] }, names);
expect(message).toBe('1 Set hinzugefügt');
});
it('benennt uebersprungene Dubletten mit Namen', () => {
const message = describeBulkAdd({ toAdd: ['a'], duplicates: ['b'], owned: [] }, names);
expect(message).toContain('1 übersprungen (schon auf der Liste)');
expect(message).toContain('71427 Lloyds Drache');
});
it('nennt bei vielen Dubletten nur die ersten drei', () => {
const message = describeBulkAdd(
{ toAdd: [], duplicates: ['a', 'b', 'c', 'd', 'e'], owned: [] },
names,
);
expect(message).toContain('und 2 weitere');
expect(message).not.toContain('70392 Novelmore');
});
it('trennt Dubletten von bereits besessenen Sets', () => {
const message = describeBulkAdd({ toAdd: [], duplicates: ['a'], owned: ['b'] }, names);
expect(message).toContain('schon auf der Liste');
expect(message).toContain('schon in der Sammlung');
});
});
describe('Prioritaeten', () => {
it('hat fuer jede Stufe ein deutsches Label', () => {
expect(priorityLabel(1)).toBe('Großer Wunsch');
expect(priorityLabel(2)).toBe('Gerne');
expect(priorityLabel(3)).toBe('Irgendwann');
});
it('faellt bei unbekannten Werten auf die mittlere Stufe zurueck', () => {
expect(priorityLabel(99)).toBe('Gerne');
});
});
+18
View File
@@ -0,0 +1,18 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(root, './src'),
},
},
test: {
environment: 'node',
include: ['tests/unit/**/*.test.ts'],
globals: false,
},
});