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

- 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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 13:26:26 +02:00
1 parent dd180563ca
commit e5b6736dcb
28 files changed
+332 -100

No files matched your search

+1 -1
View File
@@ -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 - **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 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 - **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 - **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`) - **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. - **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.
+60 -16
View File
@@ -429,7 +429,8 @@ describe("Checkout-Route", () => {
describe("Rabatte und Patenschaften", () => { describe("Rabatte und Patenschaften", () => {
// Paket "Wabenfreund": 38,00 € Patenschaft (MAIN) + 3 Glaeser Honiganteil // 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 carrier = { ...honey, id: "carrier", name: "Wabenfreund", slug: "wabenfreund", priceCents: 3800, weightGrams: 0, category: "PATENSCHAFT" };
const offer = { const offer = {
id: "offer", id: "offer",
@@ -532,29 +533,19 @@ describe("Checkout-Route", () => {
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 495 })); 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 }); 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" }); couponsCreate.mockResolvedValue({ id: "coupon_1" });
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] }); const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(198); expect(db.order.create).toHaveBeenCalled();
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 198 }));
}); });
test("Gutscheincode bei reinem Patenschafts-Warenkorb ist ungueltig", async () => { test("Gutscheincode: Mindestbestellwert unterschritten bleibt ungueltig", async () => {
db.discountCode.findUnique.mockResolvedValue(discountCode); db.discountCode.findUnique.mockResolvedValue({ ...discountCode, minOrderCents: 6000 });
const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] }); const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] });
@@ -563,6 +554,59 @@ describe("Checkout-Route", () => {
expect(db.order.create).not.toHaveBeenCalled(); 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 () => { test("Stripe-Positionsname nennt das Gewicht nur bei Ware mit Gewicht", async () => {
await post({ ...validPayload, items: [{ productId: "p1", quantity: 1 }, packageItem] }); await post({ ...validPayload, items: [{ productId: "p1", quantity: 1 }, packageItem] });
+11 -8
View File
@@ -11,7 +11,7 @@ import { isTrustedOrigin } from "@/lib/csrf";
import { verifyTurnstileToken } from "@/lib/turnstile"; import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping"; import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region"; 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 { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release"; import { releaseUnpaidOrder } from "@/lib/order-release";
import { import {
@@ -352,10 +352,10 @@ export async function POST(request: Request) {
const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId)); const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand — // Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
// haelt die Rabattlogik einfach nachvollziehbar. Und nur auf Ware // haelt die Rabattlogik einfach nachvollziehbar. Ein eingegebener Gutschein
// ausserhalb von Patenschaften (discountBase): Schwelle, Mindestwert und // wirkt auf die ganze Zwischensumme inklusive Patenschaften; Mengenrabatte
// Betrag rechnen ohne Paketpositionen. // rechnen weiter nur mit Ware ausserhalb von Patenschaften
const base = discountBase(orderItemsData); // (discountBaseForCode / bulkDiscountBase in lib/discount-calc.ts).
let discountCents = 0; let discountCents = 0;
let discountCode: { id: string; code: string } | null = null; let discountCode: { id: string; code: string } | null = null;
if (input.discountCode) { if (input.discountCode) {
@@ -363,10 +363,12 @@ export async function POST(request: Request) {
const code = await prisma.discountCode.findUnique({ const code = await prisma.discountCode.findUnique({
where: { code: input.discountCode.trim().toUpperCase() }, where: { code: input.discountCode.trim().toUpperCase() },
}); });
const base = code ? discountBaseForCode(code, orderItemsData) : null;
// Ein reiner Patenschafts-Warenkorb hat keine Rabattbasis — der Code // Ohne Rabattbasis (z.B. ein von Hand eingegebener Mengenrabatt-Code bei
// gilt dann als nicht einloesbar statt still 0 € abzuziehen. // einem reinen Patenschafts-Warenkorb) gilt der Code als nicht
if (!code || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) { // einloesbar, statt still 0 € abzuziehen.
if (!code || !base || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) {
return NextResponse.json( return NextResponse.json(
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." }, { error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
{ status: 400 } { status: 400 }
@@ -394,6 +396,7 @@ export async function POST(request: Request) {
discountCents = calculateDiscountCents(code, base.subtotalCents); discountCents = calculateDiscountCents(code, base.subtotalCents);
discountCode = code; discountCode = code;
} else { } else {
const base = bulkDiscountBase(orderItemsData);
// Kein manuell eingegebener Code — unter allen im Admin-Bereich // Kein manuell eingegebener Code — unter allen im Admin-Bereich
// gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden // gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden
// waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig, // waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig,
+28 -2
View File
@@ -50,13 +50,17 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
...overrides, ...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 }); useCartStore.setState({ items, isOpen: false });
return render( return render(
<I18nProvider locale="de"> <I18nProvider locale="de">
<CheckoutForm <CheckoutForm
tiers={tiers} tiers={tiers}
bulkDiscountTiers={bulkDiscountTiers} bulkDiscountTiers={discountTiers}
stripePublishableKey={stripePublishableKey} stripePublishableKey={stripePublishableKey}
/> />
</I18nProvider> </I18nProvider>
@@ -120,6 +124,28 @@ describe("CheckoutForm", () => {
expect(screen.getByText("kostenlos")).toBeInTheDocument(); 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), * Regression: Antwortet /api/checkout mit 502 (Stripe nicht erreichbar),
* schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir * schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir
+24 -8
View File
@@ -5,14 +5,14 @@ import dynamic from "next/dynamic";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges"; import { TrustBadges } from "@/components/trust-badges";
import Link from "next/link"; 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 { SponsorshipLineDetails } from "@/components/sponsorship-cart-line";
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop"; import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import { loginPathWithNext } from "@/lib/safe-next-path"; import { loginPathWithNext } from "@/lib/safe-next-path";
import CheckoutLoading from "./loading"; import CheckoutLoading from "./loading";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc"; 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 { useI18n } from "@/i18n/i18n-provider";
import { TurnstileWidget } from "@/components/turnstile-widget"; import { TurnstileWidget } from "@/components/turnstile-widget";
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region"; import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
@@ -140,6 +140,8 @@ export function CheckoutForm({
const [country, setCountry] = useState(countryCodes[0]); const [country, setCountry] = useState(countryCodes[0]);
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND"); const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false); 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); const [error, setError] = useState<string | null>(null);
// 401 vom Server: eine Patenschaft im Warenkorb braucht ein Konto. Dann // 401 vom Server: eine Patenschaft im Warenkorb braucht ein Konto. Dann
// steht neben der Meldung ein Weg zur Anmeldung, der hierher zurueckfuehrt. // 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. // die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
const shipping = const shipping =
digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity); digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
// Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie discountBase im Server). // Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie
const discountable = cartDiscountBase(items); // bulkDiscountBase im Server). Sobald ein Gutscheincode eingetragen ist,
const activeBulkDiscount = discountable.quantity > 0 ? resolveBulkDiscount(bulkDiscountTiers, discountable.quantity) : null; // wendet der Server statt des Mengenrabatts den Gutschein an — auf die ganze
const bulkDiscountCents = activeBulkDiscount // Zwischensumme inklusive Patenschaft. Ob er gueltig ist, weiss erst der
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100) // Server; die Vorschau zeigt dann keinen Mengenrabatt mehr, sondern den
: 0; // 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; const total = subtotal - bulkDiscountCents + shipping;
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -639,6 +645,7 @@ export function CheckoutForm({
label={t.checkoutPage.discountCodeLabel} label={t.checkoutPage.discountCodeLabel}
placeholder={t.checkoutPage.discountCodePlaceholder} placeholder={t.checkoutPage.discountCodePlaceholder}
autoComplete="off" autoComplete="off"
onValueChange={setEnteredCode}
/> />
<div> <div>
@@ -729,6 +736,11 @@ export function CheckoutForm({
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span> <span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div> </div>
) : null} ) : null}
{hasEnteredCode ? (
<p className="text-heading" aria-live="polite">
{t.checkoutPage.discountCodePendingNote(enteredCode.trim().toUpperCase())}
</p>
) : null}
</div> </div>
<div className="mt-4 pt-4 border-t border-ink/10 flex items-baseline justify-between text-heading"> <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> <span className="text-base">{t.cartPage.total}</span>
@@ -750,6 +762,7 @@ function Field({
placeholder, placeholder,
autoComplete, autoComplete,
required = false, required = false,
onValueChange,
}: { }: {
name: string; name: string;
label: 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. */ /** Browser-/Mobil-Autofill fuer Adress- und Kontaktfelder (siehe MDN autofill-Tokens) — ohne das muss jede Bestellung komplett von Hand eingetippt werden. */
autoComplete?: string; autoComplete?: string;
required?: boolean; required?: boolean;
/** Meldet jede Eingabe nach oben, ohne das Feld zu einem kontrollierten Feld zu machen. */
onValueChange?: (value: string) => void;
}) { }) {
return ( return (
<label className="block text-sm"> <label className="block text-sm">
@@ -773,6 +788,7 @@ function Field({
placeholder={placeholder} placeholder={placeholder}
autoComplete={autoComplete} autoComplete={autoComplete}
required={required} 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" className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
/> />
</label> </label>
+11
View File
@@ -314,6 +314,17 @@ textarea::placeholder {
color: color-mix(in oklab, var(--color-ink) 45%, transparent); 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. */ /* Chargen-Seite: das Echtheitssiegel "stempelt" sich beim Oeffnen ein. */
@keyframes wh-stamp { @keyframes wh-stamp {
0% { 0% {
+4 -2
View File
@@ -73,7 +73,8 @@ export function CertificatePreview({
/> />
<span <span
aria-hidden="true" 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"> <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" /> <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" 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))]"> <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 */} {/* 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"> <form method="dialog" className="absolute right-6 top-6">
<button <button
type="submit" type="submit"
+2 -1
View File
@@ -71,7 +71,8 @@ export function OfferStep({
className="py-4" 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="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 font-serif text-2xl leading-tight text-heading">{offer.name}</span>
<span className="block text-xs text-ink/60"> <span className="block text-xs text-ink/60">
{!offer.isAvailable {!offer.isAvailable
+2 -1
View File
@@ -322,7 +322,8 @@ export function SponsorshipConfigurator({
stepOf={c.stepOf} 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 ? ( {aboSession && stripePublishableKey ? (
<AboPayment <AboPayment
publishableKey={stripePublishableKey} publishableKey={stripePublishableKey}
+5 -3
View File
@@ -4,7 +4,7 @@ import { useRef, type KeyboardEvent } from "react";
import type { OfferKind } from "./draft-store"; 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 * Tab springt hinein und wieder heraus (WAI-ARIA Radio Group). Der Schieber
* gleitet zur Auswahl — die einzige Bewegung des Umschalters, bei * gleitet zur Auswahl — die einzige Bewegung des Umschalters, bei
* reduzierter Bewegung springt er. * reduzierter Bewegung springt er.
@@ -39,7 +39,9 @@ export function KindToggle({
<div <div
role="radiogroup" role="radiogroup"
aria-label={ariaLabel} 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 <span
aria-hidden="true" aria-hidden="true"
@@ -60,7 +62,7 @@ export function KindToggle({
tabIndex={checked ? 0 : -1} tabIndex={checked ? 0 : -1}
onClick={() => onChange(option)} onClick={() => onChange(option)}
onKeyDown={(event) => onKeyDown(event, index)} 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" checked ? "text-cream" : "text-ink/70 hover:text-heading"
}`} }`}
> >
+10 -7
View File
@@ -50,7 +50,7 @@ export function OfferShowcase({
return ( return (
<div> <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"> <div className="max-w-xl">
<h2 className="text-4xl leading-[1.05] tracking-[-0.01em] text-heading md:text-5xl">{strings.offers.heading}</h2> <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"> <p className="mt-4 leading-relaxed text-ink/70">
@@ -63,13 +63,16 @@ export function OfferShowcase({
onChange={setKind} onChange={setKind}
labels={{ EINMALIG: strings.offers.once, ABO: strings.offers.abo }} labels={{ EINMALIG: strings.offers.once, ABO: strings.offers.abo }}
ariaLabel={strings.offers.toggleLabel} ariaLabel={strings.offers.toggleLabel}
className="self-start md:self-auto" className="shrink-0 self-start lg:self-auto"
/> />
) : null} ) : null}
</div> </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 <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) => ( {offers.map((offer) => (
<li key={offer.slug} className="flex"> <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"> <h3 id={titleId} className="mt-6 font-serif text-3xl leading-tight text-heading">
{offer.name} {offer.name}
</h3> </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"> <span className="font-serif text-5xl leading-none text-heading tabular-nums">
{formatPrice(offer.priceCents, "EUR", locale)} {formatPrice(offer.priceCents, "EUR", locale)}
</span> </span>
@@ -139,7 +142,7 @@ function OfferCard({
{offer.features.map((feature) => ( {offer.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5"> <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" /> <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> </li>
))} ))}
</ul> </ul>
@@ -159,7 +162,7 @@ function OfferCard({
type="button" type="button"
onClick={onChoose} onClick={onChoose}
disabled={soldOut} 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} {soldOut ? strings.offers.soldOut : strings.offers.choose}
</button> </button>
+6 -2
View File
@@ -56,8 +56,12 @@ export function StepIndicator({
</text> </text>
)} )}
</svg> </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 <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" 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"> <span className="flex min-h-11 items-center">
{marker} {marker}
<span className="sr-only md:hidden">{label}</span> <span className="sr-only md:hidden lg:inline xl:hidden">{label}</span>
</span> </span>
)} )}
{index < labels.length - 1 ? ( {index < labels.length - 1 ? (
+9 -9
View File
@@ -8,14 +8,14 @@ import { TrustBadges } from "@/components/trust-badges";
import { CartItemThumbnail } from "@/components/cart-item-thumbnail"; import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
import { QuantityStepper } from "@/components/quantity-stepper"; import { QuantityStepper } from "@/components/quantity-stepper";
import { ProductCard } from "@/components/product-card"; 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 { SponsorshipLineDetails, SponsorshipThumb } from "@/components/sponsorship-cart-line";
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop"; import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import WarenkorbLoading from "./loading"; import WarenkorbLoading from "./loading";
import { useCartStock } from "@/hooks/use-cart-stock"; import { useCartStock } from "@/hooks/use-cart-stock";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc"; 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 { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data"; import type { ProductWithRelations } from "@/lib/data";
@@ -52,13 +52,13 @@ export function WarenkorbContent({
// Live-Vorschau des serverseitig in api/checkout/route.ts automatisch // Live-Vorschau des serverseitig in api/checkout/route.ts automatisch
// angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich // angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich
// (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten. // (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten.
// Basis ohne Patenschaftspakete, wie im Server (discountBase). // Basis ohne Patenschaftspakete, wie im Server (bulkDiscountBase); Auswahl
const discountable = cartDiscountBase(items); // und Betrag ueber dieselbe Funktion wie in der Kasse (computeBulkDiscount).
const discountable = cartBulkDiscountBase(items);
const hasDiscountable = discountable.quantity > 0; const hasDiscountable = discountable.quantity > 0;
const activeBulkDiscount = !hasDiscountable ? null : resolveBulkDiscount(bulkDiscountTiers, discountable.quantity); const bulkDiscount = computeBulkDiscount(bulkDiscountTiers, discountable);
const bulkDiscountCents = activeBulkDiscount const activeBulkDiscount = bulkDiscount?.tier ?? null;
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100) const bulkDiscountCents = bulkDiscount?.discountCents ?? 0;
: 0;
const nextBulkDiscount = !hasDiscountable ? null : getNextBulkDiscountTier(bulkDiscountTiers, discountable.quantity); const nextBulkDiscount = !hasDiscountable ? null : getNextBulkDiscountTier(bulkDiscountTiers, discountable.quantity);
const cartProductIds = new Set(items.map((item) => item.productId)); 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" className="h-full rounded-full bg-gradient-to-r from-honey-dark to-honey"
initial={false} initial={false}
animate={{ animate={{
width: `${(totalQuantity / (totalQuantity + nextBulkDiscount.unitsUntilNext)) * 100}%`, width: `${(discountable.quantity / (discountable.quantity + nextBulkDiscount.unitsUntilNext)) * 100}%`,
}} }}
transition={{ type: "spring", stiffness: 120, damping: 20 }} transition={{ type: "spring", stiffness: 120, damping: 20 }}
/> />
+12 -7
View File
@@ -150,13 +150,18 @@ export async function Footer({
{/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite {/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite
erreichbar und hervorgehoben — deshalb ein eigener Knopf statt erreichbar und hervorgehoben — deshalb ein eigener Knopf statt
eines weiteren Eintrags in der Rechtstext-Spalte. */} 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
{t.withdrawalPage.footerButton} nebeneinander, steht der zweite buendig unter dem ersten statt
</LinkButton> eingerueckt (frueher sm:ml-3 auch nach dem Umbruch). */}
{/* Kuendigungsknopf (§ 312k BGB) fuer Abos — ebenso auf jeder Seite erreichbar. */} <div className="mt-6 flex flex-wrap gap-3">
<LinkButton href="/vertraege-kuendigen" variant="inverted" className="mt-6 px-5 py-2.5 text-xs sm:ml-3"> <LinkButton href="/vertrag-widerrufen" variant="inverted" className="px-5 py-2.5 text-xs">
{getAboStrings(locale).footerLink} {t.withdrawalPage.footerButton}
</LinkButton> </LinkButton>
{/* Kuendigungsknopf (§ 312k BGB) fuer Abos — ebenso auf jeder Seite erreichbar. */}
<LinkButton href="/vertraege-kuendigen" variant="inverted" className="px-5 py-2.5 text-xs">
{getAboStrings(locale).footerLink}
</LinkButton>
</div>
</div> </div>
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} /> <FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
<FooterColumn <FooterColumn
+2 -1
View File
@@ -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`, 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 = 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> { interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
variant?: ButtonVariant; variant?: ButtonVariant;
+1
View File
@@ -1829,6 +1829,7 @@ export const de: Dictionary = {
shippingRegionDeNote: "Wir versenden derzeit ausschließlich innerhalb Deutschlands.", shippingRegionDeNote: "Wir versenden derzeit ausschließlich innerhalb Deutschlands.",
discountCodeLabel: "Gutscheincode (optional)", discountCodeLabel: "Gutscheincode (optional)",
discountCodePlaceholder: "z.B. WABEN10", discountCodePlaceholder: "z.B. WABEN10",
discountCodePendingNote: (code) => `Gutschein ${code}: wird beim Weiter zur Zahlung geprüft und im Zahlungsschritt abgezogen.`,
isGiftLabel: "Das ist ein Geschenk", isGiftLabel: "Das ist ein Geschenk",
giftMessageLabel: "Persönliche Nachricht (optional)", giftMessageLabel: "Persönliche Nachricht (optional)",
giftMessagePlaceholder: "Wird von Hand auf eine Karte geschrieben und der Bestellung beigelegt.", giftMessagePlaceholder: "Wird von Hand auf eine Karte geschrieben und der Bestellung beigelegt.",
+1
View File
@@ -1803,6 +1803,7 @@ export const en: Dictionary = {
shippingRegionDeNote: "We currently ship within Germany only.", shippingRegionDeNote: "We currently ship within Germany only.",
discountCodeLabel: "Discount code (optional)", discountCodeLabel: "Discount code (optional)",
discountCodePlaceholder: "e.g. WABEN10", discountCodePlaceholder: "e.g. WABEN10",
discountCodePendingNote: (code) => `Voucher ${code}: checked when you continue and deducted in the payment step.`,
isGiftLabel: "This is a gift", isGiftLabel: "This is a gift",
giftMessageLabel: "Personal message (optional)", giftMessageLabel: "Personal message (optional)",
giftMessagePlaceholder: "Will be handwritten on a card and included with your order.", giftMessagePlaceholder: "Will be handwritten on a card and included with your order.",
+2
View File
@@ -1342,6 +1342,8 @@ export interface Dictionary {
shippingRegionDeNote: string; shippingRegionDeNote: string;
discountCodeLabel: string; discountCodeLabel: string;
discountCodePlaceholder: 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; isGiftLabel: string;
giftMessageLabel: string; giftMessageLabel: string;
giftMessagePlaceholder: string; giftMessagePlaceholder: string;
+2 -2
View File
@@ -187,7 +187,7 @@ const de: AccountSponsorshipStrings = {
openDetail: (beeName) => `Patenschaft ${beeName} öffnen`, openDetail: (beeName) => `Patenschaft ${beeName} öffnen`,
manageSubscription: "Abo verwalten", manageSubscription: "Abo verwalten",
}, },
kind: { oneTime: "Einmalig", subscription: "Im Abo" }, kind: { oneTime: "Einmalig", subscription: "Süßes Abo" },
lifecycle: { lifecycle: {
SCHEDULED: "Beginnt bald", SCHEDULED: "Beginnt bald",
ACTIVE: "Aktiv", ACTIVE: "Aktiv",
@@ -364,7 +364,7 @@ const en: AccountSponsorshipStrings = {
openDetail: (beeName) => `Open sponsorship ${beeName}`, openDetail: (beeName) => `Open sponsorship ${beeName}`,
manageSubscription: "Manage subscription", manageSubscription: "Manage subscription",
}, },
kind: { oneTime: "One-time", subscription: "Subscription" }, kind: { oneTime: "One-time", subscription: "Sweet subscription" },
lifecycle: { lifecycle: {
SCHEDULED: "Starts soon", SCHEDULED: "Starts soon",
ACTIVE: "Active", ACTIVE: "Active",
+2 -2
View File
@@ -187,7 +187,7 @@ const de: SponsorshipShopStrings = {
heading: "Wähle dein Paket", heading: "Wähle dein Paket",
toggleLabel: "Art der Patenschaft", toggleLabel: "Art der Patenschaft",
once: "Einmalig", once: "Einmalig",
abo: "Im Abo", abo: "Süßes Abo",
leadOnce: "Zwölf Monate Patenschaft, einmal bezahlt. Sie endet von selbst, ohne Kündigung.", 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.", leadAbo: "Monatlich bezahlt und monatlich kündbar. Der Versand ist im Monatspreis enthalten.",
priceOnce: "einmalig", priceOnce: "einmalig",
@@ -404,7 +404,7 @@ const en: SponsorshipShopStrings = {
heading: "Choose your package", heading: "Choose your package",
toggleLabel: "Type of sponsorship", toggleLabel: "Type of sponsorship",
once: "One-off", once: "One-off",
abo: "Subscription", abo: "Sweet subscription",
leadOnce: "Twelve months of sponsorship, paid once. It ends by itself, no cancellation needed.", 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.", leadAbo: "Paid monthly, cancellable monthly. Shipping is included in the monthly price.",
priceOnce: "one-off", priceOnce: "one-off",
+20 -1
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from "vitest"; 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 = [ const tiers = [
{ code: "MENGE10", minQuantity: 3, percent: 10 }, { code: "MENGE10", minQuantity: 3, percent: 10 },
@@ -46,3 +47,21 @@ describe("getNextBulkDiscountTier", () => {
expect(getNextBulkDiscountTier(tiers, 3)).toEqual({ unitsUntilNext: 3, percent: 15 }); 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");
});
});
+22
View File
@@ -1,7 +1,11 @@
import { calculateDiscountCents, type DiscountBase } from "./discount-calc";
export interface BulkDiscountTierLike { export interface BulkDiscountTierLike {
code: string; code: string;
minQuantity: number; minQuantity: number;
percent: 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; if (!next) return null;
return { unitsUntilNext: next.minQuantity - totalQuantity, percent: next.percent }; 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) };
}
+15 -2
View File
@@ -38,7 +38,20 @@ export async function getBulkDiscountTiers(): Promise<BulkDiscountTierLike[]> {
const codes = await prisma.discountCode.findMany({ const codes = await prisma.discountCode.findMany({
where: { minQuantity: { not: null }, type: "PERCENT", isActive: true }, 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 return codes
.filter((code) => (!code.validFrom || code.validFrom <= now) && (!code.validUntil || code.validUntil >= now)) .filter(
.map((code) => ({ code: code.code, minQuantity: code.minQuantity as number, percent: code.value })); (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,
}));
} }
+25 -5
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest"; 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"); 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)", () => { test("zaehlt nur Positionen ohne Patenschaftsrolle (MAIN und HONEY bleiben draussen)", () => {
const base = discountBase([ const base = bulkDiscountBase([
{ totalCents: 1980, quantity: 2 }, { totalCents: 1980, quantity: 2 },
{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }, { totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" },
{ totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" }, { totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" },
@@ -100,7 +100,27 @@ describe("discountBase", () => {
expect(base).toEqual({ subtotalCents: 3180, quantity: 3 }); expect(base).toEqual({ subtotalCents: 3180, quantity: 3 });
}); });
test("reiner Patenschafts-Warenkorb hat keine Rabattbasis", () => { test("reiner Patenschafts-Warenkorb hat keine Mengenrabatt-Basis", () => {
expect(discountBase([{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }])).toEqual({ subtotalCents: 0, quantity: 0 }); 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
View File
@@ -44,20 +44,37 @@ export interface DiscountBase {
quantity: number; 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. * Basis fuer Mengenrabatte (DiscountCode mit minQuantity): nur Ware
* Patenschaftspositionen (MAIN und der Honiganteil HONEY) haben einen fest * ausserhalb von Patenschaften. Patenschaftspositionen (MAIN und der
* kalkulierten Paketpreis — weder Mengenrabatt-Schwelle noch Gutschein- * Honiganteil HONEY) zaehlen weder zur Mengenschwelle noch zum Rabattbetrag —
* Mindestwert noch Rabattbetrag duerfen sie einrechnen, sonst liesse sich mit * sonst liesse sich mit einem Paket ein Mengenrabatt auf andere Ware
* einem Paket ein Mengenrabatt auf andere Ware freischalten. * freischalten.
*/ */
export function discountBase( export function bulkDiscountBase(items: ReadonlyArray<DiscountableItem>): DiscountBase {
items: ReadonlyArray<{ totalCents: number; quantity: number; sponsorshipRole?: string | null }> 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 { ): DiscountBase {
return items return code.minQuantity === null ? sumBase(items) : bulkDiscountBase(items);
.filter((item) => !item.sponsorshipRole)
.reduce((base, item) => ({ subtotalCents: base.subtotalCents + item.totalCents, quantity: base.quantity + item.quantity }), {
subtotalCents: 0,
quantity: 0,
});
} }
+13
View File
@@ -209,6 +209,19 @@ function distributeProportionally(amountCents: number, weights: readonly number[
* sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze * sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze
* im selben Verhaeltnis. * 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, * Zugesichert: die Summe aller grossCents ergibt exakt den Rechnungsbetrag,
* und je Zeile gilt netCents + vatCents === grossCents. * und je Zeile gilt netCents + vatCents === grossCents.
*/ */
+6 -3
View File
@@ -4,7 +4,8 @@ import {
migrateCartState, migrateCartState,
cartPhysicalQuantity, cartPhysicalQuantity,
cartIsDigitalOnly, cartIsDigitalOnly,
cartDiscountBase, cartBulkDiscountBase,
cartTotalCents,
type CartSponsorship, type CartSponsorship,
type NewCartItem, type NewCartItem,
} from "./cart-store"; } from "./cart-store";
@@ -126,12 +127,14 @@ describe("cart store", () => {
expect(cartPhysicalQuantity(useCartStore.getState().items)).toBe(5); 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(); const { addSponsorship, addItem } = useCartStore.getState();
addItem(honey, 2); addItem(honey, 2);
addSponsorship(packageItem, sponsorship("Lotte", { honeyJars: 3 })); 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", () => { test("a cart with only digital sponsorships is digital-only", () => {
+6 -4
View File
@@ -175,11 +175,13 @@ export function cartPhysicalQuantity(items: CartItem[]): number {
} }
/** /**
* Rabattbasis wie im Checkout-Server (discountBase): nur Ware ausserhalb von * Basis fuer die Mengenrabatt-Vorschau, wie bulkDiscountBase im
* Patenschaftspaketen — weder die Urkunde noch der Honig eines Pakets zaehlen * Checkout-Server: nur Ware ausserhalb von Patenschaftspaketen — weder die
* fuer Mengenrabatt-Schwelle und Rabattbetrag. * 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 return items
.filter((item) => !item.sponsorship) .filter((item) => !item.sponsorship)
.reduce( .reduce(