From 79b01330831866c7184a277d8ce44d6c1f7dcb64 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 23:06:19 +0200 Subject: [PATCH] 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) --- src/app/(app)/sammlung/page.tsx | 150 ++++++++++ src/app/(app)/wunschlisten/[id]/page.tsx | 135 +++++++++ src/app/(app)/wunschlisten/page.tsx | 86 ++++++ src/app/w/[shareToken]/page.tsx | 103 +++++++ .../storno/[itemId]/[token]/page.tsx | 38 +++ src/app/w/actions.ts | 93 ++++++ .../sammlung/collection-filters.tsx | 132 ++++++++ .../wunschlisten/cancel-reservation.tsx | 77 +++++ .../wunschlisten/public-wishlist.tsx | 282 ++++++++++++++++++ src/components/wunschlisten/share-card.tsx | 122 ++++++++ .../wunschlisten/wishlist-form-dialog.tsx | 195 ++++++++++++ .../wunschlisten/wishlist-header-actions.tsx | 74 +++++ .../wunschlisten/wishlist-items.tsx | 196 ++++++++++++ src/lib/queries/collection.ts | 160 ++++++++++ 14 files changed, 1843 insertions(+) create mode 100644 src/app/(app)/sammlung/page.tsx create mode 100644 src/app/(app)/wunschlisten/[id]/page.tsx create mode 100644 src/app/(app)/wunschlisten/page.tsx create mode 100644 src/app/w/[shareToken]/page.tsx create mode 100644 src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx create mode 100644 src/app/w/actions.ts create mode 100644 src/components/sammlung/collection-filters.tsx create mode 100644 src/components/wunschlisten/cancel-reservation.tsx create mode 100644 src/components/wunschlisten/public-wishlist.tsx create mode 100644 src/components/wunschlisten/share-card.tsx create mode 100644 src/components/wunschlisten/wishlist-form-dialog.tsx create mode 100644 src/components/wunschlisten/wishlist-header-actions.tsx create mode 100644 src/components/wunschlisten/wishlist-items.tsx create mode 100644 src/lib/queries/collection.ts diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx new file mode 100644 index 0000000..fddc188 --- /dev/null +++ b/src/app/(app)/sammlung/page.tsx @@ -0,0 +1,150 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react'; +import { CollectionFilters } from '@/components/sammlung/collection-filters'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { formatDate, formatNumber, pluralize } from '@/lib/format'; +import { setImageSrc } from '@/lib/images'; +import { completenessLabel, statusLabel } from '@/lib/labels'; +import { formatEuro } from '@/lib/money'; +import { + countMissingSets, + getCollectionStats, + listCollectionItems, + type CollectionFilters as Filters, +} from '@/lib/queries/collection'; +import { listLocations, listManufacturers } from '@/lib/queries/master-data'; +import { requireUser } from '@/lib/session'; +import type { CollectionStatus, Completeness } from '@/db/types'; + +export const metadata: Metadata = { title: 'Sammlung' }; + +type SearchParams = Promise>; + +function single(value: string | string[] | undefined): string | undefined { + const raw = Array.isArray(value) ? value[0] : value; + return raw && raw.length > 0 ? raw : undefined; +} + +export default async function SammlungPage({ searchParams }: { searchParams: SearchParams }) { + await requireUser(); + const params = await searchParams; + + const filters: Filters = { + q: single(params.q), + status: single(params.status) as CollectionStatus | undefined, + completeness: single(params.zustand) as Completeness | undefined, + locationId: single(params.standort), + manufacturerId: single(params.hersteller), + }; + + const items = listCollectionItems(filters); + const stats = getCollectionStats(); + const missing = countMissingSets(); + + const tiles = [ + { icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) }, + { icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) }, + { icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) }, + { icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) }, + ] as const; + + return ( +
+
+

Sammlung

+

Alles, was tatsächlich da ist.

