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,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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user