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
+44
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,2 @@
|
||||
export const cn = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in new issue
Block a user