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:
@@ -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
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.`);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user