Compare commits
5
Commits
064ca3ff6d
...
76be1292ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76be1292ed | ||
|
|
dfda94ddfc | ||
|
|
e75392c75b | ||
|
|
467984d027 | ||
|
|
1c5282e84f |
No files matched your search
@@ -57,11 +57,30 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<ScrollReveal>
|
||||
<div className="flex items-center gap-2 text-honey-dark">
|
||||
<ShieldIcon />
|
||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||
{/* Echtheitssiegel: wer den QR-Code vom Glas scannt, landet hier —
|
||||
das Siegel stempelt sich ein, bevor die Details folgen. */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="motion-stamp relative flex h-16 w-14 flex-shrink-0 items-center justify-center text-cream animate-[wh-stamp_0.7s_var(--ease-arrive)_both]"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="absolute inset-0 h-full w-full">
|
||||
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="var(--color-honey-dark)" />
|
||||
<polygon
|
||||
points="12,3.2 20.5,8.1 20.5,17.9 12,22.8 3.5,17.9 3.5,8.1"
|
||||
fill="none"
|
||||
stroke="var(--color-honey-pale)"
|
||||
strokeWidth="0.6"
|
||||
strokeDasharray="1.2 1"
|
||||
/>
|
||||
</svg>
|
||||
<span className="relative">
|
||||
<ShieldIcon />
|
||||
</span>
|
||||
</span>
|
||||
<span className="tracking-wide-caps text-xs text-honey-dark">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||
<h1 className="mt-6 text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||
@@ -91,20 +110,32 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
</div>
|
||||
|
||||
<ScrollReveal className="mt-14">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ol className="grid grid-cols-3 gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="relative">
|
||||
<li key={step.label} className="relative">
|
||||
<div className="flex items-center">
|
||||
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-honey" />
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="h-4 w-4 flex-shrink-0">
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill="var(--color-honey)"
|
||||
stroke="var(--color-honey-dark)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
{i < steps.length - 1 ? (
|
||||
<span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" />
|
||||
<span className="relative ml-1 h-[2px] flex-1 overflow-hidden rounded-full bg-ink/10" aria-hidden="true">
|
||||
<span
|
||||
className="motion-grow absolute inset-0 origin-left bg-honey animate-[wh-grow_0.6s_var(--ease-arrive)_both]"
|
||||
style={{ animationDelay: `${500 + i * 220}ms` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs tracking-wide-caps text-ink/70">{step.label}</p>
|
||||
<p className="mt-1 text-forest">{step.value}</p>
|
||||
</div>
|
||||
<p className="mt-1 font-serif text-xl text-forest tabular-nums">{step.value}</p>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal className="mt-10 border-t border-ink/10 pt-8">
|
||||
|
||||
@@ -45,8 +45,8 @@ export default async function HarvestDetailPage(
|
||||
<Container>
|
||||
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||
</Container>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<ScrollReveal>
|
||||
<Container className="grid md:grid-cols-2 gap-14 lg:gap-20">
|
||||
<ScrollReveal className="self-start md:sticky md:top-28">
|
||||
<ContentImage
|
||||
src={harvest.images[0] || null}
|
||||
alt={harvest.type}
|
||||
@@ -60,26 +60,38 @@ 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-4xl text-forest">{harvest.type}</h1>
|
||||
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">{harvest.type}</h1>
|
||||
<p className="mt-8 max-w-[60ch] text-lg text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||
|
||||
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.batchesHeading}</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{harvest.batches.map((batch) => (
|
||||
<Link
|
||||
key={batch.id}
|
||||
href={`/charge/${batch.code}`}
|
||||
className="flex items-center justify-between gap-4 border border-ink/10 px-4 py-3 text-sm transition-colors hover:border-forest"
|
||||
>
|
||||
<span className="text-forest">{batch.code}</span>
|
||||
<span className="text-ink/65">
|
||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
<h2 className="mt-12 text-2xl text-forest">{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;
|
||||
return (
|
||||
<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)]"
|
||||
>
|
||||
<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="text-ink/65 tabular-nums">
|
||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
||||
</span>
|
||||
</span>
|
||||
{/* Fuellstand der Charge — die Zahl rechts bekommt eine Form. */}
|
||||
<span aria-hidden="true" className="mt-3 block h-1.5 overflow-hidden rounded-full bg-ink/10">
|
||||
<span
|
||||
className="motion-grow block h-full origin-left rounded-full bg-gradient-to-r from-honey-dark to-honey animate-[wh-grow_0.9s_var(--ease-arrive)_both]"
|
||||
style={{ width: `${fill * 100}%`, animationDelay: "250ms" }}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
||||
<h2 className="mt-12 text-2xl text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{harvest.products.map((product) => (
|
||||
<ProductTeaserLink
|
||||
|
||||
+41
-13
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import { getHarvests } from "@/lib/data";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -34,29 +35,56 @@ export default async function ErntenPage() {
|
||||
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
<div className="mt-16 space-y-24">
|
||||
{years.map((year) => (
|
||||
<div key={year}>
|
||||
<ScrollReveal>
|
||||
<h2 className="text-2xl text-forest mb-6">{year}</h2>
|
||||
{/* Jahrgang wie auf dem Etikett: grosse Zahl, auslaufende Linie. */}
|
||||
<div className="mb-8 flex items-center gap-6">
|
||||
<h2 className="font-serif text-6xl md:text-7xl leading-none text-honey-dark tabular-nums">{year}</h2>
|
||||
<span aria-hidden="true" className="h-px flex-1 bg-gradient-to-r from-honey/50 to-transparent" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{byYear[year].map((harvest, i) => (
|
||||
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
||||
<Link
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group block"
|
||||
>
|
||||
<ContentImage
|
||||
src={harvest.images[0] || null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
label={formatMonthYear(harvest.harvestDate, locale)}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
|
||||
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
|
||||
<span className="relative block overflow-hidden rounded-lg transition-[transform,box-shadow] duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:shadow-[0_28px_50px_-24px_rgba(36,53,43,0.5)]">
|
||||
<ContentImage
|
||||
src={harvest.images[0] || null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full transition-transform duration-700 ease-[var(--ease-arrive)] group-hover:scale-[1.05]"
|
||||
label={formatMonthYear(harvest.harvestDate, locale)}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -translate-x-full bg-[linear-gradient(115deg,transparent_30%,rgba(228,207,164,0.3)_48%,rgba(184,138,68,0.15)_55%,transparent_70%)] transition-transform duration-1000 ease-[var(--ease-arrive)] group-hover:translate-x-full motion-reduce:hidden"
|
||||
/>
|
||||
</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>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
|
||||
+28
-2
@@ -121,10 +121,14 @@ p {
|
||||
transition: background-size 350ms var(--ease-arrive);
|
||||
}
|
||||
.link-draw:hover,
|
||||
.link-draw:focus-visible,
|
||||
.link-draw[aria-current="page"] {
|
||||
.link-draw:focus-visible {
|
||||
background-size: 100% 1px;
|
||||
}
|
||||
/* Aktive Seite: kraeftigerer Unterstrich in Honig statt in Textfarbe. */
|
||||
.link-draw[aria-current="page"] {
|
||||
background-image: linear-gradient(var(--color-honey), var(--color-honey));
|
||||
background-size: 100% 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link-draw {
|
||||
@@ -257,3 +261,25 @@ input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: color-mix(in oklab, var(--color-ink) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Chargen-Seite: das Echtheitssiegel "stempelt" sich beim Oeffnen ein. */
|
||||
@keyframes wh-stamp {
|
||||
0% {
|
||||
transform: scale(1.6) rotate(-18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.94) rotate(3deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-stamp {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
+48
-25
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -20,33 +21,55 @@ export default async function HerkunftPage() {
|
||||
const t = getDictionary(locale);
|
||||
const image = await getContentValue("herkunft.image", locale);
|
||||
return (
|
||||
<div className="py-20">
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.herkunftPage.eyebrow}
|
||||
tone="forest"
|
||||
className="h-[50vh] min-h-[360px] w-full mb-20"
|
||||
label={t.herkunftPage.eyebrow}
|
||||
priority
|
||||
/>
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
<div className="pb-20">
|
||||
{/* Landschaft atmet langsam, Wabenraster liegt darueber — die
|
||||
Ueberschrift ragt als Karte ins Bild hinein. */}
|
||||
<div className="relative h-[60vh] min-h-[380px] w-full overflow-hidden">
|
||||
<div className="motion-drift absolute inset-0 animate-[wh-drift_26s_ease-in-out_infinite_alternate]">
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.herkunftPage.eyebrow}
|
||||
tone="forest"
|
||||
className="h-full w-full"
|
||||
label={t.herkunftPage.eyebrow}
|
||||
rounded={false}
|
||||
priority
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
||||
{t.herkunftPage.aspects.map((a, i) => (
|
||||
<ScrollReveal key={a.title} delay={i * 0.08}>
|
||||
<h3 className="text-lg text-forest">{a.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-16 text-xs text-ink/65 max-w-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-forest/50 to-transparent" />
|
||||
<HoneycombPattern className="text-honey-pale/[0.12]" size={32} fade="right" />
|
||||
</div>
|
||||
<Container>
|
||||
<ScrollReveal className="relative -mt-28 md:-mt-36">
|
||||
<div className="max-w-3xl bg-background px-8 pt-10 pb-2 md:px-12 md:pt-12 shadow-[0_-20px_40px_-30px_rgba(36,53,43,0.35)]">
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<ul className="mt-16 grid gap-x-16 border-t border-ink/10 sm:grid-cols-2">
|
||||
{t.herkunftPage.aspects.map((a, i) => (
|
||||
<li key={a.title} className="border-b border-ink/10">
|
||||
<ScrollReveal delay={(i % 2) * 0.08}>
|
||||
<div className="group grid grid-cols-[auto_1fr] gap-x-5 py-8">
|
||||
<HexMark
|
||||
filled={i % 2 === 0}
|
||||
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>
|
||||
<p className="mt-2 text-ink/70 leading-relaxed">{a.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-12 flex max-w-lg items-start gap-3 text-xs text-ink/65 leading-relaxed">
|
||||
<HexMark filled={false} className="mt-0.5 h-3 w-3 flex-shrink-0 text-honey-dark" />
|
||||
{t.herkunftPage.privacyNote}
|
||||
</p>
|
||||
</Container>
|
||||
|
||||
+19
-14
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { Container, SectionHeading, FramedMedia } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -21,16 +21,19 @@ export default async function ImkereiPage() {
|
||||
const image = await getContentValue("imkerei.image", locale);
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
<Container className="grid md:grid-cols-2 gap-16 lg:gap-24 items-center">
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.imkereiPage.eyebrow}
|
||||
title={t.imkereiPage.title}
|
||||
/>
|
||||
<div className="mt-8 space-y-5 text-ink/75 leading-relaxed">
|
||||
{t.imkereiPage.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
<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}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
@@ -41,15 +44,17 @@ export default async function ImkereiPage() {
|
||||
dem sonst greifenden Lazy-Loading erschien das Bild damit
|
||||
deutlich spaeter als noetig. Die Textspalte daneben behaelt ihre
|
||||
Einblendung, die kostet nichts. */}
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.imkereiPage.imageLabel}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
priority
|
||||
/>
|
||||
<FramedMedia>
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.imkereiPage.imageLabel}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
priority
|
||||
/>
|
||||
</FramedMedia>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
+46
-23
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -19,34 +20,56 @@ export default async function KontaktPage() {
|
||||
const company = await getCompanyInfo();
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
<Container className="max-w-5xl">
|
||||
<ScrollReveal>
|
||||
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.1} className="mt-10 space-y-2 text-ink/75">
|
||||
<p>{company.name}</p>
|
||||
<p>{company.address.street}</p>
|
||||
<p>
|
||||
{company.address.zip} {company.address.city}
|
||||
</p>
|
||||
<p className="pt-4">
|
||||
<a href={`mailto:${company.email}`} className="text-forest underline">
|
||||
{company.email}
|
||||
</a>
|
||||
</p>
|
||||
{/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
|
||||
Platzhalternummer waere schlimmer als keine: sie sieht nach
|
||||
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}
|
||||
{company.phone ? <p>{company.phone}</p> : null}
|
||||
</ScrollReveal>
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-5">
|
||||
{/* Kontaktkarte auf Wabenraster: die E-Mail ist der Hauptweg und
|
||||
steht deshalb gross in Serifenschrift. */}
|
||||
<ScrollReveal delay={0.1} className="md:col-span-3">
|
||||
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
|
||||
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
|
||||
<div className="relative">
|
||||
<a
|
||||
href={`mailto:${company.email}`}
|
||||
className="group inline-flex items-center gap-3 font-serif text-3xl md:text-4xl text-cream"
|
||||
>
|
||||
<span className="link-draw pb-1">{company.email}</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
{/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
|
||||
Platzhalternummer waere schlimmer als keine: sie sieht nach
|
||||
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}
|
||||
{company.phone ? <p className="mt-4 text-lg text-cream/85 tabular-nums">{company.phone}</p> : null}
|
||||
<address className="mt-10 not-italic text-cream/75 leading-relaxed">
|
||||
<span className="block text-cream">{company.name}</span>
|
||||
{company.address.street}
|
||||
<br />
|
||||
{company.address.zip} {company.address.city}
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.18} className="mt-12 border-t border-ink/10 pt-8">
|
||||
<h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{t.kontaktPage.pickupText}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
<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>
|
||||
<p className="mt-3 text-ink/70 leading-relaxed">{t.kontaktPage.pickupText}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } 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";
|
||||
@@ -22,14 +23,20 @@ export default async function UnternehmenPage() {
|
||||
const image = await getContentValue("unternehmen.image", locale);
|
||||
return (
|
||||
<div>
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.unternehmenPage.eyebrow}
|
||||
tone="honey"
|
||||
className="h-[45vh] min-h-[320px] w-full"
|
||||
label={t.unternehmenPage.eyebrow}
|
||||
priority
|
||||
/>
|
||||
<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]">
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.unternehmenPage.eyebrow}
|
||||
tone="honey"
|
||||
className="h-full w-full"
|
||||
label={t.unternehmenPage.eyebrow}
|
||||
rounded={false}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<HoneycombPattern className="text-cream/[0.14]" size={30} fade="right" />
|
||||
</div>
|
||||
<Container className="py-20">
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
@@ -40,19 +47,36 @@ export default async function UnternehmenPage() {
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid sm:grid-cols-3 gap-8">
|
||||
<div className="mt-14 grid sm:grid-cols-3 gap-6">
|
||||
{t.unternehmenPage.packages.map((pkg, i) => (
|
||||
<ScrollReveal key={pkg.key} delay={i * 0.08} className="border border-ink/10 p-8">
|
||||
<h3 className="text-lg text-forest">{pkg.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
|
||||
<ScrollReveal key={pkg.key} delay={i * 0.08}>
|
||||
<div className="group relative h-full overflow-hidden border border-ink/10 bg-background p-8 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-1 hover:border-honey/60 hover:shadow-[0_22px_40px_-24px_rgba(184,138,68,0.45)]">
|
||||
{/* Grosses Sechseck als Wasserzeichen, das beim Hover einrueckt. */}
|
||||
<svg
|
||||
viewBox="0 0 24 26"
|
||||
aria-hidden="true"
|
||||
className="absolute -right-6 -top-6 h-28 w-28 text-honey/15 transition-transform duration-700 ease-[var(--ease-arrive)] group-hover:-translate-x-2 group-hover:translate-y-2 group-hover:rotate-12"
|
||||
>
|
||||
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="currentColor" />
|
||||
</svg>
|
||||
<h3 className="relative text-2xl text-forest">{pkg.title}</h3>
|
||||
<p className="relative mt-3 text-sm text-ink/70 leading-relaxed">{pkg.text}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal className="mt-20 max-w-2xl">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.unternehmenPage.inquiryHeading}</h2>
|
||||
<BusinessInquiryForm turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||
</ScrollReveal>
|
||||
{/* 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>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal className="lg:col-span-8">
|
||||
<BusinessInquiryForm turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { HexCheck } from "@/components/honeycomb";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||
@@ -76,13 +78,21 @@ export function WarenkorbContent({
|
||||
) : (
|
||||
<div className="mt-14 grid lg:grid-cols-3 gap-14">
|
||||
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item) => {
|
||||
const itemStock = stock?.[item.productId];
|
||||
return (
|
||||
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<motion.div
|
||||
key={item.productId}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: 40, transition: { duration: 0.2 } }}
|
||||
className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3"
|
||||
>
|
||||
<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="text-lg text-forest">
|
||||
<Link href={`/honig/${item.slug}`} className="link-draw pb-0.5 font-serif text-2xl text-forest">
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
||||
@@ -103,31 +113,34 @@ export function WarenkorbContent({
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
max={itemStock ?? 10}
|
||||
/>
|
||||
<span className="w-20 text-right text-ink">
|
||||
<span className="w-24 text-right font-serif text-xl text-forest tabular-nums">
|
||||
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeItem(item.productId)}
|
||||
aria-label={t.cart.remove}
|
||||
className="text-ink/65 hover:text-ink"
|
||||
className="flex h-9 w-9 items-center justify-center text-ink/55 transition-[color,transform] duration-300 hover:rotate-90 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="border border-ink/10 p-8 h-fit">
|
||||
<div className="border border-ink/10 bg-background p-8 h-fit lg:sticky lg:top-28">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(subtotal, "EUR", locale)}</span>
|
||||
<span className="tabular-nums">{formatPrice(subtotal, "EUR", locale)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.cartPage.shippingLabel(totalQuantity)}</span>
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||
<span className="tabular-nums">{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||
</div>
|
||||
{nextTier && nextTier.unitsUntilNext <= 2 ? (
|
||||
<p className="text-xs text-ink/70">
|
||||
@@ -135,20 +148,47 @@ export function WarenkorbContent({
|
||||
</p>
|
||||
) : null}
|
||||
{activeBulkDiscount ? (
|
||||
<div className="flex justify-between text-forest">
|
||||
<span>
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<HexCheck className="h-4 w-4 text-honey-dark" />
|
||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||
</span>
|
||||
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||
</div>
|
||||
<span className="tabular-nums">-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||
</motion.div>
|
||||
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
|
||||
<p className="text-xs text-ink/70">
|
||||
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
|
||||
</p>
|
||||
// Der Weg zum Mengenrabatt als Glas, das sich mit jedem
|
||||
// weiteren Stueck fuellt — nicht nur als Kleingedrucktes.
|
||||
<div className="rounded-sm bg-honey/[0.07] px-3 py-3">
|
||||
<p className="text-xs text-ink/75">
|
||||
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
|
||||
</p>
|
||||
<div aria-hidden="true" className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink/10">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-honey-dark to-honey"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: `${(totalQuantity / (totalQuantity + nextBulkDiscount.unitsUntilNext)) * 100}%`,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 120, damping: 20 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
|
||||
<span>{t.cartPage.total}</span>
|
||||
<span>{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}</span>
|
||||
<div className="flex items-baseline justify-between text-forest pt-4 border-t border-ink/10">
|
||||
<span className="text-base">{t.cartPage.total}</span>
|
||||
<motion.span
|
||||
key={subtotal - bulkDiscountCents + shipping}
|
||||
initial={{ opacity: 0.4, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-serif text-3xl tabular-nums"
|
||||
>
|
||||
{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
<LinkButton href="/checkout" className="w-full mt-6">
|
||||
@@ -164,10 +204,10 @@ export function WarenkorbContent({
|
||||
|
||||
{items.length > 0 && crossSellProducts.length > 0 ? (
|
||||
<div className="mt-20">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
||||
<h2 className="text-3xl md:text-4xl text-forest 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) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
{crossSellProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ReadingProgress } from "@/components/reading-progress";
|
||||
import { Container } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { wissenSlugs } from "@/lib/site-config";
|
||||
@@ -67,6 +68,7 @@ export default async function WissenArticlePage(
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<ReadingProgress />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
|
||||
@@ -78,23 +80,38 @@ 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-3xl text-forest">{article.title}</h1>
|
||||
<div className="mt-8 space-y-5 text-ink/75 leading-relaxed">
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-forest">{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) => (
|
||||
<p key={i}>{paragraph}</p>
|
||||
// Initiale wie im Buch: der erste Buchstabe des Artikels gross in Honig.
|
||||
<p key={i} className={i === 0 ? "first-letter:float-left first-letter:mr-3 first-letter:mt-1.5 first-letter:font-serif first-letter:text-7xl first-letter:leading-[0.8] first-letter:text-honey-dark" : undefined}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{otherArticles.length > 0 ? (
|
||||
<div className="mt-16 border-t border-ink/10 pt-10">
|
||||
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.wissenArticlePage.otherArticlesHeading}</p>
|
||||
<div className="space-y-6">
|
||||
<div className="divide-y divide-ink/10 border-y border-ink/10">
|
||||
{otherArticles.map((otherSlug) => {
|
||||
const other = t.wissenArticles[otherSlug];
|
||||
return (
|
||||
<Link key={otherSlug} href={`/wissen/${otherSlug}`} className="group block">
|
||||
<h2 className="text-lg text-forest group-hover:underline">{other.title}</h2>
|
||||
<p className="mt-1 text-sm text-ink/65 leading-relaxed">{other.excerpt}</p>
|
||||
<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="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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
+15
-8
@@ -23,19 +23,26 @@ export default async function WissenPage() {
|
||||
<ScrollReveal>
|
||||
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} />
|
||||
</ScrollReveal>
|
||||
<div className="mt-14 space-y-10">
|
||||
<div className="mt-14 divide-y divide-ink/10 border-y border-ink/10">
|
||||
{wissenSlugs.map((slug, i) => {
|
||||
const article = t.wissenArticles[slug];
|
||||
return (
|
||||
<ScrollReveal key={slug} delay={(i % 5) * 0.06}>
|
||||
<Link href={`/wissen/${slug}`} className="block group">
|
||||
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm text-honey-dark tracking-wide-caps">
|
||||
<Link href={`/wissen/${slug}`} className="group block py-9">
|
||||
<h2 className="link-draw inline pb-0.5 text-3xl leading-tight text-forest">{article.title}</h2>
|
||||
<p className="mt-3 max-w-[60ch] text-ink/70 leading-relaxed">{article.excerpt}</p>
|
||||
<span className="mt-4 inline-flex items-center gap-2 text-sm text-honey-dark tracking-wide-caps">
|
||||
{t.wissenListPage.readMore}
|
||||
<span className="transition-transform group-hover:translate-x-1" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5 transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -23,14 +23,14 @@ export function LegalPage({
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<h1 className="text-3xl text-forest">{title}</h1>
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-forest">{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-lg text-forest">{section.heading}</h2>
|
||||
<h2 className="text-2xl text-forest">{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>
|
||||
|
||||
@@ -41,13 +41,31 @@ export function ProductTeaserLink({
|
||||
return (
|
||||
<Link
|
||||
href={`/honig/${slug}`}
|
||||
className="group flex items-center gap-4 border border-ink/10 p-4 transition-colors hover:border-forest"
|
||||
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)]"
|
||||
>
|
||||
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
|
||||
<span>
|
||||
<span className="block text-forest">{name}</span>
|
||||
<span className="block mt-1 text-sm text-ink/70 group-hover:text-forest transition-colors">
|
||||
{t.product.viewProduct}
|
||||
<span className="block overflow-hidden rounded-lg">
|
||||
<ContentImage
|
||||
src={image ?? null}
|
||||
alt={name}
|
||||
tone="honey"
|
||||
className="w-16 h-16 flex-shrink-0 transition-transform duration-700 ease-[var(--ease-arrive)] group-hover:scale-110"
|
||||
sizes="64px"
|
||||
/>
|
||||
</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">
|
||||
{t.product.viewProduct.replace(/\s*→\s*$/, "")}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5 transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useScroll, useSpring } from "motion/react";
|
||||
|
||||
/**
|
||||
* Feine Honiglinie am oberen Rand, die sich beim Lesen eines Artikels
|
||||
* fuellt — zeigt, wie weit man ist, ohne vom Text abzulenken. Rein
|
||||
* dekorativ fuer Sehende; die Seite bleibt ohne sie vollstaendig lesbar.
|
||||
*/
|
||||
export function ReadingProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, { stiffness: 140, damping: 26, mass: 0.4 });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
style={{ scaleX }}
|
||||
className="fixed inset-x-0 top-0 z-[55] h-[3px] origin-left bg-gradient-to-r from-honey-dark to-honey"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -359,7 +359,7 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`link-draw pb-1 text-sm whitespace-nowrap transition-colors ${
|
||||
active ? "text-forest [background-image:linear-gradient(var(--color-honey),var(--color-honey))] [background-size:100%_2px]" : "text-ink/80 hover:text-forest"
|
||||
active ? "text-forest" : "text-ink/80 hover:text-forest"
|
||||
}`}
|
||||
>
|
||||
{t.nav[item.key]}
|
||||
|
||||
+13
-1
@@ -43,9 +43,21 @@ export function getStripeClient(): Stripe {
|
||||
* nicht gesetzt — vor allem ein versehentlich hier eingetragener geheimer
|
||||
* Schluessel (sk_/rk_), der sonst im HTML jeder Kassenseite stuende.
|
||||
*/
|
||||
let missingPublishableKeyLogged = false;
|
||||
|
||||
export function getStripePublishableKey(): string | undefined {
|
||||
const key = process.env.STRIPE_PUBLISHABLE_KEY?.trim();
|
||||
if (!key) return undefined;
|
||||
if (!key) {
|
||||
// Sonst faellt das Fehlen nur in der Kasse auf ("Zahlung gerade nicht
|
||||
// verfuegbar"), nie im Log. Einmal je Prozess genuegt.
|
||||
if (!missingPublishableKeyLogged) {
|
||||
missingPublishableKeyLogged = true;
|
||||
console.error(
|
||||
"STRIPE_PUBLISHABLE_KEY ist nicht gesetzt — der Checkout nimmt keine Bestellungen an. In k8s/01-configmap.yaml eintragen und den Pod neu starten."
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!/^pk_(test|live)_[A-Za-z0-9]+$/.test(key)) {
|
||||
console.error(
|
||||
"STRIPE_PUBLISHABLE_KEY ist kein veroeffentlichbarer Schluessel (pk_test_/pk_live_) und wird ignoriert."
|
||||
|
||||
Reference in new issue
Block a user