From e5b6736dcbb98a9fdfbb2def386fe445f24c6c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 13:26:26 +0200 Subject: [PATCH] feat: Gutscheine gelten fuer Patenschaften, Layout der Patenschaftsseiten, "Suesses Abo" - 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) --- README.md | 2 +- src/app/api/checkout/route.test.ts | 76 ++++++++++++++++----- src/app/api/checkout/route.ts | 19 +++--- src/app/checkout/checkout-form.test.tsx | 30 +++++++- src/app/checkout/checkout-form.tsx | 32 ++++++--- src/app/globals.css | 11 +++ src/app/patenschaft/certificate-preview.tsx | 6 +- src/app/patenschaft/configurator-steps.tsx | 3 +- src/app/patenschaft/configurator.tsx | 3 +- src/app/patenschaft/kind-toggle.tsx | 8 ++- src/app/patenschaft/offer-showcase.tsx | 17 +++-- src/app/patenschaft/step-indicator.tsx | 8 ++- src/app/warenkorb/warenkorb-content.tsx | 18 ++--- src/components/site-footer.tsx | 19 ++++-- src/components/ui.tsx | 3 +- src/i18n/dictionaries/de.ts | 1 + src/i18n/dictionaries/en.ts | 1 + src/i18n/dictionary-type.ts | 2 + src/i18n/sponsorship/account.ts | 4 +- src/i18n/sponsorship/shop.ts | 4 +- src/lib/bulk-discount-calc.test.ts | 21 +++++- src/lib/bulk-discount-calc.ts | 22 ++++++ src/lib/bulk-discount.ts | 17 ++++- src/lib/discount-calc.test.ts | 30 ++++++-- src/lib/discount-calc.ts | 43 ++++++++---- src/lib/vat.ts | 13 ++++ src/store/cart-store.test.ts | 9 ++- src/store/cart-store.ts | 10 +-- 28 files changed, 332 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 7ff4230..40f9a58 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app/api/checkout/route.test.ts b/src/app/api/checkout/route.test.ts index 6b02567..80e4559 100644 --- a/src/app/api/checkout/route.test.ts +++ b/src/app/api/checkout/route.test.ts @@ -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] }); diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index d6c42b4..e8a1fa3 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -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, diff --git a/src/app/checkout/checkout-form.test.tsx b/src/app/checkout/checkout-form.test.tsx index ab9d927..1b672ab 100644 --- a/src/app/checkout/checkout-form.test.tsx +++ b/src/app/checkout/checkout-form.test.tsx @@ -50,13 +50,17 @@ const item = (overrides: Partial = {}): 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( @@ -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 diff --git a/src/app/checkout/checkout-form.tsx b/src/app/checkout/checkout-form.tsx index 7b971ba..7eba38c 100644 --- a/src/app/checkout/checkout-form.tsx +++ b/src/app/checkout/checkout-form.tsx @@ -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("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(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) { @@ -639,6 +645,7 @@ export function CheckoutForm({ label={t.checkoutPage.discountCodeLabel} placeholder={t.checkoutPage.discountCodePlaceholder} autoComplete="off" + onValueChange={setEnteredCode} />
@@ -729,6 +736,11 @@ export function CheckoutForm({ -{formatPrice(bulkDiscountCents, "EUR", locale)}
) : null} + {hasEnteredCode ? ( +

+ {t.checkoutPage.discountCodePendingNote(enteredCode.trim().toUpperCase())} +

+ ) : null}
{t.cartPage.total} @@ -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 ( diff --git a/src/app/globals.css b/src/app/globals.css index 4afcc55..a271d9f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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% { diff --git a/src/app/patenschaft/certificate-preview.tsx b/src/app/patenschaft/certificate-preview.tsx index ef57f7c..fb35d79 100644 --- a/src/app/patenschaft/certificate-preview.tsx +++ b/src/app/patenschaft/certificate-preview.tsx @@ -73,7 +73,8 @@ export function CertificatePreview({ />