Barbier-Website für Amin B. Ahmadi (Kaiserslautern)

One-Pager mit Vite, React, Tailwind und Motion: WebGL-Seidenhintergrund,
Chrom-Wortmarke, Scroll-Laufband, Preisliste mit Direktbuchung pro Leistung
(Setmore-Deep-Links), Team-Raster mit Farb-Spotlight, Öffnungszeiten mit
Live-Status sowie Impressum und Datenschutz. Logo ist ein Platzhalter.

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:03:55 +00:00
commit 61a40437d2
51 files changed
+4278

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
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>
)
}
+57
View File
@@ -0,0 +1,57 @@
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>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { salon } from '../data/salon'
import { Facebook } from './Icons'
import { Logo } from './Logo'
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>
<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>
<a
href={salon.facebook}
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"
>
<Facebook className="size-4" />
</a>
<span className="font-mono text-xs">© {YEAR}</span>
</div>
</div>
</footer>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { motion, useScroll, useTransform } from 'motion/react'
import { useRef } from 'react'
import { 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 },
})
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])
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 />
</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%)]"
/>
<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>
</section>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
const base = {
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.6,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': true,
} as const
export const ArrowUpRight = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M7 17 17 7M8 7h9v9" />
</svg>
)
export const ArrowDown = (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" />
</svg>
)
+26
View File
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,36 @@
// 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
@@ -0,0 +1,98 @@
// 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>
)
}
+50
View File
@@ -0,0 +1,50 @@
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>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { motion, useMotionValueEvent, useScroll } from 'motion/react'
import { useState } from 'react'
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' },
]
export function Nav() {
const { scrollY } = useScroll()
const [hidden, setHidden] = useState(false)
const [scrolled, setScrolled] = useState(false)
useMotionValueEvent(scrollY, 'change', (y) => {
const previous = scrollY.getPrevious() ?? 0
setScrolled(y > 24)
setHidden(y > previous && y > 480)
})
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">
Amin B. Ahmadi
<span className="block font-mono text-[10px] font-normal uppercase tracking-[0.2em] text-ash">Barber · Friseur</span>
</span>
</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>
<BookButton />
</div>
</motion.header>
)
}
+121
View File
@@ -0,0 +1,121 @@
// 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 { 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%' },
show: { y: '0%', transition: { duration: 1.1, ease: EASE_OUT_EXPO } },
}
/** Zeilen gleiten nacheinander aus einer Maske nach oben — ausgelöst beim Scrollen. */
export function RevealLines({
lines,
as = 'h2',
className,
delay = 0,
}: {
lines: ReactNode[]
as?: keyof typeof tags
className?: string
delay?: number
}) {
const Tag = tags[as]
return (
<Tag
className={className}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.5 }}
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.09, delayChildren: delay } } }}
>
{lines.map((line, i) => (
<span key={i} className="-mb-[0.12em] block overflow-hidden pr-[0.08em] pb-[0.12em]">
<motion.span className="block" variants={lineVariants}>
{line}
</motion.span>
</span>
))}
</Tag>
)
}
/** 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
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y, filter: 'blur(8px)' }}
whileInView={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 1, delay, ease: EASE_OUT_EXPO }}
>
{children}
</motion.div>
)
}
+47
View File
@@ -0,0 +1,47 @@
// 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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,140 @@
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>
)
}
+214
View File
@@ -0,0 +1,214 @@
/*
* 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
}
const program = gl.createProgram()!
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX))
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT))
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
canvas.style.display = 'none'
return
}
gl.useProgram(program)
// Ein Dreieck, das den ganzen Viewport abdeckt
const 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)
const uTime = u('uTime')
const uAspect = u('uAspect')
const 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)
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// Seide ist weich — eine niedrigere Auflösung sieht gleich aus und spart Akku
const dpr = Math.min(window.devicePixelRatio || 1, 1.25)
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))
}
resize()
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 })
let raf = 0
let visible = true
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 || !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)
return () => {
stop()
ro.disconnect()
io.disconnect()
window.removeEventListener('pointermove', onPointer)
document.removeEventListener('visibilitychange', onVisibility)
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}, [color, speed, scale, rotation, noiseIntensity])
return <canvas ref={canvasRef} aria-hidden="true" className={cn('block h-full w-full', className)} />
}
+19
View File
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,126 @@
// 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>
)
}
+85
View File
@@ -0,0 +1,85 @@
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>
)
}