a50524832e
- Removed the inline "Nur noch wenige verfügbar" text hint from AddToCartButton/AddToCartInlineButton (was making card heights vary in every grid that renders them — RelatedProducts, ProductSpotlight's CTA row) — now only shown via the same image-overlaid pill badge Ausverkauft/discount already use (position: absolute, doesn't affect layout). Added that badge to RelatedProducts.tsx and todo-cards' Pricing.tsx, which didn't have it before. - RelatedProducts cards now also show "inkl. X% MwSt." (was missing entirely) - VatBreakdown rows are now flex rows with a spacer instead of plain text, so every € amount right-aligns to the same edge regardless of how many digits the rate itself has (was visibly staggered with mixed 7%/19% rates) - Cart's manual discount-code field only renders when Payload actually has at least one active code right now (lib/discountServer.ts's new hasActiveDiscountCode()) — no point showing an open field that could never validate. An already-applied code (e.g. from an older session) still always shows its own result row regardless. - Checkout's "1. Rechnungsadresse" no longer offers a Packstation option — a Packstation isn't a valid billing address for an invoice. Only a plain street address now; Packstation is only offered on the separate, optional "Abweichende Lieferadresse" section, which already had its own address/Packstation toggle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
import { formatPrice } from "./format";
|
|
|
|
// Server-only — imported exclusively by app/api/discount/*/route.ts (Route
|
|
// Handlers are never bundled for the client anyway, but this file also
|
|
// touches DISCOUNT_SERVICE_SECRET, which must never end up reachable from
|
|
// a "use client" import graph). Kept out of lib/payload.ts on purpose,
|
|
// same reasoning as that file's own comment about staying free of
|
|
// next/headers — a shared module used by both server and client code is
|
|
// exactly where an accidental server-only dependency causes a build break.
|
|
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
|
const TENANT_SLUG = "einfach-produktiv";
|
|
const SERVICE_SECRET = process.env.DISCOUNT_SERVICE_SECRET || "";
|
|
|
|
type PayloadDiscountCode = {
|
|
id: number;
|
|
code: string;
|
|
type: "percent" | "fixed";
|
|
value: number;
|
|
validFrom: string | null;
|
|
validUntil: string | null;
|
|
minOrderValue: number | null;
|
|
maxRedemptions: number | null;
|
|
redemptionCount: number;
|
|
active: boolean;
|
|
};
|
|
|
|
async function fetchDiscountCode(code: string): Promise<PayloadDiscountCode | null> {
|
|
const params = new URLSearchParams({
|
|
"where[tenant.slug][equals]": TENANT_SLUG,
|
|
"where[code][equals]": code.toUpperCase().trim(),
|
|
limit: "1",
|
|
});
|
|
|
|
const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, {
|
|
headers: { "x-discount-service-secret": SERVICE_SECRET },
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) {
|
|
console.error(`fetchDiscountCode: Payload returned ${res.status} ${res.statusText}`);
|
|
return null;
|
|
}
|
|
|
|
const data: { docs?: PayloadDiscountCode[] } = await res.json();
|
|
return data.docs?.[0] ?? null;
|
|
}
|
|
|
|
// Whether it's worth showing the cart's manual "Rabattcode" input field at
|
|
// all — no point offering an open text field for a shopper to type into
|
|
// when there's nothing in Payload that could ever validate. Existence-only
|
|
// check (active: true), not the fuller validFrom/validUntil/minOrderValue
|
|
// window validateDiscountCode() does for an actual submitted code — this
|
|
// just gates whether the field renders, the real validation still happens
|
|
// at apply time regardless.
|
|
export async function hasActiveDiscountCode(): Promise<boolean> {
|
|
const params = new URLSearchParams({
|
|
"where[tenant.slug][equals]": TENANT_SLUG,
|
|
"where[active][equals]": "true",
|
|
limit: "1",
|
|
});
|
|
const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, {
|
|
headers: { "x-discount-service-secret": SERVICE_SECRET },
|
|
next: { revalidate: 60 },
|
|
});
|
|
if (!res.ok) {
|
|
console.error(`hasActiveDiscountCode: Payload returned ${res.status} ${res.statusText}`);
|
|
return false;
|
|
}
|
|
const data: { docs?: unknown[] } = await res.json();
|
|
return (data.docs?.length ?? 0) > 0;
|
|
}
|
|
|
|
export type DiscountValidation =
|
|
| { valid: true; doc: PayloadDiscountCode }
|
|
| { valid: false; reason: string };
|
|
|
|
// Shared by both routes below — /validate calls this read-only when a
|
|
// shopper applies a code in the cart; /redeem calls it again immediately
|
|
// before incrementing the counter (the window/limit may have changed
|
|
// between the two, however unlikely), so neither route duplicates these
|
|
// rules independently.
|
|
export async function validateDiscountCode(code: string, subtotal: number): Promise<DiscountValidation> {
|
|
const doc = await fetchDiscountCode(code);
|
|
if (!doc) return { valid: false, reason: "Dieser Code existiert nicht." };
|
|
if (!doc.active) return { valid: false, reason: "Dieser Code ist nicht mehr gültig." };
|
|
|
|
const now = Date.now();
|
|
if (doc.validFrom && now < new Date(doc.validFrom).getTime()) {
|
|
return { valid: false, reason: "Dieser Code ist noch nicht gültig." };
|
|
}
|
|
if (doc.validUntil && now > new Date(doc.validUntil).getTime()) {
|
|
return { valid: false, reason: "Dieser Code ist abgelaufen." };
|
|
}
|
|
if (doc.minOrderValue != null && subtotal < doc.minOrderValue) {
|
|
return { valid: false, reason: `Dieser Code gilt erst ab einem Bestellwert von ${formatPrice(doc.minOrderValue)}.` };
|
|
}
|
|
if (doc.maxRedemptions != null && doc.redemptionCount >= doc.maxRedemptions) {
|
|
return { valid: false, reason: "Dieser Code wurde bereits zu oft eingelöst." };
|
|
}
|
|
|
|
return { valid: true, doc };
|
|
}
|
|
|
|
// Read-then-write, not an atomic conditional update — a true concurrent
|
|
// race on the very last redemption of a capped code has a narrow window
|
|
// where two requests could both pass validateDiscountCode() before either
|
|
// increments. Accepted, not worth custom atomic SQL for this shop's
|
|
// traffic level.
|
|
export async function redeemDiscountCode(doc: PayloadDiscountCode): Promise<boolean> {
|
|
const res = await fetch(`${PAYLOAD_URL}/api/discount-codes/${doc.id}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"x-discount-service-secret": SERVICE_SECRET,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ redemptionCount: doc.redemptionCount + 1 }),
|
|
});
|
|
if (!res.ok) {
|
|
console.error(`redeemDiscountCode: Payload returned ${res.status} ${res.statusText}`);
|
|
}
|
|
return res.ok;
|
|
}
|