+
+ +
+ {tiles.map((tile) => ( + + + +

{tile.value}

+

{tile.label}

+
+
+ ))} +
+ + {stats.byManufacturer.length > 0 ? ( +
+ {stats.byManufacturer.map((row) => ( + + {row.name}: {formatNumber(row.count)} + + ))} +
+ ) : null} + + ({ id: row.id, name: row.name }))} + manufacturers={listManufacturers().map((row) => ({ id: row.id, name: row.name }))} + /> + +

+ {pluralize(items.length, 'Eintrag', 'Einträge')} +

+ + {items.length === 0 ? ( +
+

+ Hier ist noch nichts. Sets kommen über den Katalog in die Sammlung. +

+ + Zum Katalog + +
+ ) : ( +
    + {items.map((item) => { + const image = setImageSrc(item); + return ( +
  • + +
    + {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + +
    + )} +
    + +
    +

    {item.setName}

    +

    + {[item.setNumber, item.manufacturerName, item.locationName] + .filter(Boolean) + .join(' · ')} +

    +
    + + {statusLabel(item.status)} + + {item.completeness !== 'unknown' ? ( + {completenessLabel(item.completeness)} + ) : null} + {item.acquiredAt ? ( + + seit {formatDate(item.acquiredAt)} + + ) : null} +
    +
    + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(app)/wunschlisten/[id]/page.tsx b/src/app/(app)/wunschlisten/[id]/page.tsx new file mode 100644 index 0000000..fc433ff --- /dev/null +++ b/src/app/(app)/wunschlisten/[id]/page.tsx @@ -0,0 +1,135 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { CatalogFilters } from '@/components/katalog/catalog-filters'; +import { CatalogGrid } from '@/components/katalog/catalog-grid'; +import { Pagination } from '@/components/katalog/pagination'; +import { ShareCard } from '@/components/wunschlisten/share-card'; +import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header-actions'; +import { WishlistItems } from '@/components/wunschlisten/wishlist-items'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate, pluralize } from '@/lib/format'; +import { occasionLabel } from '@/lib/labels'; +import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; +import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; + +type Props = { + params: Promise<{ id: string }>; + searchParams: Promise>; +}; + +function single(value: string | string[] | undefined): string | undefined { + const raw = Array.isArray(value) ? value[0] : value; + return raw && raw.length > 0 ? raw : undefined; +} + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const list = getWishlist(id); + return { title: list?.title ?? 'Wunschliste' }; +} + +export default async function WunschlisteDetailPage({ params, searchParams }: Props) { + const user = await requireUser(); + const { id } = await params; + const query = await searchParams; + + const list = getWishlist(id); + if (!list || list.ownerUserId !== user.id) notFound(); + + const items = listWishlistItems(list.id); + + // The embedded picker: missing sets that are not on this list yet. It shares + // the URL parameters with the catalog, so the filters behave identically. + const yearParam = single(query.jahr); + const page = Number(single(query.seite) ?? '1') || 1; + const missing = listSets({ + q: single(query.q), + manufacturerId: single(query.hersteller), + themeId: single(query.thema), + year: yearParam ? Number(yearParam) : undefined, + ownership: 'fehlt', + excludeWishlistId: list.id, + page, + }); + + return ( +
+
+
+
+ {occasionLabel(list.occasion)} + {list.eventDate ? {formatDate(list.eventDate)} : null} + {list.surpriseMode ? Überraschungsmodus : null} +
+

{list.title}

+

+ {pluralize(items.length, 'Wunsch', 'Wünsche')} +

+
+ +
+ + + + {list.note ? ( +

{list.note}

+ ) : null} + + + + + + Aus fehlenden Sets hinzufügen + + Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel + lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen. + + + + ({ id: row.id, name: row.name })), + themes: listThemes().map((row) => ({ + id: row.id, + name: row.name, + manufacturerId: row.manufacturerId, + })), + years: listSetYears(), + }} + /> + +

+ {pluralize(missing.total, 'Set kommt infrage', 'Sets kommen infrage')} +

+ + + + +
+
+
+ ); +} diff --git a/src/app/(app)/wunschlisten/page.tsx b/src/app/(app)/wunschlisten/page.tsx new file mode 100644 index 0000000..7a99d73 --- /dev/null +++ b/src/app/(app)/wunschlisten/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { CalendarDays, Gift, Link2Off } from 'lucide-react'; +import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate, pluralize } from '@/lib/format'; +import { occasionLabel } from '@/lib/labels'; +import { listWishlists } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Wunschlisten' }; + +export default async function WunschlistenPage() { + const user = await requireUser(); + const lists = listWishlists(user.id); + + return ( +
+
+
+

