Wire up Products.layout (PDP page builder) frontend
New ProductBlocks.tsx renders a product's layout — delegates to PageBlocks.tsx's renderPageBlockSync for the 9 block types shared with Pages.layout, handles productHero/productPricingPanel itself (need live product/shipping/tax context a generic content page doesn't have). Converts /einfach-anfangen to render through this instead of its own Hero/HowItWorks/Focus/Pricing components (now deleted) — the first PDP built as CMS blocks end to end. der-eine/todo-cards/tasse-die-pause are untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
|
||||
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import { RichText } from "./RichText";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
|
||||
// Renders a Payload Products document's `layout` blocks field — same
|
||||
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
|
||||
// this delegates to directly for every block type the two fields share
|
||||
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
|
||||
// ctaCard are the exact same Block configs, just registered a second time
|
||||
// on Products.layout — see that field's own comment in Products.ts). Only
|
||||
// `productHero` and `productPricingPanel` are product-specific, since they
|
||||
// need the live product/shipping/tax context that a generic content page
|
||||
// doesn't have.
|
||||
export async function ProductBlocks({ product }: { product: Product }) {
|
||||
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
|
||||
|
||||
return (
|
||||
<>
|
||||
{product.layout.map((block) => (
|
||||
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6 first:pt-10 md:first:pt-12">
|
||||
{renderProductBlock(block, ctx)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ProductBlockContext = {
|
||||
product: Product;
|
||||
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
|
||||
taxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
};
|
||||
|
||||
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
case "productHero":
|
||||
return <ProductHero {...ctx} body={block.body} />;
|
||||
case "productPricingPanel":
|
||||
return <ProductPricingPanel {...ctx} />;
|
||||
// Every other block type is identical to Pages.layout's own — same
|
||||
// Block config, reused a second time (see this file's own comment).
|
||||
default:
|
||||
return renderPageBlockSync(block as PageBlock);
|
||||
}
|
||||
}
|
||||
|
||||
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)]">
|
||||
<div className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">
|
||||
<ProductName name={product.name} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && (
|
||||
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{body ? <RichText content={body} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
</div>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
</div>
|
||||
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="order-0 lg:order-none lg:col-span-7 relative w-full aspect-[16/10] lg:aspect-auto lg:min-h-[28rem] bg-bg-muted overflow-hidden rounded-md">
|
||||
<Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 58vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<div className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
|
||||
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 410px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{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>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user