feat: Gutscheine gelten fuer Patenschaften, Layout der Patenschaftsseiten, "Suesses Abo"
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 4m36s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 5m4s
Docker Image bauen und veroeffentlichen / test (push) Successful in 3m32s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 11m51s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 4m36s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 5m4s
Docker Image bauen und veroeffentlichen / test (push) Successful in 3m32s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 11m51s
- Eingegebene Gutscheincodes wirken auf die ganze Zwischensumme inkl. Patenschaften; automatische Mengenrabatte zaehlen weiter nur Ware ausserhalb von Patenschaftspaketen; Vorschau und Server rechnen gleich - Paketkarten erst ab lg dreispaltig, Umschalter bricht nicht mehr aus, Schrittleiste ohne Querscrollen bei 1024 px, Vorschau-Dialog passt in die Hoehe, Felder scrollen ueber die mobile Leiste, 16 px auf Touch - Umschalter-Beschriftung "Suesses Abo" / "Sweet subscription" Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dd180563ca
commit
e5b6736dcb
28 files changed
+327
-95
No files matched your search
@@ -36,7 +36,7 @@ Kurzfassung (Stand 2026-10-16):
|
||||
- **Zuletzt angesehen**: rein lokal im Browser (localStorage, kein Tracking-Cookie) gemerkte zuletzt besuchte Produkte, als Sektion auf Produktdetail- und Shop-Seite
|
||||
- **Echte Kaufsignale statt künstlicher Dringlichkeit**: Sternebewertungen (aus echten, freigeschalteten Bewertungen) jetzt auch direkt auf den Produktkarten der Shop-Übersicht, nicht nur auf der Detailseite — bewusst keine erfundenen Countdown-Timer oder Live-Besucherzahlen
|
||||
- **Echte Produktbewertungen**: eingeloggte Kund:innen können ein Produkt nur bewerten, wenn sie es tatsächlich gekauft haben (verifizierter Kauf, eine Bewertung pro Konto und Produkt); neue Bewertungen erscheinen erst nach admin-Freischaltung (`/admin/bewertungen`) öffentlich, inkl. Sternebewertung, optionalem Kommentar und `AggregateRating`-JSON-LD auf der Produktseite
|
||||
- **Rabattcodes/Gutscheine**: prozentual oder als fester Betrag, mit optionalem Mindestbestellwert, Gültigkeitszeitraum und Nutzungslimit, verwaltet unter `/admin/gutscheine`. Der Code wird beim Checkout eingelöst und als einmaliger Stripe-Coupon auf die Checkout-Session angewendet (Rabatt gilt nur auf den Warenwert, nicht auf den Versand); die Nutzung wird erst bei bestätigter Zahlung gezählt, nicht schon bei einem abgebrochenen Checkout
|
||||
- **Rabattcodes/Gutscheine**: prozentual oder als fester Betrag, mit optionalem Mindestbestellwert, Gültigkeitszeitraum und Nutzungslimit, verwaltet unter `/admin/gutscheine`. Der Code wird beim Checkout eingelöst und als einmaliger Stripe-Coupon auf die Checkout-Session angewendet (Rabatt gilt nur auf den Warenwert, nicht auf den Versand; ein eingegebener Gutschein wirkt auf die ganze Zwischensumme inklusive Bienenpatenschaften, automatische Mengenrabatte zählen dagegen nur Ware außerhalb von Patenschaftspaketen); die Nutzung wird erst bei bestätigter Zahlung gezählt, nicht schon bei einem abgebrochenen Checkout
|
||||
- **Lagerbestands-Hinweise**: „Ausverkauft"-/„Nur noch N"-Badges auf Produktkarte und Produktdetailseite (ab 5 Stück oder weniger), Bestellbutton wird bei Bestand 0 deaktiviert
|
||||
- **Benachrichtigung bei Wiederverfügbarkeit**: bei ausverkauften Produkten kann eine E-Mail-Adresse hinterlegt werden (`StockNotification`-Modell); sobald ein Admin den Lagerbestand im Admin-Bereich wieder über 0 setzt, werden alle wartenden Adressen automatisch per E-Mail benachrichtigt (`lib/stock-notifications.ts`)
|
||||
- **Retouren**: Kund:innen können im Konto (`/konto/bestellungen`) für bezahlte, nicht stornierte Bestellungen eine Rücksendung mit Begründung anfragen (`ReturnRequest`-Modell). Admin sieht alle Anfragen unter `/admin/retouren`, kann sie annehmen oder ablehnen und eine angenommene Retoure per Klick über die Stripe Refunds API erstatten — dabei wird automatisch der Lagerbestand zurückgebucht und `Payment.status` auf `REFUNDED` gesetzt. Jeder Schritt (Anfrage, Annahme/Ablehnung, Erstattung) löst eine E-Mail an die Kundschaft aus.
|
||||
|
||||
@@ -429,7 +429,8 @@ describe("Checkout-Route", () => {
|
||||
|
||||
describe("Rabatte und Patenschaften", () => {
|
||||
// Paket "Wabenfreund": 38,00 € Patenschaft (MAIN) + 3 Glaeser Honiganteil
|
||||
// zu je 7,00 € (HONEY) = 59,00 €. Beides zaehlt nicht als Rabattbasis.
|
||||
// zu je 7,00 € (HONEY) = 59,00 €. Fuer Mengenrabatte zaehlt beides nicht,
|
||||
// ein eingegebener Gutschein wirkt dagegen auf die ganze Zwischensumme.
|
||||
const carrier = { ...honey, id: "carrier", name: "Wabenfreund", slug: "wabenfreund", priceCents: 3800, weightGrams: 0, category: "PATENSCHAFT" };
|
||||
const offer = {
|
||||
id: "offer",
|
||||
@@ -532,29 +533,19 @@ describe("Checkout-Route", () => {
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 495 }));
|
||||
});
|
||||
|
||||
test("Gutscheincode: Mindestbestellwert zaehlt nur die Ware ausserhalb von Patenschaften", async () => {
|
||||
test("Gutscheincode: Mindestbestellwert zaehlt die ganze Zwischensumme inklusive Patenschaft", async () => {
|
||||
// 2 × 9,90 € + 59,00 € Paket = 78,80 € — der Mindestwert von 30 € ist erreicht.
|
||||
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, minOrderCents: 3000 });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") });
|
||||
expect(db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Gutscheincode: Rabatt nur auf die Ware ausserhalb von Patenschaften", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(198);
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 198 }));
|
||||
expect(db.order.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Gutscheincode bei reinem Patenschafts-Warenkorb ist ungueltig", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||
test("Gutscheincode: Mindestbestellwert unterschritten bleibt ungueltig", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, minOrderCents: 6000 });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] });
|
||||
|
||||
@@ -563,6 +554,59 @@ describe("Checkout-Route", () => {
|
||||
expect(db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Gutscheincode: Rabatt auf die ganze Zwischensumme inklusive Patenschaft, Stripe-Coupon gleich hoch", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// 10 % von 78,80 € — nicht nur von den 19,80 € ausserhalb des Pakets.
|
||||
expect(db.order.create.mock.calls[0][0].data).toMatchObject({ discountCents: 788, totalCents: 7880 - 788 + 700 });
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 788 }));
|
||||
});
|
||||
|
||||
test("Gutscheincode wirkt auch auf einen reinen Patenschafts-Warenkorb", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.order.create.mock.calls[0][0].data).toMatchObject({ discountCents: 590, discountCodeId: "dc_1" });
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 590 }));
|
||||
});
|
||||
|
||||
test("Fester Gutscheinbetrag wird an der ganzen Zwischensumme gekappt", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, type: "FIXED", value: 10000 });
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(5900);
|
||||
});
|
||||
|
||||
test("Von Hand eingegebener Mengenrabatt-Code zaehlt Paketglaeser weiterhin nicht zur Schwelle", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, code: "MENGE5", minQuantity: 5 });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "menge5", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Von Hand eingegebener Mengenrabatt-Code rechnet nur auf die Ware ausserhalb des Pakets", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, code: "MENGE5", minQuantity: 5 });
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "menge5", items: [{ productId: "p1", quantity: 5 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(495);
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 495 }));
|
||||
});
|
||||
|
||||
test("Stripe-Positionsname nennt das Gewicht nur bei Ware mit Gewicht", async () => {
|
||||
await post({ ...validPayload, items: [{ productId: "p1", quantity: 1 }, packageItem] });
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
|
||||
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region";
|
||||
import { isDiscountCodeValid, calculateDiscountCents, discountBase } from "@/lib/discount-calc";
|
||||
import { isDiscountCodeValid, calculateDiscountCents, bulkDiscountBase, discountBaseForCode } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import {
|
||||
@@ -352,10 +352,10 @@ export async function POST(request: Request) {
|
||||
const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
|
||||
|
||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||
// haelt die Rabattlogik einfach nachvollziehbar. Und nur auf Ware
|
||||
// ausserhalb von Patenschaften (discountBase): Schwelle, Mindestwert und
|
||||
// Betrag rechnen ohne Paketpositionen.
|
||||
const base = discountBase(orderItemsData);
|
||||
// haelt die Rabattlogik einfach nachvollziehbar. Ein eingegebener Gutschein
|
||||
// wirkt auf die ganze Zwischensumme inklusive Patenschaften; Mengenrabatte
|
||||
// rechnen weiter nur mit Ware ausserhalb von Patenschaften
|
||||
// (discountBaseForCode / bulkDiscountBase in lib/discount-calc.ts).
|
||||
let discountCents = 0;
|
||||
let discountCode: { id: string; code: string } | null = null;
|
||||
if (input.discountCode) {
|
||||
@@ -363,10 +363,12 @@ export async function POST(request: Request) {
|
||||
const code = await prisma.discountCode.findUnique({
|
||||
where: { code: input.discountCode.trim().toUpperCase() },
|
||||
});
|
||||
const base = code ? discountBaseForCode(code, orderItemsData) : null;
|
||||
|
||||
// Ein reiner Patenschafts-Warenkorb hat keine Rabattbasis — der Code
|
||||
// gilt dann als nicht einloesbar statt still 0 € abzuziehen.
|
||||
if (!code || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) {
|
||||
// Ohne Rabattbasis (z.B. ein von Hand eingegebener Mengenrabatt-Code bei
|
||||
// einem reinen Patenschafts-Warenkorb) gilt der Code als nicht
|
||||
// einloesbar, statt still 0 € abzuziehen.
|
||||
if (!code || !base || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 400 }
|
||||
@@ -394,6 +396,7 @@ export async function POST(request: Request) {
|
||||
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||
discountCode = code;
|
||||
} else {
|
||||
const base = bulkDiscountBase(orderItemsData);
|
||||
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
||||
// gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden
|
||||
// waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig,
|
||||
|
||||
@@ -50,13 +50,17 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderCheckout(items: CartItem[], stripePublishableKey: string | undefined = PUBLISHABLE_KEY) {
|
||||
function renderCheckout(
|
||||
items: CartItem[],
|
||||
stripePublishableKey: string | undefined = PUBLISHABLE_KEY,
|
||||
discountTiers: BulkDiscountTierLike[] = bulkDiscountTiers
|
||||
) {
|
||||
useCartStore.setState({ items, isOpen: false });
|
||||
return render(
|
||||
<I18nProvider locale="de">
|
||||
<CheckoutForm
|
||||
tiers={tiers}
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
bulkDiscountTiers={discountTiers}
|
||||
stripePublishableKey={stripePublishableKey}
|
||||
/>
|
||||
</I18nProvider>
|
||||
@@ -120,6 +124,28 @@ describe("CheckoutForm", () => {
|
||||
expect(screen.getByText("kostenlos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("bulk discount preview counts only goods outside sponsorship packages", () => {
|
||||
// 3 Glaeser einzeln + ein Paket mit 3 Glaesern: nur die 3 einzelnen zaehlen.
|
||||
renderCheckout([item({ quantity: 3 }), sponsorshipItem("pkg-1", "Summsi", 3)], PUBLISHABLE_KEY, [
|
||||
{ code: "MENGE10", minQuantity: 3, percent: 10 },
|
||||
]);
|
||||
|
||||
// 10 % von 3 × 9,90 € = 2,97 € — nicht von der ganzen Zwischensumme.
|
||||
expect(screen.getByText("-2,97 €")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an entered voucher replaces the bulk discount preview, like the server does", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
renderCheckout([item({ quantity: 3 }), sponsorshipItem("pkg-1", "Summsi", 3)], PUBLISHABLE_KEY, [
|
||||
{ code: "MENGE10", minQuantity: 3, percent: 10 },
|
||||
]);
|
||||
|
||||
await userEventInstance.type(screen.getByLabelText(/gutschein/i), "sommer10");
|
||||
|
||||
expect(screen.queryByText("-2,97 €")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Gutschein SOMMER10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: Antwortet /api/checkout mit 502 (Stripe nicht erreichbar),
|
||||
* schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir
|
||||
|
||||
@@ -5,14 +5,14 @@ import dynamic from "next/dynamic";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import Link from "next/link";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly, cartDiscountBase } from "@/store/cart-store";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly, cartBulkDiscountBase } from "@/store/cart-store";
|
||||
import { SponsorshipLineDetails } from "@/components/sponsorship-cart-line";
|
||||
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
||||
import { loginPathWithNext } from "@/lib/safe-next-path";
|
||||
import CheckoutLoading from "./loading";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
import { computeBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
|
||||
@@ -140,6 +140,8 @@ export function CheckoutForm({
|
||||
const [country, setCountry] = useState(countryCodes[0]);
|
||||
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Nur fuer die Vorschau rechts: ist ein Gutscheincode eingetragen, gilt im Server er statt des Mengenrabatts.
|
||||
const [enteredCode, setEnteredCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 401 vom Server: eine Patenschaft im Warenkorb braucht ein Konto. Dann
|
||||
// steht neben der Meldung ein Weg zur Anmeldung, der hierher zurueckfuehrt.
|
||||
@@ -200,12 +202,16 @@ export function CheckoutForm({
|
||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||
const shipping =
|
||||
digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
||||
// Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie discountBase im Server).
|
||||
const discountable = cartDiscountBase(items);
|
||||
const activeBulkDiscount = discountable.quantity > 0 ? resolveBulkDiscount(bulkDiscountTiers, discountable.quantity) : null;
|
||||
const bulkDiscountCents = activeBulkDiscount
|
||||
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||
: 0;
|
||||
// Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie
|
||||
// bulkDiscountBase im Server). Sobald ein Gutscheincode eingetragen ist,
|
||||
// wendet der Server statt des Mengenrabatts den Gutschein an — auf die ganze
|
||||
// Zwischensumme inklusive Patenschaft. Ob er gueltig ist, weiss erst der
|
||||
// Server; die Vorschau zeigt dann keinen Mengenrabatt mehr, sondern den
|
||||
// Hinweis, dass der Gutschein im Zahlungsschritt abgezogen wird.
|
||||
const hasEnteredCode = enteredCode.trim().length > 0;
|
||||
const bulkDiscount = hasEnteredCode ? null : computeBulkDiscount(bulkDiscountTiers, cartBulkDiscountBase(items));
|
||||
const activeBulkDiscount = bulkDiscount?.tier ?? null;
|
||||
const bulkDiscountCents = bulkDiscount?.discountCents ?? 0;
|
||||
const total = subtotal - bulkDiscountCents + shipping;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -639,6 +645,7 @@ export function CheckoutForm({
|
||||
label={t.checkoutPage.discountCodeLabel}
|
||||
placeholder={t.checkoutPage.discountCodePlaceholder}
|
||||
autoComplete="off"
|
||||
onValueChange={setEnteredCode}
|
||||
/>
|
||||
|
||||
<div>
|
||||
@@ -729,6 +736,11 @@ export function CheckoutForm({
|
||||
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hasEnteredCode ? (
|
||||
<p className="text-heading" aria-live="polite">
|
||||
{t.checkoutPage.discountCodePendingNote(enteredCode.trim().toUpperCase())}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-ink/10 flex items-baseline justify-between text-heading">
|
||||
<span className="text-base">{t.cartPage.total}</span>
|
||||
@@ -750,6 +762,7 @@ function Field({
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required = false,
|
||||
onValueChange,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -761,6 +774,8 @@ function Field({
|
||||
/** Browser-/Mobil-Autofill fuer Adress- und Kontaktfelder (siehe MDN autofill-Tokens) — ohne das muss jede Bestellung komplett von Hand eingetippt werden. */
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
/** Meldet jede Eingabe nach oben, ohne das Feld zu einem kontrollierten Feld zu machen. */
|
||||
onValueChange?: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
@@ -773,6 +788,7 @@ function Field({
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
onChange={onValueChange ? (event) => onValueChange(event.target.value) : undefined}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -314,6 +314,17 @@ textarea::placeholder {
|
||||
color: color-mix(in oklab, var(--color-ink) 45%, transparent);
|
||||
}
|
||||
|
||||
/* iOS Safari zoomt in jedes Feld unter 16 px und bleibt danach verschoben
|
||||
(Kasse, Patenschafts-Konfigurator). Nur auf Touch-Geraeten, am Desktop
|
||||
bleibt die kleinere Schrift der Formulare. */
|
||||
@media (pointer: coarse) {
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
|
||||
select,
|
||||
textarea {
|
||||
font-size: max(16px, 1em);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chargen-Seite: das Echtheitssiegel "stempelt" sich beim Oeffnen ein. */
|
||||
@keyframes wh-stamp {
|
||||
0% {
|
||||
|
||||
@@ -73,7 +73,8 @@ export function CertificatePreview({
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-forest/85 text-cream shadow-lg backdrop-blur-sm lg:opacity-0 lg:transition-opacity lg:duration-200 lg:group-hover:opacity-100 lg:group-focus-visible:opacity-100"
|
||||
// Auf der Ecke statt im Blatt: verdeckt so nicht das Kleingedruckte der Urkunde.
|
||||
className="absolute -bottom-3 -right-3 flex h-9 w-9 items-center justify-center rounded-full bg-forest/85 text-cream shadow-lg backdrop-blur-sm lg:opacity-0 lg:transition-opacity lg:duration-200 lg:group-hover:opacity-100 lg:group-focus-visible:opacity-100"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.7">
|
||||
<path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
@@ -96,8 +97,9 @@ export function CertificatePreview({
|
||||
className="m-auto max-h-[100dvh] max-w-[min(100vw,56rem)] overflow-auto bg-transparent p-0 backdrop:bg-forest/70 backdrop:backdrop-blur-sm open:animate-[wh-preview-open_220ms_var(--ease-arrive)] motion-reduce:open:animate-none"
|
||||
>
|
||||
<div className="relative px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-[max(1rem,env(safe-area-inset-top))]">
|
||||
{/* Ganz sichtbar statt nur breit: auf dem Querformat-Bildschirm passt die Urkunde in die Hoehe. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt="" className="block h-auto w-full bg-[#faf8f3] shadow-2xl" width={900} height={1273} />
|
||||
<img src={src} alt="" className="mx-auto block h-auto max-h-[calc(100dvh-2rem)] w-auto max-w-full bg-[#faf8f3] shadow-2xl" width={900} height={1273} />
|
||||
<form method="dialog" className="absolute right-6 top-6">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -71,7 +71,8 @@ export function OfferStep({
|
||||
className="py-4"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 pr-2">
|
||||
<span className="min-w-0">
|
||||
{/* flex-1 basis-40: ein langer Hinweis bricht um, statt den Preis in eine eigene Zeile zu schieben. */}
|
||||
<span className="min-w-0 flex-1 basis-40">
|
||||
<span className="block font-serif text-2xl leading-tight text-heading">{offer.name}</span>
|
||||
<span className="block text-xs text-ink/60">
|
||||
{!offer.isAvailable
|
||||
|
||||
@@ -322,7 +322,8 @@ export function SponsorshipConfigurator({
|
||||
stepOf={c.stepOf}
|
||||
/>
|
||||
|
||||
<div className="mt-6 border border-ink/10 bg-background px-5 py-7 shadow-[0_30px_60px_-46px_rgba(36,53,43,0.55)] sm:px-9 sm:py-10">
|
||||
{/* scroll-mb: ein fokussiertes Feld rollt auf dem Telefon oberhalb der klebenden Schrittleiste ins Bild, nicht darunter. */}
|
||||
<div className="mt-6 border border-ink/10 bg-background px-5 py-7 shadow-[0_30px_60px_-46px_rgba(36,53,43,0.55)] sm:px-9 sm:py-10 [&_input]:scroll-mb-28 [&_select]:scroll-mb-28 [&_textarea]:scroll-mb-28 md:[&_input]:scroll-mb-0 md:[&_select]:scroll-mb-0 md:[&_textarea]:scroll-mb-0">
|
||||
{aboSession && stripePublishableKey ? (
|
||||
<AboPayment
|
||||
publishableKey={stripePublishableKey}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRef, type KeyboardEvent } from "react";
|
||||
import type { OfferKind } from "./draft-store";
|
||||
|
||||
/**
|
||||
* Umschalter "Einmalig" / "Im Abo" als Radiogruppe: Pfeiltasten wechseln,
|
||||
* Umschalter "Einmalig" / "Süßes Abo" als Radiogruppe: Pfeiltasten wechseln,
|
||||
* Tab springt hinein und wieder heraus (WAI-ARIA Radio Group). Der Schieber
|
||||
* gleitet zur Auswahl — die einzige Bewegung des Umschalters, bei
|
||||
* reduzierter Bewegung springt er.
|
||||
@@ -39,7 +39,9 @@ export function KindToggle({
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={`relative inline-grid grid-cols-2 rounded-full border border-ink/15 bg-background p-1 ${className}`}
|
||||
// Auf dem Telefon volle Breite mit zwei gleichen Haelften, damit auch
|
||||
// laengere Beschriftungen ("Süßes Abo", "Sweet subscription") passen; notfalls zweizeilig.
|
||||
className={`relative grid w-full max-w-sm grid-cols-2 rounded-full border border-ink/15 bg-background p-1 sm:inline-grid sm:w-auto sm:max-w-none ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -60,7 +62,7 @@ export function KindToggle({
|
||||
tabIndex={checked ? 0 : -1}
|
||||
onClick={() => onChange(option)}
|
||||
onKeyDown={(event) => onKeyDown(event, index)}
|
||||
className={`relative z-10 min-h-11 min-w-32 select-none rounded-full px-6 text-sm tracking-wide-caps transition-colors duration-300 touch-manipulation ${
|
||||
className={`relative z-10 min-h-11 min-w-0 select-none rounded-full px-2 py-1.5 text-xs leading-tight sm:text-sm tracking-wide-caps [text-wrap:balance] sm:min-w-32 sm:px-6 transition-colors duration-300 touch-manipulation ${
|
||||
checked ? "text-cream" : "text-ink/70 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function OfferShowcase({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-4xl leading-[1.05] tracking-[-0.01em] text-heading md:text-5xl">{strings.offers.heading}</h2>
|
||||
<p className="mt-4 leading-relaxed text-ink/70">
|
||||
@@ -63,13 +63,16 @@ export function OfferShowcase({
|
||||
onChange={setKind}
|
||||
labels={{ EINMALIG: strings.offers.once, ABO: strings.offers.abo }}
|
||||
ariaLabel={strings.offers.toggleLabel}
|
||||
className="self-start md:self-auto"
|
||||
className="shrink-0 self-start lg:self-auto"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Drei Karten erst ab lg nebeneinander: auf dem Tablet (768 px) blieben
|
||||
je Karte nur rund 150 px Inhalt, Preis und Leistungen liefen ueber
|
||||
den Rand. Dazwischen zwei Spalten, die dritte Karte steht links. */}
|
||||
<ul
|
||||
className={`mt-12 grid gap-6 ${offers.length >= 3 ? "md:grid-cols-3" : offers.length === 2 ? "md:grid-cols-2 lg:max-w-4xl" : "max-w-md"}`}
|
||||
className={`mt-12 grid gap-6 ${offers.length >= 3 ? "md:grid-cols-2 lg:grid-cols-3" : offers.length === 2 ? "md:grid-cols-2 lg:max-w-4xl" : "max-w-md"}`}
|
||||
>
|
||||
{offers.map((offer) => (
|
||||
<li key={offer.slug} className="flex">
|
||||
@@ -125,9 +128,9 @@ function OfferCard({
|
||||
<h3 id={titleId} className="mt-6 font-serif text-3xl leading-tight text-heading">
|
||||
{offer.name}
|
||||
</h3>
|
||||
{offer.description ? <p className="mt-2 text-sm leading-relaxed text-ink/70">{offer.description}</p> : null}
|
||||
{offer.description ? <p className="mt-2 text-sm leading-relaxed text-ink/70 hyphens-auto text-pretty">{offer.description}</p> : null}
|
||||
|
||||
<p className="mt-6 flex items-baseline gap-2">
|
||||
<p className="mt-6 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="font-serif text-5xl leading-none text-heading tabular-nums">
|
||||
{formatPrice(offer.priceCents, "EUR", locale)}
|
||||
</span>
|
||||
@@ -139,7 +142,7 @@ function OfferCard({
|
||||
{offer.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5">
|
||||
<HexCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-honey-dark" />
|
||||
<span>{feature}</span>
|
||||
<span className="min-w-0 hyphens-auto break-words">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -159,7 +162,7 @@ function OfferCard({
|
||||
type="button"
|
||||
onClick={onChoose}
|
||||
disabled={soldOut}
|
||||
className="inline-flex min-h-12 w-full select-none items-center justify-center gap-2 border border-heading bg-forest bg-[linear-gradient(var(--color-honey-deep),var(--color-honey-deep))] bg-[length:100%_0%] bg-bottom bg-no-repeat px-6 text-sm tracking-wide-caps text-cream transition-[background-size,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] touch-manipulation hover:bg-[length:100%_100%] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] focus-visible:bg-[length:100%_100%] active:scale-[0.97] active:duration-100 disabled:cursor-not-allowed disabled:border-ink/20 disabled:bg-ink/10 disabled:bg-none disabled:text-ink/50 disabled:active:scale-100 motion-reduce:transition-colors"
|
||||
className="inline-flex min-h-12 w-full select-none items-center justify-center gap-2 border border-heading bg-forest py-3 text-center leading-snug bg-[linear-gradient(var(--color-honey-deep),var(--color-honey-deep))] bg-[length:100%_0%] bg-bottom bg-no-repeat px-6 text-sm tracking-wide-caps text-cream transition-[background-size,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] touch-manipulation hover:bg-[length:100%_100%] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] focus-visible:bg-[length:100%_100%] active:scale-[0.97] active:duration-100 disabled:cursor-not-allowed disabled:border-ink/20 disabled:bg-ink/10 disabled:bg-none disabled:text-ink/50 disabled:active:scale-100 motion-reduce:transition-colors"
|
||||
>
|
||||
{soldOut ? strings.offers.soldOut : strings.offers.choose}
|
||||
</button>
|
||||
|
||||
@@ -56,8 +56,12 @@ export function StepIndicator({
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
{/* Beschriftung nur, wo die Spalte breit genug ist: von lg bis xl steht der
|
||||
Konfigurator neben der Vorschau, dort liefen fuenf Beschriftungen ueber
|
||||
den Rand (waagerechtes Scrollen bei 1024 px). Der aktuelle Schritt steht
|
||||
ohnehin ueber der Leiste. */}
|
||||
<span
|
||||
className={`hidden whitespace-nowrap text-xs tracking-wide-caps md:inline ${
|
||||
className={`hidden whitespace-nowrap text-xs tracking-wide-caps md:inline lg:hidden xl:inline ${
|
||||
active ? "text-heading" : done ? "text-heading/70" : "text-ink/50"
|
||||
}`}
|
||||
>
|
||||
@@ -79,7 +83,7 @@ export function StepIndicator({
|
||||
) : (
|
||||
<span className="flex min-h-11 items-center">
|
||||
{marker}
|
||||
<span className="sr-only md:hidden">{label}</span>
|
||||
<span className="sr-only md:hidden lg:inline xl:hidden">{label}</span>
|
||||
</span>
|
||||
)}
|
||||
{index < labels.length - 1 ? (
|
||||
|
||||
@@ -8,14 +8,14 @@ import { TrustBadges } from "@/components/trust-badges";
|
||||
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartDiscountBase } from "@/store/cart-store";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartBulkDiscountBase } from "@/store/cart-store";
|
||||
import { SponsorshipLineDetails, SponsorshipThumb } from "@/components/sponsorship-cart-line";
|
||||
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
||||
import WarenkorbLoading from "./loading";
|
||||
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
import { computeBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { ProductWithRelations } from "@/lib/data";
|
||||
|
||||
@@ -52,13 +52,13 @@ export function WarenkorbContent({
|
||||
// Live-Vorschau des serverseitig in api/checkout/route.ts automatisch
|
||||
// angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich
|
||||
// (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten.
|
||||
// Basis ohne Patenschaftspakete, wie im Server (discountBase).
|
||||
const discountable = cartDiscountBase(items);
|
||||
// Basis ohne Patenschaftspakete, wie im Server (bulkDiscountBase); Auswahl
|
||||
// und Betrag ueber dieselbe Funktion wie in der Kasse (computeBulkDiscount).
|
||||
const discountable = cartBulkDiscountBase(items);
|
||||
const hasDiscountable = discountable.quantity > 0;
|
||||
const activeBulkDiscount = !hasDiscountable ? null : resolveBulkDiscount(bulkDiscountTiers, discountable.quantity);
|
||||
const bulkDiscountCents = activeBulkDiscount
|
||||
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||
: 0;
|
||||
const bulkDiscount = computeBulkDiscount(bulkDiscountTiers, discountable);
|
||||
const activeBulkDiscount = bulkDiscount?.tier ?? null;
|
||||
const bulkDiscountCents = bulkDiscount?.discountCents ?? 0;
|
||||
const nextBulkDiscount = !hasDiscountable ? null : getNextBulkDiscountTier(bulkDiscountTiers, discountable.quantity);
|
||||
|
||||
const cartProductIds = new Set(items.map((item) => item.productId));
|
||||
@@ -212,7 +212,7 @@ export function WarenkorbContent({
|
||||
className="h-full rounded-full bg-gradient-to-r from-honey-dark to-honey"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: `${(totalQuantity / (totalQuantity + nextBulkDiscount.unitsUntilNext)) * 100}%`,
|
||||
width: `${(discountable.quantity / (discountable.quantity + nextBulkDiscount.unitsUntilNext)) * 100}%`,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 120, damping: 20 }}
|
||||
/>
|
||||
|
||||
@@ -150,14 +150,19 @@ export async function Footer({
|
||||
{/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite
|
||||
erreichbar und hervorgehoben — deshalb ein eigener Knopf statt
|
||||
eines weiteren Eintrags in der Rechtstext-Spalte. */}
|
||||
<LinkButton href="/vertrag-widerrufen" variant="inverted" className="mt-6 px-5 py-2.5 text-xs">
|
||||
{/* Beide Knoepfe in einer umbrechenden Reihe: passen sie nicht
|
||||
nebeneinander, steht der zweite buendig unter dem ersten statt
|
||||
eingerueckt (frueher sm:ml-3 auch nach dem Umbruch). */}
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<LinkButton href="/vertrag-widerrufen" variant="inverted" className="px-5 py-2.5 text-xs">
|
||||
{t.withdrawalPage.footerButton}
|
||||
</LinkButton>
|
||||
{/* Kuendigungsknopf (§ 312k BGB) fuer Abos — ebenso auf jeder Seite erreichbar. */}
|
||||
<LinkButton href="/vertraege-kuendigen" variant="inverted" className="mt-6 px-5 py-2.5 text-xs sm:ml-3">
|
||||
<LinkButton href="/vertraege-kuendigen" variant="inverted" className="px-5 py-2.5 text-xs">
|
||||
{getAboStrings(locale).footerLink}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
|
||||
<FooterColumn
|
||||
title={t.footer.columnShopping}
|
||||
|
||||
@@ -38,8 +38,9 @@ const buttonStyles: Record<ButtonVariant, string> = {
|
||||
accent: `${fillBase} bg-honey-deep bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
|
||||
};
|
||||
|
||||
// text-center: bricht eine lange Beschriftung um (z.B. "Zu deinen Patenschaften" auf dem Telefon), bleibt sie mittig statt linksbuendig im Knopf.
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-size,border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] active:duration-100";
|
||||
"inline-flex items-center justify-center gap-2 px-7 py-3 text-center text-sm tracking-wide-caps transition-[color,background-size,border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] active:duration-100";
|
||||
|
||||
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
|
||||
variant?: ButtonVariant;
|
||||
|
||||
@@ -1829,6 +1829,7 @@ export const de: Dictionary = {
|
||||
shippingRegionDeNote: "Wir versenden derzeit ausschließlich innerhalb Deutschlands.",
|
||||
discountCodeLabel: "Gutscheincode (optional)",
|
||||
discountCodePlaceholder: "z.B. WABEN10",
|
||||
discountCodePendingNote: (code) => `Gutschein ${code}: wird beim Weiter zur Zahlung geprüft und im Zahlungsschritt abgezogen.`,
|
||||
isGiftLabel: "Das ist ein Geschenk",
|
||||
giftMessageLabel: "Persönliche Nachricht (optional)",
|
||||
giftMessagePlaceholder: "Wird von Hand auf eine Karte geschrieben und der Bestellung beigelegt.",
|
||||
|
||||
@@ -1803,6 +1803,7 @@ export const en: Dictionary = {
|
||||
shippingRegionDeNote: "We currently ship within Germany only.",
|
||||
discountCodeLabel: "Discount code (optional)",
|
||||
discountCodePlaceholder: "e.g. WABEN10",
|
||||
discountCodePendingNote: (code) => `Voucher ${code}: checked when you continue and deducted in the payment step.`,
|
||||
isGiftLabel: "This is a gift",
|
||||
giftMessageLabel: "Personal message (optional)",
|
||||
giftMessagePlaceholder: "Will be handwritten on a card and included with your order.",
|
||||
|
||||
@@ -1342,6 +1342,8 @@ export interface Dictionary {
|
||||
shippingRegionDeNote: string;
|
||||
discountCodeLabel: string;
|
||||
discountCodePlaceholder: string;
|
||||
/** Zusammenfassung, sobald ein Gutscheincode eingetragen ist: der Server prueft ihn und zieht ihn im Zahlungsschritt ab. */
|
||||
discountCodePendingNote: (code: string) => string;
|
||||
isGiftLabel: string;
|
||||
giftMessageLabel: string;
|
||||
giftMessagePlaceholder: string;
|
||||
|
||||
@@ -187,7 +187,7 @@ const de: AccountSponsorshipStrings = {
|
||||
openDetail: (beeName) => `Patenschaft ${beeName} öffnen`,
|
||||
manageSubscription: "Abo verwalten",
|
||||
},
|
||||
kind: { oneTime: "Einmalig", subscription: "Im Abo" },
|
||||
kind: { oneTime: "Einmalig", subscription: "Süßes Abo" },
|
||||
lifecycle: {
|
||||
SCHEDULED: "Beginnt bald",
|
||||
ACTIVE: "Aktiv",
|
||||
@@ -364,7 +364,7 @@ const en: AccountSponsorshipStrings = {
|
||||
openDetail: (beeName) => `Open sponsorship ${beeName}`,
|
||||
manageSubscription: "Manage subscription",
|
||||
},
|
||||
kind: { oneTime: "One-time", subscription: "Subscription" },
|
||||
kind: { oneTime: "One-time", subscription: "Sweet subscription" },
|
||||
lifecycle: {
|
||||
SCHEDULED: "Starts soon",
|
||||
ACTIVE: "Active",
|
||||
|
||||
@@ -187,7 +187,7 @@ const de: SponsorshipShopStrings = {
|
||||
heading: "Wähle dein Paket",
|
||||
toggleLabel: "Art der Patenschaft",
|
||||
once: "Einmalig",
|
||||
abo: "Im Abo",
|
||||
abo: "Süßes Abo",
|
||||
leadOnce: "Zwölf Monate Patenschaft, einmal bezahlt. Sie endet von selbst, ohne Kündigung.",
|
||||
leadAbo: "Monatlich bezahlt und monatlich kündbar. Der Versand ist im Monatspreis enthalten.",
|
||||
priceOnce: "einmalig",
|
||||
@@ -404,7 +404,7 @@ const en: SponsorshipShopStrings = {
|
||||
heading: "Choose your package",
|
||||
toggleLabel: "Type of sponsorship",
|
||||
once: "One-off",
|
||||
abo: "Subscription",
|
||||
abo: "Sweet subscription",
|
||||
leadOnce: "Twelve months of sponsorship, paid once. It ends by itself, no cancellation needed.",
|
||||
leadAbo: "Paid monthly, cancellable monthly. Shipping is included in the monthly price.",
|
||||
priceOnce: "one-off",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { resolveBulkDiscount, getNextBulkDiscountTier } from "./bulk-discount-calc";
|
||||
import { resolveBulkDiscount, getNextBulkDiscountTier, computeBulkDiscount } from "./bulk-discount-calc";
|
||||
import { calculateDiscountCents } from "./discount-calc";
|
||||
|
||||
const tiers = [
|
||||
{ code: "MENGE10", minQuantity: 3, percent: 10 },
|
||||
@@ -46,3 +47,21 @@ describe("getNextBulkDiscountTier", () => {
|
||||
expect(getNextBulkDiscountTier(tiers, 3)).toEqual({ unitsUntilNext: 3, percent: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBulkDiscount", () => {
|
||||
test("ohne rabattfaehige Ware gibt es keinen Mengenrabatt", () => {
|
||||
expect(computeBulkDiscount(tiers, { subtotalCents: 0, quantity: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test("rechnet den Betrag wie der Checkout-Server (calculateDiscountCents)", () => {
|
||||
const result = computeBulkDiscount(tiers, { subtotalCents: 4455, quantity: 5 });
|
||||
expect(result?.tier.code).toBe("MENGE10");
|
||||
expect(result?.discountCents).toBe(calculateDiscountCents({ type: "PERCENT", value: 10 }, 4455));
|
||||
});
|
||||
|
||||
test("ueberspringt eine Stufe, deren Mindestbestellwert nicht erreicht ist, wie der Server", () => {
|
||||
const withMinimum = [tiers[0], { ...tiers[1], minOrderCents: 10000 }];
|
||||
expect(computeBulkDiscount(withMinimum, { subtotalCents: 5940, quantity: 6 })?.tier.code).toBe("MENGE10");
|
||||
expect(computeBulkDiscount(withMinimum, { subtotalCents: 10000, quantity: 6 })?.tier.code).toBe("MENGE15");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { calculateDiscountCents, type DiscountBase } from "./discount-calc";
|
||||
|
||||
export interface BulkDiscountTierLike {
|
||||
code: string;
|
||||
minQuantity: number;
|
||||
percent: number;
|
||||
/** Mindestbestellwert des Codes in Cent (wie DiscountCode.minOrderCents), fehlt = 0. */
|
||||
minOrderCents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,3 +35,21 @@ export function getNextBulkDiscountTier(
|
||||
if (!next) return null;
|
||||
return { unitsUntilNext: next.minQuantity - totalQuantity, percent: next.percent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau des Mengenrabatts fuer Warenkorb und Kasse — dieselbe Auswahl wie
|
||||
* der Checkout-Server: unter allen Stufen, deren Mengenschwelle UND
|
||||
* Mindestbestellwert die Basis erreicht, gewinnt die mit der hoechsten
|
||||
* Schwelle; der Betrag kommt aus calculateDiscountCents. Die Basis ist
|
||||
* bulkDiscountBase bzw. cartBulkDiscountBase (ohne Patenschaften).
|
||||
*/
|
||||
export function computeBulkDiscount(
|
||||
tiers: BulkDiscountTierLike[],
|
||||
base: DiscountBase
|
||||
): { tier: BulkDiscountTierLike; discountCents: number } | null {
|
||||
if (base.quantity <= 0 || base.subtotalCents <= 0) return null;
|
||||
const reachable = tiers.filter((tier) => base.subtotalCents >= (tier.minOrderCents ?? 0));
|
||||
const tier = resolveBulkDiscount(reachable, base.quantity);
|
||||
if (!tier) return null;
|
||||
return { tier, discountCents: calculateDiscountCents({ type: "PERCENT", value: tier.percent }, base.subtotalCents) };
|
||||
}
|
||||
@@ -38,7 +38,20 @@ export async function getBulkDiscountTiers(): Promise<BulkDiscountTierLike[]> {
|
||||
const codes = await prisma.discountCode.findMany({
|
||||
where: { minQuantity: { not: null }, type: "PERCENT", isActive: true },
|
||||
});
|
||||
// Dieselben Bedingungen wie isDiscountCodeValid im Checkout-Server
|
||||
// (Zeitraum, Nutzungslimit; Mindestbestellwert prueft computeBulkDiscount),
|
||||
// damit die Vorschau keinen Rabatt verspricht, den die Kasse nicht gibt.
|
||||
return codes
|
||||
.filter((code) => (!code.validFrom || code.validFrom <= now) && (!code.validUntil || code.validUntil >= now))
|
||||
.map((code) => ({ code: code.code, minQuantity: code.minQuantity as number, percent: code.value }));
|
||||
.filter(
|
||||
(code) =>
|
||||
(!code.validFrom || code.validFrom <= now) &&
|
||||
(!code.validUntil || code.validUntil >= now) &&
|
||||
(code.maxUses === null || code.usedCount < code.maxUses)
|
||||
)
|
||||
.map((code) => ({
|
||||
code: code.code,
|
||||
minQuantity: code.minQuantity as number,
|
||||
percent: code.value,
|
||||
minOrderCents: code.minOrderCents,
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { isDiscountCodeValid, calculateDiscountCents, discountBase, type DiscountCodeLike } from "./discount-calc";
|
||||
import { isDiscountCodeValid, calculateDiscountCents, bulkDiscountBase, discountBaseForCode, type DiscountCodeLike } from "./discount-calc";
|
||||
|
||||
const now = new Date("2026-08-12T12:00:00Z");
|
||||
|
||||
@@ -89,9 +89,9 @@ describe("calculateDiscountCents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("discountBase", () => {
|
||||
describe("bulkDiscountBase", () => {
|
||||
test("zaehlt nur Positionen ohne Patenschaftsrolle (MAIN und HONEY bleiben draussen)", () => {
|
||||
const base = discountBase([
|
||||
const base = bulkDiscountBase([
|
||||
{ totalCents: 1980, quantity: 2 },
|
||||
{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" },
|
||||
{ totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" },
|
||||
@@ -100,7 +100,27 @@ describe("discountBase", () => {
|
||||
expect(base).toEqual({ subtotalCents: 3180, quantity: 3 });
|
||||
});
|
||||
|
||||
test("reiner Patenschafts-Warenkorb hat keine Rabattbasis", () => {
|
||||
expect(discountBase([{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }])).toEqual({ subtotalCents: 0, quantity: 0 });
|
||||
test("reiner Patenschafts-Warenkorb hat keine Mengenrabatt-Basis", () => {
|
||||
expect(bulkDiscountBase([{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }])).toEqual({ subtotalCents: 0, quantity: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("discountBaseForCode", () => {
|
||||
const items = [
|
||||
{ totalCents: 1980, quantity: 2 },
|
||||
{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" },
|
||||
{ totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" },
|
||||
];
|
||||
|
||||
test("ein normaler Gutschein rechnet mit der ganzen Zwischensumme inklusive Patenschaft", () => {
|
||||
expect(discountBaseForCode({ minQuantity: null }, items)).toEqual({ subtotalCents: 7880, quantity: 6 });
|
||||
});
|
||||
|
||||
test("ein normaler Gutschein wirkt auch auf einen reinen Patenschafts-Warenkorb", () => {
|
||||
expect(discountBaseForCode({ minQuantity: null }, items.slice(1))).toEqual({ subtotalCents: 5900, quantity: 4 });
|
||||
});
|
||||
|
||||
test("ein Mengenrabatt-Code (minQuantity gesetzt) zaehlt weiterhin nur Ware ausserhalb von Patenschaften", () => {
|
||||
expect(discountBaseForCode({ minQuantity: 3 }, items)).toEqual({ subtotalCents: 1980, quantity: 2 });
|
||||
});
|
||||
});
|
||||
+30
-13
@@ -44,20 +44,37 @@ export interface DiscountBase {
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
type DiscountableItem = { totalCents: number; quantity: number; sponsorshipRole?: string | null };
|
||||
|
||||
function sumBase(items: ReadonlyArray<DiscountableItem>): DiscountBase {
|
||||
return items.reduce<DiscountBase>(
|
||||
(base, item) => ({ subtotalCents: base.subtotalCents + item.totalCents, quantity: base.quantity + item.quantity }),
|
||||
{ subtotalCents: 0, quantity: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rabattbasis einer Bestellung: nur Ware ausserhalb von Patenschaften.
|
||||
* Patenschaftspositionen (MAIN und der Honiganteil HONEY) haben einen fest
|
||||
* kalkulierten Paketpreis — weder Mengenrabatt-Schwelle noch Gutschein-
|
||||
* Mindestwert noch Rabattbetrag duerfen sie einrechnen, sonst liesse sich mit
|
||||
* einem Paket ein Mengenrabatt auf andere Ware freischalten.
|
||||
* Basis fuer Mengenrabatte (DiscountCode mit minQuantity): nur Ware
|
||||
* ausserhalb von Patenschaften. Patenschaftspositionen (MAIN und der
|
||||
* Honiganteil HONEY) zaehlen weder zur Mengenschwelle noch zum Rabattbetrag —
|
||||
* sonst liesse sich mit einem Paket ein Mengenrabatt auf andere Ware
|
||||
* freischalten.
|
||||
*/
|
||||
export function discountBase(
|
||||
items: ReadonlyArray<{ totalCents: number; quantity: number; sponsorshipRole?: string | null }>
|
||||
export function bulkDiscountBase(items: ReadonlyArray<DiscountableItem>): DiscountBase {
|
||||
return sumBase(items.filter((item) => !item.sponsorshipRole));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rabattbasis fuer einen konkreten Code. Ein eingegebener Gutschein (ohne
|
||||
* minQuantity) wirkt auf die ganze Zwischensumme, Patenschaften
|
||||
* eingeschlossen: Mindestbestellwert und Rabattbetrag rechnen mit allen
|
||||
* Positionen. Ein Mengenrabatt-Code bleibt bei bulkDiscountBase — auch dann,
|
||||
* wenn ihn jemand von Hand eintippt, damit sich die Schwelle nicht ueber
|
||||
* Paketglaeser umgehen laesst.
|
||||
*/
|
||||
export function discountBaseForCode(
|
||||
code: Pick<DiscountCodeLike, "minQuantity">,
|
||||
items: ReadonlyArray<DiscountableItem>
|
||||
): DiscountBase {
|
||||
return items
|
||||
.filter((item) => !item.sponsorshipRole)
|
||||
.reduce((base, item) => ({ subtotalCents: base.subtotalCents + item.totalCents, quantity: base.quantity + item.quantity }), {
|
||||
subtotalCents: 0,
|
||||
quantity: 0,
|
||||
});
|
||||
return code.minQuantity === null ? sumBase(items) : bulkDiscountBase(items);
|
||||
}
|
||||
@@ -209,6 +209,19 @@ function distributeProportionally(amountCents: number, weights: readonly number[
|
||||
* sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze
|
||||
* im selben Verhaeltnis.
|
||||
*
|
||||
* Ein eingegebener Gutschein wirkt auf die ganze Zwischensumme inklusive
|
||||
* Patenschaften (lib/discount-calc.ts, discountBaseForCode) — dafuer ist die
|
||||
* Verteilung ueber alle Positionen exakt richtig. Ein Mengenrabatt rechnet
|
||||
* dagegen nur mit Ware ausserhalb von Patenschaften (bulkDiscountBase), wird
|
||||
* hier aber trotzdem ueber alle Positionen verteilt: die Bestellung haelt
|
||||
* nicht fest, auf welcher Basis ihr Rabatt berechnet wurde (der verknuepfte
|
||||
* DiscountCode ist nachtraeglich editierbar und kann geloescht werden, taugt
|
||||
* also nicht als Beleg). Liegen Patenschaft (Urkunde) und rabattierte Ware
|
||||
* auf verschiedenen Saetzen, verschiebt sich dadurch ein kleiner Teil des
|
||||
* Rabatts zwischen den Saetzen; der Rechnungsbetrag und die Summe aller
|
||||
* Zeilen bleiben exakt. Genau waere es erst mit einem eigenen Feld an der
|
||||
* Bestellung (Rabattbasis), das bewusst noch nicht angelegt ist.
|
||||
*
|
||||
* Zugesichert: die Summe aller grossCents ergibt exakt den Rechnungsbetrag,
|
||||
* und je Zeile gilt netCents + vatCents === grossCents.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
migrateCartState,
|
||||
cartPhysicalQuantity,
|
||||
cartIsDigitalOnly,
|
||||
cartDiscountBase,
|
||||
cartBulkDiscountBase,
|
||||
cartTotalCents,
|
||||
type CartSponsorship,
|
||||
type NewCartItem,
|
||||
} from "./cart-store";
|
||||
@@ -126,12 +127,14 @@ describe("cart store", () => {
|
||||
expect(cartPhysicalQuantity(useCartStore.getState().items)).toBe(5);
|
||||
});
|
||||
|
||||
test("discount base excludes sponsorship packages including their honey (like the checkout server)", () => {
|
||||
test("bulk discount base excludes sponsorship packages including their honey (like the checkout server)", () => {
|
||||
const { addSponsorship, addItem } = useCartStore.getState();
|
||||
addItem(honey, 2);
|
||||
addSponsorship(packageItem, sponsorship("Lotte", { honeyJars: 3 }));
|
||||
|
||||
expect(cartDiscountBase(useCartStore.getState().items)).toEqual({ subtotalCents: 1980, quantity: 2 });
|
||||
expect(cartBulkDiscountBase(useCartStore.getState().items)).toEqual({ subtotalCents: 1980, quantity: 2 });
|
||||
// Ein eingegebener Gutschein rechnet dagegen mit der ganzen Zwischensumme.
|
||||
expect(cartTotalCents(useCartStore.getState().items)).toBe(1980 + 3900);
|
||||
});
|
||||
|
||||
test("a cart with only digital sponsorships is digital-only", () => {
|
||||
|
||||
@@ -175,11 +175,13 @@ export function cartPhysicalQuantity(items: CartItem[]): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rabattbasis wie im Checkout-Server (discountBase): nur Ware ausserhalb von
|
||||
* Patenschaftspaketen — weder die Urkunde noch der Honig eines Pakets zaehlen
|
||||
* fuer Mengenrabatt-Schwelle und Rabattbetrag.
|
||||
* Basis fuer die Mengenrabatt-Vorschau, wie bulkDiscountBase im
|
||||
* Checkout-Server: nur Ware ausserhalb von Patenschaftspaketen — weder die
|
||||
* Urkunde noch der Honig eines Pakets zaehlen fuer Schwelle und Betrag. Ein
|
||||
* eingegebener Gutschein wirkt dagegen auf die ganze Zwischensumme
|
||||
* (cartTotalCents), siehe discountBaseForCode.
|
||||
*/
|
||||
export function cartDiscountBase(items: CartItem[]): { subtotalCents: number; quantity: number } {
|
||||
export function cartBulkDiscountBase(items: CartItem[]): { subtotalCents: number; quantity: number } {
|
||||
return items
|
||||
.filter((item) => !item.sponsorship)
|
||||
.reduce(
|
||||
|
||||
Reference in new issue
Block a user