Wire up real Live Preview for block-driven PDPs

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<PayloadProduct> + 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8
This commit is contained in:
Marco
2026-08-30 00:09:07 +00:00
parent 9c6b717920
commit 14d824491f
5 changed files with 74 additions and 13 deletions
+36
View File
@@ -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<ProductBlockContext, "product">) {
const { data } = useLivePreview<PayloadProduct>({
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) => <div key={block.id}>{renderProductBlock(block, ctx)}</div>)}</>;
}
+17 -8
View File
@@ -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<ReturnType<typeof getShippingSettings>>;
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<ProductBlockContext> {
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 <ProductHero {...ctx} body={block.body} />;
+10 -2
View File
@@ -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<Metadata> {
}
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 (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
<main className="flex flex-col flex-1">
<ProductBlocks product={product} />
{ctx ? (
<LiveProductContent initialProduct={product} shipping={ctx.shipping} taxRate={ctx.taxRate} kleinunternehmer={ctx.kleinunternehmer} />
) : (
<ProductBlocks product={product} />
)}
</main>
<Footer />
</>
+1 -1
View File
@@ -286,7 +286,7 @@ export type Product = {
badge: "none" | "new";
};
type PayloadProduct = {
export type PayloadProduct = {
id: number;
name: string;
subline: string | null;
+10 -2
View File
@@ -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";
@@ -25,18 +27,24 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function TasseDiePausePage() {
const { isEnabled: isPreview } = await draftMode();
const [product, seller] = await Promise.all([
getProductBySlug("tasse-die-pause"),
getCompanySettings(),
]);
if (!product) notFound();
const productSchema = buildProductSchema(product, "https://einfach-produktiv.mk360.de/tasse-die-pause", seller);
const ctx = isPreview ? await getProductBlockContext(product) : null;
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
<main className="flex flex-col flex-1">
<ProductBlocks product={product} />
{ctx ? (
<LiveProductContent initialProduct={product} shipping={ctx.shipping} taxRate={ctx.taxRate} kleinunternehmer={ctx.kleinunternehmer} />
) : (
<ProductBlocks product={product} />
)}
</main>
<Footer />
</>