feat(wunschliste): mehrfachauswahl, teilen und oeffentliche ansicht
Der zentrale Weg auf eine Wunschliste ist die Ansicht "Fehlende Sets": Kachel lange druecken startet den Auswahlmodus, die Aktionsleiste am unteren Rand uebernimmt die Auswahl in eine bestehende oder neue Liste. Dubletten und bereits besessene Sets werden uebersprungen und im Toast benannt; der Unique-Index auf (wishlist_id, set_id) macht das zur Datenbankgarantie. Dieselbe Auswahlansicht steckt eingebettet auf der Wunschlisten-Seite, vorgefiltert auf Sets, die noch nicht drauf sind. Die oeffentliche Ansicht unter /w/[shareToken] braucht keine Anmeldung, gibt nichts ausser den Wunschlisten-Daten preis und ist noindex. Besucher reservieren mit ihrem Namen und bekommen einen Storno-Link; in der Datenbank liegt nur der SHA-256-Hash des Tokens. Die Reservierung ist ein bedingtes UPDATE, damit bei zwei gleichzeitigen Klicks nur einer gewinnt. Erfuellte Wuensche verschwinden aus der oeffentlichen Ansicht. Dazu die Sammlungsuebersicht mit Statistik-Kacheln und Filtern nach Status, Vollstaendigkeit, Standort und Hersteller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
import { Loader2, Search, X } from 'lucide-react';
|
||||
import { COMPLETENESS_LABELS, STATUS_LABELS } from '@/lib/labels';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
export function CollectionFilters({
|
||||
locations,
|
||||
manufacturers,
|
||||
}: {
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
manufacturers: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [query, setQuery] = useState(searchParams.get('q') ?? '');
|
||||
|
||||
function apply(changes: Record<string, string | null>) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
startTransition(() => router.push(`${pathname}?${params.toString()}`));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const current = searchParams.get('q') ?? '';
|
||||
if (query === current) return;
|
||||
const timer = setTimeout(() => apply({ q: query || null }), 350);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const hasFilters = [...searchParams.keys()].length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Setnummer oder Name"
|
||||
aria-label="Sammlung durchsuchen"
|
||||
className="pl-9"
|
||||
/>
|
||||
{pending ? (
|
||||
<Loader2 className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<NativeSelect
|
||||
aria-label="Status"
|
||||
value={searchParams.get('status') ?? ''}
|
||||
onChange={(event) => apply({ status: event.target.value || null })}
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
aria-label="Vollständigkeit"
|
||||
value={searchParams.get('zustand') ?? ''}
|
||||
onChange={(event) => apply({ zustand: event.target.value || null })}
|
||||
>
|
||||
<option value="">Alle Vollständigkeiten</option>
|
||||
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
aria-label="Standort"
|
||||
value={searchParams.get('standort') ?? ''}
|
||||
onChange={(event) => apply({ standort: event.target.value || null })}
|
||||
>
|
||||
<option value="">Alle Standorte</option>
|
||||
{locations.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
aria-label="Hersteller"
|
||||
value={searchParams.get('hersteller') ?? ''}
|
||||
onChange={(event) => apply({ hersteller: event.target.value || null })}
|
||||
>
|
||||
<option value="">Alle Hersteller</option>
|
||||
{manufacturers.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
startTransition(() => router.push(pathname));
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Check, Loader2, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cancelReservation } from '@/app/w/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function CancelReservation({
|
||||
shareToken,
|
||||
itemId,
|
||||
token,
|
||||
setName,
|
||||
setNumber,
|
||||
isReserved,
|
||||
listTitle,
|
||||
}: {
|
||||
shareToken: string;
|
||||
itemId: string;
|
||||
token: string;
|
||||
setName: string;
|
||||
setNumber: string;
|
||||
isReserved: boolean;
|
||||
listTitle: string;
|
||||
}) {
|
||||
const [done, setDone] = useState(!isReserved);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function cancel() {
|
||||
startTransition(async () => {
|
||||
const result = await cancelReservation(shareToken, itemId, token);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
toast.success('Reservierung aufgehoben.');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{done ? 'Nicht mehr reserviert' : 'Reservierung aufheben?'}</CardTitle>
|
||||
<CardDescription>
|
||||
{setNumber} · {setName} – aus der Liste „{listTitle}“.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{done ? (
|
||||
<p className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<Check className="size-4" aria-hidden />
|
||||
Dieser Wunsch ist wieder für alle offen.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Danach kann jemand anderes das Geschenk übernehmen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{done ? null : (
|
||||
<Button onClick={cancel} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Undo2 />}
|
||||
Reservierung aufheben
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/w/${shareToken}`}>Zur Wunschliste</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
import { Check, ExternalLink, Gift, ImageOff, Loader2, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cancelReservation, reserveItem } from '@/app/w/actions';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { priorityLabel } from '@/lib/wishlist-logic';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export type PublicItem = {
|
||||
id: string;
|
||||
setNumber: string;
|
||||
setName: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
imageUrl: string | null;
|
||||
imagePath: string | null;
|
||||
retailPriceCents: number | null;
|
||||
manufacturerName: string;
|
||||
manufacturerWebsite: string | null;
|
||||
themeName: string | null;
|
||||
priority: number;
|
||||
note: string | null;
|
||||
isReserved: boolean;
|
||||
reservedByName: string | null;
|
||||
};
|
||||
|
||||
/** Cancel tokens live in the visitor's browser - they have no account. */
|
||||
const STORAGE_KEY = 'bricktrack.reservations';
|
||||
|
||||
function readTokens(): Record<string, string> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, string>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeTokens(tokens: Record<string, string>) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
|
||||
} catch {
|
||||
/* private mode - the link shown after reserving is the fallback */
|
||||
}
|
||||
}
|
||||
|
||||
export function PublicWishlist({
|
||||
shareToken,
|
||||
items,
|
||||
}: {
|
||||
shareToken: string;
|
||||
items: PublicItem[];
|
||||
}) {
|
||||
const [tokens, setTokens] = useState<Record<string, string>>({});
|
||||
const [reserving, setReserving] = useState<PublicItem | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [cancelLink, setCancelLink] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setTokens(readTokens());
|
||||
// Remember the name so the second gift does not need it typed again.
|
||||
setName(window.localStorage.getItem('bricktrack.name') ?? '');
|
||||
}, []);
|
||||
|
||||
function reserve() {
|
||||
const item = reserving;
|
||||
if (!item) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reserveItem(shareToken, { itemId: item.id, name });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = { ...tokens, [item.id]: result.data.cancelToken };
|
||||
setTokens(next);
|
||||
writeTokens(next);
|
||||
try {
|
||||
window.localStorage.setItem('bricktrack.name', name.trim());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
setCancelLink(
|
||||
`${window.location.origin}/w/${shareToken}/storno/${item.id}/${result.data.cancelToken}`,
|
||||
);
|
||||
setReserving(null);
|
||||
toast.success('Danke! Der Wunsch ist für dich reserviert.');
|
||||
});
|
||||
}
|
||||
|
||||
function undo(item: PublicItem) {
|
||||
const token = tokens[item.id];
|
||||
if (!token) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await cancelReservation(shareToken, item.id, token);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = { ...tokens };
|
||||
delete next[item.id];
|
||||
setTokens(next);
|
||||
writeTokens(next);
|
||||
toast.success('Reservierung aufgehoben.');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
const mine = Boolean(tokens[item.id]);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="flex gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<div className="size-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:size-28">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-6" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.setNumber}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{item.manufacturerName}</Badge>
|
||||
{item.priority === 1 ? <Badge>{priorityLabel(1)}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 font-medium">{item.setName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
item.themeName,
|
||||
item.year,
|
||||
item.pieceCount ? `${item.pieceCount} Teile` : null,
|
||||
item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{item.note ? <p className="mt-1 text-sm">{item.note}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{mine ? (
|
||||
<>
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
Das schenkst du
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={() => undo(item)} disabled={pending}>
|
||||
<Undo2 />
|
||||
Doch nicht
|
||||
</Button>
|
||||
</>
|
||||
) : item.isReserved ? (
|
||||
<Badge variant="secondary">
|
||||
{item.reservedByName
|
||||
? `Übernimmt ${item.reservedByName}`
|
||||
: 'Schon reserviert'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReserving(item);
|
||||
setCancelLink(null);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<Gift />
|
||||
Das schenke ich
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={item.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Shop
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog open={reserving !== null} onOpenChange={(open) => !open && setReserving(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Das schenke ich</DialogTitle>
|
||||
<DialogDescription>
|
||||
Damit sieht jeder andere, dass dieser Wunsch schon vergeben ist. Dein Name ist nur
|
||||
für die Familie sichtbar.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Field label="Dein Name" htmlFor="reserver-name" required>
|
||||
<Input
|
||||
id="reserver-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Tante Anne"
|
||||
maxLength={60}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReserving(null)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={reserve} disabled={pending || name.trim().length < 2}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Reservieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={cancelLink !== null} onOpenChange={(open) => !open && setCancelLink(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reservierung gespeichert</DialogTitle>
|
||||
<DialogDescription>
|
||||
Merke dir diesen Link, falls du die Reservierung später von einem anderen Gerät
|
||||
aufheben willst. Auf diesem Gerät geht es auch ohne.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
readOnly
|
||||
value={cancelLink ?? ''}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (cancelLink) void navigator.clipboard.writeText(cancelLink);
|
||||
toast.success('Link kopiert.');
|
||||
}}
|
||||
>
|
||||
Link kopieren
|
||||
</Button>
|
||||
<Button onClick={() => setCancelLink(null)}>Alles klar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Check, Copy, Link2Off, RefreshCw, Share2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { rotateShareToken } from '@/app/(app)/wunschlisten/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export function ShareCard({
|
||||
wishlistId,
|
||||
shareToken,
|
||||
isPublic,
|
||||
title,
|
||||
}: {
|
||||
wishlistId: string;
|
||||
shareToken: string;
|
||||
isPublic: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
const [token, setToken] = useState(shareToken);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [rotateOpen, setRotateOpen] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
// Built in the browser so the link always matches the host actually used.
|
||||
const url = typeof window === 'undefined' ? '' : `${window.location.origin}/w/${token}`;
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success('Link kopiert.');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.');
|
||||
}
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!navigator.share) {
|
||||
void copy();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
} catch {
|
||||
/* the user cancelled the share sheet */
|
||||
}
|
||||
}
|
||||
|
||||
function rotate() {
|
||||
startTransition(async () => {
|
||||
const result = await rotateShareToken(wishlistId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setToken(result.data.token);
|
||||
setRotateOpen(false);
|
||||
toast.success('Neuer Link erzeugt. Der alte funktioniert nicht mehr.');
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublic) {
|
||||
return (
|
||||
<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 />
|
||||
Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||
<p className="text-sm font-medium">Link zum Teilen</p>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={url} onFocus={(event) => event.currentTarget.select()} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren">
|
||||
{copied ? <Check className="text-emerald-600" /> : <Copy />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={share} aria-label="Link teilen">
|
||||
<Share2 />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben.
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRotateOpen(true)} disabled={pending}>
|
||||
<RefreshCw />
|
||||
Neuen Link erzeugen
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={rotateOpen} onOpenChange={setRotateOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Neuen Link erzeugen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene
|
||||
Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden
|
||||
ungültig.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={rotate}>Neuen Link erzeugen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { createWishlist, updateWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import type { Occasion } from '@/db/types';
|
||||
import { toDateInputValue } from '@/lib/format';
|
||||
import { OCCASION_LABELS } from '@/lib/labels';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export type WishlistFormValues = {
|
||||
id: string;
|
||||
title: string;
|
||||
occasion: Occasion;
|
||||
eventDate: Date | null;
|
||||
note: string | null;
|
||||
isPublic: boolean;
|
||||
surpriseMode: boolean;
|
||||
};
|
||||
|
||||
export function WishlistFormDialog({
|
||||
wishlist,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
wishlist?: WishlistFormValues;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const setOpen = onOpenChange;
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
|
||||
startTransition(async () => {
|
||||
if (wishlist) {
|
||||
const result = await updateWishlist(wishlist.id, input);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Wunschliste gespeichert.');
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createWishlist(input);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Wunschliste angelegt.');
|
||||
setOpen(false);
|
||||
router.push(`/wunschlisten/${result.data.id}`);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{wishlist ? 'Wunschliste bearbeiten' : 'Neue Wunschliste'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Der Anlass und das Datum erscheinen auch auf der geteilten Ansicht.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="wishlist-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Titel" htmlFor="title" required>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={wishlist?.title ?? ''}
|
||||
required
|
||||
maxLength={80}
|
||||
placeholder="Weihnachten 2026"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Anlass" htmlFor="occasion">
|
||||
<NativeSelect
|
||||
id="occasion"
|
||||
name="occasion"
|
||||
defaultValue={wishlist?.occasion ?? 'other'}
|
||||
>
|
||||
{Object.entries(OCCASION_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Termin" htmlFor="eventDate">
|
||||
<Input
|
||||
id="eventDate"
|
||||
name="eventDate"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(wishlist?.eventDate)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Hinweis an die Schenkenden"
|
||||
htmlFor="note"
|
||||
hint="Steht oben auf der geteilten Liste."
|
||||
>
|
||||
<Textarea
|
||||
id="note"
|
||||
name="note"
|
||||
defaultValue={wishlist?.note ?? ''}
|
||||
rows={3}
|
||||
placeholder="Bitte keine Doppelungen."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox name="isPublic" value="on" defaultChecked={wishlist?.isPublic ?? true} />
|
||||
<span>
|
||||
<Label className="font-medium">Über Link teilbar</Label>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Nur wer den Link kennt, sieht die Liste. Ohne diesen Haken ist der Link tot.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
name="surpriseMode"
|
||||
value="on"
|
||||
defaultChecked={wishlist?.surpriseMode ?? false}
|
||||
/>
|
||||
<span>
|
||||
<Label className="font-medium">Überraschungsmodus</Label>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Du siehst dann nur, dass etwas reserviert ist – nicht von wem.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" form="wishlist-form" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
{wishlist ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Button plus dialog, for the wishlist overview page. */
|
||||
export function NewWishlistButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="lg" onClick={() => setOpen(true)}>
|
||||
<Plus />
|
||||
Neue Wunschliste
|
||||
</Button>
|
||||
<WishlistFormDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import { WishlistFormDialog, type WishlistFormValues } from './wishlist-form-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValues }) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function remove() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteWishlist(wishlist.id);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Wunschliste gelöscht.');
|
||||
router.push('/wunschlisten');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="icon-lg" aria-label="Bearbeiten" onClick={() => setEditOpen(true)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label="Wunschliste löschen"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={pending}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
|
||||
<WishlistFormDialog wishlist={wishlist} open={editOpen} onOpenChange={setEditOpen} />
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Wunschliste löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
„{wishlist.title}“ und alle Einträge darauf werden gelöscht. Der geteilte Link
|
||||
funktioniert danach nicht mehr. Die Sets selbst bleiben im Katalog.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { ArrowDown, ArrowUp, Check, ImageOff, Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
removeWishlistItem,
|
||||
reorderWishlistItems,
|
||||
updateWishlistItem,
|
||||
} from '@/app/(app)/wunschlisten/actions';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import type { WishlistItemRow } from '@/lib/queries/wishlists';
|
||||
import { PRIORITY_LABELS, priorityLabel } from '@/lib/wishlist-logic';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
export function WishlistItems({
|
||||
wishlistId,
|
||||
items,
|
||||
surpriseMode,
|
||||
}: {
|
||||
wishlistId: string;
|
||||
items: WishlistItemRow[];
|
||||
surpriseMode: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [order, setOrder] = useState(items);
|
||||
|
||||
// Keep the local order in sync when the server sends a new list.
|
||||
if (items.map((item) => item.id).join() !== order.map((item) => item.id).join()) {
|
||||
setOrder(items);
|
||||
}
|
||||
|
||||
function move(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= order.length) return;
|
||||
|
||||
const next = [...order];
|
||||
const [moved] = next.splice(index, 1);
|
||||
if (!moved) return;
|
||||
next.splice(target, 0, moved);
|
||||
setOrder(next);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reorderWishlistItems(
|
||||
wishlistId,
|
||||
next.map((item) => item.id),
|
||||
);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
setOrder(items);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function setPriority(itemId: string, priority: string, note: string | null) {
|
||||
startTransition(async () => {
|
||||
const result = await updateWishlistItem(itemId, { priority, note: note ?? '' });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove(itemId: string) {
|
||||
startTransition(async () => {
|
||||
const result = await removeWishlistItem(itemId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Vom Wunschzettel entfernt.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (order.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
Noch nichts drauf. Nimm unten Sets aus der Liste der fehlenden Sets dazu.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{order.map((item, index) => {
|
||||
const image = setImageSrc(item);
|
||||
|
||||
return (
|
||||
<li key={item.id} className="flex items-start gap-3 p-3">
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Nach oben"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={pending || index === 0}
|
||||
>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Nach unten"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={pending || index === order.length - 1}
|
||||
>
|
||||
<ArrowDown />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/katalog/${item.setId}`}
|
||||
className="size-16 shrink-0 overflow-hidden rounded-lg border border-border bg-muted"
|
||||
>
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-5" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<Link href={`/katalog/${item.setId}`} className="text-sm font-medium hover:underline">
|
||||
{item.setName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[item.setNumber, item.manufacturerName, formatEuro(item.retailPriceCents)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
aria-label={`Priorität für ${item.setName}`}
|
||||
className="h-9 w-auto text-sm"
|
||||
value={String(item.priority)}
|
||||
onChange={(event) => setPriority(item.id, event.target.value, item.note)}
|
||||
disabled={pending}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
{item.fulfilledAt ? (
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
Erfüllt – schon in der Sammlung
|
||||
</Badge>
|
||||
) : item.reservedAt ? (
|
||||
<Badge variant="secondary">
|
||||
{surpriseMode
|
||||
? 'Reserviert'
|
||||
: `Reserviert von ${item.reservedByName ?? 'jemandem'}`}
|
||||
{` · ${formatDate(item.reservedAt)}`}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{priorityLabel(item.priority)} · noch offen</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Entfernen"
|
||||
onClick={() => remove(item.id)}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Trash2 className="text-destructive" />}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user