Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem Satz Klassen entstehen: - heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme - surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen - forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe, Admin-Leiste), cream der helle Text darauf - honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird im Dunkelmodus heller und bleibt lesbar Codemod ueber src: text-/border-forest -> *-heading, bg-cream -> bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich. Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme" (nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der Systemeinstellung. Gewaehlter und automatischer Dunkelblock in globals.css werden per Test auf Gleichheit geprueft. Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst ab 1680 px. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
274 lines
11 KiB
TypeScript
274 lines
11 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 { isEntraConfigured } from "@/lib/entra";
|
|
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 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">
|
|
<div className="md:pr-12">
|
|
<form action={loginAction} className="space-y-4">
|
|
<h2 className="text-3xl text-heading">{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-heading underline">
|
|
{t.account.forgotPassword}
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
{/* Microsoft-Anmeldung nur, wenn sie eingerichtet ist (ENTRA_*,
|
|
siehe .env.example) — ein Knopf, der nur "SSO ist noch nicht
|
|
konfiguriert" meldet, ist fuer Kundschaft schlechter als keiner.
|
|
Sie gehoert zur Anmeldung, steht deshalb in dieser Spalte.
|
|
Bewusst ein einfaches <a> statt next/link: der Klick muss eine
|
|
echte Top-Level-Navigation ausloesen, die dem Redirect bis zu
|
|
login.microsoftonline.com folgt; ein clientseitiger fetch()
|
|
wuerde von der connect-src-CSP in proxy.ts blockiert. */}
|
|
{isEntraConfigured() ? (
|
|
<>
|
|
<div className="mt-8 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>
|
|
<a
|
|
href="/api/auth/entra/login"
|
|
className="mt-6 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-heading border border-heading hover:bg-forest hover:text-cream"
|
|
>
|
|
{t.account.ssoButton}
|
|
</a>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<form action={registerAction} className="space-y-4 md:pl-12">
|
|
<h2 className="text-3xl text-heading">{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 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-heading 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-heading">{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-heading">{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-heading 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-heading">{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-heading"
|
|
/>
|
|
</label>
|
|
);
|
|
}
|