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