Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e48107470a | |||
| 2d88fb86a1 |
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
@@ -12,7 +13,7 @@ export async function PATCH(request: Request) {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country } = body ?? {};
|
||||
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
!firstName ||
|
||||
@@ -34,6 +35,12 @@ export async function PATCH(request: Request) {
|
||||
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Both independently optional (see Customers.ts's own comment) — only
|
||||
// format-checked when actually provided, same as the backend field itself.
|
||||
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
|
||||
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(session.token, session.customer.id, {
|
||||
firstName,
|
||||
@@ -45,6 +52,8 @@ export async function PATCH(request: Request) {
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
|
||||
vatId: normalizedVatId,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
|
||||
import { describeBundleContents } from "../../lib/bundleContents";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
|
||||
// Plain float arithmetic on money (quantity × unitPrice summed across
|
||||
// lines, a percent discount, subtracting/adding those together) drifts
|
||||
@@ -30,6 +31,8 @@ type CheckoutBody = {
|
||||
lastName: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string;
|
||||
packstationNumber?: string;
|
||||
@@ -85,6 +88,15 @@ export async function POST(request: Request) {
|
||||
if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Optional — only format-checked when actually provided, same "never
|
||||
// trust the client" reasoning as every other checkout field re-validated
|
||||
// here. Normalized the same way Orders.ts's own field does (uppercase +
|
||||
// trim), so the snapshot on the order matches what would've been
|
||||
// accepted directly through the Payload admin.
|
||||
const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined;
|
||||
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
if (body.hasDifferentShippingAddress) {
|
||||
if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
@@ -201,6 +213,8 @@ export async function POST(request: Request) {
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
customerEmail: body.email,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
|
||||
@@ -191,6 +191,19 @@ export function CartContent({
|
||||
const lowStock = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false)
|
||||
: product.lowStock;
|
||||
// Same per-line resolution as lowStock above — caps how high
|
||||
// the quantity stepper below can go, instead of only finding
|
||||
// out at checkout that this many aren't actually available
|
||||
// (api/checkout/route.ts's own stock check stays as the
|
||||
// authoritative server-side guard). null (no cap) falls back
|
||||
// to the stepper's original fixed 1-9 range; at least 1 is
|
||||
// always offered even if maxQty is somehow lower than the
|
||||
// qty already in this line, so the remove (×) button stays
|
||||
// the only way down, never an empty <select>.
|
||||
const maxQty = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.maxQty ?? null)
|
||||
: product.maxQty;
|
||||
const qtyOptions = Array.from({ length: Math.max(1, Math.min(9, maxQty ?? 9)) }, (_, n) => n + 1);
|
||||
return (
|
||||
<div key={lineKey} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
@@ -238,7 +251,7 @@ export function CartContent({
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
|
||||
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
|
||||
{qtyOptions.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -198,7 +198,7 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">
|
||||
{anyLowStock ? "Nur noch wenige verfügbar" : null}
|
||||
</p>
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -102,6 +102,13 @@ export function CheckoutContent({
|
||||
const [firstName, setFirstName] = useState(savedProfile?.firstName ?? "");
|
||||
const [lastName, setLastName] = useState(savedProfile?.lastName ?? "");
|
||||
const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? "");
|
||||
// Optional B2B fields — sit right next to Rechnungsadresse (not a
|
||||
// separately gated "order as a business" toggle) since each is
|
||||
// independently optional (see Customers.ts/Orders.ts's own comment on
|
||||
// why neither implies the other). Prefilled from the saved profile, same
|
||||
// as every other Card 1 field.
|
||||
const [companyName, setCompanyName] = useState(savedProfile?.companyName ?? "");
|
||||
const [vatId, setVatId] = useState(savedProfile?.vatId ?? "");
|
||||
// Always a plain street address — a Packstation isn't a valid Rechnungs-
|
||||
// adresse (an invoice needs a real postal address). Packstation is only
|
||||
// ever offered on the separate, optional shipping-address override below.
|
||||
@@ -143,6 +150,8 @@ export function CheckoutContent({
|
||||
if (draft.firstName) setFirstName(draft.firstName);
|
||||
if (draft.lastName) setLastName(draft.lastName);
|
||||
if (draft.email) setEmail(draft.email);
|
||||
if (draft.companyName) setCompanyName(draft.companyName);
|
||||
if (draft.vatId) setVatId(draft.vatId);
|
||||
if (draft.street) setStreet(draft.street);
|
||||
if (draft.zip) setZip(draft.zip);
|
||||
if (draft.city) setCity(draft.city);
|
||||
@@ -171,6 +180,8 @@ export function CheckoutContent({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
companyName,
|
||||
vatId,
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
@@ -194,6 +205,8 @@ export function CheckoutContent({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
companyName,
|
||||
vatId,
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
@@ -317,6 +330,8 @@ export function CheckoutContent({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
companyName: companyName || undefined,
|
||||
vatId: vatId || undefined,
|
||||
// Deliberately still read from FormData, not state — password is the
|
||||
// one address-card field that stays uncontrolled/unpersisted (see
|
||||
// lib/checkoutDraft.ts's own comment on why).
|
||||
@@ -516,6 +531,32 @@ export function CheckoutContent({
|
||||
<FormField label="Vorname" name="firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required />
|
||||
<FormField label="Nachname" name="lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
|
||||
</div>
|
||||
{/* Optional B2B fields — both independently optional (see
|
||||
Orders.ts's own comment: a sole proprietor might give a VAT
|
||||
ID with no separate "company name", and vice versa), so
|
||||
neither is required just because the other is filled in. */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Firma (optional)"
|
||||
name="companyName"
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Muster GmbH"
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormField
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
value={vatId}
|
||||
onChange={(e) => setVatId(e.target.value)}
|
||||
placeholder="DE123456789"
|
||||
autoComplete="off"
|
||||
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
|
||||
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
|
||||
/>
|
||||
</div>
|
||||
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
|
||||
actual rendered width in the 2-col row above (each half of
|
||||
a gap-4 flex row), instead of stretching full-width. */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
@@ -18,6 +18,7 @@ export function AddToCartButton({
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
}: {
|
||||
label: string;
|
||||
@@ -29,23 +30,31 @@ export function AddToCartButton({
|
||||
/** Product-level — only meaningful when `variants` is empty, same split as
|
||||
* AddToCartInlineButton. */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level cap on total cart quantity — only meaningful when
|
||||
* `variants` is empty, same split as `outOfStock`. null means no cap. */
|
||||
maxQty?: number | null;
|
||||
/** Optional — same shape/semantics as AddToCartInlineButton's own
|
||||
* `variants` prop; all three callers already fetch the full product
|
||||
* server-side, so this is just threaded straight through. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[];
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
const cart = useCart();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
|
||||
function handleClick() {
|
||||
if (currentlyOutOfStock) return;
|
||||
if (disabled) return;
|
||||
addToCart(productId, 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
@@ -68,12 +77,12 @@ export function AddToCartButton({
|
||||
// a solid bright-green button read as too loud here. `border` (width) is
|
||||
// added here too since `base` has none by default, unlike
|
||||
// AddToCartInlineButton's own base which already carries a plain border.
|
||||
const stateClasses = currentlyOutOfStock
|
||||
const stateClasses = disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: added
|
||||
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
|
||||
: "";
|
||||
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : label;
|
||||
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
|
||||
|
||||
return (
|
||||
// Low stock is deliberately NOT surfaced here as its own text line
|
||||
@@ -106,7 +115,7 @@ export function AddToCartButton({
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={currentlyOutOfStock}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
@@ -116,8 +125,9 @@ export function AddToCartButton({
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. Now
|
||||
also reserves space for "Ausverkauft" — the widest of the three
|
||||
wins regardless of which is showing. */}
|
||||
also reserves space for "Ausverkauft"/"Maximale Menge im
|
||||
Warenkorb" — the widest of the four wins regardless of which is
|
||||
showing. */}
|
||||
<span className="relative grid">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
@@ -128,6 +138,9 @@ export function AddToCartButton({
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Ausverkauft
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Maximale Menge im Warenkorb
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
// Exported so consumers like RelatedProducts.tsx can delay their own
|
||||
@@ -21,6 +21,7 @@ export function AddToCartInlineButton({
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
}: {
|
||||
id: string;
|
||||
@@ -29,27 +30,40 @@ export function AddToCartInlineButton({
|
||||
/** Product-level — only meaningful when `variants` is empty. A varianted
|
||||
* product's buyability is entirely per-variant instead (see below). */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level cap on total cart quantity — only meaningful when
|
||||
* `variants` is empty, same split as `outOfStock`. null means no cap
|
||||
* (backorder allowed / inventory untracked). See lib/payload.ts's
|
||||
* maxPurchasableQty(). */
|
||||
maxQty?: number | null;
|
||||
/** Optional — products.variants (name + optional priceOverride + its own
|
||||
* outOfStock). When non-empty, a variant must be picked (defaults to the
|
||||
* first *in-stock* one, or just the first if all are out) before "add to
|
||||
* cart" is enabled — the selected variant's name is snapshotted onto the
|
||||
* cart line and, later, the order itself. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[];
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
const cart = useCart();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
// Whichever is actually being offered right now — the selected variant's
|
||||
// own flag if there are variants, otherwise the plain product-level one.
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
// How much of this exact (id, variant) line is already sitting in the
|
||||
// cart — capped adds mean "In den Warenkorb" must go disabled once this
|
||||
// reaches currentMaxQty, not just when the product is fully sold out.
|
||||
const qtyInCart = cart.find((i) => i.id === id && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
|
||||
function handleClick() {
|
||||
if (currentlyOutOfStock) return;
|
||||
if (disabled) return;
|
||||
addToCart(id, 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
@@ -65,7 +79,7 @@ export function AddToCartInlineButton({
|
||||
// anymore (it's a trailing `!` now), so two conflicting utilities like
|
||||
// border-border/border-success both being present would silently race on
|
||||
// CSS source order instead of one cleanly winning.
|
||||
const stateClasses = currentlyOutOfStock
|
||||
const stateClasses = disabled
|
||||
? "border-border opacity-60 cursor-not-allowed"
|
||||
: added
|
||||
? "border-success bg-success-subtle"
|
||||
@@ -97,16 +111,16 @@ export function AddToCartInlineButton({
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={currentlyOutOfStock}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(currentlyOutOfStock ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{currentlyOutOfStock ? "Ausverkauft" : added ? "Hinzugefügt ✓" : label}
|
||||
{currentlyOutOfStock ? "Ausverkauft" : 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>
|
||||
|
||||
@@ -105,7 +105,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} variants={product.variants} />
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
|
||||
@@ -45,6 +45,8 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
|
||||
zip: String(form.get("zip") ?? ""),
|
||||
city: String(form.get("city") ?? ""),
|
||||
country: String(form.get("country") ?? ""),
|
||||
companyName: String(form.get("companyName") ?? "") || undefined,
|
||||
vatId: String(form.get("vatId") ?? "") || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -83,6 +85,22 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
|
||||
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
|
||||
</div>
|
||||
|
||||
{/* Optional B2B fields — prefills /checkout's own Firma/USt-IdNr.
|
||||
fields, same "profile default, order keeps its own snapshot"
|
||||
split as the address fields below (see Customers.ts). */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Firma (optional)" name="companyName" type="text" defaultValue={profile.companyName ?? ""} />
|
||||
<Field
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
defaultValue={profile.vatId ?? ""}
|
||||
placeholder="DE123456789"
|
||||
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
|
||||
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
|
||||
@@ -20,6 +20,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ export type CheckoutDraft = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
// Optional B2B fields — see CheckoutContent.tsx's own comment on why
|
||||
// they sit here (right next to the Rechnungsadresse fields, not a
|
||||
// separate persisted concept).
|
||||
companyName: string;
|
||||
vatId: string;
|
||||
// Rechnungsadresse is always a plain street address now — no
|
||||
// deliveryMethod/packstationNumber/postNumber here, only on the
|
||||
// shipping* override fields below (see CheckoutContent.tsx).
|
||||
|
||||
@@ -199,6 +199,10 @@ export type CustomerAddress = {
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
// Optional B2B profile default — see Customers.ts's own comment. Prefills
|
||||
// /checkout's Firma/USt-IdNr. fields for a returning customer.
|
||||
companyName: string | null;
|
||||
vatId: string | null;
|
||||
};
|
||||
|
||||
export type CustomerProfile = CustomerSummary & CustomerAddress;
|
||||
@@ -217,6 +221,8 @@ type PayloadCustomerMe = {
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
companyName: string | null;
|
||||
vatId: string | null;
|
||||
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
|
||||
};
|
||||
|
||||
@@ -243,6 +249,8 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
|
||||
zip: u.zip,
|
||||
city: u.city,
|
||||
country: u.country,
|
||||
companyName: u.companyName,
|
||||
vatId: u.vatId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +267,8 @@ export async function updateCustomerProfile(
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
},
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
|
||||
|
||||
@@ -34,6 +34,10 @@ export type CreateOrderInput = {
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
// Optional B2B snapshot fields — see Orders.ts's own comment on why both
|
||||
// are independently optional.
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string;
|
||||
packstationNumber?: string;
|
||||
@@ -87,6 +91,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
customerFirstName: input.customerFirstName,
|
||||
customerLastName: input.customerLastName,
|
||||
customerEmail: input.customerEmail,
|
||||
companyName: input.companyName,
|
||||
vatId: input.vatId,
|
||||
deliveryMethod: input.deliveryMethod,
|
||||
street: input.street,
|
||||
packstationNumber: input.packstationNumber,
|
||||
|
||||
+18
-1
@@ -179,13 +179,21 @@ export type Product = {
|
||||
// Derived, like outOfStock — no raw stock count/threshold leaked, callers
|
||||
// only ever need "should a low-stock hint show for this right now".
|
||||
lowStock: boolean;
|
||||
// Unlike outOfStock/lowStock, this DOES expose the real number — it's
|
||||
// the cap the add-to-cart controls (AddToCartButton/AddToCartInlineButton,
|
||||
// CartContent's quantity stepper) need client-side to stop a shopper from
|
||||
// putting more in the cart than checkout would actually accept, instead
|
||||
// of only finding out at the very last step (api/checkout/route.ts's own
|
||||
// stock check, which stays as the authoritative server-side guard). null
|
||||
// means "no cap" — backorder allowed or inventory not tracked.
|
||||
maxQty: number | null;
|
||||
// Per-product override — null means "use the tenant's default rate"
|
||||
// (CompanySettings.taxRatePercent, fetched separately since it's behind
|
||||
// an admin-only secret, see getCompanySettings()). Display-only on the
|
||||
// storefront; the actual rate used for order totals is resolved and
|
||||
// snapshotted server-side at checkout (api/checkout/route.ts).
|
||||
taxRatePercent: number | null;
|
||||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[];
|
||||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
};
|
||||
|
||||
type PayloadProduct = {
|
||||
@@ -237,6 +245,13 @@ function isLowStock(trackInventory: boolean, stock: number | null, threshold: nu
|
||||
return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold;
|
||||
}
|
||||
|
||||
// null (no cap) whenever backorder is allowed or inventory isn't tracked —
|
||||
// only a hard-tracked, non-backorderable stock count actually limits what a
|
||||
// shopper can add to their cart.
|
||||
function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowBackorder: boolean): number | null {
|
||||
return trackInventory && !allowBackorder ? (stock ?? 0) : null;
|
||||
}
|
||||
|
||||
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
|
||||
// one place instead of duplicating the same field mapping, which is
|
||||
// exactly the kind of drift this session's Shipping Settings work was
|
||||
@@ -260,12 +275,14 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
|
||||
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
|
||||
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
|
||||
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
|
||||
taxRatePercent: product.taxRatePercent ?? null,
|
||||
variants: (product.variants ?? []).map((v) => ({
|
||||
name: v.name,
|
||||
priceOverride: v.priceOverride,
|
||||
outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder),
|
||||
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
|
||||
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
|
||||
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
|
||||
// client+server-safe helper here since this repo's frontend needs the same
|
||||
// check twice (checkout's instant client-side pattern + api/checkout's own
|
||||
// server-side re-validation, same "never trust the client" reasoning as
|
||||
// every other checkout field).
|
||||
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
|
||||
|
||||
export function normalizeVatId(value: string): string {
|
||||
return value.toUpperCase().trim();
|
||||
}
|
||||
|
||||
export function isValidVatId(value: string): boolean {
|
||||
return VAT_ID_PATTERN.test(value);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export async function ProductGrid() {
|
||||
equal-height lesson). */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function Pricing() {
|
||||
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"
|
||||
outOfStock={product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.variants}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function TodoKartenHero() {
|
||||
</div>
|
||||
|
||||
{product && (
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
Reference in New Issue
Block a user