Redesign im Stil von crispmtl.com: minimalistisch, Foto-Hero, weniger CTAs

- Hell mit feinen Rasterlinien, breite Versal-Headlines (Archivo Expanded),
  kleine Serifen-Labels, eckige Buttons mit Füll-/Text-Roll-Hover
- Hero: Foto aus dem Salon statt animiertem Hintergrund, eine Headline,
  ein Button; "Termin buchen" oben rechts nur noch als Textlink
- Chrom-Wortmarke, Seidenhintergrund, Laufband, Kreis-Badge, großer
  Schluss-CTA und mobile Buchungsleiste entfernt; deutlich weniger Text
- Neue interaktive Elemente: Menü-Overlay mit Bildwechsel pro Link,
  Team als Namensliste mit Porträt-Wisch-Übergang, Preise als Akkordeon
  mit Team-Thumbnails, Footer mit Fotoraster zwischen den Wörtern
- Bilder aus den echten Salon-Fotos (Logo-Wand), neues Favicon/OG-Bild

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
This commit is contained in:
Claude committed 2026-09-29 12:41:03 +00:00
1 parent f21d79c08d
commit c25a3f9381
49 files changed
+815 -1494

No files matched your search

-42
View File
@@ -1,42 +0,0 @@
import { BOOKING_URL } from '../data/salon'
import { cn } from '../lib/cn'
import { ArrowUpRight } from './Icons'
type Props = {
children?: React.ReactNode
href?: string
variant?: 'solid' | 'ghost'
size?: 'md' | 'lg'
className?: string
}
/** Pill-Button mit „rollendem“ Pfeil — führt standardmäßig zur Online-Buchung. */
export function BookButton({ children = 'Termin buchen', href = BOOKING_URL, variant = 'solid', size = 'md', className }: Props) {
const external = href.startsWith('http')
return (
<a
href={href}
{...(external ? { target: '_blank', rel: 'noopener' } : {})}
className={cn(
'group relative inline-flex items-center gap-3 rounded-full font-medium tracking-tight transition-colors duration-300',
size === 'lg' ? 'h-14 pl-7 pr-2 text-base' : 'h-11 pl-5 pr-1.5 text-sm',
variant === 'solid'
? 'bg-bone text-ink hover:bg-champagne'
: 'border border-white/15 text-bone backdrop-blur-md hover:border-white/40 hover:bg-white/5',
className,
)}
>
<span>{children}</span>
<span
className={cn(
'relative grid place-items-center overflow-hidden rounded-full',
size === 'lg' ? 'size-10' : 'size-8',
variant === 'solid' ? 'bg-ink text-bone' : 'bg-bone text-ink',
)}
>
<ArrowUpRight className="size-4 transition-transform duration-500 ease-out-expo group-hover:translate-x-5 group-hover:-translate-y-5" />
<ArrowUpRight className="absolute size-4 -translate-x-5 translate-y-5 transition-transform duration-500 ease-out-expo group-hover:translate-x-0 group-hover:translate-y-0" />
</span>
</a>
)
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import { BOOKING_URL } from '../data/salon'
import { cn } from '../lib/cn'
type Props = {
children?: ReactNode
href?: string
tone?: 'dark' | 'light'
className?: string
}
/** Eckiger Button: beim Hover läuft eine Fläche von unten hoch und der Text rollt nach oben. */
export function Button({ children = 'Termin buchen', href = BOOKING_URL, tone = 'dark', className }: Props) {
const external = href.startsWith('http')
return (
<a
href={href}
{...(external ? { target: '_blank', rel: 'noopener' } : {})}
className={cn(
'group relative inline-flex overflow-hidden border px-4 pt-[13px] pb-[10px] text-[14px] leading-none font-extrabold uppercase [font-stretch:125%] md:text-[15px]',
tone === 'dark' ? 'border-ink bg-ink text-paper' : 'border-paper bg-paper text-ink',
className,
)}
>
<span
aria-hidden="true"
className={cn(
'absolute inset-0 translate-y-full transition-transform duration-500 ease-out-expo group-hover:translate-y-0',
tone === 'dark' ? 'bg-graphite' : 'bg-[#dcdad5]',
)}
/>
<span className="relative block overflow-hidden">
<span className="block transition-transform duration-500 ease-out-expo group-hover:-translate-y-full">
{children}
</span>
<span
aria-hidden="true"
className="absolute inset-0 block translate-y-full transition-transform duration-500 ease-out-expo group-hover:translate-y-0"
>
{children}
</span>
</span>
</a>
)
}
-57
View File
@@ -1,57 +0,0 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { BOOKING_URL } from '../data/salon'
import { useFitText } from '../lib/useFitText'
import { ArrowUpRight } from './Icons'
import { Logo } from './Logo'
import { FadeIn } from './Reveal'
import { SectionLabel } from './SectionLabel'
export function FinalCta() {
const fit = useFitText<HTMLSpanElement>()
// Kreis mit Pfeil folgt dem Mauszeiger über dem großen Link
const x = useSpring(useMotionValue(0), { stiffness: 300, damping: 30 })
const y = useSpring(useMotionValue(0), { stiffness: 300, damping: 30 })
const onMove = (e: React.PointerEvent<HTMLAnchorElement>) => {
const r = e.currentTarget.getBoundingClientRect()
x.set(e.clientX - r.left)
y.set(e.clientY - r.top)
}
return (
<section id="termin" className="relative isolate overflow-hidden border-t border-white/10">
<Logo
label=""
className="pointer-events-none absolute top-1/2 -right-[12%] -z-10 size-[min(90vw,52rem)] -translate-y-1/2 text-white/[0.035]"
/>
<div className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionLabel index="04">Termin</SectionLabel>
<FadeIn>
<p className="max-w-sm text-ash">Leistung wählen, Profi wählen, Uhrzeit wählen. Online — rund um die Uhr.</p>
</FadeIn>
</div>
<a
href={BOOKING_URL}
target="_blank"
rel="noopener"
onPointerMove={onMove}
className="group relative mt-10 block cursor-pointer md:mt-16 md:cursor-none"
>
<span ref={fit} className="cta-text block w-max pr-[0.06em] font-serif leading-[0.9] tracking-[-0.035em]">
Termin
<br className="md:hidden" /> <em>buchen</em>
</span>
<motion.span aria-hidden="true" style={{ x, y }} className="pointer-events-none absolute top-0 left-0 hidden md:block">
<span className="grid size-28 -translate-x-1/2 -translate-y-1/2 scale-0 place-items-center rounded-full bg-champagne text-ink transition-transform duration-500 ease-out-expo group-hover:scale-100">
<ArrowUpRight className="size-8" />
</span>
</motion.span>
<span className="mt-8 inline-flex items-center gap-2 text-sm text-bone/70 md:hidden">
Jetzt online buchen <ArrowUpRight className="size-4" />
</span>
</a>
</div>
</section>
)
}
+109 -28
View File
@@ -1,41 +1,122 @@
import { salon } from '../data/salon'
import { Facebook } from './Icons'
import { Logo } from './Logo'
import { motion } from 'motion/react'
import { image, salon, team } from '../data/salon'
import { compactHours } from '../lib/hours'
import { EASE_OUT_EXPO } from '../lib/motion'
import { useOpenStatus } from '../lib/useOpenStatus'
import { Button } from './Button'
import { GridLines } from './GridLines'
import { ArrowUpRight } from './Icons'
const YEAR = new Date().getFullYear()
export function Footer() {
return (
<footer className="border-t border-white/10">
<div className="mx-auto flex max-w-[1600px] flex-col gap-10 px-5 pt-12 pb-28 md:flex-row md:items-end md:justify-between md:px-10 md:pb-12">
<div className="flex items-center gap-4">
<Logo className="size-12 text-bone" />
<div className="text-sm">
<p className="font-medium">{salon.legalName}</p>
<p className="text-ash">
{salon.street}, {salon.zip} {salon.city}
</p>
</div>
</div>
// 3 × 2 Fotoraster zwischen den Wörtern: fünf Porträts und das Logo an der Wand
const tiles = [
...team.filter((m) => m.key !== 'amin').map((m) => ({ src: m.photo, alt: m.name })),
{ src: image('photos/emblem-quadrat'), alt: 'Logo an der Salonwand' },
]
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-ash">
<a href="impressum.html" className="transition-colors hover:text-bone">
Impressum
</a>
<a href="datenschutz.html" className="transition-colors hover:text-bone">
Datenschutz
</a>
function PhotoGrid() {
return (
<span aria-hidden="true" className="my-3 grid w-[min(62vw,19rem)] grid-cols-3 gap-[2px] border-2 border-paper bg-paper md:my-5">
{tiles.map((t, i) => (
<motion.span
key={t.alt}
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, amount: 0.6 }}
transition={{ duration: 0.7, delay: i * 0.06, ease: EASE_OUT_EXPO }}
className="block aspect-square overflow-hidden bg-ink"
>
<img
src={t.src}
alt=""
loading="lazy"
className="size-full object-cover grayscale transition duration-500 hover:scale-110 hover:grayscale-0"
/>
</motion.span>
))}
</span>
)
}
export function Footer() {
const status = useOpenStatus()
return (
<footer id="kontakt" className="relative isolate bg-ink px-4 pt-24 pb-6 text-paper md:px-8 md:pt-36">
<GridLines tone="dark" />
<p className="mini text-center text-paper/60">Termin</p>
<h2 className="display mt-4 flex flex-col items-center text-center text-[clamp(2.1rem,5vw,4.5rem)]">
<span>Bis zum</span>
<PhotoGrid />
<span>nächsten Schnitt.</span>
</h2>
<div className="mt-8 flex justify-center md:mt-10">
<Button tone="light" />
</div>
<div className="mt-24 grid gap-10 text-[15px] leading-relaxed md:mt-36 md:grid-cols-3 md:gap-8">
<div>
<p className="mini text-paper/50">Adresse</p>
<address className="mt-2 not-italic">
{salon.street}
<br />
{salon.zip} {salon.city}
</address>
<a
href={salon.facebook}
href={salon.mapsUrl}
target="_blank"
rel="noopener"
aria-label="Facebook"
className="grid size-9 place-items-center rounded-full border border-white/15 transition-colors hover:border-white/40 hover:text-bone"
className="mt-2 inline-flex items-center gap-1 underline underline-offset-4 hover:opacity-60"
>
<Facebook className="size-4" />
Route planen <ArrowUpRight className="size-3.5" />
</a>
<span className="font-mono text-xs">© {YEAR}</span>
</div>
<div>
<p className="mini text-paper/50">Öffnungszeiten</p>
<p className="mt-2">
{compactHours().map((line) => (
<span key={line} className="block">
{line}
</span>
))}
</p>
<p className="mt-2 flex items-center gap-2 text-paper/70">
<span className={`size-2 rounded-full ${status.open ? 'bg-emerald-400' : 'bg-paper/40'}`} />
{status.label}
</p>
</div>
<div>
<p className="mini text-paper/50">Kontakt</p>
<p className="mt-2">
<a href={`tel:${salon.phone}`} className="block hover:opacity-60">
{salon.phoneDisplay}
</a>
<a href={`mailto:${salon.email}`} className="block hover:opacity-60">
{salon.email}
</a>
</p>
</div>
</div>
<div className="mt-20 flex flex-wrap items-center justify-between gap-4 border-t border-paper/15 pt-6 text-xs text-paper/60 md:mt-28">
<span>
© {YEAR} {salon.legalName}
</span>
<span className="flex gap-6">
<a href="impressum.html" className="hover:text-paper">
Impressum
</a>
<a href="datenschutz.html" className="hover:text-paper">
Datenschutz
</a>
<a href={salon.facebook} target="_blank" rel="noopener" className="hover:text-paper">
Facebook
</a>
</span>
</div>
</footer>
)
+14
View File
@@ -0,0 +1,14 @@
import { cn } from '../lib/cn'
/** Feine vertikale Rasterlinien hinter dem Inhalt (4 Spalten mobil, 8 ab Tablet). */
export function GridLines({ tone = 'light' }: { tone?: 'light' | 'dark' }) {
return (
<div
aria-hidden="true"
className={cn(
'grid-lines pointer-events-none absolute inset-y-0 inset-x-4 -z-10 [--grid-cols:4] md:inset-x-8 md:[--grid-cols:8]',
tone === 'dark' && '[--grid-line:rgb(245_244_241/0.08)]',
)}
/>
)
}
+38 -70
View File
@@ -1,84 +1,52 @@
import { motion, useScroll, useTransform } from 'motion/react'
import { useRef } from 'react'
import { salon } from '../data/salon'
import { image, salon } from '../data/salon'
import { EASE_OUT_EXPO } from '../lib/motion'
import { useFitText } from '../lib/useFitText'
import { useOpenStatus } from '../lib/useOpenStatus'
import { BookButton } from './BookButton'
import { ArrowDown } from './Icons'
import { RevealChars } from './Reveal'
import { RotatingBadge } from './RotatingBadge'
import { SilkBackground } from './SilkBackground'
import { StatusPill } from './StatusPill'
const fadeUp = (delay: number) => ({
initial: { opacity: 0, y: 24, filter: 'blur(10px)' },
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
transition: { duration: 1.2, delay, ease: EASE_OUT_EXPO },
})
import { Button } from './Button'
import { RevealLines } from './Reveal'
export function Hero() {
const ref = useRef<HTMLElement>(null)
const status = useOpenStatus()
const wordmark = useFitText<HTMLSpanElement>()
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] })
const contentY = useTransform(scrollYProgress, [0, 1], ['0%', '18%'])
const contentOpacity = useTransform(scrollYProgress, [0, 0.75], [1, 0])
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15])
const y = useTransform(scrollYProgress, [0, 1], ['0%', '20%'])
return (
<section id="top" ref={ref} className="relative isolate flex h-svh min-h-[640px] flex-col overflow-hidden">
<motion.div style={{ scale: bgScale }} className="absolute inset-0 -z-20 bg-[radial-gradient(120%_80%_at_70%_20%,#2a2724_0%,#0a0a0a_70%)]">
<SilkBackground />
<section id="top" ref={ref} className="relative isolate h-svh min-h-[560px] overflow-hidden bg-ink text-paper">
<motion.div style={{ y }} className="absolute inset-0 -z-20">
<motion.img
src={image('photos/salon-wand')}
srcSet={`${image('photos/salon-wand-800')} 800w, ${image('photos/salon-wand')} 1317w`}
sizes="100vw"
alt={`Barber vor dem Logo an der Wand im Salon ${salon.name}`}
fetchPriority="high"
initial={{ scale: 1.15 }}
animate={{ scale: 1 }}
transition={{ duration: 2.2, ease: EASE_OUT_EXPO }}
className="size-full object-cover object-[50%_60%] md:object-[50%_100%]"
/>
</motion.div>
<div
aria-hidden="true"
className="absolute inset-0 -z-10 bg-[radial-gradient(90%_70%_at_50%_40%,transparent_0%,rgba(10,10,10,0.55)_100%),linear-gradient(to_bottom,rgba(10,10,10,0.5)_0%,transparent_30%,transparent_55%,#0a0a0a_100%)]"
/>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-ink/35" />
<div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-40 bg-linear-to-b from-ink/60 to-transparent" />
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 -z-10 h-2/3 bg-linear-to-t from-ink/70 to-transparent" />
<motion.div
style={{ y: contentY, opacity: contentOpacity }}
className="mx-auto flex w-full max-w-[1600px] flex-1 flex-col justify-end px-5 pb-6 md:px-10 md:pb-10"
>
<div className="flex items-end justify-between gap-8">
<div className="max-w-2xl">
<motion.div {...fadeUp(0.5)}>
<StatusPill status={status} />
</motion.div>
<motion.p {...fadeUp(0.6)} className="mt-6 font-serif text-[2.6rem] leading-[0.95] tracking-tight sm:text-6xl lg:text-7xl">
Barber & Friseur
<br />
<em className="text-bone/55">in {salon.city}.</em>
</motion.p>
<motion.div {...fadeUp(0.75)} className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-4">
<BookButton size="lg" />
<a
href="#leistungen"
className="group inline-flex items-center gap-2 text-sm text-bone/70 transition-colors hover:text-bone"
>
Preise ansehen
<ArrowDown className="size-4 transition-transform duration-500 ease-out-expo group-hover:translate-y-1" />
</a>
</motion.div>
</div>
<motion.div {...fadeUp(0.9)} className="hidden md:block">
<RotatingBadge />
</motion.div>
</div>
<h1 className="mt-10 md:mt-12">
<span className="sr-only">
{salon.name} – Barber & Friseur in {salon.city}
</span>
<span ref={wordmark} className="block w-max font-serif leading-[0.82] tracking-[-0.035em]">
<RevealChars
charClassName="chrome-text"
words={[{ text: 'Amin' }, { text: 'B.', className: 'italic', mobileBreak: true }, { text: 'Ahmadi' }]}
/>
</span>
</h1>
</motion.div>
<div className="flex h-full flex-col items-center justify-end px-4 pb-10 text-center md:pb-14">
<RevealLines
as="h1"
onLoad
delay={0.3}
srLabel={`${salon.name} – Barber & Friseur in ${salon.city}. Dein Look, unser Handwerk.`}
className="display text-[clamp(1.55rem,6.4vw,5.75rem)]"
lines={['Dein Look.', 'Unser Handwerk.']}
/>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.9, ease: EASE_OUT_EXPO }}
className="mt-6 md:mt-8"
>
<Button tone="light" />
</motion.div>
</div>
</section>
)
}
+5 -31
View File
@@ -8,9 +8,9 @@ const base = {
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.6,
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 1.8,
strokeLinecap: 'square',
strokeLinejoin: 'miter',
'aria-hidden': true,
} as const
@@ -20,34 +20,8 @@ export const ArrowUpRight = (p: IconProps) => (
</svg>
)
export const ArrowDown = (p: IconProps) => (
export const Plus = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M12 5v14M6 13l6 6 6-6" />
</svg>
)
export const Phone = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M5 4h3.5l1.8 4.5-2.3 1.4a11 11 0 0 0 6.1 6.1l1.4-2.3L20 15.5V19a1.5 1.5 0 0 1-1.6 1.5A16.5 16.5 0 0 1 3.5 5.6 1.5 1.5 0 0 1 5 4Z" />
</svg>
)
export const Pin = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z" />
<circle cx="12" cy="10" r="2.3" />
</svg>
)
export const Mail = (p: IconProps) => (
<svg {...base} {...p}>
<rect x="3.5" y="5.5" width="17" height="13" rx="2" />
<path d="m4 7 8 6 8-6" />
</svg>
)
export const Facebook = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M14.5 8.5H16V5.2a17 17 0 0 0-2.3-.2c-2.3 0-3.8 1.4-3.8 4v2.4H7.4v3.7h2.5V21h3.1v-5.9h2.5l.4-3.7H13V9.3c0-.5.3-.8 1.5-.8Z" />
<path d="M12 4v16M4 12h16" />
</svg>
)
+36
View File
@@ -0,0 +1,36 @@
import { image, salon } from '../data/salon'
import { FadeIn, ImageReveal, RevealLines } from './Reveal'
export function Intro() {
return (
<section id="salon" className="px-4 py-24 md:px-8 md:py-36">
<div className="grid gap-10 md:grid-cols-12 md:gap-8">
<ImageReveal
src={image('photos/emblem')}
alt={`Das Logo von ${salon.name} aus Edelstahl an der Schieferwand im Salon`}
className="aspect-[4/5] md:col-span-4"
/>
<div className="flex flex-col justify-between gap-12 md:col-span-8">
<div className="mini flex justify-between">
<span>Über uns</span>
<span>{salon.city}</span>
</div>
<div className="grid gap-6 md:grid-cols-8 md:items-end md:gap-8">
<RevealLines
className="display text-[clamp(2.1rem,5vw,4.5rem)] md:col-span-5"
lines={['Sechs Profis.', 'Ein Salon.']}
/>
<FadeIn className="md:col-span-3">
<p className="max-w-sm text-[15px] leading-snug">
Barber & Friseur in {salon.city}: präzise Schnitte, gepflegte Bärte, Farbe und Styling – für
Herren, Damen und Kinder.
</p>
</FadeIn>
</div>
</div>
</div>
</section>
)
}
-26
View File
@@ -1,26 +0,0 @@
import logoUrl from '../assets/logo.svg'
import { cn } from '../lib/cn'
/**
* Das Logo wird als Maske gerendert und übernimmt so die Textfarbe (bzw. einen Verlauf).
* Zum Austauschen einfach `src/assets/logo.svg` ersetzen — die Farbe im SVG spielt keine Rolle,
* nur der Hintergrund muss transparent sein.
*/
export function Logo({ className, label = 'Amin B. Ahmadi' }: { className?: string; label?: string }) {
return (
<span
{...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
className={cn('inline-block shrink-0 bg-current', className)}
style={{
maskImage: `url("${logoUrl}")`,
WebkitMaskImage: `url("${logoUrl}")`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
}}
/>
)
}
-36
View File
@@ -1,36 +0,0 @@
// Magnet-Effekt, inspiriert von „Magnet“ aus React Bits (reactbits.dev).
import { motion, useMotionValue, useReducedMotion, useSpring } from 'motion/react'
import { useRef, type ReactNode } from 'react'
import { cn } from '../lib/cn'
type Props = { children: ReactNode; strength?: number; className?: string }
export function Magnetic({ children, strength = 0.35, className }: Props) {
const ref = useRef<HTMLDivElement>(null)
const reduce = useReducedMotion()
const x = useSpring(useMotionValue(0), { stiffness: 220, damping: 18, mass: 0.4 })
const y = useSpring(useMotionValue(0), { stiffness: 220, damping: 18, mass: 0.4 })
const onMove = (e: React.PointerEvent) => {
if (reduce || e.pointerType !== 'mouse' || !ref.current) return
const r = ref.current.getBoundingClientRect()
x.set((e.clientX - (r.left + r.width / 2)) * strength)
y.set((e.clientY - (r.top + r.height / 2)) * strength)
}
const reset = () => {
x.set(0)
y.set(0)
}
return (
<motion.div
ref={ref}
onPointerMove={onMove}
onPointerLeave={reset}
style={{ x, y }}
className={cn('inline-block', className)}
>
{children}
</motion.div>
)
}
-98
View File
@@ -1,98 +0,0 @@
// Laufband, das auf Scroll-Geschwindigkeit reagiert — adaptiert von „Scroll Velocity“ aus React Bits (reactbits.dev).
import {
motion,
useAnimationFrame,
useInView,
useMotionValue,
useReducedMotion,
useScroll,
useSpring,
useTransform,
useVelocity,
} from 'motion/react'
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { cn } from '../lib/cn'
const wrap = (min: number, max: number, v: number) => {
const range = max - min
return ((((v - min) % range) + range) % range) + min
}
function VelocityRow({ children, baseVelocity, className }: { children: ReactNode; baseVelocity: number; className?: string }) {
const container = useRef<HTMLDivElement>(null)
const copy = useRef<HTMLSpanElement>(null)
const [copyWidth, setCopyWidth] = useState(0)
const inView = useInView(container)
const reduce = useReducedMotion()
const baseX = useMotionValue(0)
const { scrollY } = useScroll()
const velocity = useSpring(useVelocity(scrollY), { damping: 50, stiffness: 400 })
const factor = useTransform(velocity, [0, 1000], [0, 4], { clamp: false })
const direction = useRef(1)
useLayoutEffect(() => {
const el = copy.current
if (!el) return
const measure = () => setCopyWidth(el.offsetWidth)
measure()
document.fonts?.ready.then(measure)
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
const x = useTransform(baseX, (v) => (copyWidth ? `${wrap(-copyWidth, 0, v)}px` : '0px'))
useAnimationFrame((_, delta) => {
if (!inView || reduce) return
const f = factor.get()
if (f < 0) direction.current = -1
else if (f > 0) direction.current = 1
let moveBy = direction.current * baseVelocity * (delta / 1000)
moveBy += direction.current * moveBy * f
baseX.set(baseX.get() + moveBy)
})
return (
<div ref={container} className="overflow-hidden">
<motion.div className={cn('flex whitespace-nowrap', className)} style={{ x }}>
{Array.from({ length: 4 }, (_, i) => (
<span key={i} ref={i === 0 ? copy : undefined} className="flex shrink-0 items-center">
{children}
</span>
))}
</motion.div>
</div>
)
}
const Star = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="mx-[0.35em] size-[0.4em] shrink-0 fill-champagne">
<path d="M12 0c.6 6.4 5.6 11.4 12 12-6.4.6-11.4 5.6-12 12-.6-6.4-5.6-11.4-12-12C6.4 11.4 11.4 6.4 12 0Z" />
</svg>
)
const row = (words: string[]) =>
words.map((w) => (
<span key={w} className="flex items-center">
{w}
<Star />
</span>
))
export function Marquee() {
return (
<section aria-hidden="true" className="relative -mt-px border-y border-white/10 bg-ink py-6 md:py-10">
<VelocityRow baseVelocity={-40} className="font-serif text-6xl italic leading-none tracking-tight md:text-8xl">
{row(['Haarschnitt', 'Bart', 'Nassrasur', 'Styling', 'Farbe', 'Balayage', 'Make-up'])}
</VelocityRow>
<VelocityRow
baseVelocity={30}
className="mt-2 font-mono text-sm uppercase tracking-[0.3em] text-ash md:mt-4 md:text-base"
>
{row(['Kaiserslautern', 'Mo – Sa 9 – 19 Uhr', 'Online buchen', 'Herren', 'Damen', 'Kinder'])}
</VelocityRow>
</section>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { BOOKING_URL, image, salon } from '../data/salon'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { scrollToHash, unlockScroll } from '../lib/scroll'
import { GridLines } from './GridLines'
const items = [
{ href: '#preise', label: 'Preise', photo: image('photos/emblem-quadrat') },
{ href: '#team', label: 'Team', photo: image('photos/mo-wand') },
{ href: '#kontakt', label: 'Kontakt', photo: image('photos/emblem-breit-900') },
]
/** Vollbild-Menü: große Links, daneben wechselt das Bild zum Link unter dem Mauszeiger. */
export function Menu({ open, onClose }: { open: boolean; onClose: () => void }) {
const [active, setActive] = useState(0)
const first = useRef<HTMLAnchorElement>(null)
useEffect(() => {
if (open) first.current?.focus()
}, [open])
const go = (e: React.MouseEvent, href: string) => {
e.preventDefault()
onClose()
unlockScroll()
scrollToHash(href)
}
return (
<AnimatePresence>
{open && (
<motion.div
id="menu"
role="dialog"
aria-modal="true"
aria-label="Menü"
initial={{ clipPath: 'inset(0% 0% 100% 0%)' }}
animate={{ clipPath: 'inset(0% 0% 0% 0%)' }}
exit={{ clipPath: 'inset(0% 0% 100% 0%)' }}
transition={{ duration: 0.8, ease: EASE_OUT_EXPO }}
className="fixed inset-0 isolate z-40 overflow-y-auto bg-ink text-paper"
>
<GridLines tone="dark" />
<div className="flex min-h-full flex-col px-4 pt-28 pb-6 md:px-8 md:pt-32 md:pb-8">
<div className="grid flex-1 gap-10 md:grid-cols-2">
<nav aria-label="Menü">
<ul onMouseLeave={() => setActive(0)}>
{items.map((item, i) => (
<li key={item.href} className="overflow-hidden">
<motion.a
ref={i === 0 ? first : undefined}
href={item.href}
onClick={(e) => go(e, item.href)}
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
initial={{ y: '100%' }}
animate={{ y: '0%' }}
transition={{ duration: 0.9, delay: 0.25 + i * 0.07, ease: EASE_OUT_EXPO }}
className={cn(
'display block py-1 text-[clamp(3rem,7vw,6.5rem)] transition-opacity duration-300',
active !== i && 'opacity-30',
)}
>
{item.label}
</motion.a>
</li>
))}
</ul>
</nav>
<div className="relative hidden md:block">
{items.map((item, i) => (
<img
key={item.href}
src={item.photo}
alt=""
className={cn(
'absolute inset-0 size-full object-cover transition-all duration-700 ease-out-expo',
active === i ? 'scale-100 opacity-100' : 'scale-105 opacity-0',
)}
/>
))}
</div>
</div>
<div className="mt-12 grid gap-6 text-sm md:grid-cols-3">
<p>
<span className="mini block text-paper/50">Adresse</span>
{salon.street}, {salon.zip} {salon.city}
</p>
<p>
<span className="mini block text-paper/50">Öffnungszeiten</span>
Mo – Sa 9 – 19 Uhr
</p>
<p>
<span className="mini block text-paper/50">Online</span>
<a href={BOOKING_URL} target="_blank" rel="noopener" className="underline underline-offset-4 hover:opacity-60">
Termin buchen
</a>
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
-50
View File
@@ -1,50 +0,0 @@
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from 'motion/react'
import { useState } from 'react'
import { BOOKING_URL, salon } from '../data/salon'
import { EASE_OUT_EXPO } from '../lib/motion'
import { ArrowUpRight, Phone } from './Icons'
/** Fixierte Buchungsleiste am unteren Rand — nur auf dem Smartphone, sobald der Hero verlassen wurde. */
export function MobileBookBar() {
const { scrollY } = useScroll()
const [show, setShow] = useState(false)
useMotionValueEvent(scrollY, 'change', (y) => {
const cta = document.getElementById('termin')
const ctaVisible = cta ? cta.getBoundingClientRect().top < window.innerHeight : false
setShow(y > window.innerHeight * 0.8 && !ctaVisible)
})
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ y: '130%' }}
animate={{ y: '0%' }}
exit={{ y: '130%' }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-40 md:hidden"
>
<div className="flex gap-1.5 rounded-full border border-white/10 bg-ink/75 p-1.5 shadow-2xl shadow-black/60 backdrop-blur-xl">
<a
href={BOOKING_URL}
target="_blank"
rel="noopener"
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-full bg-bone font-medium text-ink"
>
Termin buchen
<ArrowUpRight className="size-4" />
</a>
<a
href={`tel:${salon.phone}`}
aria-label={`Anrufen: ${salon.phoneDisplay}`}
className="grid size-12 place-items-center rounded-full border border-white/15"
>
<Phone className="size-5" />
</a>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+60 -56
View File
@@ -1,67 +1,71 @@
import { motion, useMotionValueEvent, useScroll } from 'motion/react'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { BOOKING_URL } from '../data/salon'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { BookButton } from './BookButton'
import { Logo } from './Logo'
const links = [
{ href: '#leistungen', label: 'Preise' },
{ href: '#team', label: 'Team' },
{ href: '#kontakt', label: 'Kontakt' },
]
import { lockScroll, unlockScroll } from '../lib/scroll'
import { Menu } from './Menu'
export function Nav() {
const { scrollY } = useScroll()
const [hidden, setHidden] = useState(false)
const [scrolled, setScrolled] = useState(false)
const [open, setOpen] = useState(false)
const toggle = useRef<HTMLButtonElement>(null)
useMotionValueEvent(scrollY, 'change', (y) => {
const previous = scrollY.getPrevious() ?? 0
setScrolled(y > 24)
setHidden(y > previous && y > 480)
})
useEffect(() => {
if (!open) return
const button = toggle.current
lockScroll()
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
unlockScroll()
button?.focus()
}
}, [open])
return (
<motion.header
initial={{ y: '-100%' }}
animate={{ y: hidden ? '-100%' : '0%' }}
transition={{ duration: 0.7, ease: EASE_OUT_EXPO }}
className="fixed inset-x-0 top-0 z-50"
>
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 -bottom-6 bg-linear-to-b from-ink/90 via-ink/60 to-transparent transition-opacity duration-500',
scrolled ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="relative mx-auto flex max-w-[1600px] items-center justify-between gap-4 px-5 py-4 md:px-10 md:py-5">
<a href="#top" className="flex items-center gap-3" aria-label="Amin B. Ahmadi – zum Seitenanfang">
<Logo className="size-10 text-bone" label="" />
<span className="hidden text-sm font-medium tracking-tight sm:block">
<>
{/* mix-blend-difference: die Leiste bleibt auf hellen, dunklen und Bild-Flächen lesbar */}
<header className="fixed inset-x-0 top-0 z-50 text-paper mix-blend-difference">
<div className="grid grid-cols-[1fr_auto_1fr] items-center px-4 py-5 md:px-8 md:py-6">
<button
ref={toggle}
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls="menu"
aria-label={open ? 'Menü schließen' : 'Menü öffnen'}
className="relative -m-2 size-10 cursor-pointer justify-self-start"
>
<span
className={cn(
'absolute top-1/2 left-2 h-[2px] w-6 bg-current transition-transform duration-500 ease-out-expo',
open ? 'rotate-45' : '-translate-y-[5px]',
)}
/>
<span
className={cn(
'absolute top-1/2 left-2 h-[2px] w-6 bg-current transition-transform duration-500 ease-out-expo',
open ? '-rotate-45' : 'translate-y-[5px]',
)}
/>
</button>
<a href="#top" onClick={() => setOpen(false)} className="display text-[13px] tracking-[0.14em] md:text-[15px]">
Amin B. Ahmadi
<span className="block font-mono text-[10px] font-normal uppercase tracking-[0.2em] text-ash">Barber · Friseur</span>
</span>
</a>
</a>
<nav aria-label="Hauptnavigation" className="absolute left-1/2 hidden -translate-x-1/2 md:block">
<ul className="flex items-center gap-1 rounded-full border border-white/10 bg-ink/40 p-1 backdrop-blur-xl">
{links.map((l) => (
<li key={l.href}>
<a
href={l.href}
className="block rounded-full px-4 py-2 text-sm text-bone/70 transition-colors hover:bg-white/8 hover:text-bone"
>
{l.label}
</a>
</li>
))}
</ul>
</nav>
<a
href={BOOKING_URL}
target="_blank"
rel="noopener"
className="display justify-self-end text-[13px] transition-opacity hover:opacity-60 md:text-[15px]"
>
<span className="md:hidden">Termin</span>
<span className="hidden md:inline">Termin buchen</span>
</a>
</div>
</header>
<BookButton />
</div>
</motion.header>
<Menu open={open} onClose={() => setOpen(false)} />
</>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { AnimatePresence, motion } from 'motion/react'
import { useState } from 'react'
import { member, serviceUrl, services, type Service, type ServiceCategory } from '../data/salon'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { ArrowUpRight, Plus } from './Icons'
import { RevealLines } from './Reveal'
/** Kleine, überlappende Porträts: wer diese Leistungen anbietet. */
function Faces({ keys, className }: { keys: string[]; className?: string }) {
return (
<span className={cn('flex -space-x-1.5', className)}>
{keys.map((key) => {
const m = member(key)
return (
<img
key={key}
src={m.photo}
alt={m.name}
title={m.name}
loading="lazy"
className="size-8 border-2 border-paper object-cover grayscale transition duration-300 hover:relative hover:z-10 hover:scale-125 hover:grayscale-0"
/>
)
})}
</span>
)
}
function Row({ service: s }: { service: Service }) {
return (
<a
href={serviceUrl(s)}
target="_blank"
rel="noopener"
className="group flex items-baseline gap-3 border-t border-ink/10 py-3.5 transition-colors hover:bg-ink hover:text-paper md:px-2"
>
<span className="min-w-0 flex-1">
<span className="font-semibold">{s.name}</span>
{s.note && <span className="ml-2 hidden text-sm text-stone group-hover:text-paper/60 sm:inline">{s.note}</span>}
</span>
<span className="text-sm text-stone tabular-nums group-hover:text-paper/60">{s.minutes} Min.</span>
<span className="w-20 text-right font-bold tabular-nums">
{s.from && <span className="mr-1 text-xs font-medium">ab</span>}
{s.price} €
</span>
<ArrowUpRight className="size-4 shrink-0 self-center opacity-0 transition-opacity group-hover:opacity-100" />
<span className="sr-only">– online buchen</span>
</a>
)
}
function Category({ category, open, onToggle }: { category: ServiceCategory; open: boolean; onToggle: () => void }) {
const id = `preise-${category.key}`
return (
<div className="border-b border-ink">
<h3>
<button
type="button"
id={`${id}-button`}
aria-expanded={open}
aria-controls={id}
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between gap-6 py-5 text-left md:py-7"
>
<span className="display text-[clamp(1.6rem,3.6vw,3.25rem)]">{category.label}</span>
<span className="flex items-center gap-5">
<Faces keys={category.team} className="hidden md:flex" />
<Plus className={cn('size-6 transition-transform duration-500 ease-out-expo md:size-7', open && 'rotate-45')} />
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={id}
role="region"
aria-labelledby={`${id}-button`}
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="overflow-hidden"
>
<div className="pb-8 md:pb-10">
<Faces keys={category.team} className="mb-4 md:hidden" />
<div className="grid md:grid-cols-2 md:gap-x-10">
{category.items.map((s) => (
<Row key={s.id} service={s} />
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function Prices() {
const [open, setOpen] = useState<string | null>(services[0].key)
return (
<section id="preise" className="border-t border-ink/10 px-4 py-24 md:px-8 md:py-36">
<div className="mini flex justify-between gap-6">
<span>Leistungen</span>
<span className="text-right">„ab“ = je nach Haarlänge</span>
</div>
<RevealLines className="display mt-8 text-[clamp(2.1rem,5vw,4.5rem)]" lines={['Preise.']} />
<div className="mt-10 border-t border-ink md:mt-14">
{services.map((c) => (
<Category key={c.key} category={c} open={open === c.key} onToggle={() => setOpen(open === c.key ? null : c.key)} />
))}
</div>
</section>
)
}
+49 -73
View File
@@ -1,39 +1,44 @@
// Text-Reveals im Stil von „Split Text“ / „Blur Text“ aus React Bits (reactbits.dev).
import { motion, type Variants } from 'motion/react'
import { Fragment, type ReactNode } from 'react'
import type { ReactNode } from 'react'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
const tags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p, div: motion.div }
const lineVariants: Variants = {
hidden: { y: '110%' },
hidden: { y: '105%' },
show: { y: '0%', transition: { duration: 1.1, ease: EASE_OUT_EXPO } },
}
/** Zeilen gleiten nacheinander aus einer Maske nach oben — ausgelöst beim Scrollen. */
/** Zeilen gleiten nacheinander aus einer Maske nach oben — beim Scrollen oder direkt beim Laden. */
export function RevealLines({
lines,
as = 'h2',
className,
delay = 0,
onLoad = false,
srLabel,
}: {
lines: ReactNode[]
as?: keyof typeof tags
className?: string
delay?: number
onLoad?: boolean
/** Text für Screenreader, wenn die sichtbaren Zeilen nur ein Ausschnitt sind */
srLabel?: string
}) {
const Tag = tags[as]
const trigger = onLoad ? { animate: 'show' } : { whileInView: 'show', viewport: { once: true, amount: 0.5 } }
return (
<Tag
className={className}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.5 }}
{...trigger}
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.09, delayChildren: delay } } }}
>
{srLabel && <span className="sr-only">{srLabel}</span>}
{lines.map((line, i) => (
<span key={i} className="-mb-[0.12em] block overflow-hidden pr-[0.08em] pb-[0.12em]">
<span key={i} aria-hidden={srLabel ? true : undefined} className="-mb-[0.08em] block overflow-hidden pb-[0.08em]">
<motion.span className="block" variants={lineVariants}>
{line}
</motion.span>
@@ -43,75 +48,13 @@ export function RevealLines({
)
}
/** Einzelne Buchstaben steigen gestaffelt auf — für die große Wortmarke im Hero. */
export function RevealChars({
words,
className,
charClassName,
delay = 0.15,
stagger = 0.035,
}: {
/** `mobileBreak`: auf kleinen Bildschirmen nach diesem Wort umbrechen */
words: { text: string; className?: string; mobileBreak?: boolean }[]
className?: string
/** wird auf jeden Buchstaben angewendet (z. B. Verlauf per background-clip) */
charClassName?: string
delay?: number
stagger?: number
}) {
let index = 0
return (
<motion.span
aria-hidden="true"
className={cn('inline-block whitespace-nowrap', className)}
initial="hidden"
animate="show"
variants={{ hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } } }}
>
{words.map((word, w) => (
<Fragment key={w}>
<span className={cn('inline-block', word.className)}>
{Array.from(word.text).map((char) => (
<span key={index++} className="-mb-[0.1em] inline-block overflow-hidden pb-[0.1em] align-top">
<motion.span
className={cn('inline-block', charClassName)}
variants={{
hidden: { y: '105%', rotate: 6 },
show: { y: '0%', rotate: 0, transition: { duration: 1.2, ease: EASE_OUT_EXPO } },
}}
>
{char}
</motion.span>
</span>
))}
</span>
{w < words.length - 1 && (
<span className={cn('w-[0.2em]', word.mobileBreak ? 'hidden md:inline-block' : 'inline-block')} />
)}
{word.mobileBreak && <br className="md:hidden" />}
</Fragment>
))}
</motion.span>
)
}
/** Sanftes Einblenden mit Unschärfe beim Scrollen. */
export function FadeIn({
children,
className,
delay = 0,
y = 24,
}: {
children: ReactNode
className?: string
delay?: number
y?: number
}) {
/** Sanftes Einblenden beim Scrollen. */
export function FadeIn({ children, className, delay = 0 }: { children: ReactNode; className?: string; delay?: number }) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y, filter: 'blur(8px)' }}
whileInView={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 1, delay, ease: EASE_OUT_EXPO }}
>
@@ -119,3 +62,36 @@ export function FadeIn({
</motion.div>
)
}
/** Bild, das sich von unten aufdeckt und dabei leicht herauszoomt. */
export function ImageReveal({
src,
alt,
className,
imgClassName,
}: {
src: string
alt: string
className?: string
imgClassName?: string
}) {
const transition = { duration: 1.3, ease: EASE_OUT_EXPO }
// Beobachtet wird der ungeclippte Rahmen: ein komplett weggeclipptes Element gilt nie als sichtbar
return (
<motion.div className={className} initial="hidden" whileInView="show" viewport={{ once: true, amount: 0.25 }}>
<motion.div
className="size-full overflow-hidden"
variants={{ hidden: { clipPath: 'inset(100% 0% 0% 0%)' }, show: { clipPath: 'inset(0% 0% 0% 0%)', transition } }}
>
<motion.img
src={src}
alt={alt}
loading="lazy"
decoding="async"
className={cn('size-full object-cover', imgClassName)}
variants={{ hidden: { scale: 1.2 }, show: { scale: 1, transition: { ...transition, duration: 1.7 } } }}
/>
</motion.div>
</motion.div>
)
}
-47
View File
@@ -1,47 +0,0 @@
// Rotierender Kreistext, inspiriert von „Circular Text“ aus React Bits (reactbits.dev).
import { useId } from 'react'
import { BOOKING_URL } from '../data/salon'
import { cn } from '../lib/cn'
import { ArrowUpRight } from './Icons'
import { Magnetic } from './Magnetic'
const RADIUS = 78
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
export function RotatingBadge({ className, text = 'Termin buchen • Online • 24/7 • ' }: { className?: string; text?: string }) {
const id = useId()
return (
<Magnetic strength={0.25} className={className}>
<a
href={BOOKING_URL}
target="_blank"
rel="noopener"
aria-label="Online Termin buchen"
className="group relative grid size-40 place-items-center rounded-full text-bone"
>
<svg
viewBox="0 0 200 200"
aria-hidden="true"
className="absolute inset-0 size-full animate-spin-slow group-hover:[animation-duration:7s] motion-reduce:animate-none"
>
<defs>
<path id={id} d={`M100,100 m-${RADIUS},0 a${RADIUS},${RADIUS} 0 1,1 ${RADIUS * 2},0 a${RADIUS},${RADIUS} 0 1,1 -${RADIUS * 2},0`} />
</defs>
<text className="fill-current font-mono text-[15px] uppercase">
<textPath href={`#${id}`} textLength={CIRCUMFERENCE - 2} lengthAdjust="spacing">
{text}
</textPath>
</text>
</svg>
<span
className={cn(
'grid size-[4.5rem] place-items-center rounded-full bg-bone text-ink transition-all duration-500 ease-out-expo',
'group-hover:scale-110 group-hover:bg-champagne',
)}
>
<ArrowUpRight className="size-6 transition-transform duration-500 ease-out-expo group-hover:rotate-45" />
</span>
</a>
</Magnetic>
)
}
-11
View File
@@ -1,11 +0,0 @@
import { cn } from '../lib/cn'
export function SectionLabel({ index, children, className }: { index: string; children: React.ReactNode; className?: string }) {
return (
<p className={cn('flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.25em] text-ash', className)}>
<span className="text-champagne">({index})</span>
<span aria-hidden="true" className="h-px w-8 bg-white/20" />
{children}
</p>
)
}
-140
View File
@@ -1,140 +0,0 @@
import { AnimatePresence, motion } from 'motion/react'
import { useRef, useState } from 'react'
import { serviceUrl, services, type Service } from '../data/salon'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { ArrowUpRight } from './Icons'
import { FadeIn, RevealLines } from './Reveal'
import { SectionLabel } from './SectionLabel'
const rowVariants = {
hidden: { opacity: 0, y: 16 },
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: EASE_OUT_EXPO } },
}
function ServiceRow({ service: s }: { service: Service }) {
return (
<motion.li variants={rowVariants}>
<a
href={serviceUrl(s)}
target="_blank"
rel="noopener"
className="group relative flex items-center gap-4 border-b border-white/10 py-5 md:gap-6 md:py-6"
>
<span
aria-hidden="true"
className="absolute inset-0 origin-bottom scale-y-0 bg-linear-to-r from-white/[0.06] to-transparent transition-transform duration-500 ease-out-expo group-hover:scale-y-100"
/>
<span className="relative min-w-0 flex-1 md:flex md:items-center md:gap-6">
<span className="block font-serif text-[1.7rem] leading-tight transition-transform duration-500 ease-out-expo group-hover:translate-x-2 md:w-1/2 md:text-4xl">
{s.name}
</span>
<span className="mt-1 block text-sm text-ash md:mt-0 md:flex-1">
{s.note}
<span className="md:hidden">
{s.note && ' · '}
{s.minutes} Min.
</span>
</span>
</span>
<span className="relative hidden w-20 font-mono text-sm text-ash md:block">{s.minutes} Min.</span>
<span className="relative whitespace-nowrap text-right font-mono text-lg md:w-28 md:text-xl">
{s.from && <span className="mr-1 text-sm text-ash">ab</span>}
{s.price} €
</span>
<span className="relative hidden size-11 shrink-0 place-items-center rounded-full border border-white/15 transition-colors duration-300 group-hover:border-bone group-hover:bg-bone group-hover:text-ink md:grid">
<ArrowUpRight className="size-4 transition-transform duration-500 ease-out-expo group-hover:rotate-45" />
</span>
<span className="sr-only">– jetzt buchen</span>
</a>
</motion.li>
)
}
export function Services() {
const [active, setActive] = useState(services[0].key)
const tabs = useRef<Array<HTMLButtonElement | null>>([])
const category = services.find((c) => c.key === active) ?? services[0]
const onKeyDown = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
if (!dir) return
e.preventDefault()
const next = (i + dir + services.length) % services.length
setActive(services[next].key)
tabs.current[next]?.focus()
}
return (
<section id="leistungen" className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
<SectionLabel index="01">Leistungen & Preise</SectionLabel>
<div className="mt-8 flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between">
<RevealLines
className="font-serif text-6xl leading-[0.9] tracking-tight md:text-8xl lg:text-9xl"
lines={['Klarer Schnitt.', <em key="preis" className="text-bone/45">Klarer Preis.</em>]}
/>
<FadeIn>
<div
role="tablist"
aria-label="Kategorien"
className="flex flex-wrap gap-2 md:w-max md:gap-1 md:rounded-full md:border md:border-white/10 md:bg-white/[0.03] md:p-1"
>
{services.map((c, i) => {
const selected = c.key === active
return (
<button
key={c.key}
ref={(el) => {
tabs.current[i] = el
}}
role="tab"
id={`tab-${c.key}`}
aria-selected={selected}
aria-controls="leistungen-panel"
tabIndex={selected ? 0 : -1}
onClick={() => setActive(c.key)}
onKeyDown={(e) => onKeyDown(e, i)}
className={cn(
'relative cursor-pointer rounded-full border px-4 py-2.5 text-sm whitespace-nowrap transition-colors duration-300 md:border-transparent md:px-5',
selected ? 'border-transparent text-ink' : 'border-white/10 text-bone/65 hover:text-bone',
)}
>
{selected && (
<motion.span
layoutId="tab-pill"
className="absolute inset-0 rounded-full bg-bone"
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
/>
)}
<span className="relative">{c.label}</span>
</button>
)
})}
</div>
</FadeIn>
</div>
<div id="leistungen-panel" role="tabpanel" aria-labelledby={`tab-${category.key}`} className="mt-12 border-t border-white/10 md:mt-16">
<AnimatePresence mode="wait" initial={false}>
<motion.ul
key={category.key}
initial="hidden"
animate="show"
exit={{ opacity: 0, transition: { duration: 0.15 } }}
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.04 } } }}
>
{category.items.map((s) => (
<ServiceRow key={s.id} service={s} />
))}
</motion.ul>
</AnimatePresence>
</div>
<p className="mt-6 max-w-xl text-sm text-ash">
„ab“-Preise je nach Haarlänge und Aufwand. Ein Klick auf eine Leistung öffnet direkt die Online-Buchung.
</p>
</section>
)
}
-250
View File
@@ -1,250 +0,0 @@
/*
* Animierter Seiden-Hintergrund (WebGL).
* Shader adaptiert von „Silk“ aus React Bits — https://reactbits.dev
* Copyright (c) 2026 David Haz — MIT + Commons Clause License
* Hier ohne three.js umgesetzt (reines WebGL), damit die Seite leicht bleibt.
*/
import { useEffect, useRef } from 'react'
import { cn } from '../lib/cn'
const VERTEX = /* glsl */ `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAGMENT = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSpeed;
uniform float uScale;
uniform float uRotation;
uniform float uNoiseIntensity;
uniform float uAspect;
uniform vec2 uPointer;
const float e = 2.71828182845904523536;
float noise(vec2 texCoord) {
float G = e;
vec2 r = (G * sin(G * texCoord));
return fract(r.x * r.y * (1.0 + texCoord.x));
}
vec2 rotateUvs(vec2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
return mat2(c, -s, s, c) * uv;
}
void main() {
float rnd = noise(gl_FragCoord.xy);
vec2 base = vec2(vUv.x * min(uAspect, 1.6), vUv.y);
vec2 uv = rotateUvs(base * uScale, uRotation);
vec2 tex = uv * uScale;
float tOffset = uSpeed * uTime;
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
float pattern = 0.6 +
0.4 * sin(5.0 * (tex.x + tex.y +
cos(3.0 * tex.x + 5.0 * tex.y) +
0.02 * tOffset) +
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
// weiches Licht, das dem Zeiger folgt
float glow = smoothstep(0.75, 0.0, distance(vUv * vec2(uAspect, 1.0), uPointer * vec2(uAspect, 1.0)));
vec3 col = uColor * pattern * (0.85 + 0.45 * glow) - vec3(rnd / 15.0 * uNoiseIntensity);
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
`
type Props = {
color?: string
speed?: number
scale?: number
rotation?: number
noiseIntensity?: number
className?: string
}
const hexToRgb = (hex: string) => {
const n = parseInt(hex.replace('#', ''), 16)
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] as const
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)!
gl.shaderSource(shader, source)
gl.compileShader(shader)
return shader
}
export function SilkBackground({
color = '#5d5750',
speed = 4,
scale = 1,
rotation = 0.35,
noiseIntensity = 1.2,
className,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, powerPreference: 'low-power' })
if (!gl) {
canvas.style.display = 'none'
return
}
// Seide ist weich — eine niedrigere Auflösung sieht gleich aus und spart Akku
const dpr = Math.min(window.devicePixelRatio || 1, 1.25)
let program: WebGLProgram | null = null
let buffer: WebGLBuffer | null = null
let uTime: WebGLUniformLocation | null = null
let uAspect: WebGLUniformLocation | null = null
let uPointer: WebGLUniformLocation | null = null
const resize = () => {
const { clientWidth: w, clientHeight: h } = canvas
canvas.width = Math.max(1, Math.round(w * dpr))
canvas.height = Math.max(1, Math.round(h * dpr))
gl.viewport(0, 0, canvas.width, canvas.height)
gl.uniform1f(uAspect, w / Math.max(1, h))
}
// Programm und Geometrie anlegen — auch erneut, falls der Browser den WebGL-Kontext zurücksetzt
const setup = () => {
program = gl.createProgram()
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX)
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
gl.deleteShader(vs)
gl.deleteShader(fs)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return false
gl.useProgram(program)
// Ein Dreieck, das den ganzen Viewport abdeckt
buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
const position = gl.getAttribLocation(program, 'position')
gl.enableVertexAttribArray(position)
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
const u = (name: string) => gl.getUniformLocation(program!, name)
uTime = u('uTime')
uAspect = u('uAspect')
uPointer = u('uPointer')
gl.uniform3fv(u('uColor'), hexToRgb(color))
gl.uniform1f(u('uSpeed'), speed)
gl.uniform1f(u('uScale'), scale)
gl.uniform1f(u('uRotation'), rotation)
gl.uniform1f(u('uNoiseIntensity'), noiseIntensity)
resize()
return true
}
if (!setup()) {
canvas.style.display = 'none'
return
}
canvas.style.display = ''
const ro = new ResizeObserver(resize)
ro.observe(canvas)
const pointer = { x: 0.7, y: 0.6, tx: 0.7, ty: 0.6 }
const onPointer = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect()
pointer.tx = (e.clientX - r.left) / r.width
pointer.ty = 1 - (e.clientY - r.top) / r.height
}
window.addEventListener('pointermove', onPointer, { passive: true })
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
let raf = 0
let visible = true
let lost = false
let elapsed = 0
let last = performance.now()
const draw = (now: number) => {
elapsed += Math.min(now - last, 100) / 1000
last = now
pointer.x += (pointer.tx - pointer.x) * 0.04
pointer.y += (pointer.ty - pointer.y) * 0.04
gl.uniform1f(uTime, elapsed * 0.1 + 2)
gl.uniform2f(uPointer, pointer.x, pointer.y)
gl.drawArrays(gl.TRIANGLES, 0, 3)
}
const loop = (now: number) => {
draw(now)
raf = requestAnimationFrame(loop)
}
const start = () => {
if (reduceMotion || raf || lost || !visible || document.hidden) return
last = performance.now()
raf = requestAnimationFrame(loop)
}
const stop = () => {
cancelAnimationFrame(raf)
raf = 0
}
draw(performance.now())
start()
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
if (visible) start()
else stop()
})
io.observe(canvas)
const onVisibility = () => (document.hidden ? stop() : start())
document.addEventListener('visibilitychange', onVisibility)
const onLost = (e: Event) => {
e.preventDefault() // erlaubt dem Browser, den Kontext wiederherzustellen
lost = true
stop()
}
const onRestored = () => {
lost = false
if (!setup()) return
draw(performance.now())
start()
}
canvas.addEventListener('webglcontextlost', onLost)
canvas.addEventListener('webglcontextrestored', onRestored)
return () => {
stop()
ro.disconnect()
io.disconnect()
window.removeEventListener('pointermove', onPointer)
document.removeEventListener('visibilitychange', onVisibility)
canvas.removeEventListener('webglcontextlost', onLost)
canvas.removeEventListener('webglcontextrestored', onRestored)
// Ressourcen freigeben, den Kontext aber behalten (React Strict Mode mountet im Dev-Modus zweimal)
gl.deleteBuffer(buffer)
gl.deleteProgram(program)
}
}, [color, speed, scale, rotation, noiseIntensity])
return <canvas ref={canvasRef} aria-hidden="true" className={cn('block h-full w-full', className)} />
}
-19
View File
@@ -1,19 +0,0 @@
import type { OpenStatus } from '../lib/hours'
import { cn } from '../lib/cn'
export function StatusPill({ status, className }: { status: OpenStatus; className?: string }) {
return (
<p
className={cn(
'inline-flex items-center gap-2.5 rounded-full border border-white/10 bg-ink/40 px-3.5 py-1.5 text-xs text-bone/80 backdrop-blur-md',
className,
)}
>
<span className="relative flex size-2">
{status.open && <span className="absolute inset-0 animate-ping rounded-full bg-emerald-400/70 motion-reduce:animate-none" />}
<span className={cn('relative size-2 rounded-full', status.open ? 'bg-emerald-400' : 'bg-rose-400')} />
</span>
{status.label}
</p>
)
}
-126
View File
@@ -1,126 +0,0 @@
// Team-Raster mit Farb-Spotlight — inspiriert von „Chroma Grid“ aus React Bits (reactbits.dev).
import { motion } from 'motion/react'
import { useEffect, useRef } from 'react'
import { team, type Member } from '../data/salon'
import { cn } from '../lib/cn'
import { Logo } from './Logo'
import { EASE_OUT_EXPO } from '../lib/motion'
import { RevealLines } from './Reveal'
import { SectionLabel } from './SectionLabel'
const SPOTLIGHT = 320
function MemberCard({ member, index }: { member: Member; index: number }) {
return (
<motion.figure
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.25 }}
transition={{ duration: 1, delay: (index % 3) * 0.08, ease: EASE_OUT_EXPO }}
className="group relative aspect-[4/5] overflow-hidden rounded-2xl bg-smoke md:rounded-3xl"
>
{member.photo ? (
<img
src={member.photo}
alt={`Porträt von ${member.name}`}
loading="lazy"
decoding="async"
className="size-full object-cover transition-transform duration-1000 ease-out-expo group-hover:scale-[1.04]"
/>
) : (
<div className="grid size-full place-items-center bg-[radial-gradient(80%_60%_at_50%_35%,#34302c_0%,#141312_70%)]">
<Logo label="" className="size-2/5 bg-linear-to-b from-white via-[#a39d96] to-[#ece6df]" />
</div>
)}
<figcaption className="absolute inset-x-0 bottom-0 bg-linear-to-t from-ink/90 via-ink/50 to-transparent p-4 pt-16 md:p-6 md:pt-24">
<span className="block font-serif text-2xl leading-none md:text-4xl">{member.name}</span>
<span className="mt-2 block font-mono text-[10px] uppercase tracking-[0.2em] text-bone/60 md:text-[11px]">{member.role}</span>
</figcaption>
</motion.figure>
)
}
export function Team() {
const grid = useRef<HTMLDivElement>(null)
// Weiches Nachziehen des Spotlights (x, y, Radius) per requestAnimationFrame
useEffect(() => {
const el = grid.current
if (!el || !window.matchMedia('(pointer: fine)').matches) return
const s = { x: 0, y: 0, r: 0, tx: 0, ty: 0, tr: 0 }
let raf = 0
const tick = () => {
s.x += (s.tx - s.x) * 0.14
s.y += (s.ty - s.y) * 0.14
s.r += (s.tr - s.r) * 0.1
el.style.setProperty('--x', `${s.x}px`)
el.style.setProperty('--y', `${s.y}px`)
el.style.setProperty('--r', `${s.r}px`)
const settled = Math.abs(s.tx - s.x) < 0.5 && Math.abs(s.ty - s.y) < 0.5 && Math.abs(s.tr - s.r) < 0.5
raf = settled ? 0 : requestAnimationFrame(tick)
}
const kick = () => {
if (!raf) raf = requestAnimationFrame(tick)
}
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect()
s.tx = e.clientX - r.left
s.ty = e.clientY - r.top
if (s.r < 1) {
s.x = s.tx
s.y = s.ty
}
s.tr = SPOTLIGHT
kick()
}
const onLeave = () => {
s.tr = 0
kick()
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
return () => {
cancelAnimationFrame(raf)
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
}
}, [])
return (
<section id="team" className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
<SectionLabel index="02">Team</SectionLabel>
<div className="mt-8 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<RevealLines
className="font-serif text-6xl leading-[0.9] tracking-tight md:text-8xl lg:text-9xl"
lines={['Sechs Profis.', <em key="anspruch" className="text-bone/45">Ein Anspruch.</em>]}
/>
<p className="max-w-xs text-ash md:pb-3 md:text-right">Bei der Online-Buchung wählst du einfach deinen Favoriten.</p>
</div>
<div
ref={grid}
className="relative mt-12 grid grid-cols-2 gap-3 md:mt-16 md:grid-cols-3 md:gap-4"
style={{ '--x': '50%', '--y': '50%', '--r': '0px' } as React.CSSProperties}
>
{team.map((m, i) => (
<MemberCard key={m.name} member={m} index={i} />
))}
{/* Graustufen-Ebene mit Loch am Mauszeiger (nur bei Maus/Trackpad) */}
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 z-10 hidden [@media(pointer:fine)]:block',
'backdrop-grayscale backdrop-brightness-[0.8]',
)}
style={{
maskImage:
'radial-gradient(circle var(--r) at var(--x) var(--y), transparent 0%, transparent 20%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.6) 75%, #000 100%)',
WebkitMaskImage:
'radial-gradient(circle var(--r) at var(--x) var(--y), transparent 0%, transparent 20%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.6) 75%, #000 100%)',
}}
/>
</div>
</section>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { team } from '../data/salon'
import { cn } from '../lib/cn'
/**
* Team als große Namensliste — das Porträt daneben wechselt beim Überfahren
* (auf dem Handy per Tippen) und schiebt sich von unten ins Bild.
*/
export function TeamList() {
// `previous` bleibt sichtbar, bis das neue Bild darübergeglitten ist
const [{ active, previous }, setSelection] = useState({ active: 0, previous: 0 })
const select = (i: number) => setSelection((s) => (s.active === i ? s : { active: i, previous: s.active }))
const current = team[active]
return (
<section id="team" className="border-t border-ink/10 px-4 py-24 md:px-8 md:py-36">
<div className="mini flex justify-between gap-6">
<span>Team</span>
<span className="text-right" aria-live="polite">
{current.name} — {current.role}
</span>
</div>
<div className="mt-10 grid gap-8 md:mt-14 md:grid-cols-2">
<ul className="order-2 md:order-1">
{team.map((m, i) => (
<li key={m.key}>
<button
type="button"
aria-pressed={active === i}
onMouseEnter={() => select(i)}
onFocus={() => select(i)}
onClick={() => select(i)}
className={cn(
'display block cursor-pointer py-0.5 text-left text-[clamp(2.6rem,6.5vw,6rem)] transition-opacity duration-300',
active === i ? 'opacity-100' : 'opacity-25 hover:opacity-60',
)}
>
{m.short}
</button>
</li>
))}
</ul>
<div className="relative order-1 aspect-square overflow-hidden bg-ink md:order-2 md:aspect-auto md:min-h-[32rem]">
{team.map((m, i) => (
<img
key={m.key}
src={m.photo}
alt={i === active ? `Porträt: ${m.name}` : ''}
loading="lazy"
decoding="async"
className={cn(
'absolute inset-0 size-full object-cover',
i === active
? 'z-20 [clip-path:inset(0_0_0_0)] transition-[clip-path] duration-700 ease-out-expo'
: i === previous
? 'z-10 [clip-path:inset(0_0_0_0)]'
: 'z-0 [clip-path:inset(100%_0_0_0)]',
)}
/>
))}
</div>
</div>
</section>
)
}
-85
View File
@@ -1,85 +0,0 @@
import { hours, salon } from '../data/salon'
import { cn } from '../lib/cn'
import { useOpenStatus } from '../lib/useOpenStatus'
import { BookButton } from './BookButton'
import { Mail, Phone } from './Icons'
import { FadeIn, RevealLines } from './Reveal'
import { SectionLabel } from './SectionLabel'
import { StatusPill } from './StatusPill'
export function Visit() {
const status = useOpenStatus()
return (
<section id="kontakt" className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
<SectionLabel index="03">Besuch</SectionLabel>
<div className="mt-8 grid gap-16 lg:grid-cols-[1.15fr_1fr] lg:gap-24">
<div>
<RevealLines
className="font-serif text-6xl leading-[0.9] tracking-tight md:text-8xl"
lines={['Richard-Wagner-', 'Straße 10']}
/>
<FadeIn>
<p className="mt-5 text-xl text-ash md:text-2xl">
{salon.zip} {salon.city}
</p>
<div className="mt-10 flex flex-wrap items-center gap-3">
<BookButton href={salon.mapsUrl} variant="ghost">
Route planen
</BookButton>
<a
href={`tel:${salon.phone}`}
className="inline-flex h-11 items-center gap-2 rounded-full border border-white/15 px-5 text-sm transition-colors hover:border-white/40 hover:bg-white/5"
>
<Phone className="size-4" />
{salon.phoneDisplay}
</a>
</div>
<a
href={`mailto:${salon.email}`}
className="mt-6 inline-flex items-center gap-2 text-sm text-ash transition-colors hover:text-bone"
>
<Mail className="size-4" />
{salon.email}
</a>
<p className="mt-12 font-mono text-[11px] uppercase tracking-[0.25em] text-ash">
{salon.geo.lat.toFixed(4)}° N · {salon.geo.lng.toFixed(4)}° E
</p>
</FadeIn>
</div>
<FadeIn delay={0.1}>
<div className="flex flex-wrap items-center justify-between gap-4">
<h3 className="font-serif text-4xl">Öffnungszeiten</h3>
<StatusPill status={status} />
</div>
<ul className="mt-8 border-t border-white/10">
{hours.map((h, i) => {
const today = i === status.today
return (
<li
key={h.day}
className={cn(
'flex items-center justify-between border-b border-white/10 py-4 md:py-5',
today ? 'text-bone' : 'text-bone/55',
)}
>
<span className="flex items-center gap-3">
{h.day}
{today && (
<span className="rounded-full bg-champagne/15 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.15em] text-champagne">
Heute
</span>
)}
</span>
<span className="font-mono text-sm md:text-base">{h.open ? `${h.open} – ${h.close}` : 'Geschlossen'}</span>
</li>
)
})}
</ul>
</FadeIn>
</div>
</section>
)
}