From 14d824491f534bb9346ad15b876a1eea05fc82cf Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 30 Aug 2026 00:09:07 +0000 Subject: [PATCH] Wire up real Live Preview for block-driven PDPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Products.ts's admin.livePreview (added earlier this session) only made the admin UI show a preview iframe — the actual page had no mechanism to receive postMessage updates and re-render with unsaved draft data, unlike Pages/Posts which already have this (LivePageContent.tsx/ LivePostContent.tsx). Edits in the backend were invisible in the preview until an actual save (reported 2026-08-30). New LiveProductContent.tsx mirrors LivePageContent.tsx's pattern exactly: useLivePreview + mapPayloadProduct, rendering via ProductBlocks.tsx's now-exported renderProductBlock (sync) instead of the full async ProductBlocks wrapper. testimonialsRef/crossSell skipped in the live subset (need an async fetch a client component can't perform inline) — same "editable subset only" scope Pages/Posts already accept. shipping/tax/Kleinunternehmer context is fetched once via the newly-exported getProductBlockContext() and passed in rather than re-fetched per keystroke. /der-eine and /tasse-die-pause now check draftMode() and swap in LiveProductContent instead of the normal async ProductBlocks when Payload's Live Preview iframe has it enabled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8 --- app/components/LiveProductContent.tsx | 36 +++++++++++++++++++++++++++ app/components/ProductBlocks.tsx | 25 +++++++++++++------ app/der-eine/page.tsx | 12 +++++++-- app/lib/payload.ts | 2 +- app/tasse-die-pause/page.tsx | 12 +++++++-- 5 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 app/components/LiveProductContent.tsx diff --git a/app/components/LiveProductContent.tsx b/app/components/LiveProductContent.tsx new file mode 100644 index 0000000..703e7cb --- /dev/null +++ b/app/components/LiveProductContent.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useLivePreview } from "@payloadcms/live-preview-react"; +import { renderProductBlock, type ProductBlockContext } from "./ProductBlocks"; +import { mapPayloadProduct, type PayloadProduct, type Product } from "../lib/payload"; + +const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de"; + +// Live-previewable subset of a Products document's `layout` — same +// "editable subset only" scope as LivePageContent.tsx/LivePostContent.tsx: +// `testimonialsRef` and `crossSell` are skipped here (both need an async +// fetch a client component can't perform inline — testimonialsRef isn't +// even registered on Products.layout anymore, kept out of the switch for +// the same reason; crossSell's related-product card just doesn't update +// live, shows correctly once saved and reloaded outside the iframe). +// shipping/tax/Kleinunternehmer are passed in once from the server page +// component instead of re-fetched on every keystroke — see +// getProductBlockContext's own comment. +export function LiveProductContent({ + initialProduct, + shipping, + taxRate, + kleinunternehmer, +}: { + initialProduct: Product; +} & Omit) { + const { data } = useLivePreview({ + initialData: initialProduct as unknown as PayloadProduct, + serverURL: PAYLOAD_URL, + depth: 2, + }); + const product = data?.slug ? mapPayloadProduct(data) : initialProduct; + const ctx: ProductBlockContext = { product, shipping, taxRate, kleinunternehmer }; + + return <>{product.layout.map((block) =>
{renderProductBlock(block, ctx)}
)}; +} diff --git a/app/components/ProductBlocks.tsx b/app/components/ProductBlocks.tsx index fb38273..62bb2bf 100644 --- a/app/components/ProductBlocks.tsx +++ b/app/components/ProductBlocks.tsx @@ -30,12 +30,7 @@ import { TestimonialsGrid } from "./TestimonialsGrid"; // 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 }; + const ctx = await getProductBlockContext(product); const rendered = await Promise.all( product.layout.map(async (block) => { @@ -114,14 +109,28 @@ export async function ProductBlocks({ product }: { product: Product }) { return <>{rendered}; } -type ProductBlockContext = { +export type ProductBlockContext = { product: Product; shipping: Awaited>; taxRate: number; kleinunternehmer: boolean; }; -function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode { +// Split out so LiveProductContent.tsx (the "use client" live-preview +// counterpart, same pattern as LivePageContent.tsx/renderPageBlockSync) +// can fetch this once itself instead of duplicating the three calls — +// shipping/tax/Kleinunternehmer status essentially never change between +// keystrokes in a preview session, unlike the product doc itself. +export async function getProductBlockContext(product: Product): Promise { + const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([ + getShippingSettings(), + getDefaultTaxRatePercent(), + getKleinunternehmer(), + ]); + return { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer }; +} + +export function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode { switch (block.blockType) { case "productHero": return ; diff --git a/app/der-eine/page.tsx b/app/der-eine/page.tsx index 08bb455..12dd7a8 100644 --- a/app/der-eine/page.tsx +++ b/app/der-eine/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { ProductBlocks } from "../components/ProductBlocks"; +import { draftMode } from "next/headers"; +import { ProductBlocks, getProductBlockContext } from "../components/ProductBlocks"; +import { LiveProductContent } from "../components/LiveProductContent"; import { Footer } from "../components/Footer"; import { getProductBySlug, getCompanySettings } from "../lib/payload"; import { buildProductSchema } from "../lib/structuredData"; @@ -30,18 +32,24 @@ export async function generateMetadata(): Promise { } export default async function DerEinePage() { + const { isEnabled: isPreview } = await draftMode(); const [product, seller] = await Promise.all([ getProductBySlug("stift-kugelschreiber"), getCompanySettings(), ]); if (!product) notFound(); const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller); + const ctx = isPreview ? await getProductBlockContext(product) : null; return ( <>