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]) => ( + + ))} + + + + + + +
+ + +