feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
import { useI18n } from "@/i18n/i18n-provider";
type Fulfillment = "VERSAND" | "ABHOLUNG";
export interface CheckoutInitialValues {
firstName: string;
lastName: string;
email: string;
phone: string;
street: string;
zip: string;
city: string;
}
export function CheckoutForm({
initialValues,
tiers,
bulkDiscountTiers,
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
bulkDiscountTiers: BulkDiscountTierLike[];
}) {
const { t } = useI18n();
const items = useCartStore((s) => s.items);
const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items);
const router = useRouter();
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGift, setIsGift] = useState(false);
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
const shipping =
fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
const bulkDiscountCents = activeBulkDiscount
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
: 0;
const total = subtotal - bulkDiscountCents + shipping;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
const formData = new FormData(event.currentTarget);
const payload = {
email: String(formData.get("email") ?? ""),
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
phone: String(formData.get("phone") ?? "") || undefined,
fulfillmentMethod: fulfillment,
street: String(formData.get("street") ?? "") || undefined,
zip: String(formData.get("zip") ?? "") || undefined,
city: String(formData.get("city") ?? "") || undefined,
country: "DE",
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
};
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
if (data.orderNumber) {
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`);
return;
}
setError(data.error ?? t.checkoutPage.genericError);
setSubmitting(false);
return;
}
window.location.href = data.url;
} catch {
setError(t.checkoutPage.genericError);
setSubmitting(false);
}
}
if (items.length === 0) {
return (
<div className="py-20">
<Container>
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.emptyCart} />
</Container>
</div>
);
}
return (
<div className="py-16">
<Container className="grid lg:grid-cols-3 gap-14">
<form onSubmit={handleSubmit} className="lg:col-span-2 space-y-8">
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
<div className="grid sm:grid-cols-2 gap-4">
<Field name="firstName" label={t.checkoutPage.firstName} defaultValue={initialValues?.firstName} required />
<Field name="lastName" label={t.checkoutPage.lastName} defaultValue={initialValues?.lastName} required />
</div>
<div className="grid sm:grid-cols-2 gap-4">
<Field name="email" label={t.checkoutPage.email} type="email" defaultValue={initialValues?.email} required />
<Field
name="phone"
label={t.checkoutPage.phoneOptional}
defaultValue={initialValues?.phone}
/>
</div>
<div>
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
<div className="flex gap-4">
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
<button
type="button"
key={option}
onClick={() => setFulfillment(option)}
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
fulfillment === option
? "border-forest bg-forest text-cream"
: "border-ink/20 text-ink/70"
}`}
>
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
</button>
))}
</div>
{fulfillment === "VERSAND" ? (
<p className="mt-2 text-xs text-ink/60">
{t.checkoutPage.deliveryTimeNote}
</p>
) : null}
</div>
{fulfillment === "VERSAND" ? (
<div className="space-y-4">
<Field
name="street"
label={t.checkoutPage.streetLabel}
defaultValue={initialValues?.street}
required
/>
<div className="grid sm:grid-cols-2 gap-4">
<Field name="zip" label={t.checkoutPage.zipLabel} defaultValue={initialValues?.zip} required />
<Field name="city" label={t.checkoutPage.cityLabel} defaultValue={initialValues?.city} required />
</div>
</div>
) : null}
<Field name="discountCode" label={t.checkoutPage.discountCodeLabel} placeholder={t.checkoutPage.discountCodePlaceholder} />
<div>
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
<input
type="checkbox"
checked={isGift}
onChange={(event) => setIsGift(event.target.checked)}
className="h-4 w-4 border-ink/20 accent-forest"
/>
{t.checkoutPage.isGiftLabel}
</label>
{isGift ? (
<label className="block text-sm mt-3">
<span className="text-ink/70">{t.checkoutPage.giftMessageLabel}</span>
<textarea
name="giftMessage"
maxLength={500}
rows={3}
placeholder={t.checkoutPage.giftMessagePlaceholder}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest resize-none"
/>
</label>
) : null}
</div>
{error ? <p className="text-sm text-red-700">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
</Button>
<p className="text-xs text-ink/65">
{t.checkoutPage.paymentMethodsNote}
</p>
</form>
<aside className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
<ul className="space-y-3 text-sm">
{items.map((item) => (
<li key={item.productId} className="flex justify-between">
<span>
{item.quantity} × {item.name}
</span>
<span>{formatPrice(item.priceCents * item.quantity)}</span>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
<div className="flex justify-between text-ink/70">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between text-ink/70">
<span>
{fulfillment === "VERSAND"
? t.checkoutPage.shippingWithCount(totalQuantity)
: t.checkoutPage.shippingPickup}
</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
</div>
{activeBulkDiscount ? (
<div className="flex justify-between text-forest">
<span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span>
<span>-{formatPrice(bulkDiscountCents)}</span>
</div>
) : null}
</div>
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
<span>{t.cartPage.total}</span>
<span>{formatPrice(total)}</span>
</div>
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
</aside>
</Container>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
/>
</label>
);
}