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

+257
View File
@@ -0,0 +1,257 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon } from "@/components/account-ui";
import { TurnstileField } from "@/components/turnstile-widget";
import { PasswordField } from "@/components/password-field";
import { ConfirmForm } from "@/components/confirm-form";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getCurrentCustomer } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
registerAction,
loginAction,
logoutAction,
toggleNewsletterAction,
logoutAllDevicesAction,
resendVerificationEmailAction,
deleteOwnAccountAction,
} from "./actions";
export const metadata: Metadata = { title: "Mein Konto" };
export const dynamic = "force-dynamic";
export default async function KontoPage(props: PageProps<"/konto">) {
const t = getDictionary(await getLocale());
const { error, message } = await props.searchParams;
const customer = await getCurrentCustomer();
if (!customer) {
return (
<div className="py-20">
<Container className="max-w-3xl">
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{error}
</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{message}
</p>
) : null}
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
<form action={loginAction} className="space-y-4 md:pr-12">
<h2 className="text-lg text-forest">{t.account.login}</h2>
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
<div className="flex items-center justify-between gap-4">
<Button type="submit">{t.account.login}</Button>
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
{t.account.forgotPassword}
</Link>
</div>
</form>
<form action={registerAction} className="space-y-4 md:pl-12">
<h2 className="text-lg text-forest">{t.account.register}</h2>
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
</div>
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
<PasswordField
name="password"
label={t.account.passwordMinLabel}
required
autoComplete="new-password"
showStrength
/>
<PasswordField
name="passwordConfirm"
label={t.account.passwordConfirmLabel}
autoComplete="new-password"
/>
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
) : null}
<Button type="submit">{t.account.register}</Button>
</form>
</div>
</Container>
</div>
);
}
const newsletter = await prisma.newsletterSubscriber.findUnique({
where: { customerId: customer.id },
});
const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null);
return (
<div className="py-20">
<Container className="max-w-2xl">
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
{error ? (
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{error}
</p>
) : null}
{message ? (
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{message}
</p>
) : null}
{!customer.emailVerifiedAt ? (
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
<p className="text-ink/70">
{t.account.verifyEmailPrompt}
</p>
<form action={resendVerificationEmailAction}>
<button type="submit" className="text-forest underline whitespace-nowrap">
{t.account.resendVerification}
</button>
</form>
</div>
) : null}
<div className="mt-10 grid sm:grid-cols-2 gap-6">
<AccountNavCard
href="/konto/bestellungen"
icon={<OrderIcon />}
title={t.account.navOrders.title}
description={t.account.navOrders.description}
/>
<AccountNavCard
href="/konto/adressen"
icon={<AddressIcon />}
title={t.account.navAddresses.title}
description={t.account.navAddresses.description}
/>
<AccountNavCard
href="/konto/wunschliste"
icon={<HeartIcon />}
title={t.account.navWishlist.title}
description={t.account.navWishlist.description}
/>
<AccountNavCard
href="/konto/zwei-faktor"
icon={<ShieldIcon />}
title={t.account.navSecurity.title}
description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`}
/>
{customer.role === "ADMIN" ? (
<AccountNavCard
href="/admin"
icon={<ShieldIcon />}
title={t.account.navAdmin.title}
description={t.account.navAdmin.description}
accent
className="sm:col-span-2"
/>
) : null}
</div>
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg text-forest">{t.account.newsletterHeading}</h2>
<p className="mt-1 text-sm text-ink/70">
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
</p>
</div>
<form action={toggleNewsletterAction}>
<Button type="submit" variant="secondary">
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
</Button>
</form>
</div>
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg text-forest">{t.account.logoutAllDevicesHeading}</h2>
<p className="mt-1 text-sm text-ink/70">
{t.account.logoutAllDevicesDescription}
</p>
</div>
<form action={logoutAllDevicesAction}>
<Button type="submit" variant="secondary">
{t.account.logout}
</Button>
</form>
</div>
<form action={logoutAction} className="mt-6 mb-10">
<Button type="submit" variant="ghost">
{t.account.logout}
</Button>
</form>
<h2 className="text-lg text-forest mb-4">{t.account.privacy.heading}</h2>
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4">
<div>
<h3 className="text-sm text-forest">{t.account.privacy.downloadDataHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
</div>
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
{t.account.privacy.downloadDataButton}
</LinkButton>
</div>
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
<h3 className="text-sm text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
<ConfirmForm
action={deleteOwnAccountAction}
confirmMessage={t.account.privacy.deleteAccountConfirm}
className="mt-4 flex flex-wrap items-end gap-3"
>
<label className="block text-sm">
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
/>
</label>
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
{t.account.privacy.deleteAccountButton}
</Button>
</ConfirmForm>
</div>
</Container>
</div>
);
}
function Field({
name,
label,
type = "text",
required = false,
autoComplete,
}: {
name: string;
label: string;
type?: string;
required?: boolean;
autoComplete?: string;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
required={required}
autoComplete={autoComplete}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
/>
</label>
);
}