feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -8,7 +8,9 @@ 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, cartItemCount } from "@/store/cart-store";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity } 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";
|
||||
@@ -29,6 +31,7 @@ export function WarenkorbContent({
|
||||
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);
|
||||
@@ -36,21 +39,24 @@ export function WarenkorbContent({
|
||||
// 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(items.map((item) => item.productId));
|
||||
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.
|
||||
const totalQuantity = cartItemCount(items);
|
||||
const shipping = items.length === 0 ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
||||
const nextTier = items.length === 0 ? null : getNextShippingTier(tiers, totalQuantity);
|
||||
// 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.
|
||||
const activeBulkDiscount = items.length === 0 ? null : resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
||||
const activeBulkDiscount = !hasPhysical ? null : resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
||||
const bulkDiscountCents = activeBulkDiscount
|
||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
||||
: 0;
|
||||
const nextBulkDiscount = items.length === 0 ? null : getNextBulkDiscountTier(bulkDiscountTiers, totalQuantity);
|
||||
const nextBulkDiscount = !hasPhysical ? null : getNextBulkDiscountTier(bulkDiscountTiers, totalQuantity);
|
||||
|
||||
const cartProductIds = new Set(items.map((item) => item.productId));
|
||||
const crossSellProducts = availableProducts
|
||||
@@ -95,45 +101,62 @@ export function WarenkorbContent({
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item) => {
|
||||
const itemStock = stock?.[item.productId];
|
||||
const sponsorship = item.sponsorship;
|
||||
return (
|
||||
<motion.div
|
||||
key={item.productId}
|
||||
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"
|
||||
>
|
||||
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0 rounded-lg" />
|
||||
{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={`/honig/${item.slug}`} className="link-draw pb-0.5 font-serif text-2xl text-heading">
|
||||
<Link
|
||||
href={sponsorship ? "/patenschaft" : `/honig/${item.slug}`}
|
||||
className="link-draw pb-0.5 font-serif text-2xl text-heading"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
||||
{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">{t.product.outOfStock}</p>
|
||||
) : itemStock !== undefined && itemStock <= 5 ? (
|
||||
<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">
|
||||
<QuantityStepper
|
||||
quantity={item.quantity}
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
max={itemStock ?? 10}
|
||||
/>
|
||||
{/* 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.productId)}
|
||||
onClick={() => removeItem(item.key)}
|
||||
aria-label={t.cart.remove}
|
||||
className="flex h-9 w-9 items-center justify-center text-ink/55 transition-[color,transform] duration-300 hover:rotate-90 hover:text-red-700"
|
||||
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" />
|
||||
@@ -153,7 +176,7 @@ export function WarenkorbContent({
|
||||
<span className="tabular-nums">{formatPrice(subtotal, "EUR", locale)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.cartPage.shippingLabel(totalQuantity)}</span>
|
||||
<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 ? (
|
||||
|
||||
Reference in new issue
Block a user