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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user