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

+21
View File
@@ -0,0 +1,21 @@
"use server";
import { getProductsByIds, type ProductWithRelations } from "@/lib/data";
/**
* Loest lokal im Browser gespeicherte Produkt-IDs ("zuletzt angesehen") in
* anzeigbare Produktdaten auf. Inaktive Produkte werden bewusst
* herausgefiltert (getProductsByIds selbst filtert nicht danach, da es auch
* vom Checkout fuer bereits bestellte, ggf. inzwischen deaktivierte Produkte
* genutzt wird).
*/
export async function getRecentlyViewedProducts(ids: string[]): Promise<ProductWithRelations[]> {
if (ids.length === 0) return [];
const products = await getProductsByIds(ids);
const byId = new Map(products.map((product) => [product.id, product]));
return ids
.map((id) => byId.get(id))
.filter((product): product is ProductWithRelations => product !== undefined && product.isActive);
}
+40
View File
@@ -0,0 +1,40 @@
import { Container } from "@/components/ui";
/**
* Next.js-Suspense-Skeleton fuer /shop: die Seite ist "force-dynamic" (jede
* Filter-/Sortier-/Suchaenderung loest einen neuen DB-Roundtrip aus) und
* zeigte bisher waehrend dieser Navigation keinerlei Feedback. Bewusst am
* tatsaechlichen Layout orientiert statt eines generischen Spinners.
*/
export default function ShopLoading() {
return (
<div className="py-20 animate-pulse">
<Container>
<div className="max-w-2xl">
<div className="h-3 w-16 bg-ink/10 rounded-sm" />
<div className="mt-3 h-10 w-72 bg-ink/10 rounded-sm" />
<div className="mt-4 h-4 w-full bg-ink/10 rounded-sm" />
</div>
<div className="mt-8 flex flex-wrap gap-3">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="h-9 w-24 border border-ink/10 bg-ink/5" />
))}
</div>
<div className="mt-6 h-10 w-64 bg-ink/5" />
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{Array.from({ length: 6 }, (_, i) => (
<div key={i}>
<div className="aspect-[4/5] w-full bg-ink/10" />
<div className="mt-4 h-5 w-2/3 bg-ink/10 rounded-sm" />
<div className="mt-2 h-4 w-1/2 bg-ink/10 rounded-sm" />
<div className="mt-3 h-4 w-1/3 bg-ink/10 rounded-sm" />
</div>
))}
</div>
</Container>
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui";
import { ProductCard } from "@/components/product-card";
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
import { getActiveProducts, type ProductSort } from "@/lib/data";
import { getReviewSummariesByProductIds } from "@/lib/reviews";
import { siteConfig } from "@/lib/site-config";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { ProductCategory } from "@/generated/prisma/client";
export const metadata: Metadata = {
title: "Shop",
description: "Hochwertiger Honig, Bienenwachskerzen und Propolis aus eigener Imkerei in Altenmünster.",
alternates: { canonical: "/shop" },
};
// Siehe Begruendung in app/page.tsx.
export const dynamic = "force-dynamic";
function buildHref(params: {
q?: string;
kategorie?: string;
sortierung?: string;
verfuegbar?: string;
}): string {
const search = new URLSearchParams();
if (params.q) search.set("q", params.q);
if (params.kategorie) search.set("kategorie", params.kategorie);
if (params.sortierung) search.set("sortierung", params.sortierung);
if (params.verfuegbar) search.set("verfuegbar", params.verfuegbar);
const qs = search.toString();
return qs ? `/shop?${qs}` : "/shop";
}
export default async function ShopPage(props: PageProps<"/shop">) {
const t = getDictionary(await getLocale());
const categoryFilters = [
{ value: undefined, label: t.shop.allCategories },
{ value: "HONIG", label: t.common.categories.HONIG },
{ value: "KERZEN", label: t.common.categories.KERZEN },
{ value: "PROPOLIS", label: t.common.categories.PROPOLIS },
] as const;
const sortOptions: { value: ProductSort | ""; label: string }[] = [
{ value: "", label: t.shop.sortNewest },
{ value: "preis-auf", label: t.shop.sortPriceAsc },
{ value: "preis-ab", label: t.shop.sortPriceDesc },
{ value: "verfuegbarkeit", label: t.shop.sortAvailability },
];
const { q, kategorie, sortierung, verfuegbar } = await props.searchParams;
const query = typeof q === "string" ? q : undefined;
const category =
typeof kategorie === "string" && ["HONIG", "KERZEN", "PROPOLIS"].includes(kategorie)
? (kategorie as ProductCategory)
: undefined;
const sort =
typeof sortierung === "string" && ["preis-auf", "preis-ab", "verfuegbarkeit"].includes(sortierung)
? (sortierung as ProductSort)
: undefined;
const onlyAvailable = verfuegbar === "1";
const products = await getActiveProducts({ query, category, sort, onlyAvailable });
const reviewSummaries = await getReviewSummariesByProductIds(products.map((p) => p.id));
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Start", item: `https://${siteConfig.domain}` },
{ "@type": "ListItem", position: 2, name: "Shop", item: `https://${siteConfig.domain}/shop` },
],
};
return (
<div className="py-20">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
/>
<Container>
<SectionHeading
level="h1"
eyebrow={t.shop.eyebrow}
title={t.shop.title}
subtitle={t.shop.subtitle}
/>
<div className="mt-8 flex flex-wrap gap-3">
{categoryFilters.map((filter) => {
const isActive = filter.value === category;
const href = buildHref({
q: query,
kategorie: filter.value,
sortierung: sort,
verfuegbar: onlyAvailable ? "1" : undefined,
});
return (
<Link
key={filter.label}
href={href}
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
isActive
? "bg-forest text-cream border-forest"
: "border-ink/20 text-ink/70 hover:border-forest"
}`}
>
{filter.label}
</Link>
);
})}
</div>
<div className="mt-6 flex flex-wrap items-center gap-4">
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen. */}
<form action="/shop" method="GET" className="flex items-center gap-2 text-sm text-ink/70">
{query ? <input type="hidden" name="q" value={query} /> : null}
{category ? <input type="hidden" name="kategorie" value={category} /> : null}
{onlyAvailable ? <input type="hidden" name="verfuegbar" value="1" /> : null}
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
<select
id="sortierung"
name="sortierung"
defaultValue={sort ?? ""}
className="border border-ink/20 bg-background px-2 py-2"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button type="submit" className="border border-ink/20 px-3 py-2 hover:border-forest">
{t.shop.apply}
</button>
</form>
<Link
href={buildHref({
q: query,
kategorie: category,
sortierung: sort,
verfuegbar: onlyAvailable ? undefined : "1",
})}
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
onlyAvailable
? "bg-forest text-cream border-forest"
: "border-ink/20 text-ink/70 hover:border-forest"
}`}
>
{t.shop.onlyAvailable}
</Link>
</div>
{query ? (
<p className="mt-6 text-sm text-ink/70">
{t.shop.searchResults(query, products.length)}
</p>
) : null}
<h2 className="sr-only">{t.shop.productsHeading}</h2>
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{products.map((product) => (
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} />
))}
</div>
{products.length === 0 ? (
<p className="mt-10 text-ink/70">
{query ? t.shop.noSearchResults : t.shop.noProducts}
</p>
) : null}
<RecentlyViewedSection />
</Container>
</div>
);
}