Add back-in-stock notification signup form

NotifyMeForm.tsx replaces the disabled Add-to-cart button's spot once
a product/variant is out of stock — POSTs to the new
/api/stock-notifications route, which forwards to the backend's new
stock-notifications collection. Threaded product.numericId (not the
commerce slug id) into AddToCartButton/AddToCartInlineButton for this,
same split WishlistButton already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-08-01 19:48:00 +00:00
parent b0df2f13fa
commit 0dcaad8b8c
11 changed files with 139 additions and 4 deletions
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { isValidEmail } from "../../lib/email";
import { createStockNotification } from "../../lib/stockNotifications";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email.trim() : "";
const productId = Number(body?.productId);
const variantName = typeof body?.variantName === "string" ? body.variantName : "";
if (!isValidEmail(email)) {
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
}
if (!Number.isInteger(productId) || productId <= 0) {
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
}
const result = await createStockNotification(email, productId, variantName);
if (!result.ok) return NextResponse.json(result, { status: 500 });
return NextResponse.json({ ok: true });
}
+1 -1
View File
@@ -221,7 +221,7 @@ export function RelatedProducts({
lines (see ProductGrid.tsx's identical spacer). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
<AddToCartInlineButton id={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
);
+7
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm } from "./NotifyMeForm";
const FEEDBACK_MS = 2000;
@@ -17,6 +18,7 @@ export function AddToCartButton({
label,
className,
productId = "todo-karten",
numericId,
outOfStock = false,
maxQty = null,
variants = [],
@@ -27,6 +29,10 @@ export function AddToCartButton({
* ProductSpotlight passes the actual CMS-selected spotlight product's id
* explicitly, since that can now be a different product. */
productId?: string;
/** Payload's real numeric product id (`product.numericId`) — only used to
* scope a NotifyMeForm signup once out of stock, never for the cart/
* checkout path itself (that stays on the slug `productId` above). */
numericId: number;
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
@@ -151,6 +157,7 @@ export function AddToCartButton({
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
</button>
{currentlyOutOfStock && <NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />}
</div>
);
}
+7
View File
@@ -4,6 +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 } 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
@@ -18,6 +19,7 @@ export const FEEDBACK_MS = 2000;
*/
export function AddToCartInlineButton({
id,
numericId,
label = "In den Warenkorb",
className,
outOfStock = false,
@@ -25,6 +27,10 @@ export function AddToCartInlineButton({
variants = [],
}: {
id: string;
/** Payload's real numeric product id (`product.numericId`) — only used to
* scope a NotifyMeForm signup once out of stock, never for the cart/
* checkout path itself (that stays on the slug `id` above). */
numericId: number;
label?: string;
className?: string;
/** Product-level — only meaningful when `variants` is empty. A varianted
@@ -124,6 +130,7 @@ export function AddToCartInlineButton({
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
{currentlyOutOfStock && <NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { isValidEmail } from "../lib/email";
/**
* Replaces the (disabled) Add-to-cart button's spot once a product/variant
* 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. `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.
*/
export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isValidEmail(email)) {
setError("Bitte gib eine gültige E-Mail-Adresse ein.");
return;
}
setError("");
setStatus("submitting");
try {
const res = await fetch("/api/stock-notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, productId, variantName }),
});
const data: { ok: boolean; reason?: string } = await res.json();
if (!data.ok) {
setStatus("error");
setError(data.reason || "Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
return;
}
setStatus("success");
} catch {
setStatus("error");
setError("Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
}
}
if (status === "success") {
return <p className="text-body-sm text-success">Danke! Wir melden uns, sobald es wieder verfügbar ist.</p>;
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full">
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="E-Mail für Verfügbarkeits-Info"
aria-label="E-Mail für Verfügbarkeits-Info"
className="flex-1 min-w-0 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"
/>
<button
type="submit"
disabled={status === "submitting"}
className="shrink-0 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" ? "…" : "Benachrichtigen"}
</button>
</div>
{error && <p className="text-label text-red-600">{error}</p>}
</form>
);
}
+1 -1
View File
@@ -127,7 +127,7 @@ export async function ProductSpotlight() {
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
<AddToCartButton label="In den Warenkorb" productId={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
{product.href && (
<Link
href={product.href}
@@ -105,7 +105,7 @@ export function MerklisteGrid({
not a merge), so passing just "w-full" here previously threw
away all the button's actual styling. Its default is
already `w-full`. */}
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
<AddToCartInlineButton id={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
+25
View File
@@ -0,0 +1,25 @@
// Server-only — imported exclusively by app/api/stock-notifications/route.ts.
// Reuses ORDER_SERVICE_SECRET rather than a new dedicated secret, matching
// StockNotifications.ts's own choice on the backend (see that collection's
// comment) — it's already the general-purpose "frontend server calling
// into Payload" credential used by getWishlistEnabled/getSearchEnabled/etc.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
export async function createStockNotification(email: string, productId: number, variantName: string): Promise<{ ok: boolean; reason?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/stock-notifications`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-order-service-secret": SERVICE_SECRET },
body: JSON.stringify({ email, product: productId, variantName }),
});
if (res.ok) return { ok: true };
// The (tenant, email, product, variantName) unique index rejects a
// second signup for the same thing with a 400 — read as "already on the
// list", not a real error, so the UI can still show success rather than
// a confusing failure for someone who's already signed up.
if (res.status === 400) return { ok: true };
console.error(`createStockNotification: Payload returned ${res.status} ${res.statusText}`);
return { ok: false, reason: "Eintragen hat nicht geklappt. Bitte versuch es später erneut." };
}
+1 -1
View File
@@ -218,7 +218,7 @@ export async function ProductGrid({
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
<AddToCartInlineButton id={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
+1
View File
@@ -125,6 +125,7 @@ export async function Pricing() {
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
@@ -149,6 +149,7 @@ export async function TodoKartenHero() {
// isn't enough once variants is non-empty.
<AddToCartButton
label="ToDo-Karten bestellen"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}