Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ddceaa39 | ||
|
|
e6455b05e6 | ||
|
|
578ec767b2 | ||
|
|
d4bb6ec1cf | ||
|
|
baebf78ee4 | ||
|
|
ee854bd77b |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
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 { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||||
import { getOrderForTracking } from "@/lib/account-data";
|
import { getOrderForTracking } from "@/lib/account-data";
|
||||||
import { formatPrice, formatDate } from "@/lib/format";
|
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} />
|
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
|
||||||
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
<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">
|
<label className="block text-sm">
|
||||||
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
||||||
<input
|
<input
|
||||||
@@ -77,6 +78,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
</label>
|
</label>
|
||||||
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormPanel>
|
||||||
|
|
||||||
{rateLimited ? (
|
{rateLimited ? (
|
||||||
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
<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}
|
) : null}
|
||||||
|
|
||||||
{order ? (
|
{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 className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-forest">{order.orderNumber}</p>
|
<p className="text-forest">{order.orderNumber}</p>
|
||||||
|
|||||||
@@ -303,6 +303,15 @@ export function CheckoutForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-16">
|
<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">
|
<Container className="grid lg:grid-cols-3 gap-14">
|
||||||
{payment && stripePublishableKey ? (
|
{payment && stripePublishableKey ? (
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
@@ -358,21 +367,52 @@ export function CheckoutForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
||||||
<div className="flex gap-4">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
|
{(["VERSAND", "ABHOLUNG"] as const).map((option) => {
|
||||||
|
const selected = fulfillment === option;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={option}
|
key={option}
|
||||||
onClick={() => setFulfillment(option)}
|
onClick={() => setFulfillment(option)}
|
||||||
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
|
aria-pressed={selected}
|
||||||
fulfillment === option
|
className={`group relative flex items-center gap-4 border px-5 py-4 text-left transition-[border-color,background-color,box-shadow] duration-300 ${
|
||||||
? "border-forest bg-forest text-cream"
|
selected
|
||||||
: "border-ink/20 text-ink/70"
|
? "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}
|
{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>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{fulfillment === "VERSAND" ? (
|
{fulfillment === "VERSAND" ? (
|
||||||
<p className="mt-2 text-xs text-ink/70">
|
<p className="mt-2 text-xs text-ink/70">
|
||||||
@@ -603,15 +643,16 @@ export function CheckoutForm({
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside hidden={payment !== null} className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
<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-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
<h2 className="text-2xl text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||||
<ul className="space-y-3 text-sm">
|
<ul className="space-y-3 text-sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.productId} className="flex justify-between">
|
<li key={item.productId} className="flex items-baseline justify-between gap-4">
|
||||||
<span>
|
<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>
|
||||||
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
<span className="tabular-nums">{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -637,9 +678,9 @@ export function CheckoutForm({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
<div className="mt-4 pt-4 border-t border-ink/10 flex items-baseline justify-between text-forest">
|
||||||
<span>{t.cartPage.total}</span>
|
<span className="text-base">{t.cartPage.total}</span>
|
||||||
<span>{formatPrice(total, "EUR", locale)}</span>
|
<span className="font-serif text-3xl tabular-nums">{formatPrice(total, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -685,3 +726,52 @@ function Field({
|
|||||||
</label>
|
</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 (
|
return (
|
||||||
<div className="py-24">
|
<div className="py-24">
|
||||||
<Container className="max-w-xl text-center">
|
<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
|
<SectionHeading
|
||||||
eyebrow={t.siteErrorPage.eyebrow}
|
eyebrow={t.siteErrorPage.eyebrow}
|
||||||
title={t.siteErrorPage.title}
|
title={t.siteErrorPage.title}
|
||||||
|
|||||||
@@ -283,3 +283,21 @@ textarea::placeholder {
|
|||||||
animation: none !important;
|
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 { Metadata } from "next";
|
||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
import { redirect } from "next/navigation";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { AccountEmptyState, AddressIcon } from "@/components/account-ui";
|
import { AccountEmptyState, AddressIcon } from "@/components/account-ui";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
@@ -27,21 +28,28 @@ export default async function KontoAdressenPage() {
|
|||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
|
<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) => (
|
{addresses.map((address) => (
|
||||||
<div
|
<div
|
||||||
key={address.id}
|
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">
|
<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">
|
<span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-forest">
|
||||||
<AddressIcon />
|
<AddressIcon />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-forest">
|
<p className="font-serif text-xl text-forest">{address.label}</p>
|
||||||
{address.label}{" "}
|
|
||||||
{address.isDefault ? <span className="text-honey-dark text-xs">· {t.addressesPage.standard}</span> : null}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-ink/70 mt-1">
|
<p className="text-sm text-ink/70 mt-1">
|
||||||
{address.firstName} {address.lastName}
|
{address.firstName} {address.lastName}
|
||||||
<br />
|
<br />
|
||||||
@@ -51,7 +59,7 @@ export default async function KontoAdressenPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{!address.isDefault ? (
|
||||||
<form action={setDefaultAddressAction}>
|
<form action={setDefaultAddressAction}>
|
||||||
<input type="hidden" name="addressId" value={address.id} />
|
<input type="hidden" name="addressId" value={address.id} />
|
||||||
@@ -78,9 +86,9 @@ export default async function KontoAdressenPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
<FormPanel className="mt-14 max-w-xl">
|
||||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
<h2 className="text-2xl text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
<form action={addAddressAction} className="space-y-4">
|
||||||
<Field
|
<Field
|
||||||
name="label"
|
name="label"
|
||||||
label={t.addressesPage.labelField}
|
label={t.addressesPage.labelField}
|
||||||
@@ -109,7 +117,7 @@ export default async function KontoAdressenPage() {
|
|||||||
</label>
|
</label>
|
||||||
<Button type="submit">{t.addressesPage.save}</Button>
|
<Button type="submit">{t.addressesPage.save}</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect, notFound } from "next/navigation";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
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} />
|
<BackLink href="/konto/bestellungen" label={t.retourePage.backLabel} />
|
||||||
<SectionHeading eyebrow={t.retourePage.orderEyebrow(order.orderNumber)} title={t.retourePage.title} />
|
<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) => (
|
{order.items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id} className="flex items-center gap-3 py-3">
|
||||||
{item.quantity} × {item.productName}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
<form action={requestReturnAction} className="mt-6 space-y-4">
|
<form action={requestReturnAction} className="mt-6 space-y-4">
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
<div>
|
<div>
|
||||||
@@ -73,6 +76,7 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
|||||||
</div>
|
</div>
|
||||||
<Button type="submit">{t.retourePage.submit}</Button>
|
<Button type="submit">{t.retourePage.submit}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { TurnstileField } from "@/components/turnstile-widget";
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { requestPasswordResetAction } from "../actions";
|
import { requestPasswordResetAction } from "../actions";
|
||||||
@@ -14,7 +14,8 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-md">
|
<Container className="max-w-lg">
|
||||||
|
<FormPanel>
|
||||||
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
|||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { PasswordField } from "@/components/password-field";
|
import { PasswordField } from "@/components/password-field";
|
||||||
import { TurnstileField } from "@/components/turnstile-widget";
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
@@ -17,7 +17,8 @@ export default async function PasswortZuruecksetzenPage(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-md">
|
<Container className="max-w-lg">
|
||||||
|
<FormPanel>
|
||||||
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
||||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
|
<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>
|
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { TurnstileField } from "@/components/turnstile-widget";
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
||||||
@@ -19,7 +19,8 @@ export default async function ZweiFaktorCodePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-md">
|
<Container className="max-w-lg">
|
||||||
|
<FormPanel>
|
||||||
<BackLink href="/konto" label={t.twoFactorCodePage.cancelLabel} />
|
<BackLink href="/konto" label={t.twoFactorCodePage.cancelLabel} />
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow={t.twoFactorCodePage.eyebrow}
|
eyebrow={t.twoFactorCodePage.eyebrow}
|
||||||
@@ -38,7 +39,7 @@ export default async function ZweiFaktorCodePage() {
|
|||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
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>
|
</label>
|
||||||
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
@@ -46,6 +47,7 @@ export default async function ZweiFaktorCodePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
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 { getCurrentCustomer, getTwoFactorBackupCodesDisplay } from "@/lib/auth";
|
||||||
import { dismissBackupCodesAction } from "../../actions";
|
import { dismissBackupCodesAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -19,20 +19,25 @@ export default async function ZweiFaktorBackupCodesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-md">
|
<Container className="max-w-lg">
|
||||||
|
<FormPanel>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow={t.twoFactorBackupCodesPage.eyebrow}
|
eyebrow={t.twoFactorBackupCodesPage.eyebrow}
|
||||||
title={t.twoFactorBackupCodesPage.title}
|
title={t.twoFactorBackupCodesPage.title}
|
||||||
subtitle={t.twoFactorBackupCodesPage.subtitle}
|
subtitle={t.twoFactorBackupCodesPage.subtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ul className="mt-8 border border-ink/10 divide-y divide-ink/10 font-mono text-sm">
|
<ol className="mt-8 grid grid-cols-2 gap-2">
|
||||||
{codes.map((code) => (
|
{codes.map((code, i) => (
|
||||||
<li key={code} className="px-4 py-3">
|
<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}
|
{code}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ol>
|
||||||
|
|
||||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
{t.twoFactorBackupCodesPage.warning}
|
{t.twoFactorBackupCodesPage.warning}
|
||||||
@@ -41,6 +46,7 @@ export default async function ZweiFaktorBackupCodesPage() {
|
|||||||
<form action={dismissBackupCodesAction} className="mt-8">
|
<form action={dismissBackupCodesAction} className="mt-8">
|
||||||
<Button type="submit">{t.twoFactorBackupCodesPage.done}</Button>
|
<Button type="submit">{t.twoFactorBackupCodesPage.done}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { getCurrentCustomer, getTwoFactorSetup } from "@/lib/auth";
|
import { getCurrentCustomer, getTwoFactorSetup } from "@/lib/auth";
|
||||||
import { generateTotpQrCodeDataUrl } from "@/lib/two-factor";
|
import { generateTotpQrCodeDataUrl } from "@/lib/two-factor";
|
||||||
@@ -24,7 +24,8 @@ export default async function ZweiFaktorEinrichtenPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-md">
|
<Container className="max-w-lg">
|
||||||
|
<FormPanel>
|
||||||
<BackLink href="/konto/zwei-faktor" label={t.twoFactorSetupPage.backLabel} />
|
<BackLink href="/konto/zwei-faktor" label={t.twoFactorSetupPage.backLabel} />
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow={t.twoFactorSetupPage.eyebrow}
|
eyebrow={t.twoFactorSetupPage.eyebrow}
|
||||||
@@ -55,6 +56,7 @@ export default async function ZweiFaktorEinrichtenPage() {
|
|||||||
</label>
|
</label>
|
||||||
<Button type="submit">{t.twoFactorSetupPage.activate}</Button>
|
<Button type="submit">{t.twoFactorSetupPage.activate}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
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 { BackLink } from "@/components/back-link";
|
||||||
import { ShieldIcon } from "@/components/account-ui";
|
import { ShieldIcon } from "@/components/account-ui";
|
||||||
import { PasswordField } from "@/components/password-field";
|
import { PasswordField } from "@/components/password-field";
|
||||||
@@ -25,7 +25,8 @@ export default async function ZweiFaktorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-md">
|
<Container className="max-w-lg">
|
||||||
|
<FormPanel>
|
||||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow={t.twoFactorPage.eyebrow}
|
eyebrow={t.twoFactorPage.eyebrow}
|
||||||
@@ -61,6 +62,7 @@ export default async function ZweiFaktorPage() {
|
|||||||
<Button type="submit">{t.twoFactorPage.setupButton}</Button>
|
<Button type="submit">{t.twoFactorPage.setupButton}</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
</FormPanel>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { CopyCodeButton } from "@/components/copy-code-button";
|
||||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
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>
|
<p className="mt-6 text-ink/70">{text}</p>
|
||||||
|
|
||||||
{status === "ok" ? (
|
{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="text-sm text-ink/70">{t.newsletterBestaetigtPage.discountHeading}</p>
|
||||||
<p className="mt-2 text-2xl text-forest tracking-wide">
|
<p className="mt-2 font-serif text-2xl text-forest">
|
||||||
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}{" "}
|
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}
|
||||||
<span className="font-medium">{NEWSLETTER_DISCOUNT_CODE}</span>
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Logomark } from "@/components/logomark";
|
import { Logomark } from "@/components/logomark";
|
||||||
|
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
@@ -51,12 +52,17 @@ function NavLinks({
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={`block px-5 py-2.5 text-sm border-l-2 transition-colors ${
|
className={`group flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${
|
||||||
active
|
active ? "bg-cream/10 text-cream" : "text-cream/70 hover:text-cream hover:bg-cream/5"
|
||||||
? "border-honey bg-cream/10 text-cream"
|
|
||||||
: "border-transparent 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}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -95,7 +101,8 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
<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">
|
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||||
<Wordmark />
|
<Wordmark />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { HoneycombPattern } from "@/components/honeycomb";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
@@ -31,31 +32,33 @@ export function CookieConsentBanner() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{visible ? (
|
{visible ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: 100, opacity: 0 }}
|
initial={{ y: 40, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.6 } }}
|
||||||
exit={{ y: 100, opacity: 0 }}
|
exit={{ y: 24, opacity: 0, transition: { duration: 0.2 } }}
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t.cookieConsent.dialogLabel}
|
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">
|
<HoneycombPattern className="text-honey-light/[0.1]" size={18} fade="right" />
|
||||||
<p className="text-sm text-cream/85 leading-relaxed flex-1">
|
<div className="relative px-6 py-6 flex flex-col gap-5">
|
||||||
|
<p className="text-sm text-cream/85 leading-relaxed">
|
||||||
{t.cookieConsent.bannerTextBeforeLink}
|
{t.cookieConsent.bannerTextBeforeLink}
|
||||||
<Link href="/cookie-richtlinie" className="underline">
|
<Link href="/cookie-richtlinie" className="underline">
|
||||||
{t.cookieConsent.policyLinkLabel}
|
{t.cookieConsent.policyLinkLabel}
|
||||||
</Link>
|
</Link>
|
||||||
{t.cookieConsent.bannerTextAfterLink}
|
{t.cookieConsent.bannerTextAfterLink}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-shrink-0 flex-wrap gap-3">
|
{/* Beide Wahlmoeglichkeiten gleich gewichtet (Datenschutzbehoerden
|
||||||
<Button
|
verlangen, dass Ablehnen nicht schwerer faellt als Zustimmen).
|
||||||
variant="inverted"
|
Vorher war "Alle akzeptieren" Waldgruen auf Waldgruen und damit
|
||||||
className="whitespace-nowrap"
|
kaum als Knopf erkennbar. */}
|
||||||
onClick={() => choose("essential")}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
>
|
<Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("essential")}>
|
||||||
{t.cookieConsent.essentialOnly}
|
{t.cookieConsent.essentialOnly}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="whitespace-nowrap" onClick={() => choose("all")}>
|
<Button variant="inverted" className="whitespace-nowrap px-4" onClick={() => choose("all")}>
|
||||||
{t.cookieConsent.acceptAll}
|
{t.cookieConsent.acceptAll}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>;
|
statuses: Record<DhlTrackingStatus, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_DOT: Record<DhlTrackingStatus, string> = {
|
// Ausgeschrieben: Tailwind erzeugt nur Klassen, die woertlich im Quelltext stehen.
|
||||||
"pre-transit": "bg-ink/30",
|
const STATUS_FILL: Record<DhlTrackingStatus, string> = {
|
||||||
transit: "bg-honey-dark",
|
"pre-transit": "fill-ink/30",
|
||||||
delivered: "bg-forest",
|
transit: "fill-honey-dark",
|
||||||
failure: "bg-red-500",
|
delivered: "fill-forest",
|
||||||
unknown: "bg-ink/30",
|
failure: "fill-red-500",
|
||||||
|
unknown: "fill-ink/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
||||||
@@ -74,15 +75,31 @@ export function ShipmentTrackingHistory({
|
|||||||
{info.events.length > 0 ? (
|
{info.events.length > 0 ? (
|
||||||
<ol className="mt-4">
|
<ol className="mt-4">
|
||||||
{info.events.map((event, i) => (
|
{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 ? (
|
{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}
|
) : null}
|
||||||
<span
|
{/* Neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
|
||||||
className={`absolute left-0 top-1.5 w-2.5 h-2.5 rounded-full ${i === 0 ? STATUS_DOT[event.status] : "bg-ink/20"}`}
|
das Paket unterwegs ist), aeltere als leere Waben. */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 26"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
<p className={`text-sm ${i === 0 ? "text-ink" : "text-ink/70"}`}>{event.description}</p>
|
</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">
|
<p className="mt-0.5 text-xs text-ink/55">
|
||||||
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
||||||
{event.location ? ` · ${event.location}` : null}
|
{event.location ? ` · ${event.location}` : null}
|
||||||
|
|||||||
@@ -143,13 +143,19 @@ function SearchButton() {
|
|||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open ? (
|
{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
|
<motion.form
|
||||||
initial={{ width: 0, opacity: 0 }}
|
initial={{ opacity: 0, x: 16 }}
|
||||||
animate={{ width: 180, opacity: 1 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ width: 0, opacity: 0 }}
|
exit={{ opacity: 0, x: 16 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||||
action="/shop"
|
action="/shop"
|
||||||
method="GET"
|
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
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -159,23 +165,45 @@ function SearchButton() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder={t.nav.searchPlaceholder}
|
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 ? (
|
{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">
|
<motion.ul
|
||||||
{visibleSuggestions.map((s) => (
|
initial={{ opacity: 0, y: -6 }}
|
||||||
<li key={s.slug}>
|
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
|
<Link
|
||||||
href={`/honig/${s.slug}`}
|
href={`/honig/${s.slug}`}
|
||||||
onClick={closeSearch}
|
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="font-serif text-lg leading-tight">
|
||||||
<span className="text-ink/70 whitespace-nowrap">{s.price}</span>
|
<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>
|
</Link>
|
||||||
</li>
|
</motion.li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</motion.ul>
|
||||||
) : null}
|
) : null}
|
||||||
</motion.form>
|
</motion.form>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -187,15 +215,49 @@ function SearchButton() {
|
|||||||
aria-label={t.nav.openSearch}
|
aria-label={t.nav.openSearch}
|
||||||
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0"
|
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">
|
<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" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
<path d="m20 20-3.5-3.5" />
|
<path d="m20 20-3.5-3.5" />
|
||||||
</svg>
|
</>
|
||||||
|
)}
|
||||||
|
</motion.svg>
|
||||||
|
</AnimatePresence>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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() {
|
function AccountIconButton() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
import { HexMark } from "@/components/honeycomb";
|
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||||
|
|
||||||
export function Container({
|
export function Container({
|
||||||
id,
|
id,
|
||||||
@@ -164,3 +164,21 @@ export function FramedMedia({
|
|||||||
</div>
|
</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",
|
discountHeading: "Dein Willkommensrabatt",
|
||||||
discountText: (percent) => `${percent}% auf deine nächste Bestellung mit Code`,
|
discountText: (percent) => `${percent}% auf deine nächste Bestellung mit Code`,
|
||||||
discountCopyHint: "Code beim Checkout eingeben.",
|
discountCopyHint: "Code beim Checkout eingeben.",
|
||||||
|
copyCode: "Code kopieren",
|
||||||
|
codeCopied: "Kopiert",
|
||||||
},
|
},
|
||||||
adminReviewsPage: {
|
adminReviewsPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
@@ -1451,6 +1453,8 @@ export const de: Dictionary = {
|
|||||||
eyebrow: "Checkout",
|
eyebrow: "Checkout",
|
||||||
emptyCart: "Dein Warenkorb ist leer",
|
emptyCart: "Dein Warenkorb ist leer",
|
||||||
yourDetails: "Deine Daten",
|
yourDetails: "Deine Daten",
|
||||||
|
stepPayment: "Bezahlen",
|
||||||
|
stepsAriaLabel: "Schritte der Bestellung",
|
||||||
firstName: "Vorname",
|
firstName: "Vorname",
|
||||||
lastName: "Nachname",
|
lastName: "Nachname",
|
||||||
email: "E-Mail",
|
email: "E-Mail",
|
||||||
|
|||||||
@@ -656,6 +656,8 @@ export const en: Dictionary = {
|
|||||||
discountHeading: "Your welcome discount",
|
discountHeading: "Your welcome discount",
|
||||||
discountText: (percent) => `${percent}% off your next order with code`,
|
discountText: (percent) => `${percent}% off your next order with code`,
|
||||||
discountCopyHint: "Enter the code at checkout.",
|
discountCopyHint: "Enter the code at checkout.",
|
||||||
|
copyCode: "Copy code",
|
||||||
|
codeCopied: "Copied",
|
||||||
},
|
},
|
||||||
adminReviewsPage: {
|
adminReviewsPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
@@ -1437,6 +1439,8 @@ export const en: Dictionary = {
|
|||||||
eyebrow: "Checkout",
|
eyebrow: "Checkout",
|
||||||
emptyCart: "Your cart is empty",
|
emptyCart: "Your cart is empty",
|
||||||
yourDetails: "Your Details",
|
yourDetails: "Your Details",
|
||||||
|
stepPayment: "Payment",
|
||||||
|
stepsAriaLabel: "Checkout steps",
|
||||||
firstName: "First name",
|
firstName: "First name",
|
||||||
lastName: "Last name",
|
lastName: "Last name",
|
||||||
email: "Email",
|
email: "Email",
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ export interface Dictionary {
|
|||||||
discountHeading: string;
|
discountHeading: string;
|
||||||
discountText: (percent: number) => string;
|
discountText: (percent: number) => string;
|
||||||
discountCopyHint: string;
|
discountCopyHint: string;
|
||||||
|
copyCode: string;
|
||||||
|
codeCopied: string;
|
||||||
};
|
};
|
||||||
adminReviewsPage: {
|
adminReviewsPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
@@ -1100,6 +1102,8 @@ export interface Dictionary {
|
|||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
emptyCart: string;
|
emptyCart: string;
|
||||||
yourDetails: string;
|
yourDetails: string;
|
||||||
|
stepPayment: string;
|
||||||
|
stepsAriaLabel: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
Reference in new issue
Block a user