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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestellung abgebrochen" };
|
||||
|
||||
export default async function CheckoutCancelPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={t.checkoutCancelPage.eyebrow}
|
||||
title={t.checkoutCancelPage.title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
<p className="mt-6 text-ink/70">
|
||||
{t.checkoutCancelPage.text}
|
||||
</p>
|
||||
<LinkButton href="/warenkorb" className="mt-10">
|
||||
{t.checkoutCancelPage.backToCart}
|
||||
</LinkButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CheckoutForm } from "./checkout-form";
|
||||
import { useCartStore, type CartItem } from "@/store/cart-store";
|
||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||
import type { ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
|
||||
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||
|
||||
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
productId: "p1",
|
||||
slug: "fruehtracht-2026",
|
||||
name: "Frühtracht 2026",
|
||||
weightGrams: 500,
|
||||
priceCents: 990,
|
||||
image: null,
|
||||
quantity: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderCheckout(items: CartItem[]) {
|
||||
useCartStore.setState({ items, isOpen: false });
|
||||
return render(
|
||||
<I18nProvider locale="de">
|
||||
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCartStore.setState({ items: [], isOpen: false });
|
||||
});
|
||||
|
||||
describe("CheckoutForm", () => {
|
||||
test("shows the empty-cart message instead of the form when the cart is empty", () => {
|
||||
renderCheckout([]);
|
||||
expect(screen.queryByRole("button", { name: /kostenpflichtig bestellen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("charges shipping by default and drops it when switching to pickup", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
renderCheckout([item()]);
|
||||
|
||||
expect(screen.getByText("7,00 €")).toBeInTheDocument();
|
||||
|
||||
await userEventInstance.click(screen.getByRole("button", { name: /abholung/i }));
|
||||
|
||||
expect(screen.queryByText("7,00 €")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("kostenlos")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Container } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Next.js-Suspense-Skeleton fuer /checkout: die Seite ist "force-dynamic"
|
||||
* und laedt Versandstaffeln sowie (bei angemeldeten Kund:innen) gespeicherte
|
||||
* Adressen serverseitig, bevor CheckoutForm ueberhaupt rendern kann.
|
||||
* Orientiert sich am Formular-plus-Zusammenfassung-Layout der Seite.
|
||||
*/
|
||||
export default function CheckoutLoading() {
|
||||
return (
|
||||
<div className="py-16 animate-pulse">
|
||||
<Container className="grid lg:grid-cols-[1fr_360px] gap-12">
|
||||
<div className="space-y-4">
|
||||
<div className="h-9 w-40 bg-ink/10 rounded-sm mb-4" />
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="h-12 w-full bg-ink/10" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-80 bg-ink/5 border border-ink/10" />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getAddressesForCustomer } from "@/lib/account-data";
|
||||
import { getShippingTiers } from "@/lib/shipping";
|
||||
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
|
||||
|
||||
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
|
||||
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
|
||||
// Rendering pro Request statt statischem Build entkoppelt zudem den
|
||||
// Docker-Build-Schritt von einer zur Build-Zeit erreichbaren Datenbank.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return { title: t.checkoutPage.eyebrow };
|
||||
}
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
const [tiers, bulkDiscountTiers] = await Promise.all([getShippingTiers(), getBulkDiscountTiers()]);
|
||||
const tierValues = tiers.map((t) => ({
|
||||
minQuantity: t.minQuantity,
|
||||
maxQuantity: t.maxQuantity,
|
||||
priceCents: t.priceCents,
|
||||
}));
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return <CheckoutForm tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
|
||||
|
||||
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
||||
// die zuletzt angelegte — die erste Adresse in der Liste ist also immer die
|
||||
// sinnvollste Vorbelegung fuer den Checkout.
|
||||
const addresses = await getAddressesForCustomer(customer.id);
|
||||
const defaultAddress = addresses[0];
|
||||
|
||||
const initialValues: CheckoutInitialValues = {
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
email: customer.email,
|
||||
phone: customer.phone ?? "",
|
||||
street: defaultAddress?.street ?? "",
|
||||
zip: defaultAddress?.zip ?? "",
|
||||
city: defaultAddress?.city ?? "",
|
||||
};
|
||||
|
||||
return <CheckoutForm initialValues={initialValues} tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return { title: t.checkoutSuccessPage.successEyebrow };
|
||||
}
|
||||
|
||||
export default function CheckoutSuccessLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { use } from "react";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
export default function CheckoutSuccessPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ order?: string; pending?: string }>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { order, pending } = use(searchParams);
|
||||
const clear = useCartStore((s) => s.clear);
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={pending ? t.checkoutSuccessPage.pendingEyebrow : t.checkoutSuccessPage.successEyebrow}
|
||||
title={pending ? t.checkoutSuccessPage.pendingTitle : t.checkoutSuccessPage.successTitle}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
{order ? (
|
||||
<p className="mt-4 text-ink/70">
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{order}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-ink/70">
|
||||
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText}
|
||||
</p>
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton>
|
||||
{order ? (
|
||||
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(order)}`} variant="secondary">
|
||||
{t.checkoutSuccessPage.trackOrder}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user