Compare commits
6
Commits
76be1292ed
...
15ddceaa39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ddceaa39 | ||
|
|
e6455b05e6 | ||
|
|
578ec767b2 | ||
|
|
d4bb6ec1cf | ||
|
|
baebf78ee4 | ||
|
|
ee854bd77b |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||
import { getOrderForTracking } from "@/lib/account-data";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
@@ -52,7 +52,8 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
|
||||
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
||||
|
||||
<form method="GET" className="mt-8 space-y-4">
|
||||
<FormPanel className="mt-8">
|
||||
<form method="GET" className="space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
||||
<input
|
||||
@@ -77,6 +78,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
</label>
|
||||
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
||||
</form>
|
||||
</FormPanel>
|
||||
|
||||
{rateLimited ? (
|
||||
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
@@ -91,7 +93,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
) : null}
|
||||
|
||||
{order ? (
|
||||
<div className="mt-10 border border-ink/10 p-6">
|
||||
<div className="mt-10 border border-ink/10 bg-background p-6 shadow-[0_24px_50px_-34px_rgba(36,53,43,0.45)] sm:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
|
||||
@@ -303,6 +303,15 @@ export function CheckoutForm({
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
{/* Zwei Schritte, sichtbar gemacht: erst Daten, dann die eingebettete
|
||||
Zahlung. Rein anzeigend — gesteuert wird weiter ueber "payment". */}
|
||||
<Container className="mb-10">
|
||||
<CheckoutSteps
|
||||
current={payment ? 1 : 0}
|
||||
labels={[t.checkoutPage.yourDetails, t.checkoutPage.stepPayment]}
|
||||
ariaLabel={t.checkoutPage.stepsAriaLabel}
|
||||
/>
|
||||
</Container>
|
||||
<Container className="grid lg:grid-cols-3 gap-14">
|
||||
{payment && stripePublishableKey ? (
|
||||
<div className="lg:col-span-3">
|
||||
@@ -358,21 +367,52 @@ export function CheckoutForm({
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
||||
<div className="flex gap-4">
|
||||
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option}
|
||||
onClick={() => setFulfillment(option)}
|
||||
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
|
||||
fulfillment === option
|
||||
? "border-forest bg-forest text-cream"
|
||||
: "border-ink/20 text-ink/70"
|
||||
}`}
|
||||
>
|
||||
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
|
||||
</button>
|
||||
))}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{(["VERSAND", "ABHOLUNG"] as const).map((option) => {
|
||||
const selected = fulfillment === option;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option}
|
||||
onClick={() => setFulfillment(option)}
|
||||
aria-pressed={selected}
|
||||
className={`group relative flex items-center gap-4 border px-5 py-4 text-left transition-[border-color,background-color,box-shadow] duration-300 ${
|
||||
selected
|
||||
? "border-forest bg-forest/[0.04] text-forest shadow-[0_14px_30px_-22px_rgba(36,53,43,0.6)]"
|
||||
: "border-ink/15 text-ink/70 hover:border-forest/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center border transition-colors ${
|
||||
selected ? "border-forest bg-forest text-cream" : "border-ink/15 text-forest"
|
||||
}`}
|
||||
>
|
||||
{option === "VERSAND" ? (
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 7h11v9H3zM14 10h4l3 3v3h-7z" strokeLinejoin="round" />
|
||||
<circle cx="7" cy="17.5" r="1.5" />
|
||||
<circle cx="17.5" cy="17.5" r="1.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 11l8-6 8 6v9H4z" strokeLinejoin="round" />
|
||||
<path d="M10 20v-5h4v5" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-serif text-xl">
|
||||
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
|
||||
</span>
|
||||
{selected ? (
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-honey">
|
||||
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="currentColor" />
|
||||
<path d="M8 13.2l2.8 2.8 5.2-5.6" stroke="var(--color-cream)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{fulfillment === "VERSAND" ? (
|
||||
<p className="mt-2 text-xs text-ink/70">
|
||||
@@ -603,15 +643,16 @@ export function CheckoutForm({
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside hidden={payment !== null} className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
||||
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||
<aside hidden={payment !== null} className="border border-ink/10 bg-background p-8 h-fit lg:sticky lg:top-28 shadow-[0_24px_50px_-34px_rgba(36,53,43,0.45)]">
|
||||
<h2 className="text-2xl text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{items.map((item) => (
|
||||
<li key={item.productId} className="flex justify-between">
|
||||
<li key={item.productId} className="flex items-baseline justify-between gap-4">
|
||||
<span>
|
||||
{item.quantity} × {item.name}
|
||||
<span className="text-ink/55 tabular-nums">{item.quantity} ×</span>{" "}
|
||||
<span className="font-serif text-lg text-forest">{item.name}</span>
|
||||
</span>
|
||||
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||
<span className="tabular-nums">{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -637,9 +678,9 @@ export function CheckoutForm({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
||||
<span>{t.cartPage.total}</span>
|
||||
<span>{formatPrice(total, "EUR", locale)}</span>
|
||||
<div className="mt-4 pt-4 border-t border-ink/10 flex items-baseline justify-between text-forest">
|
||||
<span className="text-base">{t.cartPage.total}</span>
|
||||
<span className="font-serif text-3xl tabular-nums">{formatPrice(total, "EUR", locale)}</span>
|
||||
</div>
|
||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||
</aside>
|
||||
@@ -685,3 +726,52 @@ function Field({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schrittanzeige mit Waben: erledigt = gefuellt, aktuell = Honig mit Puls. */
|
||||
function CheckoutSteps({ current, labels, ariaLabel }: { current: number; labels: string[]; ariaLabel: string }) {
|
||||
return (
|
||||
<ol aria-label={ariaLabel} className="flex items-center gap-3 text-sm">
|
||||
{labels.map((label, i) => {
|
||||
const done = i < current;
|
||||
const active = i === current;
|
||||
return (
|
||||
<li key={label} className="flex items-center gap-3" aria-current={active ? "step" : undefined}>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<svg
|
||||
viewBox="0 0 24 26"
|
||||
aria-hidden="true"
|
||||
className={`h-6 w-6 rounded-full ${active ? "motion-pulse animate-[wh-pulse_2.2s_ease-out_infinite]" : ""}`}
|
||||
>
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill={done ? "var(--color-forest)" : active ? "var(--color-honey)" : "var(--color-background)"}
|
||||
stroke={done ? "var(--color-forest)" : active ? "var(--color-honey-dark)" : "color-mix(in oklab, var(--color-ink) 25%, transparent)"}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{done ? (
|
||||
<path d="M8 13.2l2.8 2.8 5.2-5.6" stroke="var(--color-cream)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<text x="12" y="16.5" textAnchor="middle" fontSize="10" fill={active ? "var(--color-cream)" : "var(--color-ink)"}>
|
||||
{i + 1}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
<span className={`tracking-wide-caps text-xs ${active ? "text-forest" : done ? "text-forest/70" : "text-ink/50"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
{i < labels.length - 1 ? (
|
||||
<span aria-hidden="true" className="relative h-[2px] w-12 overflow-hidden rounded-full bg-ink/10 sm:w-20">
|
||||
<span
|
||||
className={`absolute inset-0 origin-left bg-honey transition-transform duration-700 ease-[var(--ease-arrive)] ${
|
||||
done ? "scale-x-100" : "scale-x-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,23 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
{/* Eine Wabe mit Riss: etwas ist kaputtgegangen, aber reparierbar. */}
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="mx-auto mb-10 h-20 w-20">
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill="color-mix(in oklab, var(--color-honey) 25%, transparent)"
|
||||
stroke="var(--color-honey-dark)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d="M12 3.5l-1.6 5 2.6 2.2-2.4 4.4 2.2 3.1-1.3 4.3"
|
||||
fill="none"
|
||||
stroke="var(--color-honey-dark)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<SectionHeading
|
||||
eyebrow={t.siteErrorPage.eyebrow}
|
||||
title={t.siteErrorPage.title}
|
||||
|
||||
@@ -283,3 +283,21 @@ textarea::placeholder {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Listeneintraege erscheinen gestaffelt (Delay per style am Element). */
|
||||
@keyframes wh-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-rise {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AccountEmptyState, AddressIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
@@ -27,21 +28,28 @@ export default async function KontoAdressenPage() {
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
|
||||
|
||||
|
||||
<div className="mt-10 space-y-4">
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2">
|
||||
{addresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className="border border-ink/10 p-6 flex justify-between gap-4 transition-colors hover:border-ink/25"
|
||||
className={`relative flex flex-col justify-between gap-6 border p-6 transition-[border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] ${
|
||||
address.isDefault
|
||||
? "border-honey/60 bg-honey/[0.05] shadow-[0_18px_34px_-26px_rgba(184,138,68,0.5)]"
|
||||
: "border-ink/10 hover:border-forest/40"
|
||||
}`}
|
||||
>
|
||||
{address.isDefault ? (
|
||||
<span className="absolute right-4 top-4 inline-flex items-center gap-1.5 text-[11px] tracking-wide-caps text-honey-dark">
|
||||
<HexMark className="h-2.5 w-2.5" />
|
||||
{t.addressesPage.standard}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex gap-4">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-forest">
|
||||
<AddressIcon />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-forest">
|
||||
{address.label}{" "}
|
||||
{address.isDefault ? <span className="text-honey-dark text-xs">· {t.addressesPage.standard}</span> : null}
|
||||
</p>
|
||||
<p className="font-serif text-xl text-forest">{address.label}</p>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{address.firstName} {address.lastName}
|
||||
<br />
|
||||
@@ -51,7 +59,7 @@ export default async function KontoAdressenPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm flex-shrink-0">
|
||||
<div className="flex items-center gap-5 border-t border-ink/10 pt-4 text-sm">
|
||||
{!address.isDefault ? (
|
||||
<form action={setDefaultAddressAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
@@ -78,9 +86,9 @@ export default async function KontoAdressenPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
||||
<FormPanel className="mt-14 max-w-xl">
|
||||
<h2 className="text-2xl text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<form action={addAddressAction} className="space-y-4">
|
||||
<Field
|
||||
name="label"
|
||||
label={t.addressesPage.labelField}
|
||||
@@ -109,7 +117,7 @@ export default async function KontoAdressenPage() {
|
||||
</label>
|
||||
<Button type="submit">{t.addressesPage.save}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -46,15 +47,17 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
||||
<BackLink href="/konto/bestellungen" label={t.retourePage.backLabel} />
|
||||
<SectionHeading eyebrow={t.retourePage.orderEyebrow(order.orderNumber)} title={t.retourePage.title} />
|
||||
|
||||
<ul className="mt-8 text-sm text-ink/70 space-y-1">
|
||||
<FormPanel className="mt-8">
|
||||
<ul className="divide-y divide-ink/10 border-y border-ink/10 text-sm text-ink/75">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
<li key={item.id} className="flex items-center gap-3 py-3">
|
||||
<HexMark className="h-2.5 w-2.5 text-honey" />
|
||||
<span className="tabular-nums text-ink/55">{item.quantity} ×</span>
|
||||
<span className="font-serif text-lg text-forest">{item.productName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
|
||||
<form action={requestReturnAction} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<div>
|
||||
@@ -73,6 +76,7 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
||||
</div>
|
||||
<Button type="submit">{t.retourePage.submit}</Button>
|
||||
</form>
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { TurnstileField } from "@/components/turnstile-widget";
|
||||
import { requestPasswordResetAction } from "../actions";
|
||||
@@ -14,7 +14,8 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
||||
|
||||
@@ -46,6 +47,7 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { TurnstileField } from "@/components/turnstile-widget";
|
||||
@@ -17,7 +17,8 @@ export default async function PasswortZuruecksetzenPage(
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
|
||||
|
||||
@@ -47,6 +48,7 @@ export default async function PasswortZuruecksetzenPage(
|
||||
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { TurnstileField } from "@/components/turnstile-widget";
|
||||
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
||||
@@ -19,7 +19,8 @@ export default async function ZweiFaktorCodePage() {
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto" label={t.twoFactorCodePage.cancelLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorCodePage.eyebrow}
|
||||
@@ -38,7 +39,7 @@ export default async function ZweiFaktorCodePage() {
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||
className="mt-2 w-full border border-ink/20 bg-background px-4 py-4 text-center font-serif text-3xl tracking-[0.4em] tabular-nums focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||
@@ -46,6 +47,7 @@ export default async function ZweiFaktorCodePage() {
|
||||
) : null}
|
||||
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
||||
</form>
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { getCurrentCustomer, getTwoFactorBackupCodesDisplay } from "@/lib/auth";
|
||||
import { dismissBackupCodesAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -19,20 +19,25 @@ export default async function ZweiFaktorBackupCodesPage() {
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorBackupCodesPage.eyebrow}
|
||||
title={t.twoFactorBackupCodesPage.title}
|
||||
subtitle={t.twoFactorBackupCodesPage.subtitle}
|
||||
/>
|
||||
|
||||
<ul className="mt-8 border border-ink/10 divide-y divide-ink/10 font-mono text-sm">
|
||||
{codes.map((code) => (
|
||||
<li key={code} className="px-4 py-3">
|
||||
<ol className="mt-8 grid grid-cols-2 gap-2">
|
||||
{codes.map((code, i) => (
|
||||
<li
|
||||
key={code}
|
||||
className="flex items-center gap-3 border border-ink/10 bg-cream/50 px-3 py-3 font-mono text-sm tabular-nums tracking-wider text-forest"
|
||||
>
|
||||
<span className="text-[10px] text-ink/45 tabular-nums">{String(i + 1).padStart(2, "0")}</span>
|
||||
{code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ol>
|
||||
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.twoFactorBackupCodesPage.warning}
|
||||
@@ -41,6 +46,7 @@ export default async function ZweiFaktorBackupCodesPage() {
|
||||
<form action={dismissBackupCodesAction} className="mt-8">
|
||||
<Button type="submit">{t.twoFactorBackupCodesPage.done}</Button>
|
||||
</form>
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getCurrentCustomer, getTwoFactorSetup } from "@/lib/auth";
|
||||
import { generateTotpQrCodeDataUrl } from "@/lib/two-factor";
|
||||
@@ -24,7 +24,8 @@ export default async function ZweiFaktorEinrichtenPage() {
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto/zwei-faktor" label={t.twoFactorSetupPage.backLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorSetupPage.eyebrow}
|
||||
@@ -55,6 +56,7 @@ export default async function ZweiFaktorEinrichtenPage() {
|
||||
</label>
|
||||
<Button type="submit">{t.twoFactorSetupPage.activate}</Button>
|
||||
</form>
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { Container, SectionHeading, Button, FormPanel } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
@@ -25,7 +25,8 @@ export default async function ZweiFaktorPage() {
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorPage.eyebrow}
|
||||
@@ -61,6 +62,7 @@ export default async function ZweiFaktorPage() {
|
||||
<Button type="submit">{t.twoFactorPage.setupButton}</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CopyCodeButton } from "@/components/copy-code-button";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -38,13 +39,24 @@ export default async function NewsletterBestaetigtPage(props: PageProps<"/newsle
|
||||
<p className="mt-6 text-ink/70">{text}</p>
|
||||
|
||||
{status === "ok" ? (
|
||||
<div className="mt-8 border border-forest/20 bg-forest/5 p-6">
|
||||
// Der Rabatt als Gutschein: gestrichelter Rand wie zum Ausschneiden,
|
||||
// Code gross und mit einem Klick kopierbar.
|
||||
<div className="relative mt-10 border-2 border-dashed border-honey/60 bg-honey/[0.06] px-6 py-8">
|
||||
<span aria-hidden="true" className="absolute -left-3 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full bg-background" />
|
||||
<span aria-hidden="true" className="absolute -right-3 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full bg-background" />
|
||||
<p className="text-sm text-ink/70">{t.newsletterBestaetigtPage.discountHeading}</p>
|
||||
<p className="mt-2 text-2xl text-forest tracking-wide">
|
||||
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}{" "}
|
||||
<span className="font-medium">{NEWSLETTER_DISCOUNT_CODE}</span>
|
||||
<p className="mt-2 font-serif text-2xl text-forest">
|
||||
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink/70">{t.newsletterBestaetigtPage.discountCopyHint}</p>
|
||||
<p className="mt-4 font-mono text-3xl tracking-[0.2em] text-honey-dark select-all">{NEWSLETTER_DISCOUNT_CODE}</p>
|
||||
<div className="mt-5 flex flex-col items-center gap-2">
|
||||
<CopyCodeButton
|
||||
code={NEWSLETTER_DISCOUNT_CODE}
|
||||
label={t.newsletterBestaetigtPage.copyCode}
|
||||
copiedLabel={t.newsletterBestaetigtPage.codeCopied}
|
||||
/>
|
||||
<p className="text-xs text-ink/70">{t.newsletterBestaetigtPage.discountCopyHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
|
||||
@@ -51,12 +52,17 @@ function NavLinks({
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`block px-5 py-2.5 text-sm border-l-2 transition-colors ${
|
||||
active
|
||||
? "border-honey bg-cream/10 text-cream"
|
||||
: "border-transparent text-cream/70 hover:text-cream hover:bg-cream/5"
|
||||
className={`group flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${
|
||||
active ? "bg-cream/10 text-cream" : "text-cream/70 hover:text-cream hover:bg-cream/5"
|
||||
}`}
|
||||
>
|
||||
{/* Wabe statt farbigem Seitenbalken markiert die aktive Seite. */}
|
||||
<HexMark
|
||||
filled={active}
|
||||
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
||||
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
||||
}`}
|
||||
/>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
@@ -95,7 +101,8 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
||||
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||
<Wordmark />
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
@@ -31,31 +32,33 @@ export function CookieConsentBanner() {
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.6 } }}
|
||||
exit={{ y: 24, opacity: 0, transition: { duration: 0.2 } }}
|
||||
role="dialog"
|
||||
aria-label={t.cookieConsent.dialogLabel}
|
||||
className="fixed inset-x-0 bottom-0 z-50 bg-forest text-cream border-t border-cream/20"
|
||||
// Schwebende Karte statt bildschirmbreiter Leiste: verdeckt weniger
|
||||
// Inhalt, bleibt aber unuebersehbar.
|
||||
className="fixed inset-x-4 bottom-4 z-50 overflow-hidden bg-forest text-cream shadow-[0_24px_60px_-20px_rgba(36,53,43,0.7)] sm:left-6 sm:right-auto sm:bottom-6 sm:max-w-md"
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-6 py-6 flex flex-col md:flex-row md:items-center gap-4">
|
||||
<p className="text-sm text-cream/85 leading-relaxed flex-1">
|
||||
<HoneycombPattern className="text-honey-light/[0.1]" size={18} fade="right" />
|
||||
<div className="relative px-6 py-6 flex flex-col gap-5">
|
||||
<p className="text-sm text-cream/85 leading-relaxed">
|
||||
{t.cookieConsent.bannerTextBeforeLink}
|
||||
<Link href="/cookie-richtlinie" className="underline">
|
||||
{t.cookieConsent.policyLinkLabel}
|
||||
</Link>
|
||||
{t.cookieConsent.bannerTextAfterLink}
|
||||
</p>
|
||||
<div className="flex flex-shrink-0 flex-wrap gap-3">
|
||||
<Button
|
||||
variant="inverted"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => choose("essential")}
|
||||
>
|
||||
{/* Beide Wahlmoeglichkeiten gleich gewichtet (Datenschutzbehoerden
|
||||
verlangen, dass Ablehnen nicht schwerer faellt als Zustimmen).
|
||||
Vorher war "Alle akzeptieren" Waldgruen auf Waldgruen und damit
|
||||
kaum als Knopf erkennbar. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("essential")}>
|
||||
{t.cookieConsent.essentialOnly}
|
||||
</Button>
|
||||
<Button className="whitespace-nowrap" onClick={() => choose("all")}>
|
||||
<Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("all")}>
|
||||
{t.cookieConsent.acceptAll}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Kopiert einen Gutscheincode in die Zwischenablage und bestaetigt das kurz
|
||||
* am Knopf selbst. Ohne Clipboard-API (aeltere Browser, unsicherer Kontext)
|
||||
* bleibt der Code sichtbar und markierbar — der Knopf meldet dann nichts.
|
||||
*/
|
||||
export function CopyCodeButton({ code, label, copiedLabel }: { code: string; label: string; copiedLabel: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Kein Zugriff auf die Zwischenablage — der Code bleibt zum Markieren sichtbar.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="inline-flex h-10 items-center gap-2 border border-forest px-4 text-xs tracking-wide-caps text-forest transition-colors hover:bg-forest hover:text-cream"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={copied ? "done" : "idle"}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
{copied ? (
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<>
|
||||
<rect x="8" y="8" width="12" height="12" rx="1.5" />
|
||||
<path d="M16 8V5.5A1.5 1.5 0 0 0 14.5 4h-9A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16H8" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{copied ? copiedLabel : label}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,13 @@ export interface ShipmentTrackingHistoryLabels {
|
||||
statuses: Record<DhlTrackingStatus, string>;
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<DhlTrackingStatus, string> = {
|
||||
"pre-transit": "bg-ink/30",
|
||||
transit: "bg-honey-dark",
|
||||
delivered: "bg-forest",
|
||||
failure: "bg-red-500",
|
||||
unknown: "bg-ink/30",
|
||||
// Ausgeschrieben: Tailwind erzeugt nur Klassen, die woertlich im Quelltext stehen.
|
||||
const STATUS_FILL: Record<DhlTrackingStatus, string> = {
|
||||
"pre-transit": "fill-ink/30",
|
||||
transit: "fill-honey-dark",
|
||||
delivered: "fill-forest",
|
||||
failure: "fill-red-500",
|
||||
unknown: "fill-ink/30",
|
||||
};
|
||||
|
||||
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
||||
@@ -74,15 +75,31 @@ export function ShipmentTrackingHistory({
|
||||
{info.events.length > 0 ? (
|
||||
<ol className="mt-4">
|
||||
{info.events.map((event, i) => (
|
||||
<li key={`${event.timestamp.getTime()}-${i}`} className="relative pl-5 pb-4 last:pb-0">
|
||||
<li
|
||||
key={`${event.timestamp.getTime()}-${i}`}
|
||||
className="motion-rise relative pl-7 pb-5 last:pb-0 animate-[wh-rise_0.45s_var(--ease-arrive)_both]"
|
||||
style={{ animationDelay: `${Math.min(i, 8) * 70}ms` }}
|
||||
>
|
||||
{i < info.events.length - 1 ? (
|
||||
<span className="absolute left-[4px] top-3 bottom-0 w-px bg-ink/15" aria-hidden="true" />
|
||||
<span className="absolute left-[6px] top-4 bottom-0 w-px bg-ink/15" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={`absolute left-0 top-1.5 w-2.5 h-2.5 rounded-full ${i === 0 ? STATUS_DOT[event.status] : "bg-ink/20"}`}
|
||||
{/* Neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
|
||||
das Paket unterwegs ist), aeltere als leere Waben. */}
|
||||
<svg
|
||||
viewBox="0 0 24 26"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className={`text-sm ${i === 0 ? "text-ink" : "text-ink/70"}`}>{event.description}</p>
|
||||
className={`absolute left-0 top-1 h-3.5 w-3.5 rounded-full ${
|
||||
i === 0 && event.status === "transit" ? "motion-pulse animate-[wh-pulse_2.2s_ease-out_infinite]" : ""
|
||||
}`}
|
||||
>
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
className={i === 0 ? STATUS_FILL[event.status] : "fill-background"}
|
||||
stroke={i === 0 ? "none" : "color-mix(in oklab, var(--color-ink) 30%, transparent)"}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
<p className={`text-sm ${i === 0 ? "font-serif text-lg text-forest" : "text-ink/70"}`}>{event.description}</p>
|
||||
<p className="mt-0.5 text-xs text-ink/55">
|
||||
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
||||
{event.location ? ` · ${event.location}` : null}
|
||||
|
||||
@@ -143,13 +143,19 @@ function SearchButton() {
|
||||
<div className="relative flex items-center">
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
// Legt sich UEBER die Navigation (absolut, von rechts aufklappend)
|
||||
// statt sie beiseitezuschieben — vorher drueckte das Feld die
|
||||
// Kopfzeile auseinander und erzeugte einen waagerechten Scrollbalken.
|
||||
<motion.form
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 180, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
initial={{ opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 16 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
action="/shop"
|
||||
method="GET"
|
||||
className="overflow-visible"
|
||||
// Deckende Unterlage ueber die volle Kopfzeilenhoehe, damit nichts
|
||||
// von der Navigation darunter durchscheint.
|
||||
className="absolute right-full top-1/2 mr-1 flex h-16 w-[21rem] -translate-y-1/2 items-center bg-background pl-6"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
@@ -159,23 +165,45 @@ function SearchButton() {
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
|
||||
className="w-full border-b border-forest bg-transparent px-2 py-1.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
/>
|
||||
{visibleSuggestions.length > 0 ? (
|
||||
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
|
||||
{visibleSuggestions.map((s) => (
|
||||
<li key={s.slug}>
|
||||
<motion.ul
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute top-full right-0 mt-3 w-80 bg-background border border-ink/10 py-2 shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)] z-50"
|
||||
>
|
||||
{visibleSuggestions.map((s, i) => (
|
||||
<motion.li
|
||||
key={s.slug}
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: i * 0.04 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/honig/${s.slug}`}
|
||||
onClick={closeSearch}
|
||||
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
|
||||
className="group flex items-center justify-between gap-4 px-4 py-2.5 text-ink transition-colors hover:bg-honey/10"
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="text-ink/70 whitespace-nowrap">{s.price}</span>
|
||||
<span className="font-serif text-lg leading-tight">
|
||||
<HighlightMatch text={s.name} query={query} />
|
||||
</span>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap text-sm text-ink/70 tabular-nums">
|
||||
{s.price}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5 -translate-x-1 opacity-0 transition-[opacity,transform] duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.ul>
|
||||
) : null}
|
||||
</motion.form>
|
||||
) : null}
|
||||
@@ -187,15 +215,49 @@ function SearchButton() {
|
||||
aria-label={t.nav.openSearch}
|
||||
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0"
|
||||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.svg
|
||||
key={open ? "close" : "search"}
|
||||
initial={{ rotate: -45, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: 45, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
{open ? (
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
) : (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</>
|
||||
)}
|
||||
</motion.svg>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hebt den gesuchten Begriff im Vorschlag hervor — der Blick findet sofort, warum er passt. */
|
||||
function HighlightMatch({ text, query }: { text: string; query: string }) {
|
||||
const needle = query.trim();
|
||||
const index = needle ? text.toLowerCase().indexOf(needle.toLowerCase()) : -1;
|
||||
if (index < 0) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, index)}
|
||||
<mark className="bg-honey-pale/70 text-forest">{text.slice(index, index + needle.length)}</mark>
|
||||
{text.slice(index + needle.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountIconButton() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
|
||||
export function Container({
|
||||
id,
|
||||
@@ -164,3 +164,21 @@ export function FramedMedia({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Karte fuer die kurzen Formularseiten (Passwort, Zwei-Faktor, ...): hebt
|
||||
* das Formular von der Flaeche ab, mit Wabenraster in der oberen Ecke als
|
||||
* leiser Markengruss.
|
||||
*/
|
||||
export function FormPanel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden border border-ink/10 bg-background px-7 py-9 shadow-[0_24px_50px_-34px_rgba(36,53,43,0.45)] sm:px-10 sm:py-11 ${className}`}
|
||||
>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute -right-8 -top-8 h-40 w-40">
|
||||
<HoneycombPattern className="text-honey/30" size={14} />
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -668,6 +668,8 @@ export const de: Dictionary = {
|
||||
discountHeading: "Dein Willkommensrabatt",
|
||||
discountText: (percent) => `${percent}% auf deine nächste Bestellung mit Code`,
|
||||
discountCopyHint: "Code beim Checkout eingeben.",
|
||||
copyCode: "Code kopieren",
|
||||
codeCopied: "Kopiert",
|
||||
},
|
||||
adminReviewsPage: {
|
||||
backToDashboard: "Zurück zum Dashboard",
|
||||
@@ -1451,6 +1453,8 @@ export const de: Dictionary = {
|
||||
eyebrow: "Checkout",
|
||||
emptyCart: "Dein Warenkorb ist leer",
|
||||
yourDetails: "Deine Daten",
|
||||
stepPayment: "Bezahlen",
|
||||
stepsAriaLabel: "Schritte der Bestellung",
|
||||
firstName: "Vorname",
|
||||
lastName: "Nachname",
|
||||
email: "E-Mail",
|
||||
|
||||
@@ -656,6 +656,8 @@ export const en: Dictionary = {
|
||||
discountHeading: "Your welcome discount",
|
||||
discountText: (percent) => `${percent}% off your next order with code`,
|
||||
discountCopyHint: "Enter the code at checkout.",
|
||||
copyCode: "Copy code",
|
||||
codeCopied: "Copied",
|
||||
},
|
||||
adminReviewsPage: {
|
||||
backToDashboard: "Back to dashboard",
|
||||
@@ -1437,6 +1439,8 @@ export const en: Dictionary = {
|
||||
eyebrow: "Checkout",
|
||||
emptyCart: "Your cart is empty",
|
||||
yourDetails: "Your Details",
|
||||
stepPayment: "Payment",
|
||||
stepsAriaLabel: "Checkout steps",
|
||||
firstName: "First name",
|
||||
lastName: "Last name",
|
||||
email: "Email",
|
||||
|
||||
@@ -347,6 +347,8 @@ export interface Dictionary {
|
||||
discountHeading: string;
|
||||
discountText: (percent: number) => string;
|
||||
discountCopyHint: string;
|
||||
copyCode: string;
|
||||
codeCopied: string;
|
||||
};
|
||||
adminReviewsPage: {
|
||||
backToDashboard: string;
|
||||
@@ -1100,6 +1102,8 @@ export interface Dictionary {
|
||||
eyebrow: string;
|
||||
emptyCart: string;
|
||||
yourDetails: string;
|
||||
stepPayment: string;
|
||||
stepsAriaLabel: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
|
||||
Reference in new issue
Block a user