Compare commits
3
Commits
b77516b9f5
...
86cc0ad310
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86cc0ad310 | ||
|
|
1ff5f8c722 | ||
|
|
6950657c91 |
No files matched your search
@@ -64,7 +64,7 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-forest">{inquiry.companyName}</p>
|
||||
<p className="text-heading">{inquiry.companyName}</p>
|
||||
<AdminBadge tone={STATUS_TONES[inquiry.status] ?? "neutral"} pulse={inquiry.status === "NEU"}>
|
||||
{statusLabels[inquiry.status] ?? inquiry.status}
|
||||
</AdminBadge>
|
||||
@@ -91,7 +91,7 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-forest px-3 py-1.5 text-xs text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
className="border border-heading px-3 py-1.5 text-xs text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{t.adminInquiriesPage.save}
|
||||
</button>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async function AdminAuditLogPage() {
|
||||
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 whitespace-nowrap tabular-nums text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
||||
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
|
||||
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
|
||||
<td className="p-4 whitespace-nowrap font-mono text-xs text-heading">{entry.action}</td>
|
||||
<td className="p-4 whitespace-nowrap text-ink/70">
|
||||
{entry.entityType}
|
||||
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/benutzer" label={tu.backToUsers} />
|
||||
<h1 className="text-2xl text-forest mb-2">
|
||||
<h1 className="text-2xl text-heading mb-2">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">{tu.createdOn(formatDate(customer.createdAt))}</p>
|
||||
@@ -61,7 +61,7 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
||||
</AdminFormPanel>
|
||||
|
||||
<section className="mt-8 space-y-6 border border-ink/10 bg-background p-6 sm:p-8">
|
||||
<h2 className="font-serif text-xl text-forest">{tu.securityHeading}</h2>
|
||||
<h2 className="font-serif text-xl text-heading">{tu.securityHeading}</h2>
|
||||
<dl className="divide-y divide-ink/10 text-sm">
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<dt className="text-ink/70">{tu.twoFactorLabel}</dt>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default async function AdminBenutzerNewPage() {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/benutzer" label={tu.backToUsers} />
|
||||
<h1 className="text-2xl text-forest mb-2">{tu.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-2">{tu.title}</h1>
|
||||
<p className="text-sm text-ink/70 mb-8">{tu.intro}</p>
|
||||
|
||||
<AdminFormPanel action={createCustomerAction} actions={<Button type="submit">{tu.createAndSend}</Button>}>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
|
||||
return (
|
||||
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
<td className="p-4 text-heading">
|
||||
{customer.firstName} {customer.lastName}
|
||||
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
|
||||
</td>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
|
||||
<h1 className="text-2xl text-heading">{order.orderNumber}</h1>
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
</div>
|
||||
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||
@@ -161,7 +161,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
) : dhlConfigured ? (
|
||||
<form action={createDhlLabelAction}>
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<button type="submit" className="bg-honey-dark text-cream px-4 py-2 text-sm">
|
||||
<button type="submit" className="bg-honey-deep text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrderDetailPage.dhlCreateButton}
|
||||
</button>
|
||||
</form>
|
||||
@@ -215,7 +215,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="py-2">
|
||||
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline">
|
||||
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
|
||||
{item.productName}
|
||||
</Link>{" "}
|
||||
<span className="text-ink/65">({item.weightGrams} g)</span>
|
||||
@@ -249,7 +249,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-forest pt-1 border-t border-ink/10">
|
||||
<div className="flex justify-between text-heading pt-1 border-t border-ink/10">
|
||||
<span>{t.adminOrderDetailPage.totalLabel}</span>
|
||||
<span>{formatPrice(order.totalCents, order.currency)}</span>
|
||||
</div>
|
||||
@@ -282,7 +282,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
<span>{order.invoice.invoiceNumber}</span>
|
||||
<a
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
|
||||
className="link-draw text-sm text-forest"
|
||||
className="link-draw text-sm text-heading"
|
||||
>
|
||||
{t.adminOrderDetailPage.downloadPdf}
|
||||
</a>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest whitespace-nowrap">
|
||||
<td className="p-4 text-heading whitespace-nowrap">
|
||||
<Link href={`/admin/bestellungen/${order.id}`} className="link-draw">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
@@ -155,7 +155,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-forest px-3 py-1.5 text-xs text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
className="border border-heading px-3 py-1.5 text-xs text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{t.adminOrdersPage.save}
|
||||
</button>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
|
||||
{reviews.map((review) => (
|
||||
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4">
|
||||
<Link href={`/honig/${review.product.slug}`} className="link-draw text-forest">
|
||||
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
|
||||
{review.product.name}
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function AdminBatchEditPage(props: PageProps<"/admin/charge
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/chargen" label={tb.backToBatches} />
|
||||
<h1 className="text-2xl text-forest mb-2">{tb.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-2">{tb.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">
|
||||
{batch.code} · {batch.harvest.type} {batch.harvest.year}
|
||||
</p>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default async function AdminBatchesPage() {
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{batches.map((batch) => (
|
||||
<tr key={batch.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">{batch.code}</td>
|
||||
<td className="p-4 text-heading">{batch.code}</td>
|
||||
<td className="p-4">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</td>
|
||||
@@ -81,7 +81,7 @@ export default async function AdminBatchesPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||
<h2 className="font-serif text-xl text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||
<h2 className="font-serif text-xl text-heading mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||
<form action={createBatchAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 items-end gap-4">
|
||||
<AdminField
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/ernten" label={th.backToHarvests} />
|
||||
<h1 className="text-2xl text-forest mb-8">{th.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-8">{th.title}</h1>
|
||||
|
||||
{harvest.isPublished ? null : (
|
||||
<div className="mb-6 flex flex-col justify-between gap-3 border border-honey/40 bg-honey/5 px-4 py-3 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function AdminHarvestsPage() {
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{harvests.map((harvest) => (
|
||||
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">{harvest.type}</td>
|
||||
<td className="p-4 text-heading">{harvest.type}</td>
|
||||
<td className="p-4 tabular-nums">{harvest.year}</td>
|
||||
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(harvest.harvestDate)}</td>
|
||||
<td className="p-4">{harvest.location}</td>
|
||||
@@ -93,7 +93,7 @@ export default async function AdminHarvestsPage() {
|
||||
|
||||
<div className="mt-10 max-w-xl">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="font-serif text-xl text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<h2 className="font-serif text-xl text-heading mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<form action={createHarvestAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 items-end gap-4">
|
||||
<AdminField name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||
|
||||
@@ -21,13 +21,13 @@ export default function AdminError({
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
||||
<h1 className="text-2xl text-forest">{t.errorPage.title}</h1>
|
||||
<h1 className="text-2xl text-heading">{t.errorPage.title}</h1>
|
||||
<p className="mt-3 text-ink/70">{t.errorPage.description}</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-4">
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{t.errorPage.retry}
|
||||
</Button>
|
||||
<Link href="/admin" className="text-sm text-forest underline">
|
||||
<Link href="/admin" className="text-sm text-heading underline">
|
||||
{t.adminVouchersPage.backToDashboard}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gu
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
|
||||
<h1 className="text-2xl text-forest mb-2">{tv.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-2">{tv.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">{tv.redeemedSoFar(code.usedCount, code.maxUses)}</p>
|
||||
|
||||
<AdminFormPanel action={updateDiscountCodeAction} actions={<Button type="submit">{tv.save}</Button>}>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function AdminGutscheinNewPage() {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
|
||||
<h1 className="text-2xl text-forest mb-8">{tv.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-8">{tv.title}</h1>
|
||||
|
||||
<AdminFormPanel action={createDiscountCodeAction} actions={<Button type="submit">{tv.create}</Button>}>
|
||||
<AdminFieldset legend={tv.sectionDiscount}>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default async function AdminGutscheinePage() {
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{codes.map((code) => (
|
||||
<tr key={code.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest font-mono">{code.code}</td>
|
||||
<td className="p-4 text-heading font-mono">{code.code}</td>
|
||||
<td className="p-4">
|
||||
{code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}
|
||||
</td>
|
||||
|
||||
@@ -72,7 +72,7 @@ function FieldEditor({
|
||||
<select
|
||||
name={def.key}
|
||||
defaultValue={current}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||
>
|
||||
{(def.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -92,7 +92,7 @@ function FieldEditor({
|
||||
rows={rows}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -100,7 +100,7 @@ function FieldEditor({
|
||||
name={def.key}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
@@ -120,7 +120,7 @@ function FieldEditor({
|
||||
rows={rows}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -128,7 +128,7 @@ function FieldEditor({
|
||||
name={`de::${def.key}`}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
@@ -140,7 +140,7 @@ function FieldEditor({
|
||||
rows={rows}
|
||||
defaultValue={row?.valueEn ?? ""}
|
||||
placeholder={def.getDefault("en")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -148,7 +148,7 @@ function FieldEditor({
|
||||
name={`en::${def.key}`}
|
||||
defaultValue={row?.valueEn ?? ""}
|
||||
placeholder={def.getDefault("en")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
@@ -185,7 +185,7 @@ function GroupForm({
|
||||
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
|
||||
>
|
||||
<input type="hidden" name="group" value={groupKeyValue} />
|
||||
<h3 className="text-lg text-forest">{groupHeading}</h3>
|
||||
<h3 className="text-lg text-heading">{groupHeading}</h3>
|
||||
|
||||
{fields.map((def) => (
|
||||
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
|
||||
@@ -216,11 +216,11 @@ export default async function AdminInhaltePage() {
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-2">{t.adminContentPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
|
||||
<h2 className="text-xl text-heading mb-1">{t.adminContentPage.sectionImages}</h2>
|
||||
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
|
||||
{groups.map((group) => (
|
||||
<GroupForm
|
||||
@@ -236,7 +236,7 @@ export default async function AdminInhaltePage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionText}</h2>
|
||||
<h2 className="text-xl text-heading mb-1">{t.adminContentPage.sectionText}</h2>
|
||||
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionTextIntro}</p>
|
||||
{groups.map((group) => (
|
||||
<GroupForm
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AdminShell } from "@/components/admin-shell";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getBrandAssets } from "@/lib/brand";
|
||||
import { cookies } from "next/headers";
|
||||
import { THEME_COOKIE, parseThemeChoice } from "@/lib/theme";
|
||||
|
||||
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
||||
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
||||
@@ -49,6 +51,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
|
||||
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
|
||||
const brand = await getBrandAssets();
|
||||
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
||||
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
|
||||
prisma.order.count({
|
||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||
@@ -62,6 +65,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
<AdminShell
|
||||
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||
logoSrc={brand.logoInverted}
|
||||
themeChoice={themeChoice}
|
||||
badges={{
|
||||
"/admin/bestellungen": openOrders,
|
||||
"/admin/retouren": openReturns,
|
||||
|
||||
+16
-16
@@ -303,7 +303,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
{/* Begruessung statt nackter Ueberschrift: der Tag beginnt hier. */}
|
||||
<div className="mb-10">
|
||||
<p className="text-sm text-ink/60">{todayLine}</p>
|
||||
<h1 className="mt-1 text-4xl md:text-5xl leading-tight text-forest">
|
||||
<h1 className="mt-1 text-4xl md:text-5xl leading-tight text-heading">
|
||||
{t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -396,23 +396,23 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
{/* Was jetzt ansteht — direkt anklickbar. Wartet nichts, gibt es eine
|
||||
kleine Belohnung statt einer leeren Liste. */}
|
||||
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
|
||||
<h2 className="text-lg text-forest">{t.adminDashboardPage.toDoHeading}</h2>
|
||||
<h2 className="text-lg text-heading">{t.adminDashboardPage.toDoHeading}</h2>
|
||||
{toDo.length > 0 ? (
|
||||
<ul className="mt-4 divide-y divide-ink/10">
|
||||
{toDo.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="group flex items-center gap-3 py-3 text-sm text-ink/80 transition-colors hover:text-forest"
|
||||
className="group flex items-center gap-3 py-3 text-sm text-ink/80 transition-colors hover:text-heading"
|
||||
>
|
||||
<span className="flex h-7 min-w-7 items-center justify-center rounded-full bg-honey-dark px-2 text-xs font-semibold text-cream tabular-nums">
|
||||
<span className="flex h-7 min-w-7 items-center justify-center rounded-full bg-honey-deep px-2 text-xs font-semibold text-cream tabular-nums">
|
||||
{item.count}
|
||||
</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 text-ink/40 transition-[color,transform] duration-300 group-hover:translate-x-1 group-hover:text-forest"
|
||||
className="h-4 w-4 text-ink/40 transition-[color,transform] duration-300 group-hover:translate-x-1 group-hover:text-heading"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
@@ -430,7 +430,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
<path d="M7.5 13.2l3 3 6-6.4" stroke="var(--color-cream)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-serif text-xl text-forest">{t.adminDashboardPage.allDoneTitle}</p>
|
||||
<p className="font-serif text-xl text-heading">{t.adminDashboardPage.allDoneTitle}</p>
|
||||
<p className="mt-1 text-sm text-ink/65">{t.adminDashboardPage.allDoneText}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,11 +448,11 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
key={stat.key}
|
||||
className="group border border-ink/10 bg-background p-5 transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-honey/50"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center border border-ink/10 text-forest transition-colors group-hover:border-honey/50 group-hover:text-honey-dark">
|
||||
<span className="flex h-9 w-9 items-center justify-center border border-ink/10 text-heading transition-colors group-hover:border-honey/50 group-hover:text-honey-dark">
|
||||
<Icon />
|
||||
</span>
|
||||
<p className="mt-4 text-xs text-ink/60">{statLabels[stat.key]}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-forest">{stat.value}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-heading">{stat.value}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -460,7 +460,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
|
||||
<div className="mt-10 grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||
<h2 className="text-lg text-heading mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||
{topProducts.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||
) : (
|
||||
@@ -469,13 +469,13 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
<li key={product.name} className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-3 text-ink/80">
|
||||
<span className="relative flex h-6 w-6 flex-shrink-0 items-center justify-center text-[10px] font-semibold text-cream">
|
||||
<HexMark className={`absolute inset-0 h-6 w-6 ${i === 0 ? "text-[var(--color-chart-honey)]" : "text-forest/70"}`} />
|
||||
<HexMark className={`absolute inset-0 h-6 w-6 ${i === 0 ? "text-[var(--color-chart-honey)]" : "text-heading/70"}`} />
|
||||
<span className="relative">{i + 1}</span>
|
||||
</span>
|
||||
{product.name}{" "}
|
||||
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
|
||||
</span>
|
||||
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
||||
<span className="text-heading whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -483,7 +483,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">
|
||||
<h2 className="text-lg text-heading mb-4">
|
||||
{t.adminDashboardPage.revenueByCategoryHeading}
|
||||
</h2>
|
||||
{revenueByCategory.length === 0 ? (
|
||||
@@ -496,7 +496,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
<span className="text-ink/70">
|
||||
{t.common.categories[category as keyof typeof t.common.categories] ?? category}
|
||||
</span>
|
||||
<span className="text-forest">{formatPrice(totals.revenueCents)}</span>
|
||||
<span className="text-heading">{formatPrice(totals.revenueCents)}</span>
|
||||
</div>
|
||||
{/* Meter: Spur ist eine hellere Stufe desselben Tons. */}
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--color-chart-honey)_15%,transparent)]">
|
||||
@@ -514,7 +514,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
|
||||
<div className="mt-6 grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">
|
||||
<h2 className="text-lg text-heading mb-4">
|
||||
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
|
||||
</h2>
|
||||
{/* Balken nach dataviz-Skill: duenn, 4px gerundete Kappe an der
|
||||
@@ -553,7 +553,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||
<h2 className="text-lg text-heading mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||
{topDiscountCodes.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
|
||||
) : (
|
||||
@@ -566,7 +566,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
({code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)})
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-forest whitespace-nowrap">
|
||||
<span className="text-heading whitespace-nowrap">
|
||||
{t.adminDashboardPage.usedCountLabel(code.usedCount, code.maxUses)}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/produkte" label={te.backToProducts} />
|
||||
<h1 className="text-2xl text-forest mb-2">{te.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-2">{te.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">
|
||||
{t.common.categories[product.category]}
|
||||
{product.harvest && product.batch
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function AdminProductNewPage() {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<BackLink href="/admin/produkte" label={tn.backToProducts} />
|
||||
<h1 className="text-2xl text-forest mb-8">{tn.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-8">{tn.title}</h1>
|
||||
<AdminFormPanel
|
||||
action={createProductAction}
|
||||
className="group/product"
|
||||
|
||||
@@ -99,7 +99,7 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
|
||||
<div key={request.id} className="bg-background border border-ink/10 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-forest">{request.order.orderNumber}</p>
|
||||
<p className="text-heading">{request.order.orderNumber}</p>
|
||||
<p className="text-sm text-ink/70">
|
||||
{request.order.firstName} {request.order.lastName} · {request.order.email}
|
||||
</p>
|
||||
@@ -136,7 +136,7 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-forest tabular-nums">{formatPrice(request.order.totalCents, request.order.currency)}</p>
|
||||
<p className="mt-2 text-heading tabular-nums">{formatPrice(request.order.totalCents, request.order.currency)}</p>
|
||||
|
||||
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function AdminVersandEditPage(props: PageProps<"/admin/vers
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/versand" label={ts.backToShipping} />
|
||||
<h1 className="text-2xl text-forest mb-8">{ts.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-8">{ts.title}</h1>
|
||||
|
||||
<AdminFormPanel action={updateShippingTierAction} actions={<Button type="submit">{ts.save}</Button>}>
|
||||
<input type="hidden" name="id" value={tier.id} />
|
||||
|
||||
@@ -12,7 +12,7 @@ export default async function AdminVersandNewPage() {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/versand" label={ts.backToShipping} />
|
||||
<h1 className="text-2xl text-forest mb-8">{ts.title}</h1>
|
||||
<h1 className="text-2xl text-heading mb-8">{ts.title}</h1>
|
||||
|
||||
<AdminFormPanel action={createShippingTierAction} actions={<Button type="submit">{ts.create}</Button>}>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async function AdminVersandPage() {
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{tiers.map((tier) => (
|
||||
<tr key={tier.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
<td className="p-4 text-heading">
|
||||
{tier.maxQuantity === null
|
||||
? t.adminShippingPage.quantityFrom(tier.minQuantity)
|
||||
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
defaultValue={typeof orderNumber === "string" ? orderNumber : ""}
|
||||
placeholder={t.trackOrderPage.orderNumberPlaceholder}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
@@ -73,7 +73,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
defaultValue={typeof email === "string" ? email : ""}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
||||
@@ -96,7 +96,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
<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>
|
||||
<p className="text-heading">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt, locale)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -122,7 +122,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} ×{" "}
|
||||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-heading hover:underline">
|
||||
{item.productName}
|
||||
</Link>
|
||||
</li>
|
||||
@@ -157,7 +157,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-heading underline">
|
||||
{t.shipmentInfo.trackPackage}
|
||||
</a>
|
||||
</>
|
||||
@@ -181,9 +181,9 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
/>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-4">
|
||||
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<p className="font-serif text-2xl text-heading tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
{order.invoice ? (
|
||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-heading underline">
|
||||
{t.trackOrderPage.viewInvoice}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
</span>
|
||||
<span className="tracking-wide-caps text-xs text-honey-dark">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-6 text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">
|
||||
<h1 className="mt-6 text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-heading">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||
@@ -132,7 +132,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs tracking-wide-caps text-ink/70">{step.label}</p>
|
||||
<p className="mt-1 font-serif text-xl text-forest tabular-nums">{step.value}</p>
|
||||
<p className="mt-1 font-serif text-xl text-heading tabular-nums">{step.value}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -378,14 +378,14 @@ export function CheckoutForm({
|
||||
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"
|
||||
? "border-heading bg-forest/[0.04] text-heading shadow-[0_14px_30px_-22px_rgba(36,53,43,0.6)]"
|
||||
: "border-ink/15 text-ink/70 hover:border-heading/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"
|
||||
selected ? "border-heading bg-forest text-cream" : "border-ink/15 text-heading"
|
||||
}`}
|
||||
>
|
||||
{option === "VERSAND" ? (
|
||||
@@ -431,8 +431,8 @@ export function CheckoutForm({
|
||||
aria-pressed={deliveryTarget === option}
|
||||
className={`flex-1 border px-4 py-3 text-sm transition-colors ${
|
||||
deliveryTarget === option
|
||||
? "border-forest bg-forest text-cream"
|
||||
: "border-ink/20 hover:border-forest"
|
||||
? "border-heading bg-forest text-cream"
|
||||
: "border-ink/20 hover:border-heading"
|
||||
}`}
|
||||
>
|
||||
{option === "ADRESSE"
|
||||
@@ -482,7 +482,7 @@ export function CheckoutForm({
|
||||
value={country}
|
||||
onChange={(event) => setCountry(event.target.value)}
|
||||
autoComplete="country"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||
>
|
||||
{countryCodes.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
@@ -502,12 +502,12 @@ export function CheckoutForm({
|
||||
<p className="text-xs text-ink/70">{t.checkoutPage.packstationIntro}</p>
|
||||
|
||||
{selectedPackstation ? (
|
||||
<div className="flex items-start justify-between gap-4 border border-forest/40 bg-forest/5 p-3">
|
||||
<div className="flex items-start justify-between gap-4 border border-heading/40 bg-forest/5 p-3">
|
||||
<div className="text-sm">
|
||||
<p className="text-xs tracking-wide-caps text-ink/70">
|
||||
{t.checkoutPage.packstationSelectedLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-forest">{selectedPackstation.name}</p>
|
||||
<p className="mt-1 text-heading">{selectedPackstation.name}</p>
|
||||
<p className="text-ink/70">
|
||||
{selectedPackstation.street ? `${selectedPackstation.street}, ` : ""}
|
||||
{selectedPackstation.zip} {selectedPackstation.city}
|
||||
@@ -539,7 +539,7 @@ export function CheckoutForm({
|
||||
type="button"
|
||||
onClick={searchPackstations}
|
||||
disabled={packstationSearching || packstationZip.trim().length !== 5}
|
||||
className="border border-ink/20 bg-background px-4 py-2 text-sm hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-4 py-2 text-sm hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{packstationSearching
|
||||
? t.checkoutPage.packstationSearching
|
||||
@@ -562,9 +562,9 @@ export function CheckoutForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPackstation(packstation)}
|
||||
className="w-full border border-ink/20 px-3 py-2 text-left text-sm hover:border-forest"
|
||||
className="w-full border border-ink/20 px-3 py-2 text-left text-sm hover:border-heading"
|
||||
>
|
||||
<span className="text-forest">{packstation.name}</span>
|
||||
<span className="text-heading">{packstation.name}</span>
|
||||
<span className="block text-xs text-ink/70">
|
||||
{packstation.street ? `${packstation.street}, ` : ""}
|
||||
{packstation.zip} {packstation.city}
|
||||
@@ -618,7 +618,7 @@ export function CheckoutForm({
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder={t.checkoutPage.giftMessagePlaceholder}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest resize-none"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading resize-none"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
@@ -644,13 +644,13 @@ export function CheckoutForm({
|
||||
</form>
|
||||
|
||||
<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>
|
||||
<h2 className="text-2xl text-heading mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{items.map((item) => (
|
||||
<li key={item.productId} className="flex items-baseline justify-between gap-4">
|
||||
<span>
|
||||
<span className="text-ink/55 tabular-nums">{item.quantity} ×</span>{" "}
|
||||
<span className="font-serif text-lg text-forest">{item.name}</span>
|
||||
<span className="font-serif text-lg text-heading">{item.name}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||
</li>
|
||||
@@ -670,7 +670,7 @@ export function CheckoutForm({
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||
</div>
|
||||
{activeBulkDiscount ? (
|
||||
<div className="flex justify-between text-forest">
|
||||
<div className="flex justify-between text-heading">
|
||||
<span>
|
||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||
</span>
|
||||
@@ -678,7 +678,7 @@ export function CheckoutForm({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-ink/10 flex items-baseline justify-between text-forest">
|
||||
<div className="mt-4 pt-4 border-t border-ink/10 flex items-baseline justify-between text-heading">
|
||||
<span className="text-base">{t.cartPage.total}</span>
|
||||
<span className="font-serif text-3xl tabular-nums">{formatPrice(total, "EUR", locale)}</span>
|
||||
</div>
|
||||
@@ -721,7 +721,7 @@ function Field({
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -756,7 +756,7 @@ function CheckoutSteps({ current, labels, ariaLabel }: { current: number; labels
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
<span className={`tracking-wide-caps text-xs ${active ? "text-forest" : done ? "text-forest/70" : "text-ink/50"}`}>
|
||||
<span className={`tracking-wide-caps text-xs ${active ? "text-heading" : done ? "text-heading/70" : "text-ink/50"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function EmbeddedPayment({
|
||||
id="payment-heading"
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-24 text-2xl text-forest outline-none"
|
||||
className="scroll-mt-24 text-2xl text-heading outline-none"
|
||||
>
|
||||
{t.checkoutPage.paymentHeading}
|
||||
</h2>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default async function CheckoutSuccessPage(props: PageProps<"/checkout/su
|
||||
/>
|
||||
{orderNumber ? (
|
||||
<p className="mt-4 text-ink/70">
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{orderNumber}</span>
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-heading">{orderNumber}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-ink/70">
|
||||
|
||||
@@ -60,10 +60,10 @@ export default async function HarvestDetailPage(
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
{harvest.year} · {formatDate(harvest.harvestDate, locale)}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">{harvest.type}</h1>
|
||||
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-heading">{harvest.type}</h1>
|
||||
<p className="mt-8 max-w-[60ch] text-lg text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||
|
||||
<h2 className="mt-12 text-2xl text-forest">{t.erntenDetailPage.batchesHeading}</h2>
|
||||
<h2 className="mt-12 text-2xl text-heading">{t.erntenDetailPage.batchesHeading}</h2>
|
||||
<div className="mt-5 space-y-3">
|
||||
{harvest.batches.map((batch) => {
|
||||
const fill = batch.glassCount > 0 ? Math.min(1, batch.remainingGlasses / batch.glassCount) : 0;
|
||||
@@ -71,10 +71,10 @@ export default async function HarvestDetailPage(
|
||||
<Link
|
||||
key={batch.id}
|
||||
href={`/charge/${batch.code}`}
|
||||
className="group block border border-ink/10 px-5 py-4 transition-[border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-forest/50 hover:shadow-[0_18px_34px_-24px_rgba(36,53,43,0.45)]"
|
||||
className="group block border border-ink/10 px-5 py-4 transition-[border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-heading/50 hover:shadow-[0_18px_34px_-24px_rgba(36,53,43,0.45)]"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="font-serif text-xl text-forest tabular-nums">{batch.code}</span>
|
||||
<span className="font-serif text-xl text-heading tabular-nums">{batch.code}</span>
|
||||
<span className="text-ink/65 tabular-nums">
|
||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
||||
</span>
|
||||
@@ -91,7 +91,7 @@ export default async function HarvestDetailPage(
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-12 text-2xl text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
||||
<h2 className="mt-12 text-2xl text-heading">{t.erntenDetailPage.productsHeading}</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{harvest.products.map((product) => (
|
||||
<ProductTeaserLink
|
||||
|
||||
@@ -69,7 +69,7 @@ export default async function ErntenPage() {
|
||||
</span>
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="link-draw inline pb-0.5 font-serif text-2xl text-forest">{harvest.type}</h3>
|
||||
<h3 className="link-draw inline pb-0.5 font-serif text-2xl text-heading">{harvest.type}</h3>
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-ink/65">
|
||||
<HexMark className="h-2 w-2 text-honey" />
|
||||
{t.erntenPage.batchCount(harvest.batches.length)}
|
||||
@@ -78,7 +78,7 @@ export default async function ErntenPage() {
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 text-forest transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 text-heading transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
|
||||
+54
-5
@@ -22,6 +22,52 @@
|
||||
/* Diagrammton, per dataviz-Validator geprueft: Helligkeitsband, Chroma
|
||||
und >=3:1 gegen die helle Flaeche (#b88a44 erreichte nur 2,93:1). */
|
||||
--color-chart-honey: #a0722f;
|
||||
|
||||
/* Rollen statt reiner Markenfarben — sie kippen im Dunkelmodus, die
|
||||
Markenflaechen nicht:
|
||||
- heading: Ueberschriften, Links, Rahmen auf der Seitenflaeche
|
||||
(hell: Waldgruen, dunkel: warmes Creme).
|
||||
- surface: leicht abgesetzte Flaeche (hell: Creme, dunkel: angehobenes
|
||||
Dunkelgruen).
|
||||
- forest bleibt immer die dunkle Markenflaeche (Hero, Baender, Knoepfe,
|
||||
Admin-Leiste), cream immer der helle Text darauf.
|
||||
- honey-deep ist der feste dunkle Honigton fuer FLAECHEN (Badges,
|
||||
Akzentknopf); honey-dark ist die Textvariante und wird dunkel heller. */
|
||||
--color-heading: #24352b;
|
||||
--color-surface: #f4efe5;
|
||||
--color-honey-deep: #8a6531;
|
||||
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2324352b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Dunkle Darstellung — zwei Wege dorthin: ausdruecklich gewaehlt
|
||||
(data-theme="dark", components/theme-toggle.tsx) oder "Automatisch" bei
|
||||
dunkler Systemeinstellung. Beide Bloecke muessen identisch bleiben;
|
||||
theme-tokens.test.ts prueft das. */
|
||||
:root[data-theme="dark"] {
|
||||
--color-background: #111713;
|
||||
--color-ink: #e9e3d4;
|
||||
--color-heading: #efe4c9;
|
||||
--color-surface: #18201b;
|
||||
--color-forest: #1b2820;
|
||||
--color-honey-dark: #d3ae72;
|
||||
--color-chart-honey: #c99a50;
|
||||
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e9e3d4' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--color-background: #111713;
|
||||
--color-ink: #e9e3d4;
|
||||
--color-heading: #efe4c9;
|
||||
--color-surface: #18201b;
|
||||
--color-forest: #1b2820;
|
||||
--color-honey-dark: #d3ae72;
|
||||
--color-chart-honey: #c99a50;
|
||||
--select-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e9e3d4' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -34,6 +80,9 @@
|
||||
--color-background: var(--color-background);
|
||||
--color-ink: var(--color-ink);
|
||||
--color-sage: var(--color-sage);
|
||||
--color-heading: var(--color-heading);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-honey-deep: var(--color-honey-deep);
|
||||
|
||||
--font-serif: var(--font-cormorant), Georgia, serif;
|
||||
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||
@@ -47,8 +96,8 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Browserflaechen tragen die Marke mit: Scrollbar im Waldton statt Grau. */
|
||||
scrollbar-color: color-mix(in oklab, var(--color-forest) 45%, transparent) transparent;
|
||||
accent-color: var(--color-forest);
|
||||
scrollbar-color: color-mix(in oklab, var(--color-heading) 45%, transparent) transparent;
|
||||
accent-color: var(--color-heading);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -369,7 +418,7 @@ textarea::placeholder {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: color-mix(in oklab, var(--color-cream) 70%, var(--color-background));
|
||||
background: color-mix(in oklab, var(--color-surface) 70%, var(--color-background));
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -385,7 +434,7 @@ textarea::placeholder {
|
||||
.admin-content select {
|
||||
appearance: none;
|
||||
padding-right: 2rem;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2324352b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
background-image: var(--select-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.6rem center;
|
||||
background-size: 0.8rem;
|
||||
@@ -410,5 +459,5 @@ textarea::placeholder {
|
||||
text-transform: none;
|
||||
}
|
||||
.admin-content h2.tracking-wide-caps.text-ink\/65 {
|
||||
color: var(--color-forest);
|
||||
color: var(--color-heading);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export default async function HerkunftPage() {
|
||||
className="mt-2 h-3.5 w-3.5 text-honey transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:rotate-90"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-2xl text-forest">{a.title}</h3>
|
||||
<h3 className="text-2xl text-heading">{a.title}</h3>
|
||||
<p className="mt-2 text-ink/70 leading-relaxed">{a.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,7 +264,7 @@ export default async function ProductDetailPage(
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-4">
|
||||
{company.name} · {t.common.categories[product.category]}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">{product.name}</h1>
|
||||
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-heading">{product.name}</h1>
|
||||
{/* Ohne gepflegte Noten sonst ein leerer Absatz, der nur Abstand
|
||||
zwischen Ueberschrift und Bewertung erzeugt. */}
|
||||
{product.tasteTags.length > 0 ? (
|
||||
@@ -282,7 +282,7 @@ export default async function ProductDetailPage(
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-baseline gap-3">
|
||||
<span className="font-serif text-4xl text-forest tabular-nums">
|
||||
<span className="font-serif text-4xl text-heading tabular-nums">
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
|
||||
@@ -292,7 +292,7 @@ export default async function ProductDetailPage(
|
||||
</p>
|
||||
<Link
|
||||
href="/versand-zahlung"
|
||||
className="mt-1 inline-block py-1 text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
|
||||
className="mt-1 inline-block py-1 text-xs text-ink/65 underline decoration-ink/30 hover:text-heading hover:decoration-heading"
|
||||
>
|
||||
{t.productPage.shippingHint(
|
||||
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
|
||||
@@ -341,11 +341,11 @@ export default async function ProductDetailPage(
|
||||
type="email"
|
||||
required
|
||||
placeholder={t.productPage.emailPlaceholder}
|
||||
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest"
|
||||
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-heading"
|
||||
>
|
||||
{t.productPage.notifySubmit}
|
||||
</button>
|
||||
@@ -369,7 +369,7 @@ export default async function ProductDetailPage(
|
||||
<input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} />
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 py-1.5 text-sm text-ink/70 underline hover:text-forest"
|
||||
className="inline-flex items-center gap-2 py-1.5 text-sm text-ink/70 underline hover:text-heading"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
@@ -387,7 +387,7 @@ export default async function ProductDetailPage(
|
||||
</form>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-ink/65">
|
||||
<Link href="/konto" className="text-forest underline">
|
||||
<Link href="/konto" className="text-heading underline">
|
||||
{t.productPage.loginLinkLabel}
|
||||
</Link>
|
||||
, {t.productPage.loginToSave}
|
||||
@@ -427,11 +427,11 @@ export default async function ProductDetailPage(
|
||||
{isHoney ? (
|
||||
<div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<span className="text-ink/65">{t.productPage.learnMoreLabel}</span>
|
||||
<Link href="/wissen/warum-kristallisiert-honig" className="inline-block py-0.5 text-forest underline">
|
||||
<Link href="/wissen/warum-kristallisiert-honig" className="inline-block py-0.5 text-heading underline">
|
||||
{t.wissenArticles["warum-kristallisiert-honig"].title}
|
||||
</Link>
|
||||
{harvestWissenSlug ? (
|
||||
<Link href={`/wissen/${harvestWissenSlug}`} className="inline-block py-0.5 text-forest underline">
|
||||
<Link href={`/wissen/${harvestWissenSlug}`} className="inline-block py-0.5 text-heading underline">
|
||||
{t.wissenArticles[harvestWissenSlug].title}
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -441,7 +441,7 @@ export default async function ProductDetailPage(
|
||||
{isHoney && product.batch ? (
|
||||
<Link
|
||||
href={`/charge/${product.batch.code}`}
|
||||
className="mt-2.5 inline-block py-0.5 text-sm text-forest underline"
|
||||
className="mt-2.5 inline-block py-0.5 text-sm text-heading underline"
|
||||
>
|
||||
{t.productPage.viewBatchOrigin}
|
||||
</Link>
|
||||
@@ -451,7 +451,7 @@ export default async function ProductDetailPage(
|
||||
|
||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="text-3xl md:text-4xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
<h2 className="text-3xl md:text-4xl text-heading">{t.productPage.reviewsHeading}</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
|
||||
@@ -475,7 +475,7 @@ export default async function ProductDetailPage(
|
||||
<div className="mt-10 border-t border-ink/10 pt-8">
|
||||
{!customer ? (
|
||||
<p className="text-sm text-ink/70">
|
||||
<Link href="/konto" className="text-forest underline">
|
||||
<Link href="/konto" className="text-heading underline">
|
||||
{t.productPage.loginLinkLabel}
|
||||
</Link>
|
||||
, {t.productPage.loginToReview}
|
||||
@@ -516,7 +516,7 @@ export default async function ProductDetailPage(
|
||||
name="comment"
|
||||
rows={3}
|
||||
placeholder={t.productPage.commentPlaceholder}
|
||||
className="w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
||||
className="w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
<Button type="submit">{t.productPage.submitReview}</Button>
|
||||
</form>
|
||||
@@ -532,7 +532,7 @@ export default async function ProductDetailPage(
|
||||
<ScrollReveal
|
||||
className={relatedProducts.length <= 2 ? "sm:col-span-2 lg:col-span-1" : "sm:col-span-2 lg:col-span-3"}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl text-forest text-balance">{t.productPage.relatedProductsHeading}</h2>
|
||||
<h2 className="text-3xl md:text-4xl text-heading text-balance">{t.productPage.relatedProductsHeading}</h2>
|
||||
</ScrollReveal>
|
||||
{relatedProducts.map((related, index) => (
|
||||
<ProductCard key={related.id} product={related} index={index} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export default async function ImkereiPage() {
|
||||
<div className="mt-8 max-w-[62ch] space-y-5 text-ink/75 leading-relaxed">
|
||||
{t.imkereiPage.paragraphs.map((paragraph, i) => (
|
||||
// Erster Absatz als Einstieg: groesser, in Serifenschrift.
|
||||
<p key={paragraph} className={i === 0 ? "font-serif text-2xl leading-snug text-forest" : undefined}>
|
||||
<p key={paragraph} className={i === 0 ? "font-serif text-2xl leading-snug text-heading" : undefined}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -65,7 +65,7 @@ export default async function KontaktPage() {
|
||||
<ScrollReveal delay={0.18} className="md:col-span-2">
|
||||
<div className="h-full border border-honey/30 bg-honey/[0.06] px-8 py-10">
|
||||
<HexMark className="h-4 w-4 text-honey-dark" />
|
||||
<h2 className="mt-5 text-2xl text-forest">{t.kontaktPage.pickupHeading}</h2>
|
||||
<h2 className="mt-5 text-2xl text-heading">{t.kontaktPage.pickupHeading}</h2>
|
||||
<p className="mt-3 text-ink/70 leading-relaxed">{t.kontaktPage.pickupText}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async function KontoAdressenPage() {
|
||||
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"
|
||||
: "border-ink/10 hover:border-heading/40"
|
||||
}`}
|
||||
>
|
||||
{address.isDefault ? (
|
||||
@@ -45,11 +45,11 @@ export default async function KontoAdressenPage() {
|
||||
</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">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-heading">
|
||||
<AddressIcon />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-serif text-xl text-forest">{address.label}</p>
|
||||
<p className="font-serif text-xl text-heading">{address.label}</p>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{address.firstName} {address.lastName}
|
||||
<br />
|
||||
@@ -63,7 +63,7 @@ export default async function KontoAdressenPage() {
|
||||
{!address.isDefault ? (
|
||||
<form action={setDefaultAddressAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
<button type="submit" className="text-forest hover:underline">
|
||||
<button type="submit" className="text-heading hover:underline">
|
||||
{t.addressesPage.setDefault}
|
||||
</button>
|
||||
</form>
|
||||
@@ -87,7 +87,7 @@ export default async function KontoAdressenPage() {
|
||||
</div>
|
||||
|
||||
<FormPanel className="mt-14 max-w-xl">
|
||||
<h2 className="text-2xl text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<h2 className="text-2xl text-heading mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<form action={addAddressAction} className="space-y-4">
|
||||
<Field
|
||||
name="label"
|
||||
@@ -148,7 +148,7 @@ function Field({
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
||||
<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>
|
||||
<span className="font-serif text-lg text-heading">{item.productName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -71,7 +71,7 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
||||
minLength={3}
|
||||
rows={5}
|
||||
placeholder={t.retourePage.reasonPlaceholder}
|
||||
className="mt-2 w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
||||
className="mt-2 w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">{t.retourePage.submit}</Button>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function KontoBestellungenPage() {
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-heading">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt, locale)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -81,7 +81,7 @@ export default async function KontoBestellungenPage() {
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} ×{" "}
|
||||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-heading hover:underline">
|
||||
{item.productName}
|
||||
</Link>
|
||||
</li>
|
||||
@@ -116,7 +116,7 @@ export default async function KontoBestellungenPage() {
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-heading underline">
|
||||
{t.shipmentInfo.trackPackage}
|
||||
</a>
|
||||
</>
|
||||
@@ -132,12 +132,12 @@ export default async function KontoBestellungenPage() {
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<p className="font-serif text-2xl text-heading tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.invoice ? (
|
||||
<Link
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}`}
|
||||
className="text-sm text-forest underline"
|
||||
className="text-sm text-heading underline"
|
||||
>
|
||||
{t.ordersPage.viewInvoice}
|
||||
</Link>
|
||||
@@ -149,7 +149,7 @@ export default async function KontoBestellungenPage() {
|
||||
) : RETURN_ELIGIBLE_STATUSES.includes(order.status) ? (
|
||||
<Link
|
||||
href={`/konto/bestellungen/${order.id}/retoure`}
|
||||
className="text-sm text-forest underline"
|
||||
className="text-sm text-heading underline"
|
||||
>
|
||||
{t.ordersPage.requestReturn}
|
||||
</Link>
|
||||
|
||||
+10
-10
@@ -35,7 +35,7 @@ export default async function KontoPage() {
|
||||
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
||||
<div className="md:pr-12">
|
||||
<form action={loginAction} className="space-y-4">
|
||||
<h2 className="text-3xl text-forest">{t.account.login}</h2>
|
||||
<h2 className="text-3xl text-heading">{t.account.login}</h2>
|
||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||
{/* Das Turnstile-Widget ist ein iframe mit FESTER Breite (rund
|
||||
@@ -50,7 +50,7 @@ export default async function KontoPage() {
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button type="submit">{t.account.login}</Button>
|
||||
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
||||
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-heading underline">
|
||||
{t.account.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@ export default async function KontoPage() {
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/entra/login"
|
||||
className="mt-6 inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream"
|
||||
className="mt-6 inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] bg-transparent text-heading border border-heading hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{t.account.ssoButton}
|
||||
</a>
|
||||
@@ -81,7 +81,7 @@ export default async function KontoPage() {
|
||||
</div>
|
||||
|
||||
<form action={registerAction} className="space-y-4 md:pl-12">
|
||||
<h2 className="text-3xl text-forest">{t.account.register}</h2>
|
||||
<h2 className="text-3xl text-heading">{t.account.register}</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
|
||||
@@ -128,7 +128,7 @@ export default async function KontoPage() {
|
||||
{t.account.verifyEmailPrompt}
|
||||
</p>
|
||||
<form action={resendVerificationEmailAction}>
|
||||
<button type="submit" className="text-forest underline whitespace-nowrap">
|
||||
<button type="submit" className="text-heading underline whitespace-nowrap">
|
||||
{t.account.resendVerification}
|
||||
</button>
|
||||
</form>
|
||||
@@ -174,7 +174,7 @@ export default async function KontoPage() {
|
||||
|
||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl text-forest">{t.account.newsletterHeading}</h2>
|
||||
<h2 className="text-2xl text-heading">{t.account.newsletterHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
@@ -188,7 +188,7 @@ export default async function KontoPage() {
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl text-forest">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<h2 className="text-2xl text-heading">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{t.account.logoutAllDevicesDescription}
|
||||
</p>
|
||||
@@ -206,10 +206,10 @@ export default async function KontoPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2>
|
||||
<h2 className="text-2xl text-heading mb-4">{t.account.privacy.heading}</h2>
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
@@ -266,7 +266,7 @@ function Field({
|
||||
type={type}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function PasswortZuruecksetzenPage(
|
||||
{!token ? (
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkTextBefore}
|
||||
<a href="/konto/passwort-vergessen" className="underline text-forest">
|
||||
<a href="/konto/passwort-vergessen" className="underline text-heading">
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkLabel}
|
||||
</a>
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkTextAfter}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function ZweiFaktorCodePage() {
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
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"
|
||||
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-heading"
|
||||
/>
|
||||
</label>
|
||||
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function ZweiFaktorBackupCodesPage() {
|
||||
{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"
|
||||
className="flex items-center gap-3 border border-ink/10 bg-surface/50 px-3 py-3 font-mono text-sm tabular-nums tracking-wider text-heading"
|
||||
>
|
||||
<span className="text-[10px] text-ink/45 tabular-nums">{String(i + 1).padStart(2, "0")}</span>
|
||||
{code}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default async function ZweiFaktorEinrichtenPage() {
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
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-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.twoFactorSetupPage.activate}</Button>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function ZweiFaktorPage() {
|
||||
|
||||
{customer.twoFactorEnabled ? (
|
||||
<div className="mt-8 space-y-6">
|
||||
<p className="flex items-center gap-3 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
<p className="flex items-center gap-3 text-sm text-heading bg-forest/5 border border-heading/20 px-4 py-3">
|
||||
<ShieldIcon />
|
||||
<span>
|
||||
{t.twoFactorPage.activeStatus(remainingBackupCodes)}
|
||||
@@ -44,7 +44,7 @@ export default async function ZweiFaktorPage() {
|
||||
</p>
|
||||
|
||||
<form action={disableTwoFactorAction} className="space-y-4 border border-ink/10 p-6">
|
||||
<h2 className="text-forest">{t.twoFactorPage.disableHeading}</h2>
|
||||
<h2 className="text-heading">{t.twoFactorPage.disableHeading}</h2>
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.twoFactorPage.passwordConfirmLabel}
|
||||
|
||||
+12
-1
@@ -15,6 +15,8 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||
import { getBrandAssets } from "@/lib/brand";
|
||||
import { cookies } from "next/headers";
|
||||
import { THEME_COOKIE, parseThemeChoice } from "@/lib/theme";
|
||||
|
||||
const cormorant = Cormorant_Garamond({
|
||||
variable: "--font-cormorant",
|
||||
@@ -48,6 +50,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const locale = await getLocale();
|
||||
const dict = getDictionary(locale);
|
||||
// Gewaehlte Darstellung schon serverseitig setzen — sonst blitzte die Seite
|
||||
// beim Laden hell auf, bevor ein Skript auf dunkel umschalten koennte.
|
||||
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
||||
const [company, socialLinks, flash, brand] = await Promise.all([
|
||||
getCompanyInfo(),
|
||||
getSocialLinks(),
|
||||
@@ -95,6 +100,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
data-theme={themeChoice === "system" ? undefined : themeChoice}
|
||||
data-scroll-behavior="smooth"
|
||||
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
|
||||
>
|
||||
@@ -137,7 +143,12 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
{dict.nav.skipToContent}
|
||||
</a>
|
||||
<HideOnAdmin>
|
||||
<Header companyName={company.name} logoSrc={brand.logo} logoInvertedSrc={brand.logoInverted} />
|
||||
<Header
|
||||
companyName={company.name}
|
||||
logoSrc={brand.logo}
|
||||
logoInvertedSrc={brand.logoInverted}
|
||||
themeChoice={themeChoice}
|
||||
/>
|
||||
</HideOnAdmin>
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function NewsletterBestaetigtPage(props: PageProps<"/newsle
|
||||
<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 font-serif text-2xl text-forest">
|
||||
<p className="mt-2 font-serif text-2xl text-heading">
|
||||
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}
|
||||
</p>
|
||||
<p className="mt-4 font-mono text-3xl tracking-[0.2em] text-honey-dark select-all">{NEWSLETTER_DISCOUNT_CODE}</p>
|
||||
|
||||
@@ -41,14 +41,14 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
<BackLink href="/konto/bestellungen" label={t.invoicePage.backToOrders} />
|
||||
<a
|
||||
href={`/rechnung/${invoice.invoiceNumber}/pdf`}
|
||||
className="mb-6 text-sm text-forest underline"
|
||||
className="mb-6 text-sm text-heading underline"
|
||||
>
|
||||
{t.invoicePage.downloadPdf}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-2xl tracking-[0.2em] font-serif text-forest">{company.name.toUpperCase()}</p>
|
||||
<p className="text-2xl tracking-[0.2em] font-serif text-heading">{company.name.toUpperCase()}</p>
|
||||
<p className="mt-2 text-sm text-ink/70">
|
||||
{company.address.street}
|
||||
<br />
|
||||
@@ -56,7 +56,7 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-ink/70">
|
||||
<p className="text-forest">{t.invoicePage.invoiceLabel(invoice.invoiceNumber)}</p>
|
||||
<p className="text-heading">{t.invoicePage.invoiceLabel(invoice.invoiceNumber)}</p>
|
||||
<p>{t.invoicePage.orderLabel(order.orderNumber)}</p>
|
||||
<p>{formatDate(invoice.issuedAt)}</p>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-forest pt-2 border-t border-ink/10">
|
||||
<div className="flex justify-between text-heading pt-2 border-t border-ink/10">
|
||||
<span>{t.invoicePage.totalLabel}</span>
|
||||
<span>{formatPrice(invoice.totalCents, order.currency)}</span>
|
||||
</div>
|
||||
|
||||
@@ -109,8 +109,8 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`inline-flex h-11 items-center gap-2 px-5 text-sm tracking-wide-caps border transition-[color,background-color,border-color] duration-300 ${
|
||||
isActive
|
||||
? "bg-forest text-cream border-forest"
|
||||
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
|
||||
? "bg-forest text-cream border-heading"
|
||||
: "border-ink/15 text-ink/70 hover:border-heading hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{isActive ? <HexMark className="h-2.5 w-2.5 text-honey-light" /> : null}
|
||||
@@ -151,7 +151,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
name="q"
|
||||
defaultValue={query ?? ""}
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
className="h-11 w-60 border border-ink/15 bg-background pl-10 pr-3 transition-colors placeholder:text-ink/45 hover:border-ink/35 focus:border-forest focus:outline-none"
|
||||
className="h-11 w-60 border border-ink/15 bg-background pl-10 pr-3 transition-colors placeholder:text-ink/45 hover:border-ink/35 focus:border-heading focus:outline-none"
|
||||
/>
|
||||
</span>
|
||||
<label htmlFor="sortierung" className="ml-2 text-xs tracking-wide-caps text-ink/60">
|
||||
@@ -162,7 +162,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
id="sortierung"
|
||||
name="sortierung"
|
||||
defaultValue={sort ?? ""}
|
||||
className="h-11 appearance-none border border-ink/15 bg-background pl-3 pr-9 transition-colors hover:border-ink/35 focus:border-forest focus:outline-none"
|
||||
className="h-11 appearance-none border border-ink/15 bg-background pl-3 pr-9 transition-colors hover:border-ink/35 focus:border-heading focus:outline-none"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -183,7 +183,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 bg-forest px-5 text-xs tracking-wide-caps text-cream transition-colors hover:bg-honey-dark"
|
||||
className="h-11 bg-forest px-5 text-xs tracking-wide-caps text-cream transition-colors hover:bg-honey-deep"
|
||||
>
|
||||
{t.shop.apply}
|
||||
</button>
|
||||
@@ -198,8 +198,8 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
aria-pressed={onlyAvailable}
|
||||
className={`ml-auto inline-flex h-11 items-center gap-2.5 px-4 text-xs tracking-wide-caps border transition-colors ${
|
||||
onlyAvailable
|
||||
? "bg-forest text-cream border-forest"
|
||||
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
|
||||
? "bg-forest text-cream border-heading"
|
||||
: "border-ink/15 text-ink/70 hover:border-heading hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{/* Kleiner Schalter statt reinem Textknopf — der Zustand ist auf einen Blick lesbar. */}
|
||||
@@ -208,7 +208,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
className={`relative h-4 w-7 rounded-full transition-colors ${onlyAvailable ? "bg-honey" : "bg-ink/15"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-cream shadow transition-[left] duration-300 ${
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-surface shadow transition-[left] duration-300 ${
|
||||
onlyAvailable ? "left-3.5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
@@ -222,7 +222,7 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
{t.shop.searchResults(query, products.length)}{" "}
|
||||
<Link
|
||||
href={buildHref({ kategorie: category, sortierung: sort, verfuegbar: onlyAvailable ? "1" : undefined })}
|
||||
className="underline hover:text-forest"
|
||||
className="underline hover:text-heading"
|
||||
>
|
||||
{t.shop.clearSearch}
|
||||
</Link>
|
||||
|
||||
+103
-26
@@ -1,12 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, LinkButton, FramedMedia } from "@/components/ui";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { BusinessInquiryForm } from "@/components/business-inquiry-form";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { BeeFlight } from "@/components/bee";
|
||||
import { ParallaxMedia } from "@/components/parallax-media";
|
||||
import { AudienceCycle } from "@/components/audience-cycle";
|
||||
import { PackageJar, type PackageKey } from "@/components/package-jar";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
import { getContentValues } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
@@ -17,47 +22,119 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UnternehmenPage() {
|
||||
export default async function UnternehmenPage(props: PageProps<"/unternehmen">) {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const image = await getContentValue("unternehmen.image", locale);
|
||||
const tu = t.unternehmenPage;
|
||||
const { paket } = await props.searchParams;
|
||||
// Nur bekannte Pakete uebernehmen — der Wert landet als Vorauswahl im Formular.
|
||||
const selected = tu.packages.find((p) => p.key === paket)?.key;
|
||||
const content = await getContentValues(["unternehmen.image", "home.businessTeaser.image"], locale);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative h-[50vh] min-h-[340px] w-full overflow-hidden">
|
||||
<div className="motion-drift absolute inset-0 animate-[wh-drift_26s_ease-in-out_infinite_alternate]">
|
||||
{/* Hero: Titel auf dem Foto, die Zielgruppen wechseln, eine Biene fliegt durchs Bild. */}
|
||||
<section className="relative isolate flex min-h-[72vh] items-end overflow-hidden bg-forest text-cream">
|
||||
<div className="motion-drift absolute inset-0 -z-10 animate-[wh-drift_26s_ease-in-out_infinite_alternate]">
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.unternehmenPage.eyebrow}
|
||||
tone="honey"
|
||||
src={content["unternehmen.image"] || null}
|
||||
alt={tu.eyebrow}
|
||||
tone="forest"
|
||||
className="h-full w-full"
|
||||
label={t.unternehmenPage.eyebrow}
|
||||
label={tu.eyebrow}
|
||||
rounded={false}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<HoneycombPattern className="text-cream/[0.14]" size={30} fade="right" />
|
||||
</div>
|
||||
<Container className="py-20">
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-gradient-to-t from-forest via-forest/65 to-forest/10" />
|
||||
<HoneycombPattern className="-z-10 text-cream/[0.1]" size={30} fade="right" />
|
||||
<BeeFlight className="!absolute inset-x-0 top-[18%] h-32 md:h-44" />
|
||||
<Container className="relative pb-16 pt-40 md:pb-20">
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
title={t.unternehmenPage.title}
|
||||
subtitle={t.unternehmenPage.subtitle}
|
||||
/>
|
||||
<h1 className="max-w-3xl font-serif text-5xl leading-[1.02] text-cream text-balance md:text-7xl">{tu.title}</h1>
|
||||
<AudienceCycle className="mt-6 text-xl text-cream/85 md:text-2xl" prefix={tu.forLabel} items={tu.audiences} />
|
||||
<LinkButton href="#anfrage" variant="accent" className="mt-10">
|
||||
{tu.inquiryHeading}
|
||||
</LinkButton>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Geteilt: die Ueberschrift laeuft neben dem langen Formular mit. */}
|
||||
<div className="mt-24 grid gap-10 lg:grid-cols-12 lg:gap-16">
|
||||
<ScrollReveal className="lg:col-span-4">
|
||||
<h2 className="text-4xl leading-tight text-forest lg:sticky lg:top-32">
|
||||
{t.unternehmenPage.inquiryHeading}
|
||||
</h2>
|
||||
{/* Pakete als gezeichnete Glaeser — ein Klick waehlt das Paket im Formular vor. */}
|
||||
<section className="relative overflow-hidden py-24 md:py-28">
|
||||
<HoneycombPattern className="text-honey/[0.07]" size={24} fade="radial" />
|
||||
<Container className="relative">
|
||||
<ScrollReveal>
|
||||
<SectionHeading title={tu.packagesHeading} align="center" className="mx-auto" />
|
||||
</ScrollReveal>
|
||||
<ScrollReveal className="lg:col-span-8">
|
||||
<BusinessInquiryForm turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||
<div className="mt-16 grid gap-8 md:grid-cols-3">
|
||||
{tu.packages.map((pkg, i) => (
|
||||
<ScrollReveal key={pkg.key} delay={i * 0.1}>
|
||||
<Link
|
||||
href={`/unternehmen?paket=${pkg.key}#anfrage`}
|
||||
className={`group flex h-full flex-col items-center rounded-lg border bg-background px-8 pb-8 pt-10 text-center transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-1 hover:border-honey/60 hover:shadow-[0_24px_44px_-28px_rgba(184,138,68,0.55)] motion-reduce:transition-none ${
|
||||
selected === pkg.key ? "border-honey" : "border-ink/10"
|
||||
}`}
|
||||
>
|
||||
<PackageJar variant={pkg.key as PackageKey} className="h-40 w-32 md:h-56 md:w-44" />
|
||||
<h3 className="mt-6 font-serif text-3xl text-heading">{pkg.title}</h3>
|
||||
<p className="mt-2 text-ink/70">{pkg.text}</p>
|
||||
<span className="mt-auto inline-flex items-center gap-2 pt-6 text-sm tracking-wide-caps text-honey-dark">
|
||||
{tu.packageCta}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1 motion-reduce:transition-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Anfrage: links Ueberschrift und Glaeser-Foto, die mitlaufen; rechts das Formular. */}
|
||||
<section id="anfrage" className="scroll-mt-24 bg-surface/60 py-24">
|
||||
<Container className="grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-32">
|
||||
<ScrollReveal>
|
||||
<h2 className="text-4xl leading-tight text-heading md:text-5xl">{tu.inquiryHeading}</h2>
|
||||
<p className="mt-4 max-w-sm text-ink/70">{tu.subtitle}</p>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1} className="mt-10 hidden lg:block">
|
||||
<FramedMedia>
|
||||
<ParallaxMedia className="rounded-lg">
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
tone="honey"
|
||||
className="aspect-[4/3] w-full"
|
||||
label={tu.eyebrow}
|
||||
sizes="(min-width: 1024px) 34vw, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</FramedMedia>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollReveal className="lg:col-span-7">
|
||||
{/* key: bei Wechsel der Vorauswahl (Klick auf eine Paketkarte) neu aufbauen,
|
||||
sonst bliebe die zuvor gewaehlte Option aktiv. */}
|
||||
<BusinessInquiryForm
|
||||
key={selected ?? "default"}
|
||||
defaultPackage={selected}
|
||||
turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function WarenkorbContent({
|
||||
// verfuegbaren Honige, statt nur eines Satzes und eines Knopfs.
|
||||
<div className="mt-14 grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
<div className="lg:col-span-4">
|
||||
<p className="font-serif text-2xl leading-snug text-forest">{t.cart.empty}</p>
|
||||
<p className="font-serif text-2xl leading-snug text-heading">{t.cart.empty}</p>
|
||||
<LinkButton href="/shop" variant="secondary" className="mt-6">
|
||||
{t.cartPage.goToShop}
|
||||
</LinkButton>
|
||||
@@ -106,7 +106,7 @@ export function WarenkorbContent({
|
||||
>
|
||||
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<Link href={`/honig/${item.slug}`} className="link-draw pb-0.5 font-serif text-2xl text-forest">
|
||||
<Link href={`/honig/${item.slug}`} className="link-draw pb-0.5 font-serif text-2xl text-heading">
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
||||
@@ -127,7 +127,7 @@ export function WarenkorbContent({
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
max={itemStock ?? 10}
|
||||
/>
|
||||
<span className="w-24 text-right font-serif text-xl text-forest tabular-nums">
|
||||
<span className="w-24 text-right font-serif text-xl text-heading tabular-nums">
|
||||
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||
</span>
|
||||
<button
|
||||
@@ -166,7 +166,7 @@ export function WarenkorbContent({
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 22 }}
|
||||
className="flex items-center justify-between rounded-sm bg-honey/10 px-3 py-2 text-forest"
|
||||
className="flex items-center justify-between rounded-sm bg-honey/10 px-3 py-2 text-heading"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<HexCheck className="h-4 w-4 text-honey-dark" />
|
||||
@@ -193,7 +193,7 @@ export function WarenkorbContent({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-baseline justify-between text-forest pt-4 border-t border-ink/10">
|
||||
<div className="flex items-baseline justify-between text-heading pt-4 border-t border-ink/10">
|
||||
<span className="text-base">{t.cartPage.total}</span>
|
||||
<motion.span
|
||||
key={subtotal - bulkDiscountCents + shipping}
|
||||
@@ -208,7 +208,7 @@ export function WarenkorbContent({
|
||||
<LinkButton href="/checkout" className="w-full mt-6">
|
||||
{t.cart.checkout}
|
||||
</LinkButton>
|
||||
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/70 hover:text-forest underline">
|
||||
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/70 hover:text-heading underline">
|
||||
{t.cartPage.pickupHint}
|
||||
</Link>
|
||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||
@@ -218,7 +218,7 @@ export function WarenkorbContent({
|
||||
|
||||
{items.length > 0 && crossSellProducts.length > 0 ? (
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl md:text-4xl text-forest mb-10">{t.productPage.relatedProductsHeading}</h2>
|
||||
<h2 className="text-3xl md:text-4xl text-heading mb-10">{t.productPage.relatedProductsHeading}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{crossSellProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
|
||||
@@ -80,7 +80,7 @@ export default async function WissenArticlePage(
|
||||
<Container className="max-w-2xl">
|
||||
<BackLink href="/wissen" label={t.wissenArticlePage.backLabel} />
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.wissenArticlePage.eyebrow}</p>
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-forest">{article.title}</h1>
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-heading">{article.title}</h1>
|
||||
<div className="mt-10 max-w-[65ch] space-y-6 text-lg text-ink/80 leading-[1.75]">
|
||||
{article.body.map((paragraph, i) => (
|
||||
// Initiale wie im Buch: der erste Buchstabe des Artikels gross in Honig.
|
||||
@@ -99,13 +99,13 @@ export default async function WissenArticlePage(
|
||||
return (
|
||||
<Link key={otherSlug} href={`/wissen/${otherSlug}`} className="group flex items-start justify-between gap-6 py-6">
|
||||
<span>
|
||||
<span className="link-draw pb-0.5 font-serif text-2xl text-forest">{other.title}</span>
|
||||
<span className="link-draw pb-0.5 font-serif text-2xl text-heading">{other.title}</span>
|
||||
<span className="mt-2 block text-sm text-ink/65 leading-relaxed">{other.excerpt}</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 text-forest transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 text-heading transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function WissenPage() {
|
||||
href={`/wissen/${slug}`}
|
||||
className="group grid gap-3 py-8 md:grid-cols-12 md:items-baseline md:gap-8"
|
||||
>
|
||||
<h2 className="text-2xl leading-tight text-forest text-balance md:col-span-5 md:text-3xl">
|
||||
<h2 className="text-2xl leading-tight text-heading text-balance md:col-span-5 md:text-3xl">
|
||||
<span className="link-draw pb-0.5">{article.title}</span>
|
||||
</h2>
|
||||
<p className="max-w-[60ch] text-ink/70 leading-relaxed md:col-span-6">{article.excerpt}</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Verlinkte Karte fuer die Konto-Uebersicht (Bestellungen, Adressen, ...).
|
||||
* Ersetzt den zuvor identischen, icon-losen "border + hover:border-forest"-
|
||||
* Ersetzt den zuvor identischen, icon-losen "border + hover:border-heading"-
|
||||
* Kartenstil, der auf jeder Konto-Seite gleich aussah.
|
||||
*/
|
||||
export function AccountNavCard({
|
||||
@@ -27,7 +27,7 @@ export function AccountNavCard({
|
||||
className={`group relative flex items-start gap-5 border p-6 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-1 ${
|
||||
accent
|
||||
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_22px_40px_-24px_rgba(184,138,68,0.45)]"
|
||||
: "border-ink/10 bg-background hover:border-forest/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
|
||||
: "border-ink/10 bg-background hover:border-heading/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Icon in einer Wabe, die sich beim Hover mit Honig fuellt. */}
|
||||
@@ -39,20 +39,20 @@ export function AccountNavCard({
|
||||
/>
|
||||
<span
|
||||
className={`relative transition-colors duration-500 group-hover:text-cream ${
|
||||
accent ? "text-honey-dark" : "text-forest"
|
||||
accent ? "text-honey-dark" : "text-heading"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-1 pt-1">
|
||||
<span className="block font-serif text-2xl leading-tight text-forest">{title}</span>
|
||||
<span className="block font-serif text-2xl leading-tight text-heading">{title}</span>
|
||||
<span className="block mt-1.5 text-sm text-ink/70">{description}</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 -translate-x-2 text-forest opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-0 group-hover:opacity-100"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 -translate-x-2 text-heading opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-0 group-hover:opacity-100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
@@ -68,9 +68,9 @@ const ORDER_STATUS_STYLES: Record<string, string> = {
|
||||
BEZAHLT: "border-honey/40 text-honey-dark bg-honey/5",
|
||||
IN_VORBEREITUNG: "border-honey/40 text-honey-dark bg-honey/5",
|
||||
VERSANDBEREIT: "border-honey/40 text-honey-dark bg-honey/5",
|
||||
VERSENDET: "border-forest/40 text-forest bg-forest/5",
|
||||
ABHOLBEREIT: "border-forest/40 text-forest bg-forest/5",
|
||||
ABGEHOLT: "border-forest/40 text-forest bg-forest/5",
|
||||
VERSENDET: "border-heading/40 text-heading bg-forest/5",
|
||||
ABHOLBEREIT: "border-heading/40 text-heading bg-forest/5",
|
||||
ABGEHOLT: "border-heading/40 text-heading bg-forest/5",
|
||||
STORNIERT: "border-red-200 text-red-700 bg-red-50",
|
||||
};
|
||||
|
||||
@@ -182,7 +182,7 @@ export function OrderStatusTimeline({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/55"}`}>
|
||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-heading" : "text-ink/55"}`}>
|
||||
{step.label}
|
||||
</p>
|
||||
</div>
|
||||
@@ -218,7 +218,7 @@ export function AccountEmptyState({
|
||||
</svg>
|
||||
<span className="relative">{icon}</span>
|
||||
</span>
|
||||
<p className="mt-5 font-serif text-2xl text-forest">{title}</p>
|
||||
<p className="mt-5 font-serif text-2xl text-heading">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
|
||||
) : null}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AdminArchiveTabs({
|
||||
|
||||
const tabClass = (selected: boolean): string =>
|
||||
`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
selected ? "border-forest text-forest" : "border-transparent text-ink/60 hover:text-forest"
|
||||
selected ? "border-heading text-heading" : "border-transparent text-ink/60 hover:text-heading"
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -72,7 +72,7 @@ export function AdminArchiveToggle({
|
||||
{archivedManually ? ` · ${labels.manualHint}` : null}
|
||||
</span>
|
||||
) : null}
|
||||
<button type="submit" className="link-draw cursor-pointer text-ink/70 hover:text-forest">
|
||||
<button type="submit" className="link-draw cursor-pointer text-ink/70 hover:text-heading">
|
||||
{archivedAt ? labels.restore : labels.archive}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -148,7 +148,7 @@ export function AdminCommandPalette({
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => go(entry)}
|
||||
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-honey/10 text-forest" : "text-ink/75"
|
||||
i === active ? "bg-honey/10 text-heading" : "text-ink/75"
|
||||
}`}
|
||||
>
|
||||
<HexMark
|
||||
|
||||
@@ -102,7 +102,7 @@ export function AdminFieldset({
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={`space-y-4 ${last ? "" : "border-b border-ink/10 pb-8"} ${className}`}>
|
||||
<legend className="mb-4 font-serif text-xl text-forest">{legend}</legend>
|
||||
<legend className="mb-4 font-serif text-xl text-heading">{legend}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={status === "uploading"}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{t.adminImageField.chooseFile}
|
||||
</button>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={status === "uploading"}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{t.adminImageField.chooseFile}
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AdminPageHeader({
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl text-forest">{title}</h1>
|
||||
<h1 className="text-2xl text-heading">{title}</h1>
|
||||
{action ? (
|
||||
<LinkButton href={action.href} className="px-5 py-2.5">
|
||||
{action.label}
|
||||
@@ -74,7 +74,7 @@ export function AdminSearchForm({
|
||||
{children}
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
className="border border-heading px-4 py-2 text-sm text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
@@ -90,7 +90,7 @@ export function AdminSearchForm({
|
||||
export type AdminBadgeTone = "positive" | "pending" | "neutral" | "danger";
|
||||
|
||||
const BADGE_TONES: Record<AdminBadgeTone, string> = {
|
||||
positive: "border-forest/40 bg-forest/5 text-forest",
|
||||
positive: "border-heading/40 bg-forest/5 text-heading",
|
||||
pending: "border-honey/40 bg-honey/5 text-honey-dark",
|
||||
neutral: "border-ink/20 text-ink/70",
|
||||
danger: "border-red-200 bg-red-50 text-red-700",
|
||||
@@ -155,14 +155,14 @@ export function AdminRowActions({ children }: { children: ReactNode }) {
|
||||
|
||||
export function AdminActionLink({ href, children }: { href: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link href={href} className="link-draw text-forest">
|
||||
<Link href={href} className="link-draw text-heading">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_TONES = {
|
||||
default: "text-forest",
|
||||
default: "text-heading",
|
||||
muted: "text-ink/70",
|
||||
danger: "text-red-700",
|
||||
} as const;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import type { ThemeChoice } from "@/lib/theme";
|
||||
|
||||
/** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
|
||||
export type AdminBadges = Partial<Record<string, number>>;
|
||||
@@ -118,7 +120,7 @@ function NavLinks({
|
||||
{active ? (
|
||||
<motion.span
|
||||
layoutId="admin-nav-active"
|
||||
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-cream/10"
|
||||
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-surface/10"
|
||||
transition={{ type: "spring", stiffness: 420, damping: 36 }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -131,7 +133,7 @@ function NavLinks({
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{count > 0 ? (
|
||||
<span
|
||||
className="min-w-5 rounded-full bg-honey-dark px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||
className="min-w-5 rounded-full bg-honey-deep px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||
title={waitingLabel(count)}
|
||||
>
|
||||
<span aria-hidden="true">{count}</span>
|
||||
@@ -175,6 +177,8 @@ function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; lab
|
||||
|
||||
interface AdminShellProps {
|
||||
adminName: string;
|
||||
/** Gewaehlte Darstellung (Cookie), siehe lib/theme.ts. */
|
||||
themeChoice?: ThemeChoice;
|
||||
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
|
||||
logoSrc?: string | null;
|
||||
badges?: AdminBadges;
|
||||
@@ -190,7 +194,14 @@ interface AdminShellProps {
|
||||
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
||||
* (components/hide-on-admin.tsx).
|
||||
*/
|
||||
export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutAction, children }: AdminShellProps) {
|
||||
export function AdminShell({
|
||||
adminName,
|
||||
logoSrc = null,
|
||||
themeChoice = "system",
|
||||
badges = {},
|
||||
logoutAction,
|
||||
children,
|
||||
}: AdminShellProps) {
|
||||
const { t } = useI18n();
|
||||
const groups = getAdminNavGroups(t);
|
||||
const pathname = usePathname();
|
||||
@@ -209,9 +220,10 @@ export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutActio
|
||||
<div className="px-5 py-4 border-t border-cream/10">
|
||||
{/* Der Shop-Kopf ist im Admin ausgeblendet (HideOnAdmin) — ohne diesen
|
||||
Link fuehrte kein Weg zurueck auf die Website. */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="group mb-4 inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
|
||||
className="group inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -227,6 +239,8 @@ export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutActio
|
||||
</svg>
|
||||
<span className="link-draw">{t.adminShell.backToWebsite}</span>
|
||||
</Link>
|
||||
<ThemeToggle initial={themeChoice} className="-mr-2 text-cream/70 hover:text-cream" />
|
||||
</div>
|
||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
@@ -237,7 +251,7 @@ export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutActio
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
||||
<div className="min-h-screen bg-surface/40 lg:flex">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 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">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldPr
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={status === "uploading"}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{t.adminVideoField.chooseFile}
|
||||
</button>
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
</p>
|
||||
) : null}
|
||||
{done ? (
|
||||
<div className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3 space-y-1">
|
||||
<div className="mb-6 text-sm text-heading bg-forest/5 border border-heading/20 px-4 py-3 space-y-1">
|
||||
<p>
|
||||
{t.resultHarvests(
|
||||
count(searchParams.hNew),
|
||||
@@ -92,7 +92,7 @@ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-lg text-forest mb-4">{t.heading}</h2>
|
||||
<h2 className="text-lg text-heading mb-4">{t.heading}</h2>
|
||||
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
||||
<form action={syncApiCoreAction}>
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
const INTERVAL_MS = 2200;
|
||||
|
||||
/**
|
||||
* "Für Kanzleien" — "Für Praxen" — ...: die Zielgruppe wechselt im Takt und
|
||||
* rollt von unten herein. Screenreader lesen die ganze Liste auf einmal;
|
||||
* bei reduzierter Bewegung steht die Liste still und vollstaendig da.
|
||||
*/
|
||||
export function AudienceCycle({
|
||||
prefix,
|
||||
items,
|
||||
className = "",
|
||||
}: {
|
||||
prefix: string;
|
||||
items: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion || items.length < 2) return;
|
||||
const id = window.setInterval(() => setIndex((i) => (i + 1) % items.length), INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [reduceMotion, items.length]);
|
||||
|
||||
if (reduceMotion) {
|
||||
return (
|
||||
<p className={className}>
|
||||
{prefix} {items.join(", ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
<span className="sr-only">
|
||||
{prefix} {items.join(", ")}
|
||||
</span>
|
||||
<span aria-hidden="true">
|
||||
{prefix}{" "}
|
||||
<span className="relative inline-flex overflow-hidden align-bottom">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={items[index]}
|
||||
initial={{ y: "100%", opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: "-100%", opacity: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="inline-block font-serif italic text-honey-pale"
|
||||
>
|
||||
{items[index]}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function BackLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="-mt-1.5 mb-4.5 inline-flex items-center gap-1.5 py-1.5 text-sm text-ink/70 hover:text-forest"
|
||||
className="-mt-1.5 mb-4.5 inline-flex items-center gap-1.5 py-1.5 text-sm text-ink/70 hover:text-heading"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
|
||||
@@ -19,8 +19,8 @@ export function BeeGlyph({ className = "h-8 w-10" }: { className?: string }) {
|
||||
</defs>
|
||||
{/* Fluegel — kippen um ihre Unterkante */}
|
||||
<g className="motion-flutter origin-bottom animate-[wh-flutter_0.11s_ease-in-out_infinite_alternate] [transform-box:fill-box]">
|
||||
<ellipse cx="16.5" cy="9.5" rx="5.2" ry="8" transform="rotate(-22 16.5 9.5)" fill="#FAF8F3" fillOpacity="0.9" stroke="#24352B" strokeWidth="1.1" />
|
||||
<ellipse cx="23.5" cy="10" rx="4.4" ry="6.8" transform="rotate(14 23.5 10)" fill="#FAF8F3" fillOpacity="0.9" stroke="#24352B" strokeWidth="1.1" />
|
||||
<ellipse cx="16.5" cy="9.5" rx="5.2" ry="8" transform="rotate(-22 16.5 9.5)" fill="#FAF8F3" fillOpacity="0.9" stroke="var(--color-heading)" strokeWidth="1.1" />
|
||||
<ellipse cx="23.5" cy="10" rx="4.4" ry="6.8" transform="rotate(14 23.5 10)" fill="#FAF8F3" fillOpacity="0.9" stroke="var(--color-heading)" strokeWidth="1.1" />
|
||||
</g>
|
||||
{/* Stachel */}
|
||||
<path d="M9.6 19.2 5.2 20l4.4.9z" fill="#24352B" />
|
||||
@@ -31,8 +31,8 @@ export function BeeGlyph({ className = "h-8 w-10" }: { className?: string }) {
|
||||
</g>
|
||||
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" fill="none" stroke="#24352B" strokeWidth="1.2" />
|
||||
{/* Kopf und Fuehler */}
|
||||
<circle cx="32" cy="18.8" r="4.3" fill="#24352B" />
|
||||
<path d="M33.5 15c1-2.6 2.6-3.9 4.8-4.1M31.4 14.8c-.1-2.8.8-4.6 2.6-5.6" fill="none" stroke="#24352B" strokeWidth="1" strokeLinecap="round" />
|
||||
<circle cx="32" cy="18.8" r="4.3" fill="var(--color-heading)" />
|
||||
<path d="M33.5 15c1-2.6 2.6-3.9 4.8-4.1M31.4 14.8c-.1-2.8.8-4.6 2.6-5.6" fill="none" stroke="var(--color-heading)" strokeWidth="1" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ type Status = "idle" | "loading" | "success" | "error";
|
||||
* Server Component und reichen ihn als Prop weiter. Diese Datei folgt jetzt
|
||||
* demselben Muster.
|
||||
*/
|
||||
export function BusinessInquiryForm({ turnstileSiteKey }: { turnstileSiteKey?: string }) {
|
||||
export function BusinessInquiryForm({
|
||||
turnstileSiteKey,
|
||||
defaultPackage,
|
||||
}: {
|
||||
turnstileSiteKey?: string;
|
||||
/** Vorauswahl, wenn man ueber eine Paketkarte der Seite kommt. */
|
||||
defaultPackage?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -65,7 +72,7 @@ export function BusinessInquiryForm({ turnstileSiteKey }: { turnstileSiteKey?: s
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<p className="text-forest">
|
||||
<p className="text-heading">
|
||||
{t.businessInquiryForm.successMessage}
|
||||
</p>
|
||||
);
|
||||
@@ -95,14 +102,14 @@ export function BusinessInquiryForm({ turnstileSiteKey }: { turnstileSiteKey?: s
|
||||
{t.unternehmenPage.packages.map((p, i) => (
|
||||
<label
|
||||
key={p.key}
|
||||
className="group relative flex cursor-pointer flex-col border border-ink/20 bg-background p-4 transition-colors duration-300 hover:border-ink/40 has-[:checked]:border-forest has-[:checked]:bg-forest/[0.04] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark"
|
||||
className="group relative flex cursor-pointer flex-col border border-ink/20 bg-background p-4 transition-colors duration-300 hover:border-ink/40 has-[:checked]:border-heading has-[:checked]:bg-forest/[0.04] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark"
|
||||
>
|
||||
<input type="radio" name="package" value={p.key} defaultChecked={i === 0} className="sr-only" />
|
||||
<input type="radio" name="package" value={p.key} defaultChecked={defaultPackage ? p.key === defaultPackage : i === 0} className="sr-only" />
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-serif text-xl text-forest">{p.title}</span>
|
||||
<span className="font-serif text-xl text-heading">{p.title}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full border border-ink/30 transition-[border-width,border-color] duration-200 group-has-[:checked]:border-[4.5px] group-has-[:checked]:border-forest"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full border border-ink/30 transition-[border-width,border-color] duration-200 group-has-[:checked]:border-[4.5px] group-has-[:checked]:border-heading"
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-2 text-ink/70 leading-relaxed">{p.text}</span>
|
||||
@@ -151,7 +158,7 @@ function TextField({
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function CopyCodeButton({ code, label, copiedLabel }: { code: string; lab
|
||||
<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"
|
||||
className="inline-flex h-10 items-center gap-2 border border-heading px-4 text-xs tracking-wide-caps text-heading transition-colors hover:bg-forest hover:text-cream"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FLASH_COOKIE, type Flash } from "@/lib/flash-core";
|
||||
const AUTO_HIDE_MS: Record<Flash["kind"], number> = { success: 6000, info: 8000, error: 12000 };
|
||||
|
||||
const STYLES: Record<Flash["kind"], string> = {
|
||||
success: "bg-forest text-cream border-forest",
|
||||
success: "bg-forest text-cream border-heading",
|
||||
info: "bg-background text-ink border-ink/20",
|
||||
error: "bg-red-50 text-red-800 border-red-300",
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function OriginStory() {
|
||||
return (
|
||||
// Randlos geteilt: das Foto fuellt die linke Haelfte bis an den
|
||||
// Fensterrand, der Text steht daneben — kein Rahmen, keine Karte.
|
||||
<section className="grid bg-cream/60 md:grid-cols-2">
|
||||
<section className="grid bg-surface/60 md:grid-cols-2">
|
||||
<ScrollReveal className="h-full">
|
||||
<ParallaxMedia className="h-full min-h-[24rem] md:min-h-[40rem]">
|
||||
<ContentImage
|
||||
@@ -129,7 +129,7 @@ export async function Philosophy() {
|
||||
delay={i * 0.06}
|
||||
className="border-b border-ink/15 py-8 sm:pr-8 lg:border-b-0 lg:border-l lg:px-8 lg:first:border-l-0 lg:first:pl-0"
|
||||
>
|
||||
<h3 className="font-serif text-3xl leading-tight text-forest text-balance">{item.title}</h3>
|
||||
<h3 className="font-serif text-3xl leading-tight text-heading text-balance">{item.title}</h3>
|
||||
<p className="mt-3 max-w-[34ch] text-sm leading-relaxed text-ink/70">{item.text}</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
@@ -151,7 +151,7 @@ export async function BeekeeperNote() {
|
||||
const [content, company] = await Promise.all([getContentValues(["imkerei.image"], locale), getCompanyInfo()]);
|
||||
const quote = t.imkereiPage.paragraphs[0];
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-cream/60 py-24 md:py-32">
|
||||
<section className="relative overflow-hidden bg-surface/60 py-24 md:py-32">
|
||||
<HoneycombPattern className="text-honey/[0.08]" size={26} fade="right" />
|
||||
<Container className="relative grid items-center gap-16 md:grid-cols-12 md:gap-12">
|
||||
<ScrollReveal className="mx-auto w-64 sm:w-72 md:col-span-5 md:w-full md:max-w-sm">
|
||||
@@ -195,7 +195,7 @@ export async function BeekeeperNote() {
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1} className="md:col-span-7">
|
||||
<blockquote>
|
||||
<p className="font-serif text-3xl leading-snug text-forest text-balance md:text-4xl lg:text-[2.75rem]">
|
||||
<p className="font-serif text-3xl leading-snug text-heading text-balance md:text-4xl lg:text-[2.75rem]">
|
||||
<span aria-hidden="true" className="mr-1 text-honey">„</span>
|
||||
{quote}
|
||||
<span aria-hidden="true" className="ml-0.5 text-honey">“</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function ProcessTimeline() {
|
||||
const content = await getContentValues(["home.processTimeline.steps"], locale);
|
||||
const steps = parseListNumbered(content["home.processTimeline.steps"]);
|
||||
return (
|
||||
<section className="py-24 bg-cream/60">
|
||||
<section className="py-24 bg-surface/60">
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
@@ -86,7 +86,7 @@ export async function Sustainability() {
|
||||
const content = await getContentValues(["home.sustainability.items"], locale);
|
||||
const items = parseListTitled(content["home.sustainability.items"]);
|
||||
return (
|
||||
<section className="py-28 bg-cream/60">
|
||||
<section className="py-28 bg-surface/60">
|
||||
{/* Geteilt statt Viererreihe: die Ueberschrift laeuft mit, die Punkte
|
||||
werden als ruhige Liste gelesen. */}
|
||||
<Container className="grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
@@ -102,7 +102,7 @@ export async function Sustainability() {
|
||||
<div className="group grid grid-cols-[auto_1fr] gap-x-5 py-7">
|
||||
<HexMark className="mt-2 h-3 w-3 text-honey transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:rotate-90" />
|
||||
<div>
|
||||
<h3 className="text-2xl text-forest">{item.title}</h3>
|
||||
<h3 className="text-2xl text-heading">{item.title}</h3>
|
||||
<p className="mt-2 max-w-[55ch] text-ink/70 leading-relaxed">{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function LanguageSwitcher({ className = "" }: { className?: string }) {
|
||||
aria-current={code === locale}
|
||||
title={localeLabels[code]}
|
||||
className={`uppercase tracking-wide transition-colors disabled:opacity-50 ${
|
||||
code === locale ? "text-forest font-medium" : "text-ink/70 hover:text-forest"
|
||||
code === locale ? "text-heading font-medium" : "text-ink/70 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
|
||||
@@ -23,14 +23,14 @@ export function LegalPage({
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-forest">{title}</h1>
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-heading">{title}</h1>
|
||||
{noticeText ? (
|
||||
<p className="mt-2 text-xs text-honey-dark tracking-wide-caps">{noticeText}</p>
|
||||
) : null}
|
||||
<div className="mt-10 space-y-10">
|
||||
{sections.map((section) => (
|
||||
<section key={section.heading}>
|
||||
<h2 className="text-2xl text-forest">{section.heading}</h2>
|
||||
<h2 className="text-2xl text-heading">{section.heading}</h2>
|
||||
<div className="mt-3 space-y-3 text-sm text-ink/70 leading-relaxed">
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Logomark({
|
||||
}
|
||||
|
||||
const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44";
|
||||
const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
|
||||
const treeColor = variant === "inverted" ? "#F4EFE5" : "var(--color-heading)";
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} fill="none" aria-hidden="true">
|
||||
|
||||
@@ -91,7 +91,7 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
/>
|
||||
</svg>
|
||||
<motion.p
|
||||
className="mt-4 font-serif text-2xl text-forest"
|
||||
className="mt-4 font-serif text-2xl text-heading"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
@@ -115,7 +115,7 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
placeholder={t.home.newsletter.emailPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="min-w-0 flex-1 border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
||||
className="min-w-0 flex-1 border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
<Button type="submit" disabled={status === "loading"} className="flex-shrink-0 whitespace-nowrap">
|
||||
{status === "loading" ? t.home.newsletter.subscribing : t.home.newsletter.subscribe}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Gezeichnetes Honigglas je Firmenpaket — im Strich des Logos:
|
||||
* - KLASSIK: Glas mit Firmenbanderole
|
||||
* - GESCHENK: Glas im Karton mit Schleife
|
||||
* - INDIVIDUELL: Glas mit frei gestaltetem Etikett, Stift und Funkeln
|
||||
* Liegt in einer Karte mit "group": beim Hover hebt sich das Glas, die
|
||||
* Schleife wippt, das Funkeln leuchtet auf (reduzierte Bewegung: still).
|
||||
* Farben ueber Rollen-Variablen, damit es hell und dunkel traegt.
|
||||
*/
|
||||
export type PackageKey = "KLASSIK" | "GESCHENK" | "INDIVIDUELL";
|
||||
|
||||
const LINE = "var(--color-heading)";
|
||||
|
||||
export function PackageJar({ variant, className = "h-44 w-36" }: { variant: PackageKey; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 120 150" className={className} aria-hidden="true">
|
||||
{/* Glas, hebt sich beim Hover */}
|
||||
<g className="transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-2 group-hover:-rotate-2 motion-reduce:transition-none [transform-box:fill-box] origin-bottom">
|
||||
<rect x="32" y="14" width="56" height="16" rx="3" fill="var(--color-forest)" stroke={LINE} strokeWidth="1.4" />
|
||||
<path d="M26 44c0-8 6-14 14-14h40c8 0 14 6 14 14v70c0 10-8 18-18 18H44c-10 0-18-8-18-18z" fill="#C9A468" fillOpacity="0.92" stroke={LINE} strokeWidth="1.5" />
|
||||
{/* Glanzlicht */}
|
||||
<path d="M34 50v48" stroke="#FAF8F3" strokeOpacity="0.55" strokeWidth="3" strokeLinecap="round" />
|
||||
{/* Etikett */}
|
||||
<rect
|
||||
x="36"
|
||||
y="62"
|
||||
width="48"
|
||||
height="40"
|
||||
rx="2"
|
||||
fill="#FAF8F3"
|
||||
stroke={variant === "INDIVIDUELL" ? "var(--color-honey-deep)" : "#24352B"}
|
||||
strokeWidth="1.1"
|
||||
strokeDasharray={variant === "INDIVIDUELL" ? "3 2.5" : undefined}
|
||||
/>
|
||||
<polygon points="60,70 65,73 65,79 60,82 55,79 55,73" fill="none" stroke="#B88A44" strokeWidth="1.2" />
|
||||
<path d="M46 88h28M50 94h20" stroke="#24352B" strokeWidth="1.1" strokeLinecap="round" opacity="0.6" />
|
||||
|
||||
{/* Firmenbanderole (Klassik) */}
|
||||
{variant === "KLASSIK" ? (
|
||||
<g>
|
||||
<rect x="25.5" y="104" width="69" height="11" fill="var(--color-honey-deep)" />
|
||||
<path d="M40 109.5h40" stroke="#FAF8F3" strokeWidth="1.2" strokeLinecap="round" strokeDasharray="5 3" />
|
||||
</g>
|
||||
) : null}
|
||||
</g>
|
||||
|
||||
{/* Geschenk: Karton VOR dem Glas (das Glas schaut oben heraus), Band
|
||||
senkrecht, Schleife auf der Kartonkante — sie wippt beim Hover. */}
|
||||
{variant === "GESCHENK" ? (
|
||||
<g>
|
||||
<rect x="10" y="98" width="100" height="46" rx="3" fill="var(--color-surface)" stroke={LINE} strokeWidth="1.4" />
|
||||
<path d="M10 108h100" stroke={LINE} strokeWidth="1" opacity="0.45" />
|
||||
<rect x="54" y="98" width="12" height="46" fill="var(--color-honey-deep)" />
|
||||
<g className="origin-center transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:rotate-12 motion-reduce:transition-none [transform-box:fill-box]">
|
||||
<path d="M60 98c-8-10-20-8-18-1 2 6 12 4 18 1zM60 98c8-10 20-8 18-1-2 6-12 4-18 1z" fill="var(--color-honey-deep)" stroke={LINE} strokeWidth="1" />
|
||||
<circle cx="60" cy="98" r="3" fill="var(--color-honey-deep)" stroke={LINE} strokeWidth="1" />
|
||||
</g>
|
||||
</g>
|
||||
) : null}
|
||||
|
||||
{/* Stift und Funkeln (Individuell) */}
|
||||
{variant === "INDIVIDUELL" ? (
|
||||
<g>
|
||||
<g className="transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:-translate-x-1 group-hover:translate-y-1 motion-reduce:transition-none">
|
||||
<path d="M92 118 110 96l5 4-18 22-7 2z" fill="var(--color-surface)" stroke={LINE} strokeWidth="1.3" strokeLinejoin="round" />
|
||||
<path d="M106 100l5 4" stroke={LINE} strokeWidth="1" />
|
||||
</g>
|
||||
<g className="text-honey opacity-60 transition-[opacity,transform] duration-500 group-hover:scale-125 group-hover:opacity-100 motion-reduce:transition-none [transform-box:fill-box] origin-center">
|
||||
<path d="M16 36l2 5 5 2-5 2-2 5-2-5-5-2 5-2z" fill="currentColor" />
|
||||
<path d="M100 30l1.4 3.4 3.4 1.4-3.4 1.4-1.4 3.4-1.4-3.4-3.4-1.4 3.4-1.4z" fill="currentColor" />
|
||||
</g>
|
||||
</g>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function PageOutro({ links, ariaLabel }: { links: PageOutroLink[]; ariaLa
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
<span className="block font-serif text-4xl leading-tight text-forest md:text-5xl">
|
||||
<span className="block font-serif text-4xl leading-tight text-heading md:text-5xl">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="mt-3 block max-w-[40ch] text-sm leading-relaxed text-ink/65">{link.text}</span>
|
||||
|
||||
@@ -102,13 +102,13 @@ export function PasswordField({
|
||||
minLength={minLength}
|
||||
autoComplete={autoComplete}
|
||||
onChange={showStrength ? (e) => setValue(e.target.value) : undefined}
|
||||
className="w-full border border-ink/20 bg-background px-4 py-3 pr-12 focus:outline-none focus:border-forest"
|
||||
className="w-full border border-ink/20 bg-background px-4 py-3 pr-12 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-forest"
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-heading"
|
||||
>
|
||||
{visible ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Logomark } from "@/components/logomark";
|
||||
|
||||
const SURFACES = {
|
||||
forest: "bg-forest text-cream",
|
||||
cream: "bg-cream text-forest",
|
||||
cream: "bg-surface text-heading",
|
||||
honey: "bg-honey-pale/60 text-forest",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function ProcessStep({
|
||||
</svg>
|
||||
</motion.span>
|
||||
<span className="font-serif text-sm text-honey-dark tabular-nums">{step.number}</span>
|
||||
<h3 className="mt-1 text-xl text-forest">{step.title}</h3>
|
||||
<h3 className="mt-1 text-xl text-heading">{step.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/70 leading-relaxed">{step.text}</p>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ProductTeaserLink({
|
||||
return (
|
||||
<Link
|
||||
href={`/honig/${slug}`}
|
||||
className="group flex items-center gap-5 border border-ink/10 p-4 transition-[border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-forest/50 hover:shadow-[0_18px_34px_-24px_rgba(36,53,43,0.45)]"
|
||||
className="group flex items-center gap-5 border border-ink/10 p-4 transition-[border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-heading/50 hover:shadow-[0_18px_34px_-24px_rgba(36,53,43,0.45)]"
|
||||
>
|
||||
<span className="block overflow-hidden rounded-lg">
|
||||
<ContentImage
|
||||
@@ -53,8 +53,8 @@ export function ProductTeaserLink({
|
||||
/>
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block font-serif text-xl text-forest">{name}</span>
|
||||
<span className="mt-1 inline-flex items-center gap-1.5 text-sm text-ink/70 transition-colors group-hover:text-forest">
|
||||
<span className="block font-serif text-xl text-heading">{name}</span>
|
||||
<span className="mt-1 inline-flex items-center gap-1.5 text-sm text-ink/70 transition-colors group-hover:text-heading">
|
||||
{t.product.viewProduct.replace(/\s*→\s*$/, "")}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -101,7 +101,7 @@ export function TasteScale({
|
||||
<span
|
||||
key={i}
|
||||
className={`relative h-1.5 w-6 overflow-hidden rounded-full ${
|
||||
tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
|
||||
tone === "inverted" ? "bg-surface/15" : "bg-ink/10"
|
||||
}`}
|
||||
>
|
||||
{i < value ? (
|
||||
@@ -295,14 +295,14 @@ export function ProductCard({
|
||||
{t.product.outOfStock}
|
||||
</span>
|
||||
) : stock <= 5 ? (
|
||||
<span className="absolute top-3 left-3 bg-honey-dark text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
<span className="absolute top-3 left-3 bg-honey-deep text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
{t.product.onlyLeft(stock)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="mt-5 flex flex-1 flex-col">
|
||||
<Link href={`/honig/${product.slug}`} className="self-start">
|
||||
<h3 className="link-draw text-2xl leading-tight text-forest">{product.name}</h3>
|
||||
<h3 className="link-draw text-2xl leading-tight text-heading">{product.name}</h3>
|
||||
</Link>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{product.tasteTags.join(" · ")}
|
||||
@@ -319,7 +319,7 @@ export function ProductCard({
|
||||
</p>
|
||||
<div className="mt-auto flex items-baseline justify-between pt-3">
|
||||
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
|
||||
<span className="font-serif text-2xl text-forest tabular-nums">
|
||||
<span className="font-serif text-2xl text-heading tabular-nums">
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -331,7 +331,7 @@ export function ProductCard({
|
||||
) : null}
|
||||
<Link
|
||||
href={`/honig/${product.slug}`}
|
||||
className="group/more mt-3 inline-flex items-center gap-2 self-start py-1 text-sm tracking-wide-caps text-forest"
|
||||
className="group/more mt-3 inline-flex items-center gap-2 self-start py-1 text-sm tracking-wide-caps text-heading"
|
||||
>
|
||||
<span className="link-draw pb-0.5">
|
||||
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
||||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||||
aria-current={index === activeIndex}
|
||||
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
|
||||
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
||||
index === activeIndex ? "border-heading" : "border-transparent hover:border-ink/20"
|
||||
}`}
|
||||
>
|
||||
{isImageSlide(slide) ? (
|
||||
|
||||
@@ -22,7 +22,7 @@ export function QuantityStepper({
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const buttonSize = size === "sm" ? "w-8 h-8" : "w-10 h-10";
|
||||
const buttonClass = `${buttonSize} flex items-center justify-center text-ink/70 transition-[color,background-color,transform] duration-200 hover:bg-honey/10 hover:text-forest active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink/70 disabled:active:scale-100`;
|
||||
const buttonClass = `${buttonSize} flex items-center justify-center text-ink/70 transition-[color,background-color,transform] duration-200 hover:bg-honey/10 hover:text-heading active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink/70 disabled:active:scale-100`;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-stretch border border-ink/20 bg-background">
|
||||
|
||||
@@ -43,7 +43,7 @@ export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?:
|
||||
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.recentlyViewedHeading}</h2>
|
||||
<h2 className="text-2xl text-heading mb-8">{t.productPage.recentlyViewedHeading}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
|
||||
@@ -24,7 +24,7 @@ const STATUS_FILL: Record<DhlTrackingStatus, string> = {
|
||||
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
||||
"pre-transit": "text-ink/70",
|
||||
transit: "text-honey-dark",
|
||||
delivered: "text-forest",
|
||||
delivered: "text-heading",
|
||||
failure: "text-red-700",
|
||||
unknown: "text-ink/70",
|
||||
};
|
||||
@@ -50,7 +50,7 @@ export function ShipmentTrackingHistory({
|
||||
if (result.kind !== "ok") {
|
||||
return (
|
||||
<section className="mt-5 border-t border-ink/10 pt-5">
|
||||
<h3 className="text-sm text-forest">{labels.heading}</h3>
|
||||
<h3 className="text-sm text-heading">{labels.heading}</h3>
|
||||
<p className="mt-2 text-xs text-ink/65 leading-relaxed">
|
||||
{result.kind === "not-found" ? labels.notYetScanned : labels.unavailable}
|
||||
</p>
|
||||
@@ -62,7 +62,7 @@ export function ShipmentTrackingHistory({
|
||||
return (
|
||||
<section className="mt-5 border-t border-ink/10 pt-5" aria-label={labels.ariaLabel}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm text-forest">{labels.heading}</h3>
|
||||
<h3 className="text-sm text-heading">{labels.heading}</h3>
|
||||
<span className={`text-xs ${STATUS_TEXT[info.status]}`}>{labels.statuses[info.status]}</span>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ShipmentTrackingHistory({
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
<p className={`text-sm ${i === 0 ? "font-serif text-lg text-forest" : "text-ink/70"}`}>{event.description}</p>
|
||||
<p className={`text-sm ${i === 0 ? "font-serif text-lg text-heading" : "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}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import type { ThemeChoice } from "@/lib/theme";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
@@ -81,10 +83,10 @@ function Logo({ t, companyName, logoSrc }: { t: Dictionary; companyName: string;
|
||||
{/* Kopfzeile ist bei 1280-1440 px knapp: breite Logos hier enger begrenzt. */}
|
||||
<Logomark className="h-9 w-9 max-w-14 flex-shrink-0 sm:max-w-28" src={logoSrc} />
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
|
||||
<span className="text-2xl tracking-[0.2em] text-heading font-serif">
|
||||
{companyName}
|
||||
</span>
|
||||
<span className="hidden 2xl:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
|
||||
<span className="hidden min-[1680px]:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
|
||||
{t.footer.tagline}
|
||||
</span>
|
||||
</span>
|
||||
@@ -166,7 +168,7 @@ function SearchButton() {
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
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"
|
||||
className="w-full border-b border-heading bg-transparent px-2 py-1.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
/>
|
||||
{visibleSuggestions.length > 0 ? (
|
||||
<motion.ul
|
||||
@@ -214,7 +216,7 @@ function SearchButton() {
|
||||
type="button"
|
||||
onClick={() => (open ? closeSearch() : setOpen(true))}
|
||||
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-heading flex-shrink-0"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.svg
|
||||
@@ -253,7 +255,7 @@ function HighlightMatch({ text, query }: { text: string; query: string }) {
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, index)}
|
||||
<mark className="bg-honey-pale/70 text-forest">{text.slice(index, index + needle.length)}</mark>
|
||||
<mark className="bg-honey-pale/70 text-heading">{text.slice(index, index + needle.length)}</mark>
|
||||
{text.slice(index + needle.length)}
|
||||
</>
|
||||
);
|
||||
@@ -265,7 +267,7 @@ function AccountIconButton() {
|
||||
<Link
|
||||
href="/konto"
|
||||
aria-label={t.nav.account}
|
||||
className="flex items-center justify-center w-10 h-10 text-forest"
|
||||
className="flex items-center justify-center w-10 h-10 text-heading"
|
||||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
@@ -301,7 +303,7 @@ function CartIconButton() {
|
||||
onClick={openCart}
|
||||
aria-label={t.nav.openCart(count)}
|
||||
data-cart-target=""
|
||||
className="relative flex items-center justify-center w-10 h-10 text-forest transition-colors hover:text-honey-dark"
|
||||
className="relative flex items-center justify-center w-10 h-10 text-heading transition-colors hover:text-honey-dark"
|
||||
>
|
||||
<motion.svg
|
||||
animate={basketControls}
|
||||
@@ -326,7 +328,7 @@ function CartIconButton() {
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.4, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 18 }}
|
||||
className="absolute -top-1 -right-1 bg-honey-dark text-cream text-[10px] min-w-4 h-4 px-1 rounded-full flex items-center justify-center tabular-nums"
|
||||
className="absolute -top-1 -right-1 bg-honey-deep text-cream text-[10px] min-w-4 h-4 px-1 rounded-full flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</motion.span>
|
||||
@@ -380,10 +382,12 @@ export function Header({
|
||||
companyName,
|
||||
logoSrc = null,
|
||||
logoInvertedSrc = null,
|
||||
themeChoice = "system",
|
||||
}: {
|
||||
companyName: string;
|
||||
logoSrc?: string | null;
|
||||
logoInvertedSrc?: string | null;
|
||||
themeChoice?: ThemeChoice;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
@@ -430,7 +434,7 @@ export function Header({
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`link-draw pb-1 text-sm whitespace-nowrap transition-colors ${
|
||||
active ? "text-forest" : "text-ink/80 hover:text-forest"
|
||||
active ? "text-heading" : "text-ink/80 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{t.nav[item.key]}
|
||||
@@ -440,13 +444,16 @@ export function Header({
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:block">
|
||||
{/* Zwischen xl und 2xl steht "Shop" schon in der Navigation - dort waere der
|
||||
Knopf eine Dopplung und nahm der knappen Kopfzeile den Platz. */}
|
||||
<div className="hidden md:block xl:hidden 2xl:block">
|
||||
<LinkButton href="/shop" variant="primary" className="whitespace-nowrap">
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle initial={themeChoice} className="text-ink/70 hover:text-heading" />
|
||||
<SearchButton />
|
||||
<AccountIconButton />
|
||||
</div>
|
||||
@@ -455,7 +462,7 @@ export function Header({
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
aria-label={t.nav.openMenu}
|
||||
className="xl:hidden flex items-center justify-center w-10 h-10 text-forest"
|
||||
className="xl:hidden flex items-center justify-center w-10 h-10 text-heading"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -494,6 +501,7 @@ export function Header({
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher className="!text-cream/70" />
|
||||
<ThemeToggle initial={themeChoice} className="text-cream/70 hover:text-cream" />
|
||||
<button
|
||||
aria-label={t.nav.closeMenu}
|
||||
className="w-10 h-10 flex items-center justify-center"
|
||||
@@ -625,7 +633,7 @@ export function CartDrawer() {
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-[-24px_0_60px_-30px_rgba(36,53,43,0.45)] outline-none"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
|
||||
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-forest">
|
||||
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-heading">
|
||||
{t.cart.title}
|
||||
{items.length > 0 ? (
|
||||
<span className="font-sans text-xs text-ink/55 tabular-nums">({cartItemCount(items)})</span>
|
||||
@@ -634,7 +642,7 @@ export function CartDrawer() {
|
||||
<button
|
||||
aria-label={t.cart.close}
|
||||
onClick={closeCart}
|
||||
className="flex w-9 h-9 items-center justify-center text-ink/70 transition-[color,transform] duration-300 hover:rotate-90 hover:text-forest"
|
||||
className="flex w-9 h-9 items-center justify-center text-ink/70 transition-[color,transform] duration-300 hover:rotate-90 hover:text-heading"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
@@ -655,7 +663,7 @@ export function CartDrawer() {
|
||||
strokeDasharray="6 5"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-6 font-serif text-2xl text-forest">{t.cart.empty}</p>
|
||||
<p className="mt-6 font-serif text-2xl text-heading">{t.cart.empty}</p>
|
||||
<LinkButton href="/shop" variant="secondary" onClick={closeCart} className="mt-8">
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
@@ -676,7 +684,7 @@ export function CartDrawer() {
|
||||
>
|
||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex-1">
|
||||
<p className="font-serif text-lg leading-tight text-forest">{item.name}</p>
|
||||
<p className="font-serif text-lg leading-tight text-heading">{item.name}</p>
|
||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
||||
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
||||
@@ -726,7 +734,7 @@ export function CartDrawer() {
|
||||
key={total}
|
||||
initial={{ opacity: 0.4, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-serif text-2xl text-forest tabular-nums"
|
||||
className="font-serif text-2xl text-heading tabular-nums"
|
||||
>
|
||||
{formatPrice(total, "EUR", locale)}
|
||||
</motion.span>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function StickyAddToCartBar({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{product.name}</p>
|
||||
<p className="text-sm text-forest tabular-nums">{formatPrice(product.priceCents, product.currency, locale)}</p>
|
||||
<p className="text-sm text-heading tabular-nums">{formatPrice(product.priceCents, product.currency, locale)}</p>
|
||||
</div>
|
||||
{inStock ? (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import {
|
||||
THEME_COOKIE,
|
||||
THEME_COOKIE_MAX_AGE,
|
||||
nextThemeChoice,
|
||||
parseThemeChoice,
|
||||
type ThemeChoice,
|
||||
} from "@/lib/theme";
|
||||
|
||||
const THEME_EVENT = "wh-theme-change";
|
||||
|
||||
function readChoice(): ThemeChoice {
|
||||
return parseThemeChoice(document.documentElement.dataset.theme);
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
window.addEventListener(THEME_EVENT, onChange);
|
||||
return () => window.removeEventListener(THEME_EVENT, onChange);
|
||||
}
|
||||
|
||||
function applyChoice(choice: ThemeChoice): void {
|
||||
const root = document.documentElement;
|
||||
const secure = window.location.protocol === "https:" ? "; secure" : "";
|
||||
if (choice === "system") {
|
||||
delete root.dataset.theme;
|
||||
document.cookie = `${THEME_COOKIE}=; path=/; max-age=0; samesite=lax${secure}`;
|
||||
} else {
|
||||
root.dataset.theme = choice;
|
||||
document.cookie = `${THEME_COOKIE}=${choice}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=lax${secure}`;
|
||||
}
|
||||
// Alle Umschalter (Kopfzeile, Mobilmenue, Admin-Leiste) bleiben synchron.
|
||||
window.dispatchEvent(new Event(THEME_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Umschalter Automatisch -> Hell -> Dunkel. Das Symbol zeigt die aktuelle
|
||||
* Wahl; Name und Tooltip sagen, was ein Klick bewirkt. "initial" kommt vom
|
||||
* Server (Cookie), damit der erste Render mit dem Dokument uebereinstimmt.
|
||||
*/
|
||||
export function ThemeToggle({ initial, className = "" }: { initial: ThemeChoice; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const choice = useSyncExternalStore(subscribe, readChoice, () => initial);
|
||||
const next = nextThemeChoice(choice);
|
||||
const label = t.theme.toggleLabel(t.theme[choice], t.theme[next]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyChoice(next)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`inline-flex h-10 w-10 items-center justify-center transition-colors duration-300 ${className}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
{choice === "light" ? (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" strokeLinecap="round" />
|
||||
</>
|
||||
) : choice === "dark" ? (
|
||||
<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" strokeLinejoin="round" />
|
||||
) : (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function TrustBadges({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<li key={badge.label} className="flex items-center gap-2 text-xs text-ink/70">
|
||||
{badge.href ? (
|
||||
<Link href={badge.href} className="flex items-center gap-2 hover:text-forest">
|
||||
<Link href={badge.href} className="flex items-center gap-2 hover:text-heading">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -28,14 +28,14 @@ const fillBase =
|
||||
"bg-no-repeat bg-bottom bg-[length:100%_0%] hover:bg-[length:100%_100%] focus-visible:bg-[length:100%_100%] motion-reduce:transition-colors";
|
||||
|
||||
const buttonStyles: Record<ButtonVariant, string> = {
|
||||
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-dark),var(--color-honey-dark))] text-cream border border-forest hover:border-honey-dark`,
|
||||
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-forest border border-forest hover:text-cream focus-visible:text-cream`,
|
||||
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-deep),var(--color-honey-deep))] text-cream border border-heading hover:border-honey-dark`,
|
||||
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-heading border border-heading hover:text-cream focus-visible:text-cream`,
|
||||
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
|
||||
// Fuer dunkle Flaechen (Hero, Waldgruen-Abschnitte, Menue): Creme-Rahmen,
|
||||
// fuellt sich mit Creme, Text wechselt auf Waldgruen.
|
||||
inverted: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-cream hover:text-forest focus-visible:text-forest`,
|
||||
// Hauptaktion auf dunklen Flaechen, wo Waldgruen im Grund verschwinden wuerde.
|
||||
accent: `${fillBase} bg-honey-dark bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
|
||||
accent: `${fillBase} bg-honey-deep bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
|
||||
};
|
||||
|
||||
const buttonBase =
|
||||
@@ -108,7 +108,7 @@ export function SectionHeading({
|
||||
Ueberschrift traegt den Abschnitt allein. */}
|
||||
<Heading
|
||||
className={`text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] ${
|
||||
tone === "inverted" ? "text-cream" : "text-forest"
|
||||
tone === "inverted" ? "text-cream" : "text-heading"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
|
||||
+78
-11
@@ -20,6 +20,12 @@ export const de: Dictionary = {
|
||||
harvestsText: "Jede Ernte mit Datum, Standort und Charge, zurückverfolgbar bis ins Glas.",
|
||||
originText: "Wo unsere Bienenvölker stehen und was die Landschaft um Altenmünster prägt.",
|
||||
},
|
||||
theme: {
|
||||
light: "Hell",
|
||||
dark: "Dunkel",
|
||||
system: "Automatisch",
|
||||
toggleLabel: (current, next) => `Darstellung: ${current}. Wechseln zu ${next}`,
|
||||
},
|
||||
nav: {
|
||||
shop: "Shop",
|
||||
harvests: "Unsere Ernten",
|
||||
@@ -143,6 +149,10 @@ export const de: Dictionary = {
|
||||
{ key: "INDIVIDUELL", title: "Individuell", text: "Komplett individuelles Design" },
|
||||
],
|
||||
inquiryHeading: "Anfrage senden",
|
||||
forLabel: "Für",
|
||||
audiences: ["Unternehmen", "Kanzleien", "Praxen", "Hotels", "Agenturen", "Handwerksbetriebe"],
|
||||
packagesHeading: "Drei Wege, Honig zu verschenken.",
|
||||
packageCta: "Dieses Paket anfragen",
|
||||
},
|
||||
businessInquiryForm: {
|
||||
companyLabel: "Unternehmen",
|
||||
@@ -439,43 +449,100 @@ export const de: Dictionary = {
|
||||
},
|
||||
cookieRichtliniePage: {
|
||||
title: "Cookie-Richtlinie",
|
||||
// Jeder Eintrag unten entspricht einem Cookie bzw. localStorage-Schluessel
|
||||
// im Code (lib/session.ts, lib/theme.ts, i18n/config.ts, lib/flash-core.ts,
|
||||
// lib/checkout-release.ts, store/*, components/cookie-consent.tsx). Wer dort
|
||||
// einen Namen, eine Laufzeit oder einen neuen Speicherort einfuehrt, muss
|
||||
// ihn hier nachtragen — § 25 TDDDG verlangt die Angabe fuer jeden Zugriff
|
||||
// auf das Endgeraet, nicht nur fuer Cookies. Gleiches gilt fuer en.ts.
|
||||
sections: [
|
||||
{
|
||||
heading: "Was sind Cookies?",
|
||||
heading: "Was sind Cookies und ähnliche Technologien?",
|
||||
paragraphs: [
|
||||
"Cookies sind kleine Textdateien, die beim Besuch einer Website auf deinem Endgerät gespeichert werden. Sie ermöglichen es unter anderem, dich während eines Besuchs wiederzuerkennen oder Einstellungen zu speichern.",
|
||||
"Neben Cookies nutzen wir den lokalen Speicher deines Browsers (localStorage). Rechtlich werden beide gleich behandelt: § 25 des Telekommunikation-Digitale-Dienste-Datenschutz-Gesetzes (TDDDG) gilt für jede Speicherung von Informationen auf deinem Endgerät und jeden Zugriff darauf. Diese Richtlinie nennt deshalb beides vollständig.",
|
||||
"Verantwortlich ist der im Impressum genannte Betreiber dieser Website. Ergänzende Informationen zur Verarbeitung personenbezogener Daten findest du in unserer Datenschutzerklärung.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Rechtsgrundlagen",
|
||||
paragraphs: [
|
||||
"Speichern wir Informationen auf deinem Endgerät oder lesen sie aus, ist dafür grundsätzlich deine Einwilligung erforderlich (§ 25 Abs. 1 TDDDG). Keine Einwilligung braucht es, wenn die Speicherung unbedingt erforderlich ist, damit wir einen von dir ausdrücklich gewünschten Dienst bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG), etwa die Anmeldung im Kundenkonto oder den Bestellabschluss.",
|
||||
"Soweit dabei personenbezogene Daten verarbeitet werden, stützen wir das auf Art. 6 Abs. 1 lit. b DSGVO (Vertrag und vorvertragliche Maßnahmen, z. B. Kundenkonto und Bestellung) oder Art. 6 Abs. 1 lit. f DSGVO (unser berechtigtes Interesse an einer sicheren, funktionsfähigen Website). Setzen wir künftig einwilligungspflichtige Technologien ein, ist Rechtsgrundlage deine Einwilligung nach § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Überblick",
|
||||
paragraphs: [
|
||||
"Wir verwenden ausschließlich technisch notwendige Cookies und Speichereinträge. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.",
|
||||
"Die meisten der unten genannten Cookies entstehen erst, wenn du eine bestimmte Funktion nutzt (z. B. Anmeldung, Bezahlung, Sprachwechsel). Beim bloßen Aufrufen der Website wird kein Cookie gesetzt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Technisch notwendige Cookies",
|
||||
paragraphs: [
|
||||
"Diese Cookies sind für den Betrieb der Website erforderlich und können nicht deaktiviert werden. Sie werden nur gesetzt, wenn du sie durch dein Verhalten aktiv auslöst (z.B. Anmeldung im Kundenkonto).",
|
||||
"wabenhain_session: Hält dich nach der Anmeldung im Kundenkonto eingeloggt. Läuft nach 30 Tagen automatisch ab. Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO / § 25 Abs. 2 Nr. 2 TDDDG.",
|
||||
"Alle folgenden Cookies setzen wir selbst (Erstanbieter-Cookies). Sie werden nur verschlüsselt (HTTPS) übertragen und sind, soweit nicht anders angegeben, für Skripte im Browser unzugänglich (HttpOnly). Sie lassen sich nicht abwählen, weil die jeweilige Funktion ohne sie nicht arbeitet. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b DSGVO.",
|
||||
"wabenhain_session – Hält dich nach der Anmeldung im Kundenkonto angemeldet. Inhalt: signierte Kennung deines Kontos, keine Passwörter. Speicherdauer: 30 Tage, bei Administrationskonten 7 Tage; wird beim Abmelden sofort gelöscht.",
|
||||
"wabenhain_2fa_challenge – Merkt sich bei der Anmeldung mit Zwei-Faktor-Authentifizierung, dass dein Passwort bereits geprüft wurde, bis du den Bestätigungscode eingibst. Speicherdauer: 5 Minuten; wird nach erfolgreicher Anmeldung gelöscht.",
|
||||
"wabenhain_2fa_setup – Hält den Einrichtungsschritt zusammen, während du die Zwei-Faktor-Authentifizierung in deinem Kundenkonto aktivierst. Speicherdauer: 10 Minuten; wird nach Abschluss gelöscht.",
|
||||
"wabenhain_2fa_backup_display – Zeigt dir deine Wiederherstellungscodes genau einmal an. Speicherdauer: höchstens 2 Minuten; wird beim ersten Anzeigen sofort gelöscht.",
|
||||
"wabenhain_entra_oauth – Nur bei „Mit Microsoft anmelden“: sichert die Weiterleitung zu Microsoft und zurück gegen Manipulation ab (Schutz vor Cross-Site-Request-Forgery). Speicherdauer: 10 Minuten; wird nach der Rückkehr gelöscht.",
|
||||
"wh_checkout_ref – Nur beim Bezahlen: verknüpft die gerade angelegte Bestellung mit deinem Browser, damit nur du die Zahlung erneut aufrufen oder abbrechen kannst. Wird ausschließlich an Seiten unter /checkout gesendet. Speicherdauer: 40 Minuten.",
|
||||
"wh_flash – Überträgt eine kurze Bestätigungs- oder Fehlermeldung (z. B. „Deine E-Mail-Adresse wurde bestätigt“) auf die nächste Seite. Für Skripte lesbar, damit die Meldung angezeigt werden kann. Speicherdauer: 60 Sekunden; wird nach dem Anzeigen gelöscht. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. f DSGVO.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Lokale Speicherung ohne Cookies",
|
||||
heading: "Cookies für deine Einstellungen",
|
||||
paragraphs: [
|
||||
"Dein Warenkorb wird nicht über ein Cookie, sondern über den lokalen Speicher deines Browsers (localStorage) auf deinem Gerät gespeichert und verlässt dein Gerät nicht. Ebenso wird deine Cookie-Auswahl selbst im localStorage hinterlegt.",
|
||||
"Diese beiden Cookies werden nur gesetzt, wenn du die jeweilige Einstellung selbst änderst, und speichern ausschließlich deine Wahl. Sie sind für Skripte lesbar, enthalten aber keine personenbezogenen Daten. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG (du hast das Speichern der Einstellung ausdrücklich ausgelöst) und Art. 6 Abs. 1 lit. f DSGVO.",
|
||||
"theme – Speichert deine gewählte Darstellung (hell oder dunkel). Speicherdauer: 1 Jahr. Stellst du wieder auf „System“, wird das Cookie gelöscht.",
|
||||
"locale – Speichert deine gewählte Sprache (Deutsch oder Englisch). Speicherdauer: 1 Jahr.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Lokaler Speicher (localStorage)",
|
||||
paragraphs: [
|
||||
"Die folgenden Einträge liegen im lokalen Speicher deines Browsers. Anders als Cookies werden sie nicht bei jedem Seitenaufruf automatisch an uns gesendet und laufen nicht von selbst ab: Sie bleiben, bis du sie löschst (über die Browser-Einstellungen „Websitedaten löschen“) oder die Website sie entfernt. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO.",
|
||||
"wabenhain-cart – Dein Warenkorb (Artikel, Mengen, Preise). An uns übermittelt wird er erst, wenn du eine Bestellung abschickst. Nach erfolgreicher Bestellung wird er geleert.",
|
||||
"wabenhain-recently-viewed – Die Kennungen der bis zu 6 zuletzt angesehenen Produkte, um sie dir auf Produktseiten erneut anzuzeigen. Zum Anzeigen werden nur diese Produktkennungen abgefragt; wir speichern keinen Verlauf auf unseren Servern.",
|
||||
"wabenhain-cookie-consent – Deine Auswahl im Cookie-Banner („Nur notwendige“ oder „Alle akzeptieren“), damit der Banner nicht bei jedem Besuch erneut erscheint und wir deine Entscheidung nachweisen können. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit Art. 7 Abs. 1 DSGVO.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Cookies von Drittanbietern",
|
||||
paragraphs: [
|
||||
"Stripe (Zahlungsabwicklung): Erst im Zahlungsschritt des Bestellabschlusses lädt dein Browser das Zahlungsformular von Stripe. Stripe setzt dabei zur Betrugsprävention und für die sichere Zahlungsabwicklung eigene Cookies, insbesondere __stripe_mid (Speicherdauer 1 Jahr) und __stripe_sid (Speicherdauer 30 Minuten) sowie weitere Cookies innerhalb des Zahlungsformulars auf Domains von Stripe. Name und Laufzeit dieser Cookies legt Stripe fest. Anbieter: Stripe Payments Europe, Ltd., 1 Grand Canal Street Lower, Dublin 2, Irland. Eine Übermittlung an Stripe, Inc. in die USA kann stattfinden; sie erfolgt auf Grundlage von EU-Standardvertragsklauseln (Art. 46 DSGVO). Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b DSGVO. Weitere Informationen: stripe.com/de/privacy und stripe.com/de/legal/cookies-policy.",
|
||||
"Microsoft (Anmeldung mit Microsoft-Konto): Wählst du „Mit Microsoft anmelden“, wirst du auf eine Anmeldeseite von Microsoft weitergeleitet. Die dort gesetzten Cookies verantwortet Microsoft (Microsoft Ireland Operations Limited); es gelten die Datenschutzhinweise von Microsoft. Auf unserer Website setzt Microsoft keine Cookies.",
|
||||
"Cloudflare Turnstile (Schutz der Formulare): Auf Seiten mit Formularen (Anmeldung, Registrierung, Passwort-Reset, Zwei-Faktor-Bestätigung, Bestellabschluss, Newsletter, Firmenanfrage, Verfügbarkeitsbenachrichtigung) lädt dein Browser ein Skript von challenges.cloudflare.com. Es unterscheidet Eingaben von Menschen und automatisierten Programmen. Dabei werden deine IP-Adresse sowie technische Merkmale deines Browsers an Cloudflare, Inc. (USA) übermittelt. Nach Angaben des Anbieters setzt Turnstile keine Cookies zu Werbezwecken und erstellt kein seitenübergreifendes Nutzerprofil. Cloudflare ist unter dem EU-US Data Privacy Framework zertifiziert. Einzelheiten in unserer Datenschutzerklärung.",
|
||||
"Google-Rezensionen: Die Bewertungen auf unserer Startseite rufen wir serverseitig über die Google Places API ab. Dein Browser baut dabei keine Verbindung zu Google auf, und es wird kein Cookie von Google gesetzt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Cookies mit Einwilligung",
|
||||
paragraphs: [
|
||||
"Aktuell setzen wir keine Analyse- oder Marketing-Cookies ein. Sollten wir künftig z.B. eine Reichweitenmessung einsetzen, wird dies nur nach deiner ausdrücklichen Einwilligung über den Cookie-Banner aktiviert und an dieser Stelle vorab benannt.",
|
||||
"Derzeit setzen wir keine Cookies oder Technologien ein, die deine Einwilligung erfordern. Die Auswahl „Alle akzeptieren“ im Cookie-Banner schaltet deshalb aktuell nichts Zusätzliches frei; mit „Nur notwendige“ nutzt du die Website ohne jede Einschränkung.",
|
||||
"Sollten wir künftig z. B. eine Reichweitenmessung einsetzen, nennen wir sie vorher an dieser Stelle mit Anbieter, Zweck und Speicherdauer und aktivieren sie erst nach deiner ausdrücklichen, freiwilligen Einwilligung über den Cookie-Banner. Eine Ablehnung hat keine Nachteile für dich.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Externe Inhalte",
|
||||
heading: "Cookies im Browser verwalten und löschen",
|
||||
paragraphs: [
|
||||
"Google-Rezensionen auf unserer Startseite werden serverseitig über die Google Places API abgerufen und angezeigt. Dabei wird kein Cookie in deinem Browser gesetzt, und es findet keine direkte Verbindung deines Browsers zu Google statt.",
|
||||
"Zahlungen werden über Stripe abgewickelt. Während des Bezahlvorgangs kann Stripe eigene, für die Betrugsprävention notwendige Cookies setzen. Es gelten die Datenschutzhinweise von Stripe.",
|
||||
"Auf Seiten mit Formularen (Anmeldung, Registrierung, Passwort-Reset, Bestellabschluss, Newsletter, Firmenanfrage) lädt dein Browser ein Skript von Cloudflare Turnstile. Der Dienst unterscheidet Eingaben von Menschen und automatisierten Programmen. Dabei wird deine IP-Adresse an Cloudflare übermittelt. Nach Angaben des Anbieters setzt Turnstile keine Cookies zu Werbezwecken und erstellt kein seitenübergreifendes Nutzerprofil. Einzelheiten in unserer Datenschutzerklärung.",
|
||||
"Du kannst in den Einstellungen deines Browsers jederzeit gespeicherte Cookies und Websitedaten einsehen und löschen sowie das Setzen von Cookies ganz oder teilweise blockieren. Anleitungen findest du in der Hilfe deines Browsers (z. B. Chrome, Firefox, Safari, Edge).",
|
||||
"Blockierst du technisch notwendige Cookies, funktionieren einzelne Funktionen nicht mehr, insbesondere die Anmeldung im Kundenkonto und der Bestellabschluss.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Deine Rechte",
|
||||
paragraphs: [
|
||||
"Dir stehen die Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch gegen Verarbeitungen auf Grundlage berechtigter Interessen zu (Art. 15 bis 21 DSGVO). Außerdem kannst du dich bei einer Datenschutz-Aufsichtsbehörde beschweren (Art. 77 DSGVO). Einzelheiten und Kontaktmöglichkeiten findest du in unserer Datenschutzerklärung.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Deine Auswahl ändern",
|
||||
paragraphs: ["Du kannst deine Cookie-Auswahl jederzeit widerrufen oder anpassen."],
|
||||
paragraphs: [
|
||||
"Du kannst deine Cookie-Auswahl jederzeit mit Wirkung für die Zukunft widerrufen oder anpassen (Art. 7 Abs. 3 DSGVO). Der Widerruf ist so einfach wie die Erteilung: Über die Schaltfläche unten öffnest du den Cookie-Banner erneut.",
|
||||
"Stand: September 2026",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+73
-11
@@ -20,6 +20,12 @@ export const en: Dictionary = {
|
||||
harvestsText: "Every harvest with date, location and batch, traceable down to the jar.",
|
||||
originText: "Where our colonies live and what shapes the landscape around Altenmünster.",
|
||||
},
|
||||
theme: {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
system: "Automatic",
|
||||
toggleLabel: (current, next) => `Appearance: ${current}. Switch to ${next}`,
|
||||
},
|
||||
nav: {
|
||||
shop: "Shop",
|
||||
harvests: "Our Harvests",
|
||||
@@ -143,6 +149,10 @@ export const en: Dictionary = {
|
||||
{ key: "INDIVIDUELL", title: "Custom", text: "Fully custom design" },
|
||||
],
|
||||
inquiryHeading: "Send an Inquiry",
|
||||
forLabel: "For",
|
||||
audiences: ["companies", "law firms", "medical practices", "hotels", "agencies", "trade businesses"],
|
||||
packagesHeading: "Three ways to give honey.",
|
||||
packageCta: "Ask for this package",
|
||||
},
|
||||
businessInquiryForm: {
|
||||
companyLabel: "Company",
|
||||
@@ -427,43 +437,95 @@ export const en: Dictionary = {
|
||||
},
|
||||
cookieRichtliniePage: {
|
||||
title: "Cookie Policy",
|
||||
// Mirrors de.ts entry for entry — see the comment there. Keep both in sync.
|
||||
sections: [
|
||||
{
|
||||
heading: "What are cookies?",
|
||||
heading: "What are cookies and similar technologies?",
|
||||
paragraphs: [
|
||||
"Cookies are small text files that are stored on your device when you visit a website. Among other things, they allow us to recognize you during a visit or save your preferences.",
|
||||
"Besides cookies, we use your browser's local storage (localStorage). Legally, both are treated the same: § 25 of the German Telecommunications Digital Services Data Protection Act (TDDDG) applies to any storage of information on your device and any access to it. This policy therefore lists both in full.",
|
||||
"The controller is the operator of this website named in the legal notice. Further information on how we process personal data can be found in our privacy policy.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Legal basis",
|
||||
paragraphs: [
|
||||
"Storing information on your device or reading it from there generally requires your consent (§ 25(1) TDDDG). No consent is needed where the storage is strictly necessary for us to provide a service you have explicitly requested (§ 25(2) No. 2 TDDDG), such as signing in to your customer account or completing an order.",
|
||||
"Where personal data is processed in the process, we rely on Art. 6(1)(b) GDPR (contract and pre-contractual steps, e.g. customer account and order) or Art. 6(1)(f) GDPR (our legitimate interest in a secure, functioning website). Should we use technologies requiring consent in the future, the legal basis will be your consent under § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Overview",
|
||||
paragraphs: [
|
||||
"We only use technically necessary cookies and storage entries. We do not use analytics, statistics, marketing or tracking cookies, we do not build usage profiles, and we do not pass on data to third parties for advertising purposes.",
|
||||
"Most of the cookies listed below are only created when you use a specific feature (e.g. signing in, paying, switching language). Merely visiting the website sets no cookie.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Technically necessary cookies",
|
||||
paragraphs: [
|
||||
"These cookies are required for the website to function and cannot be disabled. They are only set if you actively trigger them through your actions (e.g. logging into your customer account).",
|
||||
"wabenhain_session: keeps you logged into your customer account after signing in. Expires automatically after 30 days. Legal basis: Art. 6(1)(b) GDPR / § 25(2) No. 2 TDDDG.",
|
||||
"We set all of the following cookies ourselves (first-party cookies). They are only transmitted encrypted (HTTPS) and, unless stated otherwise, are inaccessible to scripts in the browser (HttpOnly). They cannot be deselected because the respective feature does not work without them. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) GDPR.",
|
||||
"wabenhain_session – Keeps you signed in to your customer account. Content: a signed identifier of your account, no passwords. Storage period: 30 days, 7 days for administrator accounts; deleted immediately when you sign out.",
|
||||
"wabenhain_2fa_challenge – When signing in with two-factor authentication, remembers that your password has already been checked until you enter the verification code. Storage period: 5 minutes; deleted after successful sign-in.",
|
||||
"wabenhain_2fa_setup – Holds the setup step together while you enable two-factor authentication in your customer account. Storage period: 10 minutes; deleted on completion.",
|
||||
"wabenhain_2fa_backup_display – Shows you your recovery codes exactly once. Storage period: 2 minutes at most; deleted as soon as they are displayed.",
|
||||
"wabenhain_entra_oauth – Only for “Sign in with Microsoft”: protects the redirect to Microsoft and back against tampering (cross-site request forgery protection). Storage period: 10 minutes; deleted on return.",
|
||||
"wh_checkout_ref – Only when paying: links the order just created to your browser so that only you can reopen or cancel the payment. Only sent to pages under /checkout. Storage period: 40 minutes.",
|
||||
"wh_flash – Carries a short confirmation or error message (e.g. “Your email address has been confirmed”) over to the next page. Readable by scripts so the message can be displayed. Storage period: 60 seconds; deleted once displayed. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(f) GDPR.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Local storage without cookies",
|
||||
heading: "Cookies for your settings",
|
||||
paragraphs: [
|
||||
"Your cart is not stored via a cookie, but via your browser's local storage (localStorage) on your device, and never leaves your device. Your cookie choice itself is also stored in localStorage.",
|
||||
"These two cookies are only set when you change the respective setting yourself, and they store nothing but your choice. They are readable by scripts but contain no personal data. Legal basis: § 25(2) No. 2 TDDDG (you explicitly triggered saving the setting) and Art. 6(1)(f) GDPR.",
|
||||
"theme – Stores your chosen appearance (light or dark). Storage period: 1 year. Switching back to “System” deletes the cookie.",
|
||||
"locale – Stores your chosen language (German or English). Storage period: 1 year.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Local storage (localStorage)",
|
||||
paragraphs: [
|
||||
"The following entries are kept in your browser's local storage. Unlike cookies, they are not automatically sent to us with every page request and do not expire on their own: they remain until you delete them (via your browser's “clear site data” setting) or the website removes them. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) or (f) GDPR.",
|
||||
"wabenhain-cart – Your cart (items, quantities, prices). It is only transmitted to us when you submit an order, and it is emptied after a successful order.",
|
||||
"wabenhain-recently-viewed – The identifiers of up to 6 recently viewed products, so they can be shown to you again on product pages. Only these product identifiers are requested for display; we store no browsing history on our servers.",
|
||||
"wabenhain-cookie-consent – Your choice in the cookie banner (“Essential only” or “Accept all”), so the banner does not reappear on every visit and we can demonstrate your decision. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(c) GDPR in conjunction with Art. 7(1) GDPR.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Third-party cookies",
|
||||
paragraphs: [
|
||||
"Stripe (payment processing): Only in the payment step of checkout does your browser load Stripe's payment form. Stripe sets its own cookies for fraud prevention and secure payment processing, in particular __stripe_mid (storage period 1 year) and __stripe_sid (storage period 30 minutes), as well as further cookies within the payment form on Stripe domains. Stripe determines the names and lifetimes of these cookies. Provider: Stripe Payments Europe, Ltd., 1 Grand Canal Street Lower, Dublin 2, Ireland. Data may be transferred to Stripe, Inc. in the USA on the basis of EU standard contractual clauses (Art. 46 GDPR). Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) GDPR. More information: stripe.com/privacy and stripe.com/legal/cookies-policy.",
|
||||
"Microsoft (sign-in with a Microsoft account): If you choose “Sign in with Microsoft”, you are redirected to a Microsoft sign-in page. Microsoft (Microsoft Ireland Operations Limited) is responsible for the cookies set there; Microsoft's privacy statement applies. Microsoft sets no cookies on our website.",
|
||||
"Cloudflare Turnstile (form protection): On pages with forms (sign-in, registration, password reset, two-factor verification, checkout, newsletter, business enquiry, back-in-stock notification), your browser loads a script from challenges.cloudflare.com. It distinguishes input made by humans from input made by automated programs. Your IP address and technical characteristics of your browser are transmitted to Cloudflare, Inc. (USA) in the process. According to the provider, Turnstile sets no advertising cookies and builds no cross-site user profile. Cloudflare is certified under the EU-US Data Privacy Framework. Details are in our privacy policy.",
|
||||
"Google reviews: We retrieve the reviews on our homepage server-side via the Google Places API. Your browser does not connect to Google, and Google sets no cookie.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Cookies requiring consent",
|
||||
paragraphs: [
|
||||
"We currently don't use any analytics or marketing cookies. Should we introduce something like audience measurement in the future, it will only be activated after your explicit consent via the cookie banner, and will be named here in advance.",
|
||||
"We currently use no cookies or technologies that require your consent. Choosing “Accept all” in the cookie banner therefore enables nothing additional at present; with “Essential only” you can use the website without any restriction.",
|
||||
"Should we introduce something like audience measurement in the future, we will name it here beforehand with provider, purpose and storage period, and only activate it after your explicit, voluntary consent via the cookie banner. Declining has no disadvantages for you.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "External content",
|
||||
heading: "Managing and deleting cookies in your browser",
|
||||
paragraphs: [
|
||||
"Google reviews on our homepage are retrieved and displayed server-side via the Google Places API. No cookie is set in your browser, and your browser does not connect directly to Google.",
|
||||
"Payments are processed via Stripe. During the payment process, Stripe may set its own cookies necessary for fraud prevention. Stripe's privacy policy applies.",
|
||||
"On pages with forms (sign-in, registration, password reset, checkout, newsletter, business enquiry), your browser loads a script from Cloudflare Turnstile. The service distinguishes input made by humans from input made by automated programs. Your IP address is transmitted to Cloudflare in the process. According to the provider, Turnstile sets no advertising cookies and builds no cross-site user profile. Details are in our privacy policy.",
|
||||
"In your browser settings you can view and delete stored cookies and site data at any time, and block cookies entirely or in part. Instructions can be found in your browser's help (e.g. Chrome, Firefox, Safari, Edge).",
|
||||
"If you block technically necessary cookies, some features will stop working, in particular signing in to your customer account and completing an order.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Your rights",
|
||||
paragraphs: [
|
||||
"You have the right of access, rectification, erasure, restriction of processing, data portability and to object to processing based on legitimate interests (Art. 15 to 21 GDPR). You may also lodge a complaint with a data protection supervisory authority (Art. 77 GDPR). Details and contact options are in our privacy policy.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Changing your choice",
|
||||
paragraphs: ["You can withdraw or change your cookie choice at any time."],
|
||||
paragraphs: [
|
||||
"You can withdraw or change your cookie choice at any time with effect for the future (Art. 7(3) GDPR). Withdrawing is as easy as giving consent: the button below reopens the cookie banner.",
|
||||
"Last updated: September 2026",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -58,6 +58,12 @@ export interface Dictionary {
|
||||
harvestsText: string;
|
||||
originText: string;
|
||||
};
|
||||
theme: {
|
||||
light: string;
|
||||
dark: string;
|
||||
system: string;
|
||||
toggleLabel: (current: string, next: string) => string;
|
||||
};
|
||||
nav: {
|
||||
shop: string;
|
||||
harvests: string;
|
||||
@@ -165,6 +171,10 @@ export interface Dictionary {
|
||||
subtitle: string;
|
||||
packages: { key: string; title: string; text: string }[];
|
||||
inquiryHeading: string;
|
||||
forLabel: string;
|
||||
audiences: string[];
|
||||
packagesHeading: string;
|
||||
packageCta: string;
|
||||
};
|
||||
businessInquiryForm: {
|
||||
companyLabel: string;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseThemeChoice, nextThemeChoice } from "./theme";
|
||||
|
||||
describe("parseThemeChoice", () => {
|
||||
test("uebernimmt nur light und dark", () => {
|
||||
expect(parseThemeChoice("light")).toBe("light");
|
||||
expect(parseThemeChoice("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
test("faellt bei fehlendem oder fremdem Wert auf system zurueck", () => {
|
||||
expect(parseThemeChoice(undefined)).toBe("system");
|
||||
expect(parseThemeChoice("")).toBe("system");
|
||||
expect(parseThemeChoice("dark; path=/")).toBe("system");
|
||||
expect(parseThemeChoice("<script>")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextThemeChoice", () => {
|
||||
test("wechselt reihum Automatisch -> Hell -> Dunkel -> Automatisch", () => {
|
||||
expect(nextThemeChoice("system")).toBe("light");
|
||||
expect(nextThemeChoice("light")).toBe("dark");
|
||||
expect(nextThemeChoice("dark")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dunkelmodus-Tokens in globals.css", () => {
|
||||
test("gewaehlter und automatischer Dunkelblock sind identisch", () => {
|
||||
const css = readFileSync(path.join(process.cwd(), "src/app/globals.css"), "utf8");
|
||||
const explicit = css.match(/:root\[data-theme="dark"\] \{([\s\S]*?)\n\}/)?.[1];
|
||||
const system = css.match(/:root:not\(\[data-theme="light"\]\) \{([\s\S]*?)\n \}/)?.[1];
|
||||
expect(explicit).toBeTruthy();
|
||||
expect(system).toBeTruthy();
|
||||
const normalize = (block: string) =>
|
||||
block
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
expect(normalize(system!)).toBe(normalize(explicit!));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Darstellung (hell/dunkel). "system" folgt der Einstellung des Geraets
|
||||
* (prefers-color-scheme, siehe globals.css) und wird NICHT als Cookie
|
||||
* gespeichert — nur eine ausdrueckliche Wahl landet im Cookie, damit der
|
||||
* Server data-theme gleich richtig ausliefert und nichts hell aufblitzt.
|
||||
*/
|
||||
export type ThemeChoice = "light" | "dark" | "system";
|
||||
|
||||
export const THEME_COOKIE = "theme";
|
||||
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
const CYCLE: ThemeChoice[] = ["system", "light", "dark"];
|
||||
|
||||
/** Cookie-Wert pruefen: alles ausser "light"/"dark" gilt als "system". */
|
||||
export function parseThemeChoice(value: string | undefined): ThemeChoice {
|
||||
return value === "light" || value === "dark" ? value : "system";
|
||||
}
|
||||
|
||||
export function nextThemeChoice(current: ThemeChoice): ThemeChoice {
|
||||
return CYCLE[(CYCLE.indexOf(current) + 1) % CYCLE.length];
|
||||
}
|
||||
Reference in new issue
Block a user