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:
51 files changed
+4278
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user