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