Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC) - Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache - Rabatte gelten nicht fuer Patenschaftspositionen - Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten - Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt - Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich) - Shop zeigt freien Bestand nach Abo-Reservierung - Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN) - Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %) - Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin, Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
259 lines
13 KiB
TypeScript
259 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import Link from "next/link";
|
|
import { HexCheck } from "@/components/honeycomb";
|
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
|
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 { 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 { useI18n } from "@/i18n/i18n-provider";
|
|
import type { ProductWithRelations } from "@/lib/data";
|
|
|
|
const CROSS_SELL_LIMIT = 3;
|
|
|
|
export function WarenkorbContent({
|
|
tiers,
|
|
availableProducts,
|
|
bulkDiscountTiers,
|
|
}: {
|
|
tiers: ShippingTierLike[];
|
|
availableProducts: ProductWithRelations[];
|
|
bulkDiscountTiers: BulkDiscountTierLike[];
|
|
}) {
|
|
const { t, locale } = useI18n();
|
|
const sponsorshipStrings = getSponsorshipShopStrings(locale);
|
|
const items = useCartStore((s) => s.items);
|
|
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
|
const setQuantity = useCartStore((s) => s.setQuantity);
|
|
const removeItem = useCartStore((s) => s.removeItem);
|
|
// Live-Bestand je Position — derselbe Hinweis wie auf der Produktkarte/
|
|
// -detailseite, aber hier fuer den ggf. seit dem Hinzufuegen veraenderten
|
|
// aktuellen Bestand (siehe use-cart-stock.ts).
|
|
const stock = useCartStock([...new Set(items.map((item) => item.productId))]);
|
|
const subtotal = cartTotalCents(items);
|
|
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
|
|
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
|
|
// Nur Glaeser zaehlen: eine Urkunde ohne Paket loest keinen Versand aus
|
|
// (wie physicalQuantity im Checkout-Server).
|
|
const totalQuantity = cartPhysicalQuantity(items);
|
|
const hasPhysical = totalQuantity > 0;
|
|
const shipping = !hasPhysical ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
|
const nextTier = !hasPhysical ? null : getNextShippingTier(tiers, totalQuantity);
|
|
// 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);
|
|
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 nextBulkDiscount = !hasDiscountable ? null : getNextBulkDiscountTier(bulkDiscountTiers, discountable.quantity);
|
|
|
|
const cartProductIds = new Set(items.map((item) => item.productId));
|
|
const crossSellProducts = availableProducts
|
|
.filter((product) => !cartProductIds.has(product.id))
|
|
.slice(0, CROSS_SELL_LIMIT);
|
|
// Fuer den leeren Warenkorb: Honig zuerst, sonst was gerade verfuegbar ist.
|
|
const honeyFirst = availableProducts.filter((product) => product.category === "HONIG");
|
|
const emptyCartSuggestions = (honeyFirst.length > 0 ? honeyFirst : availableProducts).slice(0, 2);
|
|
|
|
// Siehe checkout-form.tsx: ohne diese Weiche flackert kurz die
|
|
// "leer"-Meldung auf, bevor der Cart-Store aus localStorage nachliest.
|
|
if (!hasHydrated) {
|
|
return <WarenkorbLoading />;
|
|
}
|
|
|
|
return (
|
|
<div className="py-16">
|
|
<Container>
|
|
<SectionHeading title={t.cartPage.title} />
|
|
|
|
{items.length === 0 ? (
|
|
// Leerer Warenkorb ist kein Endpunkt: daneben stehen die aktuell
|
|
// verfuegbaren Honige, statt nur eines Satzes und eines Knopfs.
|
|
<div className="mt-14 grid gap-12 lg:grid-cols-12 lg:gap-16">
|
|
<div className="lg:col-span-4">
|
|
<p className="font-serif text-2xl leading-snug text-heading">{t.cart.empty}</p>
|
|
<LinkButton href="/shop" variant="secondary" className="mt-6">
|
|
{t.cartPage.goToShop}
|
|
</LinkButton>
|
|
</div>
|
|
{emptyCartSuggestions.length > 0 ? (
|
|
<div className="grid grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2 lg:col-span-8">
|
|
{emptyCartSuggestions.map((product, index) => (
|
|
<ProductCard key={product.id} product={product} index={index} />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="mt-14 grid lg:grid-cols-3 gap-14">
|
|
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
|
|
<AnimatePresence initial={false}>
|
|
{items.map((item) => {
|
|
const itemStock = stock?.[item.productId];
|
|
const sponsorship = item.sponsorship;
|
|
return (
|
|
<motion.div
|
|
key={item.key}
|
|
layout
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, x: 40, transition: { duration: 0.2 } }}
|
|
className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3"
|
|
>
|
|
{sponsorship ? (
|
|
<SponsorshipThumb className="w-20 h-20" />
|
|
) : (
|
|
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0 rounded-lg" />
|
|
)}
|
|
<div className="flex-1 min-w-[160px]">
|
|
<Link
|
|
href={sponsorship ? "/patenschaft" : `/honig/${item.slug}`}
|
|
className="link-draw pb-0.5 font-serif text-2xl text-heading"
|
|
>
|
|
{item.name}
|
|
</Link>
|
|
{sponsorship ? (
|
|
<SponsorshipLineDetails sponsorship={sponsorship} className="mt-1 text-sm text-ink/65" />
|
|
) : (
|
|
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
|
)}
|
|
{/* Siehe Kommentar im Cart-Drawer (site-chrome.tsx): persistenter
|
|
aria-live-Wrapper, damit der erst nach dem ersten Rendern
|
|
eintreffende Live-Bestand als Ansage bei Screenreadern ankommt. */}
|
|
<div aria-live="polite">
|
|
{itemStock !== undefined && itemStock === 0 ? (
|
|
<p className="mt-1 text-xs text-red-700">
|
|
{sponsorship ? sponsorshipStrings.cart.soldOut : t.product.outOfStock}
|
|
</p>
|
|
) : !sponsorship && itemStock !== undefined && itemStock <= 5 ? (
|
|
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4 ml-auto">
|
|
{/* Patenschaften: Menge fest 1 — jede hat ihre eigene Urkunde. */}
|
|
{sponsorship ? null : (
|
|
<QuantityStepper
|
|
quantity={item.quantity}
|
|
onChange={(quantity) => setQuantity(item.key, quantity)}
|
|
max={itemStock ?? 10}
|
|
/>
|
|
)}
|
|
<span className="w-24 text-right font-serif text-xl text-heading tabular-nums">
|
|
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
|
</span>
|
|
<button
|
|
onClick={() => removeItem(item.key)}
|
|
aria-label={t.cart.remove}
|
|
className="flex h-11 w-11 items-center justify-center text-ink/55 transition-[color,transform] duration-300 hover:rotate-90 hover:text-red-700"
|
|
>
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
|
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className="border border-ink/10 bg-background p-8 h-fit lg:sticky lg:top-28">
|
|
<div className="space-y-3 text-sm">
|
|
<div className="flex justify-between">
|
|
<span>{t.cart.subtotal}</span>
|
|
<span className="tabular-nums">{formatPrice(subtotal, "EUR", locale)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>{hasPhysical ? t.cartPage.shippingLabel(totalQuantity) : sponsorshipStrings.checkout.digitalShipping}</span>
|
|
<span className="tabular-nums">{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
|
</div>
|
|
{nextTier && nextTier.unitsUntilNext <= 2 ? (
|
|
<p className="text-xs text-ink/70">
|
|
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))}
|
|
</p>
|
|
) : null}
|
|
{activeBulkDiscount ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ type: "spring", stiffness: 380, damping: 22 }}
|
|
className="flex items-center justify-between rounded-sm bg-honey/10 px-3 py-2 text-heading"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<HexCheck className="h-4 w-4 text-honey-dark" />
|
|
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
|
</span>
|
|
<span className="tabular-nums">-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
|
</motion.div>
|
|
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
|
|
// Der Weg zum Mengenrabatt als Glas, das sich mit jedem
|
|
// weiteren Stueck fuellt — nicht nur als Kleingedrucktes.
|
|
<div className="rounded-sm bg-honey/[0.07] px-3 py-3">
|
|
<p className="text-xs text-ink/75">
|
|
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
|
|
</p>
|
|
<div aria-hidden="true" className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink/10">
|
|
<motion.div
|
|
className="h-full rounded-full bg-gradient-to-r from-honey-dark to-honey"
|
|
initial={false}
|
|
animate={{
|
|
width: `${(totalQuantity / (totalQuantity + nextBulkDiscount.unitsUntilNext)) * 100}%`,
|
|
}}
|
|
transition={{ type: "spring", stiffness: 120, damping: 20 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-baseline justify-between text-heading pt-4 border-t border-ink/10">
|
|
<span className="text-base">{t.cartPage.total}</span>
|
|
<motion.span
|
|
key={subtotal - bulkDiscountCents + shipping}
|
|
initial={{ opacity: 0.4, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="font-serif text-3xl tabular-nums"
|
|
>
|
|
{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}
|
|
</motion.span>
|
|
</div>
|
|
</div>
|
|
<LinkButton href="/checkout" className="w-full mt-6">
|
|
{t.cart.checkout}
|
|
</LinkButton>
|
|
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/70 hover:text-heading underline">
|
|
{t.cartPage.pickupHint}
|
|
</Link>
|
|
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{items.length > 0 && crossSellProducts.length > 0 ? (
|
|
<div className="mt-20">
|
|
<h2 className="text-3xl md:text-4xl text-heading mb-10">{t.productPage.relatedProductsHeading}</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
|
{crossSellProducts.map((product, index) => (
|
|
<ProductCard key={product.id} product={product} index={index} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|