fix(bestellbestaetigung): rebuild testimonial band, tighten hero, add spotlightEyebrow

Testimonial band: rebuilt as a proper flexbox two-column layout
(photo column with an explicit width, quote in a flex-1 sibling)
instead of an absolutely-positioned text block offset with
percentage margin/padding — that measured against the row's full
width and, combined with a max-w- on the text box, left almost no
room for the actual text on wide viewports (wrapped to one word per
line). Also dropped the muted-bg box behind the quote and widened
the photo fade to match the actual mockup, which has no separate
colored panel there at all.

Hero: removed the separate big checkmark badge — the 4-step bar
right above it already renders every step as a checkmark, so it was
just repeating that. Added more top spacing to compensate. Dropped
"Deine Bestellung macht sich jetzt auf den Weg zu dir." and added
"inkl. MwSt." under Gesamtbetrag for consistency with /cart and
/checkout.

Wired the new Products.spotlightEyebrow field (CMS-editable "Neu im
Shop" label) through lib/payload.ts into ProductSpotlight.tsx.
This commit is contained in:
Marco
2026-07-20 00:18:47 +00:00
parent 90be4696af
commit 819f87cdeb
4 changed files with 76 additions and 36 deletions
@@ -10,6 +10,32 @@ import { Reveal } from "../../components/Reveal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
// Rejects (rather than silently patching with fallback values) anything
// that doesn't match the current OrderSnapshot shape — e.g. a snapshot
// left over from before shippingCost/paymentMethodTitle were added to it.
// Fabricating "Kostenlos"/"—" for missing fields would render a receipt
// that looks real but states things about an order that were never true;
// treating it as no-order-found and clearing it is the honest fallback.
function parseOrderSnapshot(raw: string): OrderSnapshot | null {
try {
const data = JSON.parse(raw);
if (
!data ||
!Array.isArray(data.items) ||
data.items.length === 0 ||
typeof data.orderNumber !== "string" ||
typeof data.orderDateIso !== "string" ||
typeof data.shippingCost !== "number" ||
typeof data.paymentMethodTitle !== "string"
) {
return null;
}
return data as OrderSnapshot;
} catch {
return null;
}
}
export function BestellbestaetigungContent() {
const products = useProducts();
const [order, setOrder] = useState<OrderSnapshot | null>(null);
@@ -25,8 +51,15 @@ export function BestellbestaetigungContent() {
useEffect(() => {
try {
const raw = window.sessionStorage.getItem(ORDER_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading a browser-only store on mount to avoid an SSR/hydration mismatch, see comment above
if (raw) setOrder(JSON.parse(raw));
const parsed = raw ? parseOrderSnapshot(raw) : null;
if (parsed) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading a browser-only store on mount to avoid an SSR/hydration mismatch, see comment above
setOrder(parsed);
} else if (raw) {
// Present but invalid/outdated — drop it so it doesn't keep
// failing validation on every future visit either.
window.sessionStorage.removeItem(ORDER_KEY);
}
} catch {
// ignore — falls through to the "no order" state below
}
@@ -74,17 +107,13 @@ export function BestellbestaetigungContent() {
<CheckoutSteps current={5} />
</Reveal>
{/* Hero — success badge, headline, short recap */}
<Reveal className="flex flex-col gap-4 items-center text-center pt-12 pb-10 px-[var(--layout-padding-x)] w-full">
<div className="relative flex items-center justify-center size-24 mb-2">
<div className="absolute inset-0 rounded-full bg-brand/10" />
<div className="relative flex items-center justify-center size-14 rounded-full border-2 border-text-primary bg-bg-base">
<svg viewBox="0 0 20 20" className="size-6 text-brand" fill="none" aria-hidden="true">
<path d="m4 10 4 4 8-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
{/* Hero — headline, short recap. No separate big checkmark badge —
all four steps in the bar right above already render as
checkmarks (current={5}), a second one here would just repeat
it; pt-24 (up from the badge-era pt-12) keeps real breathing
room between the bar and the headline now that the badge isn't
filling that space itself. */}
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-10 px-[var(--layout-padding-x)] w-full">
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
@@ -101,7 +130,6 @@ export function BestellbestaetigungContent() {
{!productsLoading && (
<div className="flex flex-col gap-1 max-w-[26rem]">
<p className="text-body text-text-muted">Deine Bestellung macht sich jetzt auf den Weg zu dir.</p>
<p className="text-body text-text-muted">
Du erhältst in Kürze eine Bestellbestätigung per E-Mail mit allen Details.
</p>
@@ -176,15 +204,18 @@ export function BestellbestaetigungContent() {
<div className="h-px bg-border w-full" />
<div className="flex items-center w-full">
<span
className="font-semibold text-h4 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Gesamtbetrag
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span
className="font-semibold text-h4 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Gesamtbetrag
</span>
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<p className="text-label text-text-muted">inkl. MwSt.</p>
</div>
</div>
</div>
@@ -210,24 +241,30 @@ export function BestellbestaetigungContent() {
</Reveal>
)}
{/* Testimonial band — same photo+quote pattern as /not-found (see
that page's own comment); bestellbestaetigung-testimonial-photo.jpg
is cropped from the actual mockup pixels the same way. Explicit
md:h- (not just min-h-), so the image and quote columns are
pinned to an identical height regardless of how many lines the
quote wraps to — a min-height alone lets the taller of the two
content-driven columns stretch the row past what the other side
visually fills up to. */}
<Reveal className="relative w-full flex items-stretch h-[20rem] md:h-[22rem] bg-bg-muted overflow-hidden">
<div className="relative w-full md:w-[45%] shrink-0">
{/* Testimonial band — different from /not-found's version: this
page's own mockup has no separate muted-bg box on the text
side at all, just a wide photo column that fades into the
plain page background at its own trailing edge, with the
quote sitting in a normal flex-1 column beside it (not
absolute-positioned with %-based offsets — that measures
against the row's full width and, combined with a max-w- on a
narrow text box, can leave almost nothing for the text itself;
flexbox distributes the two columns' widths correctly on its
own regardless of viewport size). items-stretch on a row with
a FIXED h- (not min-h-) keeps both columns pinned to an
identical height regardless of how many lines the quote wraps
to — a min-height alone lets whichever column has more content
stretch the row past what the other side visually fills. */}
<Reveal className="relative w-full flex items-stretch h-[20rem] md:h-[24rem] bg-bg-base overflow-hidden">
<div className="relative w-full md:w-[58%] shrink-0">
<img
alt=""
src="/bestellbestaetigung-testimonial-photo.jpg"
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
<div className="hidden md:block absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-transparent to-bg-base" />
</div>
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16 py-12">
<div className="flex-1 min-w-0 flex flex-col justify-center gap-3 px-8 md:px-12">
<p
className="font-semibold text-h-section text-text-primary leading-[1.3] max-w-[28rem]"
style={{ fontFamily: "var(--font-lora)" }}
+1 -1
View File
@@ -47,7 +47,7 @@ export async function ProductSpotlight() {
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
<p className="font-bold text-h-small text-brand">Neu im Shop</p>
<p className="font-bold text-h-small text-brand">{product.spotlightEyebrow || "Neu im Shop"}</p>
<p
className="font-semibold text-h-section text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
+3
View File
@@ -169,12 +169,14 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
}
export type SpotlightProduct = Product & {
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
};
type PayloadSpotlightProduct = PayloadProduct & {
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
@@ -211,6 +213,7 @@ export async function getSpotlightProduct(): Promise<SpotlightProduct | null> {
compareAtPrice: doc.compareAtPrice ?? null,
image: typeof doc.image === "object" && doc.image ? doc.image.url : "",
href: doc.detailHref || null,
spotlightEyebrow: doc.spotlightEyebrow || null,
spotlightHeadline: doc.spotlightHeadline || null,
spotlightText: doc.spotlightText || null,
spotlightImage: