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

+44
View File
@@ -0,0 +1,44 @@
import Lenis from 'lenis'
import { MotionConfig } from 'motion/react'
import { useEffect } from 'react'
import { FinalCta } from './components/FinalCta'
import { Footer } from './components/Footer'
import { Hero } from './components/Hero'
import { Marquee } from './components/Marquee'
import { MobileBookBar } from './components/MobileBookBar'
import { Nav } from './components/Nav'
import { Services } from './components/Services'
import { Team } from './components/Team'
import { Visit } from './components/Visit'
export default function App() {
// Weiches Scrollen (Lenis) — nur, wenn keine reduzierte Bewegung gewünscht ist
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const lenis = new Lenis({ autoRaf: true, anchors: true })
return () => lenis.destroy()
}, [])
return (
<MotionConfig reducedMotion="user">
<a
href="#leistungen"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-full focus:bg-bone focus:px-4 focus:py-2 focus:text-ink"
>
Zum Inhalt springen
</a>
<Nav />
<main>
<Hero />
<Marquee />
<Services />
<Team />
<Visit />
<FinalCta />
</main>
<Footer />
<MobileBookBar />
<div aria-hidden="true" className="grain pointer-events-none fixed inset-0 z-[70]" />
</MotionConfig>
)
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="#fff">
<!-- Platzhalter-Logo: durch das echte Logo (SVG) ersetzen. -->
<circle cx="100" cy="100" r="97" fill="none" stroke="#fff" stroke-width="3"/>
<circle cx="100" cy="100" r="88" fill="none" stroke="#fff" stroke-width="1"/>
<path d="M70.82 146Q68.93 146 68.93 144.74Q68.93 143.48 70.7 143.1L72.59 142.72Q75.11 142.22 76.37 140.27Q77.63 138.31 78.51 134.78L98.05 56.02Q98.3 54.76 98.87 54.38Q99.43 54 99.94 54Q100.57 54 101.13 54.38Q101.7 54.76 101.95 56.02L121.87 135.92Q122.75 139.45 123.88 140.83Q125.02 142.22 127.41 142.72L129.3 143.1Q131.07 143.48 131.07 144.74Q131.07 146 129.18 146H107.62Q105.86 146 105.86 144.61Q105.86 143.23 108 143.1L110.4 142.85Q112.54 142.6 113.11 141.46Q113.67 140.33 113.04 137.56L108.25 118.02Q107.5 114.75 104.1 114.75H90.86Q87.59 114.75 86.7 118.02L82.42 134.91Q80.53 142.09 86.2 142.85L88.09 143.1Q90.23 143.48 90.23 144.61Q90.23 146 88.47 146ZM90.48 110.96H104.35Q106.36 110.96 105.99 108.82L97.92 75.05H97.29L88.85 108.82Q88.47 110.96 90.48 110.96Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+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>
)
}
+138
View File
@@ -0,0 +1,138 @@
// Alle Inhalte der Website an einem Ort.
// Quelle: https://aminbahmadi.setmore.com — Preise/Zeiten hier anpassen, wenn sie sich bei Setmore ändern.
const SETMORE = 'https://aminbahmadi.setmore.com'
export const BOOKING_URL = `${SETMORE}/book`
export const salon = {
name: 'Amin B. Ahmadi',
legalName: 'Amin B. Ahmadi Friseur Salon',
street: 'Richard-Wagner-Straße 10',
zip: '67655',
city: 'Kaiserslautern',
phone: '+4963131165201',
phoneDisplay: '0631 31165201',
email: 'aminbahmadi28@gmail.com',
facebook: 'https://facebook.com/amin_b_ahmadi',
geo: { lat: 49.4431737, lng: 7.7656361 },
mapsUrl:
'https://www.google.com/maps/search/?api=1&query=Richard-Wagner-Stra%C3%9Fe+10%2C+67655+Kaiserslautern',
} as const
export type DayHours = { day: string; short: string; open: string | null; close: string | null }
// Reihenfolge Montag → Sonntag. `null` = geschlossen.
export const hours: DayHours[] = [
{ day: 'Montag', short: 'Mo', open: '09:00', close: '19:00' },
{ day: 'Dienstag', short: 'Di', open: '09:00', close: '19:00' },
{ day: 'Mittwoch', short: 'Mi', open: '09:00', close: '19:00' },
{ day: 'Donnerstag', short: 'Do', open: '09:00', close: '19:00' },
{ day: 'Freitag', short: 'Fr', open: '09:00', close: '19:00' },
{ day: 'Samstag', short: 'Sa', open: '09:00', close: '19:00' },
{ day: 'Sonntag', short: 'So', open: null, close: null },
]
// Setmore-ID von Atena — Leistungen, die nur sie anbietet, springen direkt zum nächsten Buchungsschritt.
const ATENA = 'ab5d989c7b138460e9ef970bb2716a8a9'
export type Service = {
name: string
note?: string
minutes: number
price: number
/** „ab“-Preis (abhängig von Haarlänge/Aufwand) */
from?: boolean
/** Setmore-Service-ID für den Direktlink */
id: string
/** gesetzt, wenn nur eine Person die Leistung anbietet */
staff?: string
}
export const serviceUrl = (s: Service) =>
s.staff
? `${SETMORE}/book?step=additional-products&products=${s.id}&type=service&staff=${s.staff}&staffSelected=false`
: `${SETMORE}/book?step=staff&products=${s.id}&type=service`
export type ServiceCategory = { key: string; label: string; items: Service[] }
export const services: ServiceCategory[] = [
{
key: 'herren',
label: 'Herren',
items: [
{ name: 'Maschinenschnitt', note: 'ohne Waschen', minutes: 20, price: 17, id: 'scab580064626bcfc2786e3e10e359241dc0c9d54' },
{ name: 'Herren Basic', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 23, id: 'sc3f7ae51cdeb863047f5376611c179c78f33e84c' },
{ name: 'Herren Basic + Bart', note: 'Basic inklusive Bart', minutes: 45, price: 37, id: 's0d0186fc2795c7e0d5af3d7dc9b64c237551f228' },
{ name: 'Bart stutzen', note: 'mit Maschine', minutes: 15, price: 8, id: 's20de03f274a36fe115aaa5b50bae798c8f1a1a9b' },
{ name: 'Men’s Special', note: 'Basic, Zupfen & Nassrasur', minutes: 45, price: 38, id: 'sfdd1b1071cefc7092b91ad89f4407de4f31df53c' },
],
},
{
key: 'damen',
label: 'Damen',
items: [
{ name: 'Damen Basic · kurz', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 26, from: true, id: 'sbadfe20c7a6179c705a30d3cf379fbc77b113b0c' },
{ name: 'Damen Basic · lang', note: 'Waschen, Schneiden, Föhnen', minutes: 45, price: 30, from: true, id: 's6414812bfc137bb6cf41df66e12b6f5cf465b3f7', staff: ATENA },
{ name: 'Cut & Go · kurz', minutes: 25, price: 17, id: 's555fe7169cd2e0aa9dc5f4ca72cd6f156246de3c' },
{ name: 'Cut & Go · lang', minutes: 35, price: 20, id: 'sb23dec861932c50502b7b826e536d8d354f6a1e0' },
{ name: 'Waschen, Pflegen & Föhnen · kurz', minutes: 25, price: 20, from: true, id: 's1f0e304b9e921797e99fff3b758dcd953bdcc8a2', staff: ATENA },
{ name: 'Waschen, Pflegen & Föhnen · lang', minutes: 30, price: 25, from: true, id: 's5c179fe958768fa86c3594a56fd2a2679f00f115', staff: ATENA },
{ name: 'Quickstyling · kurz', note: 'Locken oder Glätten, ohne Waschen', minutes: 20, price: 25, from: true, id: 'sd017c6bba70af3eddfa59fb7652396bf696fa45f', staff: ATENA },
{ name: 'Quickstyling · lang', note: 'Locken oder Glätten, ohne Waschen', minutes: 30, price: 30, from: true, id: 's746e9e32c13956a9c77c7ef884e6961d556473c2', staff: ATENA },
{ name: 'Hochsteckfrisur', note: 'je nach Aufwand', minutes: 60, price: 40, id: 's4c0d1cb0c4806c372dbd35024bcefb2a595c6853', staff: ATENA },
],
},
{
key: 'farbe',
label: 'Farbe & Form',
items: [
{ name: 'Farbe / Tönung · Ansatz', note: 'ab 3 cm Ansatz', minutes: 50, price: 40, from: true, id: 's44a840b1c42fdb53b6af3f1ffd24dfea6ee29a0d', staff: ATENA },
{ name: 'Farbe / Tönung komplett · kurz', minutes: 50, price: 38, from: true, id: 'se060cd30916ae62e34540083e6c1f6fef8f45a4e', staff: ATENA },
{ name: 'Farbe / Tönung komplett · lang', minutes: 50, price: 48, from: true, id: 's594ab068518d2d4b6b2126f7268a28de732246c5', staff: ATENA },
{ name: 'Blondierung Ansatz · kurz', note: 'bis 3 cm Ansatz', minutes: 40, price: 40, from: true, id: 's6b2a7fb591db39072acab1aa7e7f5320a557cc3f', staff: ATENA },
{ name: 'Blondierung Ansatz · lang', minutes: 40, price: 40, from: true, id: 's9a70e2189423d76c3f612fb38d2fe3f15f229041', staff: ATENA },
{ name: 'Strähnen komplett · kurz', minutes: 60, price: 60, from: true, id: 's044c48784b1916f9725f95e6f281ba5c28722fd3', staff: ATENA },
{ name: 'Strähnen komplett · lang', minutes: 90, price: 80, from: true, id: 's9ee23b0c359db87cdfcc0c6b10efd3242733760b', staff: ATENA },
{ name: 'Balayage · kurz', minutes: 120, price: 90, from: true, id: 'sf230a3eb6df440f44051f2007a2a8148df7ad2a1', staff: ATENA },
{ name: 'Balayage · lang', minutes: 240, price: 140, from: true, id: 's243539debed9c1b284f2b907d5103d68feea1c39', staff: ATENA },
{ name: 'Dauerwelle · kurz', minutes: 45, price: 45, from: true, id: 's9935eeca17760c3f03800f87f1bc64a7a32de37e', staff: ATENA },
{ name: 'Dauerwelle · lang', minutes: 60, price: 60, from: true, id: 'sd83d9fa34a16a1290e0173cfdc157dc0e70815f6' },
{ name: 'Haarglättung', note: 'nach Aufwand', minutes: 120, price: 120, from: true, id: 's37f058393a56acd92b9a4693a5dfd2fdf1d092aa', staff: ATENA },
],
},
{
key: 'kinder',
label: 'Kinder',
items: [
{ name: 'Jungen', note: 'bis 12 Jahre', minutes: 20, price: 17, id: 's61b05497d001b8d46e3b8ecd88481f0836ff902d' },
{ name: 'Mädchen', note: 'bis 12 Jahre', minutes: 30, price: 17, id: 's73cda469cc3dbd3a134ef93916d12c6a0366084a', staff: ATENA },
],
},
{
key: 'beauty',
label: 'Beauty',
items: [
{ name: 'Augenbrauen zupfen', note: 'mit Faden', minutes: 10, price: 8, id: 's68c001a88641dfd20758033ec42ab080eb7581a5', staff: ATENA },
{ name: 'Augenbrauen färben', minutes: 10, price: 8, id: 's861a13b3cee5389e7b1b7bcee268234b2a0c0d96', staff: ATENA },
{ name: 'Wimpern färben', minutes: 15, price: 8, id: 's9ae242ebb316198745180e3c6a4e24f1bd645223', staff: ATENA },
{ name: 'Gesichtsbehandlung', minutes: 90, price: 45, id: 's9d8b1c9b46e12d30b2fc25b07768a2d3ba35dc0b', staff: ATENA },
{ name: 'Tages-Make-up', minutes: 30, price: 20, id: 'sc4d7559daf52e84b3894519538ecbd6651a6b24b', staff: ATENA },
{ name: 'Abend-Make-up', minutes: 45, price: 35, id: 's4d0682896c5c11245231e561bbfce08fd335f35f', staff: ATENA },
],
},
]
export type Member = { name: string; role: string; photo?: string }
const photos = import.meta.glob<string>('../assets/team/*.webp', { eager: true, import: 'default' })
const photo = (file: string) => photos[`../assets/team/${file}.webp`]
export const team: Member[] = [
{ name: 'Amin B. Ahmadi', role: 'Inhaber · Barber' },
{ name: 'Atena', role: 'Damen · Farbe · Beauty', photo: photo('atena') },
{ name: 'Ahmad', role: 'Barber', photo: photo('ahmad') },
{ name: 'Ali', role: 'Barber', photo: photo('ali') },
{ name: 'Younes', role: 'Barber', photo: photo('younes') },
{ name: 'Mo Bargus', role: 'Barber', photo: photo('mo') },
]
+146
View File
@@ -0,0 +1,146 @@
@import 'tailwindcss';
@theme {
--color-ink: #0a0a0a;
--color-smoke: #181716;
--color-bone: #f2ede7;
--color-ash: #8f8a84;
--color-champagne: #e5cfbb;
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Instrument Serif', ui-serif, Georgia, serif;
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--animate-spin-slow: spin-slow 22s linear infinite;
@keyframes spin-slow {
to {
transform: rotate(360deg);
}
}
}
@layer base {
html {
color-scheme: dark;
background-color: var(--color-ink);
color: var(--color-bone);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
overflow-x: clip;
}
::selection {
background: var(--color-champagne);
color: var(--color-ink);
}
:focus-visible {
outline: 2px solid var(--color-champagne);
outline-offset: 3px;
}
[id] {
scroll-margin-top: 4rem;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
}
/* Chrom-Verlauf wie das Logo an der Salonwand */
@utility chrome-text {
background-image: linear-gradient(
180deg,
#ffffff 0%,
#efeae4 28%,
#a8a29b 48%,
#6f6a64 56%,
#c9c3bc 72%,
#fbf8f4 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Großer CTA: Kontur, die sich beim Hover von links füllt */
.cta-text {
color: transparent;
-webkit-text-stroke: 1.5px color-mix(in oklab, var(--color-bone) 70%, transparent);
background-image: linear-gradient(
90deg,
var(--color-bone) 50%,
color-mix(in oklab, var(--color-bone) 6%, transparent) 50%
);
background-size: 200% 100%;
background-position: 100% 0;
-webkit-background-clip: text;
background-clip: text;
transition: background-position 1s var(--ease-out-expo);
}
.group:hover .cta-text,
.group:focus-visible .cta-text {
background-position: 0 0;
}
@media (hover: none) {
.cta-text {
background-position: 0 0;
}
}
/* Feines Filmkorn über der ganzen Seite */
.grain {
opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Rechtstexte (Impressum / Datenschutz) */
@layer components {
.legal h2 {
margin-top: 2.5rem;
font-family: var(--font-serif);
font-size: 1.875rem;
line-height: 1.1;
color: var(--color-bone);
}
.legal h3 {
margin-top: 1.5rem;
font-weight: 500;
color: var(--color-bone);
}
.legal p,
.legal ul {
margin-top: 0.75rem;
line-height: 1.7;
}
.legal ul {
list-style: disc;
padding-left: 1.25rem;
}
.legal a {
text-decoration: underline;
text-underline-offset: 3px;
}
.legal a:hover {
color: var(--color-bone);
}
}
+5
View File
@@ -0,0 +1,5 @@
// Einstieg für Impressum & Datenschutz: nur Schriften und Styles, kein JavaScript-Ballast.
import '@fontsource-variable/geist'
import '@fontsource/instrument-serif/400.css'
import '@fontsource/instrument-serif/400-italic.css'
import './index.css'
+2
View File
@@ -0,0 +1,2 @@
export const cn = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
+60
View File
@@ -0,0 +1,60 @@
import { hours } from '../data/salon'
export type OpenStatus = {
open: boolean
/** Index in `hours` (0 = Montag) für den heutigen Tag in Kaiserslautern */
today: number
label: string
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const toMinutes = (t: string) => {
const [h, m] = t.split(':').map(Number)
return h * 60 + m
}
/** „09:00“ → „9 Uhr“, „18:30“ → „18:30 Uhr“ */
export const formatTime = (t: string) => {
const [h, m] = t.split(':').map(Number)
return m === 0 ? `${h} Uhr` : `${h}:${String(m).padStart(2, '0')} Uhr`
}
/** Aktuelle Uhrzeit in Kaiserslautern — unabhängig von der Zeitzone des Besuchers. */
function berlinNow(date: Date) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Berlin',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
const jsDay = WEEKDAYS.indexOf(get('weekday'))
return { day: (jsDay + 6) % 7, minutes: Number(get('hour')) * 60 + Number(get('minute')) }
}
export function getOpenStatus(date = new Date()): OpenStatus {
const { day, minutes } = berlinNow(date)
const todayHours = hours[day]
if (todayHours.open && todayHours.close) {
const open = toMinutes(todayHours.open)
const close = toMinutes(todayHours.close)
if (minutes >= open && minutes < close) {
return { open: true, today: day, label: `Jetzt geöffnet · bis ${formatTime(todayHours.close)}` }
}
if (minutes < open) {
return { open: false, today: day, label: `Geschlossen · öffnet heute um ${formatTime(todayHours.open)}` }
}
}
for (let i = 1; i <= 7; i++) {
const next = hours[(day + i) % 7]
if (next.open) {
const when = i === 1 ? 'morgen' : next.day
return { open: false, today: day, label: `Geschlossen · öffnet ${when} um ${formatTime(next.open)}` }
}
}
return { open: false, today: day, label: 'Geschlossen' }
}
+2
View File
@@ -0,0 +1,2 @@
/** Kurve für alle Einblend-Animationen (entspricht `ease-out-expo` in CSS). */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const
+31
View File
@@ -0,0 +1,31 @@
import { useLayoutEffect, useRef } from 'react'
/**
* Skaliert die Schriftgröße eines einzeiligen Elements so, dass es genau die Breite
* seines Elternelements ausfüllt (randlose Wortmarke).
*/
export function useFitText<T extends HTMLElement>(max = 400) {
const ref = useRef<T>(null)
useLayoutEffect(() => {
const el = ref.current
const parent = el?.parentElement
if (!el || !parent) return
const fit = () => {
el.style.fontSize = '100px'
const natural = el.scrollWidth
const available = parent.clientWidth
if (!natural || !available) return
el.style.fontSize = `${Math.min(max, (100 * available) / natural) * 0.995}px`
}
fit()
document.fonts?.ready.then(fit)
const ro = new ResizeObserver(fit)
ro.observe(parent)
return () => ro.disconnect()
}, [max])
return ref
}
+13
View File
@@ -0,0 +1,13 @@
import { useEffect, useState } from 'react'
import { getOpenStatus } from './hours'
export function useOpenStatus() {
const [status, setStatus] = useState(getOpenStatus)
useEffect(() => {
const id = window.setInterval(() => setStatus(getOpenStatus()), 30_000)
return () => window.clearInterval(id)
}, [])
return status
}
+16
View File
@@ -0,0 +1,16 @@
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
import '@fontsource/instrument-serif/400.css'
import '@fontsource/instrument-serif/400-italic.css'
import 'lenis/dist/lenis.css'
import './index.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)