Wunschlisten

+

+ Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen. +

+
+ +
+ + {lists.length === 0 ? ( +
+

+ Noch keine Wunschliste. Der schnellste Weg: im Katalog auf „Fehlt noch“ filtern, + mehrere Sets auswählen und daraus eine Liste anlegen. +

+ + Zu den fehlenden Sets + +
+ ) : ( +
    + {lists.map((list) => ( +
  • + + + +
    + + {list.title} +
    + + {occasionLabel(list.occasion)} + {list.eventDate ? ` · ${formatDate(list.eventDate)}` : ''} + +
    + + {pluralize(list.itemCount, 'Wunsch', 'Wünsche')} + {list.openCount > 0 ? ( + {list.openCount} noch offen + ) : list.itemCount > 0 ? ( + + Alles vergeben + + ) : null} + {list.isPublic ? null : ( + + + Link deaktiviert + + )} + {list.eventDate ? ( + + + {formatDate(list.eventDate)} + + ) : null} + +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/w/[shareToken]/page.tsx b/src/app/w/[shareToken]/page.tsx new file mode 100644 index 0000000..0e1639c --- /dev/null +++ b/src/app/w/[shareToken]/page.tsx @@ -0,0 +1,103 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Blocks, CalendarDays, Gift } from 'lucide-react'; +import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist'; +import { Badge } from '@/components/ui/badge'; +import { formatDate, pluralize } from '@/lib/format'; +import { occasionLabel } from '@/lib/labels'; +import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists'; + +type Props = { params: Promise<{ shareToken: string }> }; + +/** + * The public face of a wishlist. + * + * Reachable with the share token alone, and it exposes nothing beyond the + * wishlist itself - no owner name, no collection, no other lists. Search + * engines are kept out; this is meant to be shared, not found. + */ +export const metadata: Metadata = { + title: 'Wunschliste', + robots: { index: false, follow: false }, +}; + +export default async function PublicWishlistPage({ params }: Props) { + const { shareToken } = await params; + + const list = getWishlistByShareToken(shareToken); + if (!list || !list.isPublic) notFound(); + + const rows = listWishlistItems(list.id); + + // Fulfilled wishes are gone from the public view: the present is already + // under the tree, no reason to let someone buy it twice. + const open = rows.filter((row) => row.fulfilledAt === null); + + const items: PublicItem[] = open.map((row) => ({ + id: row.id, + setNumber: row.setNumber, + setName: row.setName, + year: row.year, + pieceCount: row.pieceCount, + imageUrl: row.imageUrl, + imagePath: row.imagePath, + retailPriceCents: row.retailPriceCents, + manufacturerName: row.manufacturerName, + manufacturerWebsite: row.manufacturerWebsite, + themeName: row.themeName, + priority: row.priority, + note: row.note, + isReserved: row.reservedAt !== null, + reservedByName: row.reservedByName, + })); + + const openCount = items.filter((item) => !item.isReserved).length; + + return ( +
+
+
+ + Bricktrack +
+ +

{list.title}

+ +
+ + + {occasionLabel(list.occasion)} + + {list.eventDate ? ( + + + {formatDate(list.eventDate)} + + ) : null} + + {pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} + +
+ + {list.note ? ( +

{list.note}

+ ) : null} + +

+ Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung + brauchst du dafür nicht. +

+
+ +
+ {items.length === 0 ? ( +

+ Auf dieser Liste steht gerade nichts. +

+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx b/src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx new file mode 100644 index 0000000..b1f0cc1 --- /dev/null +++ b/src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { CancelReservation } from '@/components/wunschlisten/cancel-reservation'; +import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists'; + +export const metadata: Metadata = { + title: 'Reservierung aufheben', + robots: { index: false, follow: false }, +}; + +/** The link a visitor receives after reserving a gift. */ +export default async function CancelReservationPage({ + params, +}: { + params: Promise<{ shareToken: string; itemId: string; token: string }>; +}) { + const { shareToken, itemId, token } = await params; + + const list = getWishlistByShareToken(shareToken); + if (!list || !list.isPublic) notFound(); + + const item = listWishlistItems(list.id).find((row) => row.id === itemId); + if (!item) notFound(); + + return ( +
+ +
+ ); +} diff --git a/src/app/w/actions.ts b/src/app/w/actions.ts new file mode 100644 index 0000000..6469200 --- /dev/null +++ b/src/app/w/actions.ts @@ -0,0 +1,93 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '@/db'; +import { wishlist, wishlistItem } from '@/db/schema'; +import { actionData, actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { createToken, hashToken, tokensMatch } from '@/lib/tokens'; +import { reservationSchema } from '@/lib/validation'; + +/** + * Actions for the public wishlist page. + * + * These run without a session. The share token is the only key, and every + * query is scoped by it - nothing outside the one wishlist is reachable, and + * nothing but wishlist data is returned. + */ + +function loadPublicList(shareToken: string) { + const list = db + .select({ id: wishlist.id, isPublic: wishlist.isPublic }) + .from(wishlist) + .where(eq(wishlist.shareToken, shareToken)) + .get(); + + return list && list.isPublic ? list : null; +} + +export async function reserveItem( + shareToken: string, + input: unknown, +): Promise> { + const list = loadPublicList(shareToken); + if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.'); + + const parsed = parseForm(reservationSchema, input); + if (!parsed.ok) return parsed; + + const cancelToken = createToken(); + + // The `reserved_at is null` condition makes this a claim, not a read-then-write: + // if two people tap at the same moment, only the first one wins. + const result = db + .update(wishlistItem) + .set({ + reservedByName: parsed.data.name, + reservedAt: new Date(), + reservationTokenHash: hashToken(cancelToken), + }) + .where( + and( + eq(wishlistItem.id, parsed.data.itemId), + eq(wishlistItem.wishlistId, list.id), + isNull(wishlistItem.reservedAt), + ), + ) + .run(); + + if (result.changes === 0) { + return actionError('Dieser Wunsch ist gerade eben schon von jemand anderem übernommen worden.'); + } + + revalidatePath(`/w/${shareToken}`); + return actionData({ cancelToken }); +} + +export async function cancelReservation( + shareToken: string, + itemId: string, + cancelToken: string, +): Promise { + const list = loadPublicList(shareToken); + if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.'); + + const item = db + .select({ id: wishlistItem.id, hash: wishlistItem.reservationTokenHash }) + .from(wishlistItem) + .where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, list.id))) + .get(); + + if (!item) return actionError('Diesen Eintrag gibt es nicht mehr.'); + if (!tokensMatch(cancelToken, item.hash)) { + return actionError('Dieser Link passt nicht zu der Reservierung.'); + } + + db.update(wishlistItem) + .set({ reservedByName: null, reservedAt: null, reservationTokenHash: null }) + .where(eq(wishlistItem.id, itemId)) + .run(); + + revalidatePath(`/w/${shareToken}`); + return actionOk(); +} diff --git a/src/components/sammlung/collection-filters.tsx b/src/components/sammlung/collection-filters.tsx new file mode 100644 index 0000000..4c28024 --- /dev/null +++ b/src/components/sammlung/collection-filters.tsx @@ -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) { + 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 ( +
+
+ + setQuery(event.target.value)} + placeholder="Setnummer oder Name" + aria-label="Sammlung durchsuchen" + className="pl-9" + /> + {pending ? ( + + ) : null} +
+ +
+ apply({ status: event.target.value || null })} + > + + {Object.entries(STATUS_LABELS).map(([value, label]) => ( + + ))} + + + apply({ zustand: event.target.value || null })} + > + + {Object.entries(COMPLETENESS_LABELS).map(([value, label]) => ( + + ))} + + + apply({ standort: event.target.value || null })} + > + + {locations.map((item) => ( + + ))} + + + apply({ hersteller: event.target.value || null })} + > + + {manufacturers.map((item) => ( + + ))} + +
+ + {hasFilters ? ( + + ) : null} +
+ ); +} diff --git a/src/components/wunschlisten/cancel-reservation.tsx b/src/components/wunschlisten/cancel-reservation.tsx new file mode 100644 index 0000000..37fe03f --- /dev/null +++ b/src/components/wunschlisten/cancel-reservation.tsx @@ -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 ( + + + {done ? 'Nicht mehr reserviert' : 'Reservierung aufheben?'} + + {setNumber} · {setName} – aus der Liste „{listTitle}“. + + + + {done ? ( +

+ + Dieser Wunsch ist wieder für alle offen. +

+ ) : ( +

+ Danach kann jemand anderes das Geschenk übernehmen. +

+ )} + +
+ {done ? null : ( + + )} + +
+
+
+ ); +} diff --git a/src/components/wunschlisten/public-wishlist.tsx b/src/components/wunschlisten/public-wishlist.tsx new file mode 100644 index 0000000..8fb44ac --- /dev/null +++ b/src/components/wunschlisten/public-wishlist.tsx @@ -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 { + if (typeof window === 'undefined') return {}; + try { + return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; + } catch { + return {}; + } +} + +function writeTokens(tokens: Record) { + 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>({}); + const [reserving, setReserving] = useState(null); + const [name, setName] = useState(''); + const [cancelLink, setCancelLink] = useState(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 ( +
+ {items.map((item) => { + const image = setImageSrc(item); + const mine = Boolean(tokens[item.id]); + + return ( +
+
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ )} +
+ +
+
+
+ + {item.setNumber} + + {item.manufacturerName} + {item.priority === 1 ? {priorityLabel(1)} : null} +
+

{item.setName}

+

+ {[ + item.themeName, + item.year, + item.pieceCount ? `${item.pieceCount} Teile` : null, + item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null, + ] + .filter(Boolean) + .join(' · ')} +

+ {item.note ?

{item.note}

: null} +
+ +
+ {mine ? ( + <> + + + Das schenkst du + + + + ) : item.isReserved ? ( + + {item.reservedByName + ? `Übernimmt ${item.reservedByName}` + : 'Schon reserviert'} + + ) : ( + + )} + + {item.manufacturerWebsite ? ( + + ) : null} +
+
+
+ ); + })} + + !open && setReserving(null)}> + + + Das schenke ich + + Damit sieht jeder andere, dass dieser Wunsch schon vergeben ist. Dein Name ist nur + für die Familie sichtbar. + + + + + setName(event.target.value)} + placeholder="Tante Anne" + maxLength={60} + autoFocus + /> + + + + + + + + + + !open && setCancelLink(null)}> + + + Reservierung gespeichert + + 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. + + + event.currentTarget.select()} + className="font-mono text-xs" + /> + + + + + + +
+ ); +} diff --git a/src/components/wunschlisten/share-card.tsx b/src/components/wunschlisten/share-card.tsx new file mode 100644 index 0000000..edcbe15 --- /dev/null +++ b/src/components/wunschlisten/share-card.tsx @@ -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 ( +
+ + Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste. +
+ ); + } + + return ( +
+

Link zum Teilen

+
+ event.currentTarget.select()} className="font-mono text-xs" /> + + +
+

+ Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben. +

+ + + + + + Neuen Link erzeugen? + + Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene + Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden + ungültig. + + + + Abbrechen + Neuen Link erzeugen + + + +
+ ); +} diff --git a/src/components/wunschlisten/wishlist-form-dialog.tsx b/src/components/wunschlisten/wishlist-form-dialog.tsx new file mode 100644 index 0000000..30f46bf --- /dev/null +++ b/src/components/wunschlisten/wishlist-form-dialog.tsx @@ -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) { + 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 ( + + + + {wishlist ? 'Wunschliste bearbeiten' : 'Neue Wunschliste'} + + Der Anlass und das Datum erscheinen auch auf der geteilten Ansicht. + + + +
+ + + + +
+ + + {Object.entries(OCCASION_LABELS).map(([value, label]) => ( + + ))} + + + + + + +
+ + +