Fix out-of-stock CTA regressions: grid alignment, preview accuracy, label length

- Replace the invisible reserved-space trick with items-start on every
  product grid (ProductGrid/MerklisteGrid/RelatedProducts) — the
  reservation looked worse in practice (visible dead space under
  in-stock cards' buttons) than letting an out-of-stock card simply be
  taller than its siblings.
- New renderBackInStockHtml() in lib/emailTemplates.ts, used by the
  Live Preview instead of the generic order-status renderer — that one
  showed a fake order number and "Bestellung ansehen", neither of
  which apply to a back-in-stock mail (no order exists). CTA is now
  "Zum Produkt", matching the real backend send.
- Shortened NotifyMeForm's button label ("Benachrichtigen") — the
  longer version wrapped to two lines on narrow single-column cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-08-01 20:12:23 +00:00
parent 70ee518e1d
commit 87388479de
9 changed files with 99 additions and 106 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ export function RelatedProducts({
(opacity: 0) — invisible. Not worth chasing a fix for a
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
<div className="grid items-start grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
+17 -22
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm, NotifyMeFormReservedSpace } from "./NotifyMeForm";
import { NotifyMeForm } from "./NotifyMeForm";
const FEEDBACK_MS = 2000;
@@ -119,23 +119,20 @@ export function AddToCartButton({
))}
</select>
)}
{/* Same reserved-space stack as AddToCartInlineButton.tsx's identical
block — see that file's own comment. */}
<div className="relative grid w-full">
<div className="invisible pointer-events-none [grid-area:1/1]">
<NotifyMeFormReservedSpace />
</div>
<div className="[grid-area:1/1] self-start">
{currentlyOutOfStock ? (
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button — same reasoning as
// AddToCartInlineButton's identical swap (see that file's own
// comment on the `items-start` grid fix this relies on).
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly — this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
@@ -163,10 +160,8 @@ export function AddToCartButton({
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
</button>
)}
</div>
</div>
</button>
)}
</div>
);
}
+31 -41
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm, NotifyMeFormReservedSpace } from "./NotifyMeForm";
import { NotifyMeForm } from "./NotifyMeForm";
// Exported so consumers like RelatedProducts.tsx can delay their own
// follow-up UI changes (e.g. swapping out this exact card) until after
@@ -113,46 +113,36 @@ export function AddToCartInlineButton({
))}
</select>
)}
{/* grid + [grid-area:1/1] stack — NotifyMeFormReservedSpace (an
invisible, non-interactive twin of NotifyMeForm's markup) always
contributes its height here, even for an in-stock card that
never shows the real form. Without it, only out-of-stock cards
would be tall enough to need the input+button, and plain CSS
Grid's row-stretch (ProductGrid.tsx has no explicit row height)
would push every sibling card's button down to match whichever
card in the row happens to be out of stock. */}
<div className="relative grid w-full">
<div className="invisible pointer-events-none [grid-area:1/1]">
<NotifyMeFormReservedSpace />
</div>
{/* self-start, not the stretch default — the button's TOP edge is
what must align across cards (ProductGrid.tsx's flex-1 spacer
pins this whole block to a card's bottom already); the extra
reserved height below a single button just stays blank. */}
<div className="[grid-area:1/1] self-start">
{currentlyOutOfStock ? (
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
)}
</div>
</div>
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button. An out-of-stock card is taller
// than its in-stock siblings now — ProductGrid.tsx/MerklisteGrid.tsx/
// RelatedProducts.tsx all use `items-start` on their grid (not the
// CSS Grid default `stretch`) specifically so that doesn't cascade
// into pushing every other card's button down to match; an earlier
// attempt reserved the extra height invisibly on every card
// instead, which looked worse in practice (visible dead space
// under in-stock cards' buttons).
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
)}
</div>
);
}
+11 -26
View File
@@ -8,12 +8,12 @@ import { isValidEmail } from "../lib/email";
* is out of stock — lets a visitor leave their email to be notified once
* lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's
* back" mail. Always shows the email input + submit button directly (no
* extra click to reveal them) — the resulting taller CTA area is
* reserved on every card via NotifyMeFormReservedSpace below, not just
* the out-of-stock one, so the grid's row-stretch never pushes sibling
* cards' buttons down (see AddToCartInlineButton.tsx/AddToCartButton.tsx's
* own comment on why that reservation lives there). `productId` is the
* numeric Payload id (`product.numericId`), NOT AddToCartInlineButton/
* extra click to reveal them). The resulting out-of-stock card is taller
* than its in-stock siblings — ProductGrid.tsx/MerklisteGrid.tsx/
* RelatedProducts.tsx all use `items-start` on their grid so that doesn't
* cascade into pushing every other card's button down to match (see
* AddToCartInlineButton.tsx's own comment). `productId` is the numeric
* Payload id (`product.numericId`), NOT AddToCartInlineButton/
* AddToCartButton's own `id`/`productId` props — those are the commerce
* slug (see lib/payload.ts's Product.id comment) — same "numericId, not
* id" split WishlistButton already uses.
@@ -67,34 +67,19 @@ export function NotifyMeForm({ productId, variantName = "" }: { productId: numbe
aria-label="E-Mail-Adresse für Verfügbarkeits-Info"
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
/>
{/* Short on purpose — "Bei Verfügbarkeit benachrichtigen" wrapped to
two lines on narrow single-column cards (e.g. /konto/merkliste).
The "Ausverkauft" badge + email field right above already say
what this is for, so "Benachrichtigen" alone reads fine here. */}
<button
type="submit"
disabled={status === "submitting"}
className="w-full rounded-sm border border-border px-4 py-2 text-body-sm font-semibold text-text-primary hover:border-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{status === "submitting" ? "…" : "Bei Verfügbarkeit benachrichtigen"}
{status === "submitting" ? "…" : "Benachrichtigen"}
</button>
{error && <p className="text-label text-red-600">{error}</p>}
</form>
);
}
// A non-interactive, visually identical (markup/classes) twin of
// NotifyMeForm's idle state — rendered `invisible` behind every card's
// actual CTA (see the two AddToCartButton components) so an in-stock
// card's own single-button slot reserves the SAME height an out-of-stock
// sibling's input+button would need. Without this, only out-of-stock
// cards would be taller, and plain CSS Grid's row-stretch would then push
// every other card's button down to match — same class of bug as
// ProductGrid.tsx's existing min-h-reserved low-stock line, just for a
// taller block instead of one text line.
export function NotifyMeFormReservedSpace() {
return (
<div className="flex flex-col gap-2 w-full" aria-hidden="true">
<input type="email" tabIndex={-1} disabled placeholder="E-Mail-Adresse" className="w-full rounded-sm border border-border px-3 py-2 text-body-sm" />
<button type="button" tabIndex={-1} disabled className="w-full rounded-sm border border-border px-4 py-2 text-body-sm font-semibold">
Bei Verfügbarkeit benachrichtigen
</button>
</div>
);
}
@@ -6,6 +6,7 @@ import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
renderOrderStatusHtml,
renderBackInStockHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
SAMPLE_ORDER_MANUAL,
@@ -51,13 +52,15 @@ export function LiveEmailPreviewClient({
? renderOrderConfirmationHtml(data, orderSample, null)
: type === "password-reset"
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
: renderOrderStatusHtml(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
null,
);
: type === "back-in-stock"
? renderBackInStockHtml(data, "ToDo-Karten Set", "https://einfach-produktiv.mk360.de/todo-cards", null)
: renderOrderStatusHtml(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
null,
);
return (
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
+4 -7
View File
@@ -42,13 +42,10 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
// the actual sent email (orderEmail.ts) always reads the published version
// instead.
//
// "back-in-stock" shares the generic order-status renderer below like
// every other status type — an approximation, not pixel-identical (its
// real send, lib/jobs/sendBackInStockEmails.ts on the Payload side, has no
// order number/link at all, just a product name + "Jetzt ansehen" button),
// same established gap as password-reset's own. Good enough to edit
// subject/heading/bodyText/footerText live, which is what this page is
// actually for.
// "back-in-stock" has its own renderer (renderBackInStockHtml) rather than
// sharing the generic order-status one below — it has no order at all, so
// no order-number line, and its CTA points at the product page ("Zum
// Produkt"), not /konto/bestellungen.
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
const { type } = await params;
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
@@ -42,7 +42,7 @@ export function MerklisteGrid({
}
return (
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
<RevealGroup className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{visibleEntries.map(({ item, product }) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
+23
View File
@@ -361,6 +361,29 @@ export function renderOrderStatusHtml(
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
// Separate from renderOrderStatusHtml — a back-in-stock mail has no order
// at all (it fires from a "notify me" signup, not a purchase), so no
// "Bestellnummer" line, and the CTA points at the product page, not
// /konto/bestellungen. Mirrors lib/jobs/sendBackInStockEmails.ts on the
// Payload backend, which is where the real send actually happens (see
// this file's own top-of-file comment on why the two aren't literally
// shared code).
export function renderBackInStockHtml(template: EmailTemplateContent, productLabel: string, productUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(template.bodyText, "center")}
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 12px;">${escapeHtml(productLabel)}</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${productUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Zum Produkt</a>
</td>
</tr>
</table>
`;
return emailShell("🔔", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(template.bodyText, "center")}
+1 -1
View File
@@ -122,7 +122,7 @@ export async function ProductGrid({
applying a filter. */}
<RevealGroup
key={products.map((p) => p.id).join(",")}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
>
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);