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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user