Files
Wabenhain/src/app/konto/page.tsx
T
Nick SzaunigandClaude Opus 5.5 11eccec01b feat(ui): Polish-Durchgang Admin und Mobil, Erstattung nur mit Rueckfrage
Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.

Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.

Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:24:55 +02:00

276 lines
12 KiB
TypeScript

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() {
const t = getDictionary(await getLocale());
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} />
<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-3xl 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" />
{/* Das Turnstile-Widget ist ein iframe mit FESTER Breite (rund
300 px) und kann nicht mitschrumpfen. In einer Zeile mit
Knopf und Link — noch dazu in einer der beiden Spalten
dieser Seite — reichte der Platz nie: die Elemente schoben
sich uebereinander. Deshalb steht es auf einer eigenen
Zeile, und Knopf und Link duerfen darunter umbrechen. Das
Registrierungsformular daneben macht es schon immer so. */}
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
) : null}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<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-3xl 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>
<div className="mt-10 flex items-center gap-4 text-xs text-ink/70">
<span className="h-px flex-1 bg-ink/10" />
{t.account.ssoDivider}
<span className="h-px flex-1 bg-ink/10" />
</div>
<div className="mt-6">
{/*
Bewusst ein einfaches <a> statt LinkButton/next/link: der Klick
muss eine ECHTE Browser-Top-Level-Navigation ausloesen, die dem
302-Redirect dieser Route bis zu login.microsoftonline.com
folgt. Next.js' <Link> navigiert clientseitig per fetch() —
dieser Cross-Origin-Redirect-Sprung wuerde dann von der
connect-src-CSP in proxy.ts blockiert (die nur "self" u.ae.
erlaubt), waehrend eine echte Top-Level-Navigation von CSP
connect-src gar nicht erst betroffen ist.
Bewusst immer sichtbar, auch ohne konfiguriertes SSO (siehe
.env.example, ENTRA_*) — der Link fuehrt in dem Fall zu einer
klaren Fehlermeldung auf dieser Seite statt eines defekten
Buttons (src/app/api/auth/entra/login/route.ts prueft die
Konfiguration server-seitig, bevor irgendetwas passiert).
*/}
<a
href="/api/auth/entra/login"
className="inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream"
>
{t.account.ssoButton}
</a>
</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)} />
{!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-2xl 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-2xl 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-2xl text-forest mb-4">{t.account.privacy.heading}</h2>
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h3 className="text-lg 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-lg 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>
);
}