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:
@@ -1,306 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRef, useState, useTransition } from 'react';
|
||||
import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from 'cn';
|
||||
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import type { CatalogRow } from '@/lib/queries/catalog';
|
||||
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
|
||||
import { SetCard, SetCardContent } from './set-card';
|
||||
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 { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
const NEW_LIST = '__neu__';
|
||||
|
||||
export type WishlistTarget = { id: string; title: string };
|
||||
|
||||
type Props = {
|
||||
sets: CatalogRow[];
|
||||
wishlists: WishlistTarget[];
|
||||
/** Preselected target, used by the embedded view on a wishlist page. */
|
||||
defaultWishlistId?: string;
|
||||
emptyState?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The catalog grid with its selection mode.
|
||||
*
|
||||
* 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
|
||||
* in the collection cannot be selected - they do not belong on a wishlist.
|
||||
*/
|
||||
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
|
||||
const router = useRouter();
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressFired = useRef(false);
|
||||
|
||||
const selectable = sets.filter((item) => item.ownedCopies === 0);
|
||||
|
||||
function toggle(setId: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(setId)) next.delete(setId);
|
||||
else next.add(setId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function startLongPress(item: CatalogRow) {
|
||||
if (item.ownedCopies > 0) return;
|
||||
longPressFired.current = false;
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
longPressFired.current = true;
|
||||
setSelecting(true);
|
||||
toggle(item.id);
|
||||
// A short buzz confirms the mode switch where the device supports it.
|
||||
navigator.vibrate?.(15);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
|
||||
function endSelection() {
|
||||
setSelecting(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const setIds = [...selected];
|
||||
if (setIds.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
|
||||
if (wishlistId === NEW_LIST) {
|
||||
const created = await createWishlist({ title: newTitle, occasion: 'other' });
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
return;
|
||||
}
|
||||
wishlistId = created.data.id;
|
||||
}
|
||||
|
||||
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.added === 0) toast.warning(result.data.message);
|
||||
else toast.success(result.data.message);
|
||||
|
||||
setDialogOpen(false);
|
||||
endSelection();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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.'
|
||||
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
|
||||
</p>
|
||||
{selecting ? (
|
||||
<Button variant="ghost" size="sm" onClick={endSelection}>
|
||||
<X />
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelecting(true)}
|
||||
disabled={selectable.length === 0}
|
||||
>
|
||||
<CheckCheck />
|
||||
Auswählen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sets.map((item) => {
|
||||
const owned = item.ownedCopies > 0;
|
||||
const isSelected = selected.has(item.id);
|
||||
|
||||
if (!selecting) {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onPointerDown={() => startLongPress(item)}
|
||||
onPointerUp={cancelLongPress}
|
||||
onPointerLeave={cancelLongPress}
|
||||
onPointerCancel={cancelLongPress}
|
||||
onContextMenu={(event) => {
|
||||
// The long press must not open the browser context menu.
|
||||
if (longPressFired.current) event.preventDefault();
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (longPressFired.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
longPressFired.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SetCard set={item} className="block h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={owned}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggle(item.id)}
|
||||
title={owned ? 'Schon in der Sammlung' : undefined}
|
||||
className={cn(
|
||||
'relative rounded-xl text-left outline-none transition-opacity',
|
||||
'focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
owned && 'cursor-not-allowed opacity-50',
|
||||
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<SetCardContent set={item} />
|
||||
{!owned ? (
|
||||
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
|
||||
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selecting ? (
|
||||
<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">
|
||||
{selected.size} ausgewählt
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="lg"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Gift />
|
||||
Zur Wunschliste hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selected.size === 1
|
||||
? '1 Set auf die Wunschliste'
|
||||
: `${selected.size} Sets auf die Wunschliste`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
|
||||
übersprungen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Wunschliste" htmlFor="target-wishlist" required>
|
||||
<NativeSelect
|
||||
id="target-wishlist"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{wishlists.map((list) => (
|
||||
<option key={list.id} value={list.id}>
|
||||
{list.title}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{targetId === NEW_LIST ? (
|
||||
<Field label="Titel der neuen Liste" htmlFor="new-title" required>
|
||||
<Input
|
||||
id="new-title"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
placeholder="Weihnachten 2026"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Priorität"
|
||||
htmlFor="priority"
|
||||
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
|
||||
>
|
||||
<NativeSelect
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRef, useState, useTransition } from 'react';
|
||||
import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from 'cn';
|
||||
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import type { CatalogRow } from '@/lib/queries/catalog';
|
||||
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
|
||||
import { SetCard, SetCardContent } from './set-card';
|
||||
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 { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
const NEW_LIST = '__neu__';
|
||||
|
||||
export type WishlistTarget = { id: string; title: string };
|
||||
|
||||
type Props = {
|
||||
sets: CatalogRow[];
|
||||
wishlists: WishlistTarget[];
|
||||
/** Preselected target, used by the embedded view on a wishlist page. */
|
||||
defaultWishlistId?: string;
|
||||
emptyState?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The catalog grid with its selection mode.
|
||||
*
|
||||
* 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
|
||||
* in the collection cannot be selected - they do not belong on a wishlist.
|
||||
*/
|
||||
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
|
||||
const router = useRouter();
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressFired = useRef(false);
|
||||
|
||||
const selectable = sets.filter((item) => item.ownedCopies === 0);
|
||||
|
||||
function toggle(setId: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(setId)) next.delete(setId);
|
||||
else next.add(setId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function startLongPress(item: CatalogRow) {
|
||||
if (item.ownedCopies > 0) return;
|
||||
longPressFired.current = false;
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
longPressFired.current = true;
|
||||
setSelecting(true);
|
||||
toggle(item.id);
|
||||
// A short buzz confirms the mode switch where the device supports it.
|
||||
navigator.vibrate?.(15);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
|
||||
function endSelection() {
|
||||
setSelecting(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const setIds = [...selected];
|
||||
if (setIds.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
|
||||
if (wishlistId === NEW_LIST) {
|
||||
// isPublic: a list created on the fly is meant to be shared right away.
|
||||
const created = await createWishlist({
|
||||
title: newTitle,
|
||||
occasion: 'other',
|
||||
isPublic: 'on',
|
||||
});
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
return;
|
||||
}
|
||||
wishlistId = created.data.id;
|
||||
}
|
||||
|
||||
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.added === 0) toast.warning(result.data.message);
|
||||
else toast.success(result.data.message);
|
||||
|
||||
setDialogOpen(false);
|
||||
endSelection();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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.'
|
||||
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
|
||||
</p>
|
||||
{selecting ? (
|
||||
<Button variant="ghost" size="sm" onClick={endSelection}>
|
||||
<X />
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelecting(true)}
|
||||
disabled={selectable.length === 0}
|
||||
>
|
||||
<CheckCheck />
|
||||
Auswählen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sets.map((item) => {
|
||||
const owned = item.ownedCopies > 0;
|
||||
const isSelected = selected.has(item.id);
|
||||
|
||||
if (!selecting) {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onPointerDown={() => startLongPress(item)}
|
||||
onPointerUp={cancelLongPress}
|
||||
onPointerLeave={cancelLongPress}
|
||||
onPointerCancel={cancelLongPress}
|
||||
onContextMenu={(event) => {
|
||||
// The long press must not open the browser context menu.
|
||||
if (longPressFired.current) event.preventDefault();
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (longPressFired.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
longPressFired.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SetCard set={item} className="block h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={owned}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggle(item.id)}
|
||||
title={owned ? 'Schon in der Sammlung' : undefined}
|
||||
className={cn(
|
||||
'relative rounded-xl text-left outline-none transition-opacity',
|
||||
'focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
owned && 'cursor-not-allowed opacity-50',
|
||||
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<SetCardContent set={item} />
|
||||
{!owned ? (
|
||||
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
|
||||
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selecting ? (
|
||||
<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">
|
||||
{selected.size} ausgewählt
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="lg"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Gift />
|
||||
Zur Wunschliste hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selected.size === 1
|
||||
? '1 Set auf die Wunschliste'
|
||||
: `${selected.size} Sets auf die Wunschliste`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
|
||||
übersprungen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Wunschliste" htmlFor="target-wishlist" required>
|
||||
<NativeSelect
|
||||
id="target-wishlist"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{wishlists.map((list) => (
|
||||
<option key={list.id} value={list.id}>
|
||||
{list.title}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{targetId === NEW_LIST ? (
|
||||
<Field label="Titel der neuen Liste" htmlFor="new-title" required>
|
||||
<Input
|
||||
id="new-title"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
placeholder="Weihnachten 2026"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Priorität"
|
||||
htmlFor="priority"
|
||||
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
|
||||
>
|
||||
<NativeSelect
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,216 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteSet } from '@/app/(app)/katalog/actions';
|
||||
import { addSetToCollection } from '@/app/(app)/sammlung/actions';
|
||||
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
const NEW_LIST = '__neu__';
|
||||
|
||||
export function SetActions({
|
||||
setId,
|
||||
setName,
|
||||
owned,
|
||||
wishlists,
|
||||
}: {
|
||||
setId: string;
|
||||
setName: string;
|
||||
owned: boolean;
|
||||
wishlists: Array<{ id: string; title: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [wishlistOpen, setWishlistOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
|
||||
function addToCollection() {
|
||||
startTransition(async () => {
|
||||
const result = await addSetToCollection(setId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Zur Sammlung hinzugefügt.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function addToWishlist() {
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
|
||||
if (wishlistId === NEW_LIST) {
|
||||
const created = await createWishlist({ title: newTitle, occasion: 'other' });
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
return;
|
||||
}
|
||||
wishlistId = created.data.id;
|
||||
}
|
||||
|
||||
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.added === 0) toast.warning(result.data.message);
|
||||
else toast.success('Auf die Wunschliste gesetzt.');
|
||||
|
||||
setWishlistOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteSet(setId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Set gelöscht.');
|
||||
router.push('/katalog');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={addToCollection} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Zur Sammlung hinzufügen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setWishlistOpen(true)}
|
||||
disabled={pending || owned}
|
||||
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
|
||||
>
|
||||
<Gift />
|
||||
Auf Wunschliste
|
||||
</Button>
|
||||
|
||||
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
|
||||
<Trash2 className="text-destructive" />
|
||||
Löschen
|
||||
</Button>
|
||||
|
||||
{owned ? (
|
||||
<p className="w-full text-xs text-muted-foreground">
|
||||
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Auf Wunschliste setzen</DialogTitle>
|
||||
<DialogDescription>{setName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Wunschliste" htmlFor="single-target" required>
|
||||
<NativeSelect
|
||||
id="single-target"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{wishlists.map((list) => (
|
||||
<option key={list.id} value={list.id}>
|
||||
{list.title}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{targetId === NEW_LIST ? (
|
||||
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
|
||||
<Input
|
||||
id="single-new-title"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
placeholder="Weihnachten 2026"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="Priorität" htmlFor="single-priority">
|
||||
<NativeSelect
|
||||
id="single-priority"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={addToWishlist} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
|
||||
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
|
||||
machen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteSet } from '@/app/(app)/katalog/actions';
|
||||
import { addSetToCollection } from '@/app/(app)/sammlung/actions';
|
||||
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
const NEW_LIST = '__neu__';
|
||||
|
||||
export function SetActions({
|
||||
setId,
|
||||
setName,
|
||||
owned,
|
||||
wishlists,
|
||||
}: {
|
||||
setId: string;
|
||||
setName: string;
|
||||
owned: boolean;
|
||||
wishlists: Array<{ id: string; title: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [wishlistOpen, setWishlistOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
|
||||
function addToCollection() {
|
||||
startTransition(async () => {
|
||||
const result = await addSetToCollection(setId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Zur Sammlung hinzugefügt.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function addToWishlist() {
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
|
||||
if (wishlistId === NEW_LIST) {
|
||||
// isPublic: a list created on the fly is meant to be shared right away.
|
||||
const created = await createWishlist({
|
||||
title: newTitle,
|
||||
occasion: 'other',
|
||||
isPublic: 'on',
|
||||
});
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
return;
|
||||
}
|
||||
wishlistId = created.data.id;
|
||||
}
|
||||
|
||||
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.added === 0) toast.warning(result.data.message);
|
||||
else toast.success('Auf die Wunschliste gesetzt.');
|
||||
|
||||
setWishlistOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteSet(setId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Set gelöscht.');
|
||||
router.push('/katalog');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={addToCollection} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Zur Sammlung hinzufügen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setWishlistOpen(true)}
|
||||
disabled={pending || owned}
|
||||
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
|
||||
>
|
||||
<Gift />
|
||||
Auf Wunschliste
|
||||
</Button>
|
||||
|
||||
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
|
||||
<Trash2 className="text-destructive" />
|
||||
Löschen
|
||||
</Button>
|
||||
|
||||
{owned ? (
|
||||
<p className="w-full text-xs text-muted-foreground">
|
||||
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Auf Wunschliste setzen</DialogTitle>
|
||||
<DialogDescription>{setName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Wunschliste" htmlFor="single-target" required>
|
||||
<NativeSelect
|
||||
id="single-target"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{wishlists.map((list) => (
|
||||
<option key={list.id} value={list.id}>
|
||||
{list.title}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{targetId === NEW_LIST ? (
|
||||
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
|
||||
<Input
|
||||
id="single-new-title"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
placeholder="Weihnachten 2026"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="Priorität" htmlFor="single-priority">
|
||||
<NativeSelect
|
||||
id="single-priority"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={addToWishlist} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
|
||||
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
|
||||
machen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+36
-30
@@ -1,30 +1,36 @@
|
||||
import { cn } from 'cn';
|
||||
import { Label } from './label';
|
||||
|
||||
/** Label + control + optional hint, the layout every form in the app uses. */
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
required,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label}
|
||||
{required ? <span className="text-destructive"> *</span> : null}
|
||||
</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { cn } from 'cn';
|
||||
import { Label } from './label';
|
||||
|
||||
/** Label + control + optional hint, the layout every form in the app uses. */
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
required,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label}
|
||||
{/* aria-hidden keeps the accessible name clean ("Name", not "Name *");
|
||||
the required state is already carried by the input itself. */}
|
||||
{required ? (
|
||||
<span aria-hidden className="text-destructive">
|
||||
{' *'}
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +1,128 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
'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
|
||||
aria-label="Link zum Teilen"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user