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]>
276 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|