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