diff --git a/app/api/stock-notifications/route.ts b/app/api/stock-notifications/route.ts new file mode 100644 index 0000000..f335c28 --- /dev/null +++ b/app/api/stock-notifications/route.ts @@ -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 }); +} diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index a817aaf..95fbefb 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -221,7 +221,7 @@ export function RelatedProducts({ lines (see ProductGrid.tsx's identical spacer). */}
- +
); diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index ced7908..296edff 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -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({ {added ? "Hinzugefügt ✓" : displayLabel} + {currentlyOutOfStock && 0 ? (selectedVariant ?? "") : ""} />} ); } diff --git a/app/components/AddToCartInlineButton.tsx b/app/components/AddToCartInlineButton.tsx index 1c5ffa5..b7dffed 100644 --- a/app/components/AddToCartInlineButton.tsx +++ b/app/components/AddToCartInlineButton.tsx @@ -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({ + {currentlyOutOfStock && 0 ? (selectedVariant ?? "") : ""} />} ); } diff --git a/app/components/NotifyMeForm.tsx b/app/components/NotifyMeForm.tsx new file mode 100644 index 0000000..d0db649 --- /dev/null +++ b/app/components/NotifyMeForm.tsx @@ -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

Danke! Wir melden uns, sobald es wieder verfügbar ist.

; + } + + return ( +
+
+ 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" + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index 9242b3b..74b0f87 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -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. */} - + {product.href && ( + ); diff --git a/app/lib/stockNotifications.ts b/app/lib/stockNotifications.ts new file mode 100644 index 0000000..cae2c36 --- /dev/null +++ b/app/lib/stockNotifications.ts @@ -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." }; +} diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index 1e3d704..ecc4dc6 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -218,7 +218,7 @@ export async function ProductGrid({ equal-height lesson). */}
- +
); diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx index 758293e..e586109 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -125,6 +125,7 @@ export async function Pricing() {