Add wishlist feature (frontend), gated by CompanySettings.wishlistEnabled
New: useWishlist.ts (fetch+optimistic-toggle hook, server-backed since a wishlist needs a logged-in customer, unlike the guest-friendly cart), WishlistButton.tsx (heart toggle, login-redirects on 401), Navbar's wishlist icon+badge (hidden below sm: — Account+Cart are the only always-visible icons on true mobile, a 3rd icon there risks the same computed nav-overflow class of bug documented in the figma-to-nextjs skill), and /konto/merkliste (list page, 404s if the feature gets disabled after a customer already has rows). Product gained numericId (the raw Payload id) alongside its existing slug id — WishlistItems.product is a real numeric relationship field, unlike cart/checkout's slug-keyed "commerce id". Backend counterpart (WishlistItems collection, CompanySettings toggle, migration) already deployed separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
|
||||
const items = await getWishlist(session.token, session.customer.id);
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const productId = Number(body?.productId);
|
||||
const variant = typeof body?.variant === "string" ? body.variant : "";
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await toggleWishlistItem(session.token, productId, variant);
|
||||
if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
|
||||
return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -131,6 +132,40 @@ function AccountLink() {
|
||||
);
|
||||
}
|
||||
|
||||
// Wishlist icon + count badge — only rendered by the caller when
|
||||
// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
|
||||
// Account+Cart are the only always-visible icons on true mobile (see the
|
||||
// trailing-controls group's own comment: gap-2 there was already tuned
|
||||
// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
|
||||
// smallest phone widths risks the exact nav-overflow class of bug
|
||||
// documented in the figma-to-nextjs skill (computed hamburger/icon-row
|
||||
// thresholds, not assumed ones). sm+ has real room to spare.
|
||||
function WishlistLink() {
|
||||
const { count } = useWishlist();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/konto/merkliste"
|
||||
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
|
||||
className="relative hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 20 18" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{count > 0 && (
|
||||
<span className="absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Cart icon + count badge — traced from the Figma Navbar/Default component's
|
||||
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
|
||||
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
|
||||
@@ -210,7 +245,7 @@ function CartLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
|
||||
export function Navbar({ singleActiveProduct, wishlistEnabled }: { singleActiveProduct: boolean; wishlistEnabled: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
@@ -510,6 +545,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
these two icons are the only always-visible controls. */}
|
||||
<div className="flex items-center">
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
|
||||
// Heart-toggle for a product card/detail page. Login-gated (unlike the
|
||||
// cart, which works for guests) — a logged-out click redirects to
|
||||
// /konto/login?redirect=<back-here> instead of silently failing, since
|
||||
// there's no local-storage fallback that would make sense for a wishlist
|
||||
// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
|
||||
// guest-friendly pattern).
|
||||
export function WishlistButton({
|
||||
productId,
|
||||
variant = "",
|
||||
className = "",
|
||||
}: {
|
||||
productId: number;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { isWishlisted, toggle } = useWishlist();
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
const wishlisted = isWishlisted(productId, variant);
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pending) return;
|
||||
|
||||
const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
|
||||
if (res.status === 401) {
|
||||
router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
await toggle(productId, variant);
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
|
||||
aria-pressed={wishlisted}
|
||||
disabled={pending}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-transform active:scale-90 disabled:opacity-60 ${className}`}
|
||||
>
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill={wishlisted ? "currentColor" : "none"} className={wishlisted ? "text-brand" : "text-text-primary"}>
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
|
||||
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
import { WishlistButton } from "../../components/WishlistButton";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /konto/bestellungen.
|
||||
export const metadata: Metadata = {
|
||||
title: "Meine Merkliste",
|
||||
description: "Deine gemerkten Produkte bei einfach produktiv.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoMerklistePage() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
// The feature can be switched off after a customer already had rows in
|
||||
// their wishlist — 404 rather than showing a stale page for a feature
|
||||
// that's no longer offered, same reasoning as any other feature-flagged
|
||||
// route in this codebase.
|
||||
const wishlistEnabled = await getWishlistEnabled();
|
||||
if (!wishlistEnabled) notFound();
|
||||
|
||||
const [wishlistItems, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getWishlist(session.token, session.customer.id),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const products = await getProductsByIds(wishlistItems.map((i) => i.productId));
|
||||
// Preserve the wishlist's own most-recently-added-first order rather
|
||||
// than whatever order the products query happens to return in.
|
||||
const productsByNumericId = new Map(products.map((p) => [p.numericId, p]));
|
||||
const orderedProducts = wishlistItems
|
||||
.map((item) => productsByNumericId.get(item.productId))
|
||||
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[75rem] mx-auto">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Meine Merkliste
|
||||
</p>
|
||||
|
||||
{orderedProducts.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>
|
||||
) : (
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
{orderedProducts.map((product) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
return (
|
||||
<RevealItem
|
||||
key={product.id}
|
||||
className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full"
|
||||
>
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{fullyOutOfStock && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
)}
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.name}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<AddToCartInlineButton
|
||||
id={product.id}
|
||||
outOfStock={product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.variants}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
</RevealGroup>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6 self-center sm:self-start">
|
||||
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Meine Bestellungen
|
||||
</Link>
|
||||
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Weiter einkaufen
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -4,7 +4,7 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings, getCompanySettings } from "./lib/payload";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -67,7 +67,7 @@ export default async function RootLayout({
|
||||
// just to know whether Navbar's "Shop" link should behave as an anchor
|
||||
// to the homepage spotlight instead of a real /shop navigation (see
|
||||
// Navbar.tsx/ProductSpotlight.tsx).
|
||||
const [products, seller] = await Promise.all([getProducts(), getCompanySettings()]);
|
||||
const [products, seller, wishlistEnabled] = await Promise.all([getProducts(), getCompanySettings(), getWishlistEnabled()]);
|
||||
const singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
// Organization JSON-LD on every page — one canonical node (@id) that
|
||||
// Product/Article schemas elsewhere link back to via `{ "@id": ... }`
|
||||
@@ -87,7 +87,7 @@ export default async function RootLayout({
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
|
||||
<CartFlyProvider>
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} />
|
||||
{children}
|
||||
</CartFlyProvider>
|
||||
</body>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Product } from "../payload";
|
||||
|
||||
const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
id: "todo-karten",
|
||||
numericId: 1,
|
||||
name: "ToDo-Karten",
|
||||
description: "",
|
||||
price: 12.9,
|
||||
|
||||
@@ -357,6 +357,71 @@ export async function changeCustomerPassword(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type WishlistItem = {
|
||||
id: number;
|
||||
productId: number;
|
||||
variant: string;
|
||||
};
|
||||
|
||||
// `variant` empty string, not undefined — matches WishlistItems.ts's own
|
||||
// defaultValue: '' so the (customer, product, variant) unique index
|
||||
// actually catches a duplicate add for a variant-less product too.
|
||||
export async function getWishlist(token: string, customerId: number): Promise<WishlistItem[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[customer][equals]": String(customerId),
|
||||
depth: "0",
|
||||
limit: "200",
|
||||
sort: "-createdAt",
|
||||
});
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json();
|
||||
return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" }));
|
||||
}
|
||||
|
||||
// Toggles a single (product, variant) — tries to create first; a 400 here
|
||||
// means the unique (customer, product, variant) index rejected it because
|
||||
// it already exists, so this falls back to finding + deleting that row
|
||||
// instead. Avoids a separate "is it already wishlisted" read before every
|
||||
// toggle (the common case, adding something new, only needs one request).
|
||||
export async function toggleWishlistItem(
|
||||
token: string,
|
||||
productId: number,
|
||||
variant: string,
|
||||
): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> {
|
||||
const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ product: productId, variant }),
|
||||
});
|
||||
if (createRes.ok) return { ok: true, wishlisted: true };
|
||||
|
||||
const findParams = new URLSearchParams({
|
||||
"where[product][equals]": String(productId),
|
||||
"where[variant][equals]": variant,
|
||||
depth: "0",
|
||||
limit: "1",
|
||||
});
|
||||
const findRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${findParams}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!findRes.ok) return { ok: false };
|
||||
const found: { docs?: { id: number }[] } = await findRes.json();
|
||||
const existingId = found.docs?.[0]?.id;
|
||||
if (!existingId) return { ok: false };
|
||||
|
||||
const deleteRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items/${existingId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
});
|
||||
if (!deleteRes.ok) return { ok: false };
|
||||
return { ok: true, wishlisted: false };
|
||||
}
|
||||
|
||||
// Called from app/api/account/verify-email/route.ts — no customer session
|
||||
// exists at this point (cold click from an email client), so this
|
||||
// authenticates as the service instead (see SERVICE_SECRET above).
|
||||
|
||||
@@ -173,6 +173,13 @@ export async function getPostBySlug(slug: string, options?: { draft?: boolean })
|
||||
// to numeric ids here would silently orphan every existing shopper's cart.
|
||||
export type Product = {
|
||||
id: string;
|
||||
// The raw Payload numeric id — `id` above is the slug (used everywhere
|
||||
// as the "commerce id" — cart, checkout, URLs), but a few relationships
|
||||
// (Orders.items.product, WishlistItems.product) are real Payload
|
||||
// relationship fields storing this number instead. Kept alongside the
|
||||
// slug rather than replacing it, to avoid touching every existing
|
||||
// slug-based call site.
|
||||
numericId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
@@ -290,6 +297,7 @@ function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowB
|
||||
export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
return {
|
||||
id: product.slug,
|
||||
numericId: product.id,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
price: product.price,
|
||||
@@ -353,6 +361,23 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
|
||||
// from getProducts()'s slug-keyed catalog (an order can reference a
|
||||
// product that's since been deactivated/deleted, and slugs aren't even
|
||||
// the key an order item stores).
|
||||
// Powers /konto/merkliste — WishlistItems.product is a real numeric
|
||||
// relationship (see Product.numericId's own comment), so displaying the
|
||||
// wishlist needs a numeric-id lookup rather than getProducts()'s
|
||||
// slug-keyed list.
|
||||
export async function getProductsByIds(ids: number[]): Promise<Product[]> {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "2", limit: String(uniqueIds.length) });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) {
|
||||
console.error(`getProductsByIds: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
const data: { docs?: PayloadProduct[] } = await res.json();
|
||||
return (data.docs ?? []).map(mapPayloadProduct);
|
||||
}
|
||||
|
||||
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const map = new Map<number, string>();
|
||||
@@ -977,6 +1002,26 @@ export async function getKleinunternehmer(): Promise<boolean> {
|
||||
return data.docs?.[0]?.kleinunternehmer ?? false;
|
||||
}
|
||||
|
||||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||||
// above — gates the whole Wishlist feature (heart icon, /konto/merkliste,
|
||||
// the Navbar link) site-wide. Deliberately off by default (see
|
||||
// CompanySettings.ts's own field comment) so the feature stays entirely
|
||||
// invisible in the frontend until a tenant actually wants it, rather than
|
||||
// shipping a half-finished-looking icon everywhere.
|
||||
export async function getWishlistEnabled(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getWishlistEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { wishlistEnabled?: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.wishlistEnabled ?? false;
|
||||
}
|
||||
|
||||
export type SeoSettings = {
|
||||
defaultTitle: string | null;
|
||||
titleTemplate: string | null;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Server-backed (needs a logged-in customer, unlike the cart which works
|
||||
// for guests via localStorage — a wishlist tied to nothing would just
|
||||
// evaporate on the next visit, which defeats the point) — so this can't
|
||||
// reuse cart.ts's useSyncExternalStore-over-localStorage pattern. Instead:
|
||||
// a plain fetch on mount + a custom window event so every WishlistButton/
|
||||
// the Navbar badge on the page stays in sync after any one of them toggles
|
||||
// an item, without a shared cache library.
|
||||
const WISHLIST_EVENT = "ep-wishlist-updated";
|
||||
|
||||
type WishlistItem = { id: number; productId: number; variant: string };
|
||||
|
||||
let cachedItems: WishlistItem[] | null = null;
|
||||
|
||||
async function fetchWishlist(): Promise<WishlistItem[]> {
|
||||
const res = await fetch("/api/account/wishlist", { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const data: { items?: WishlistItem[] } = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
window.dispatchEvent(new Event(WISHLIST_EVENT));
|
||||
}
|
||||
|
||||
export function useWishlist() {
|
||||
const [items, setItems] = useState<WishlistItem[]>(cachedItems ?? []);
|
||||
const [loading, setLoading] = useState(cachedItems === null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const fresh = await fetchWishlist();
|
||||
cachedItems = fresh;
|
||||
setItems(fresh);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
window.addEventListener(WISHLIST_EVENT, load);
|
||||
return () => window.removeEventListener(WISHLIST_EVENT, load);
|
||||
}, [load]);
|
||||
|
||||
const isWishlisted = useCallback(
|
||||
(productId: number, variant = "") => items.some((i) => i.productId === productId && i.variant === variant),
|
||||
[items],
|
||||
);
|
||||
|
||||
// Optimistic: flips the local list immediately, reconciles with the
|
||||
// server response (or reverts on failure) rather than waiting for the
|
||||
// round trip — same "feels instant" reasoning as AddToCartButton.
|
||||
const toggle = useCallback(async (productId: number, variant = "") => {
|
||||
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
|
||||
const optimistic = wasWishlisted
|
||||
? (cachedItems ?? []).filter((i) => !(i.productId === productId && i.variant === variant))
|
||||
: [...(cachedItems ?? []), { id: -1, productId, variant }];
|
||||
cachedItems = optimistic;
|
||||
setItems(optimistic);
|
||||
broadcast();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/wishlist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ productId, variant }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
|
||||
setItems(cachedItems);
|
||||
broadcast();
|
||||
return { ok: false as const, unauthorized: true as const };
|
||||
}
|
||||
if (!res.ok) throw new Error("request failed");
|
||||
const data: { ok: boolean; wishlisted?: boolean } = await res.json();
|
||||
if (!data.ok) throw new Error("toggle failed");
|
||||
// Reconcile with the server's own id (needed for a later toggle-off
|
||||
// that hasn't refetched the list yet) rather than trusting the
|
||||
// optimistic placeholder id (-1) forever.
|
||||
await load();
|
||||
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
|
||||
} catch {
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
|
||||
setItems(cachedItems);
|
||||
broadcast();
|
||||
return { ok: false as const };
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, isWishlisted, toggle, count: items.length };
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../../lib/payload";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
import { WishlistButton } from "../../components/WishlistButton";
|
||||
|
||||
// Server Component — fetches straight from Payload (getProducts(), ISR
|
||||
// cached 60s) rather than going through the client-side useProducts()
|
||||
@@ -13,11 +14,12 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
// gives faster first paint and no loading flash.
|
||||
|
||||
export async function ProductGrid() {
|
||||
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
|
||||
getProducts(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
const products = allProducts.filter((p) => p.active);
|
||||
|
||||
@@ -73,6 +75,9 @@ export async function ProductGrid() {
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p
|
||||
|
||||
Reference in New Issue
Block a user