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
No files matched your search
@@ -1,16 +1,15 @@
|
||||
# Amin B. Ahmadi — Barber & Friseur, Kaiserslautern
|
||||
|
||||
One-Pager für den Salon: dunkel, modern, wenig Text. Jede Buchung läuft über die bestehende
|
||||
Setmore-Seite ([aminbahmadi.setmore.com](https://aminbahmadi.setmore.com)).
|
||||
Minimalistischer One-Pager für den Salon, gestalterisch angelehnt an [crispmtl.com](https://www.crispmtl.com):
|
||||
helles Layout mit feinen Rasterlinien, breite Versal-Headlines, kleine Serifen-Labels, eckige Buttons.
|
||||
Jede Buchung läuft über die bestehende Setmore-Seite ([aminbahmadi.setmore.com](https://aminbahmadi.setmore.com)).
|
||||
|
||||
**Bereiche:** Hero mit animiertem Seidenhintergrund (WebGL) · Laufband · Leistungen & Preise
|
||||
(jede Zeile öffnet direkt die passende Setmore-Buchung) · Team · Adresse & Öffnungszeiten mit
|
||||
Live-Status „Jetzt geöffnet“ · großer Buchungs-CTA · Impressum & Datenschutz.
|
||||
**Bereiche:** Menü-Overlay (Bild wechselt je Link) · Hero mit Foto aus dem Salon · Über uns mit Bild-Reveal ·
|
||||
Team als Namensliste mit Porträtwechsel · Preise als Akkordeon (jede Zeile öffnet direkt die passende
|
||||
Setmore-Buchung) · Footer mit Fotoraster, Adresse, Öffnungszeiten und Live-Status „Jetzt geöffnet“.
|
||||
|
||||
**Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Effekte nach Vorlagen von
|
||||
[React Bits](https://reactbits.dev) (Silk, Scroll Velocity, Circular Text, Chroma Grid, Magnet,
|
||||
Split Text). Schriften (Instrument Serif, Geist) liegen lokal, es gibt keine Cookies und keine
|
||||
externen Einbindungen.
|
||||
**Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Schriften (Archivo, Instrument Serif)
|
||||
liegen lokal, es gibt keine Cookies und keine externen Einbindungen.
|
||||
|
||||
## Starten
|
||||
|
||||
@@ -26,14 +25,15 @@ npm run preview # Build lokal ansehen
|
||||
| Was | Wo |
|
||||
| --- | --- |
|
||||
| Preise, Dauer, Leistungen, Öffnungszeiten, Team, Kontakt | `src/data/salon.ts` |
|
||||
| Logo | `src/assets/logo.svg` |
|
||||
| Team-Fotos | `src/assets/team/*.webp` |
|
||||
| Fotos (Hero, Über uns, Menü, Fotoraster) | `src/assets/photos/*.webp` |
|
||||
| Team-Porträts | `src/assets/team/*.webp` |
|
||||
| Schriftzug oben in der Mitte | `src/components/Nav.tsx` |
|
||||
| Favicon, App-Icon, Vorschaubild zum Teilen | `public/` |
|
||||
| Impressum & Datenschutz | `impressum.html`, `datenschutz.html` |
|
||||
|
||||
**Logo:** Das aktuelle Logo ist ein Platzhalter-Monogramm. Das echte Logo als SVG unter
|
||||
`src/assets/logo.svg` ablegen. Die Farbe im SVG ist egal (die Seite färbt es selbst ein), der
|
||||
Hintergrund muss aber transparent sein.
|
||||
**Fotos:** Alle Bilder stammen aus den Team-Fotos der Setmore-Seite (Logo-Wand im Salon). Für den
|
||||
Hero lohnt sich ein eigenes, hochauflösendes Foto (mind. 2400 px breit), z. B. ein Barber bei der Arbeit.
|
||||
Einfach `salon-wand.webp` bzw. `salon-wand-800.webp` ersetzen.
|
||||
|
||||
**Direktbuchung:** Jede Leistung trägt ihre Setmore-ID (`id`). Wird bei Setmore eine Leistung
|
||||
neu angelegt, bekommt sie eine neue ID. Sie steht im Buchungslink der Leistung auf der
|
||||
@@ -41,10 +41,10 @@ Setmore-Seite hinter `products=`.
|
||||
|
||||
## Vor dem Livegang
|
||||
|
||||
- [ ] Echtes Logo einsetzen (siehe oben)
|
||||
- [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen (`[…]`) und rechtlich prüfen lassen
|
||||
- [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen (für das Vorschaubild bei WhatsApp/Facebook)
|
||||
- [ ] Rollen im Team prüfen (`src/data/salon.ts`)
|
||||
- [ ] Einverständnis des Teams für die Verwendung der Fotos einholen
|
||||
|
||||
Der Live-Status „Jetzt geöffnet“ rechnet mit den Öffnungszeiten aus `src/data/salon.ts` und
|
||||
kennt keine Feiertage.
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## React Bits
|
||||
|
||||
Some effects on this site are adapted from React Bits (https://reactbits.dev,
|
||||
https://github.com/DavidHDev/react-bits): Silk (`src/components/SilkBackground.tsx`),
|
||||
Scroll Velocity (`src/components/Marquee.tsx`), Circular Text, Chroma Grid, Magnet and Split Text.
|
||||
|
||||
```
|
||||
MIT + Commons Clause License Condition v1.0
|
||||
|
||||
Copyright (c) 2026 David Haz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, and distribute the Software **as part of an application, website, or product**, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
## Commons Clause Restriction
|
||||
|
||||
You may use this Software, including for any commercial purpose, **so long as you do not sell, sublicense, or redistribute the components themselves-whether alone, in a bundle, or as a ported version.**
|
||||
|
||||
## No Warranty
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Fonts
|
||||
|
||||
Instrument Serif and Geist / Geist Mono are licensed under the SIL Open Font License 1.1
|
||||
(bundled via Fontsource).
|
||||
Archivo and Instrument Serif are licensed under the SIL Open Font License 1.1
|
||||
and are bundled via Fontsource.
|
||||
|
||||
## Libraries
|
||||
|
||||
React, Motion and Lenis are MIT-licensed; their license texts ship with the packages in `node_modules`.
|
||||
@@ -5,14 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Datenschutz · Amin B. Ahmadi Friseur Salon</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<meta name="theme-color" content="#f5f4f1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" src="/src/legal.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="legal mx-auto max-w-2xl px-5 py-16 text-bone/70 md:py-24">
|
||||
<a href="./" class="font-mono text-xs uppercase tracking-[0.25em] text-ash no-underline hover:text-bone">← Zur Startseite</a>
|
||||
<h1 class="mt-10 font-serif text-6xl leading-none text-bone md:text-7xl">Datenschutz</h1>
|
||||
<main class="legal mx-auto max-w-2xl px-5 py-16 text-ink/75 md:py-24">
|
||||
<a href="./" class="mini text-ink no-underline">← Zur Startseite</a>
|
||||
<h1 class="display mt-10 text-4xl text-ink md:text-6xl">Datenschutz</h1>
|
||||
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>
|
||||
@@ -61,7 +61,7 @@
|
||||
Rheinland-Pfalz.
|
||||
</p>
|
||||
|
||||
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-ash">Stand: September 2026</p>
|
||||
<p class="mini mt-12 text-stone">Stand: September 2026</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,14 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Impressum · Amin B. Ahmadi Friseur Salon</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<meta name="theme-color" content="#f5f4f1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" src="/src/legal.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="legal mx-auto max-w-2xl px-5 py-16 text-bone/70 md:py-24">
|
||||
<a href="./" class="font-mono text-xs uppercase tracking-[0.25em] text-ash no-underline hover:text-bone">← Zur Startseite</a>
|
||||
<h1 class="mt-10 font-serif text-6xl leading-none text-bone md:text-7xl">Impressum</h1>
|
||||
<main class="legal mx-auto max-w-2xl px-5 py-16 text-ink/75 md:py-24">
|
||||
<a href="./" class="mini text-ink no-underline">← Zur Startseite</a>
|
||||
<h1 class="display mt-10 text-4xl text-ink md:text-6xl">Impressum</h1>
|
||||
|
||||
<h2>Angaben gemäß § 5 DDG</h2>
|
||||
<p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
name="description"
|
||||
content="Barber & Friseur in Kaiserslautern: Herrenschnitt, Bart, Damen, Farbe & Beauty. Mo–Sa 9–19 Uhr, Richard-Wagner-Straße 10. Jetzt online Termin buchen."
|
||||
/>
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<meta name="theme-color" content="#f5f4f1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
@@ -57,10 +57,10 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<div style="max-width: 40rem; margin: 0 auto; padding: 4rem 1.5rem; font-family: system-ui, sans-serif; color: #f2ede7; line-height: 1.6">
|
||||
<div style="max-width: 40rem; margin: 0 auto; padding: 4rem 1.5rem; font-family: system-ui, sans-serif; color: #0a090c; line-height: 1.6">
|
||||
<h1>Amin B. Ahmadi – Barber & Friseur in Kaiserslautern</h1>
|
||||
<p>Richard-Wagner-Straße 10, 67655 Kaiserslautern · Mo–Sa 9–19 Uhr</p>
|
||||
<p><a style="color: #e5cfbb" href="https://aminbahmadi.setmore.com/book">Jetzt online Termin buchen</a> · <a style="color: #e5cfbb" href="tel:+4963131165201">0631 31165201</a></p>
|
||||
<p><a style="color: #0a090c" href="https://aminbahmadi.setmore.com/book">Jetzt online Termin buchen</a> · <a style="color: #0a090c" href="tel:+4963131165201">0631 31165201</a></p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"name": "amin-b-ahmadi-website",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@fontsource-variable/archivo": "^5.3.0",
|
||||
"@fontsource/instrument-serif": "^5.3.0",
|
||||
"lenis": "^1.3.26",
|
||||
"motion": "^13.4.5",
|
||||
@@ -28,19 +27,10 @@
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"node_modules/@fontsource-variable/archivo": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz",
|
||||
"integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@fontsource-variable/archivo": "^5.3.0",
|
||||
"@fontsource/instrument-serif": "^5.3.0",
|
||||
"lenis": "^1.3.26",
|
||||
"motion": "^13.4.5",
|
||||
|
||||
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -1,5 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0a0a0a"/>
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke="#e6d2bf" stroke-width="1.5"/>
|
||||
<path fill="#e6d2bf" d="M19.32 53Q18.49 53 18.49 52.45Q18.49 51.9 19.26 51.74L20.08 51.58Q21.18 51.36 21.73 50.51Q22.27 49.66 22.66 48.12L31.15 13.88Q31.26 13.33 31.51 13.16Q31.75 13 31.97 13Q32.25 13 32.49 13.16Q32.74 13.33 32.85 13.88L41.51 48.62Q41.89 50.15 42.38 50.75Q42.88 51.36 43.92 51.58L44.74 51.74Q45.51 51.9 45.51 52.45Q45.51 53 44.68 53H35.32Q34.55 53 34.55 52.4Q34.55 51.79 35.48 51.74L36.52 51.63Q37.45 51.52 37.7 51.03Q37.95 50.53 37.67 49.33L35.59 40.84Q35.26 39.41 33.78 39.41H28.03Q26.6 39.41 26.22 40.84L24.36 48.18Q23.53 51.3 26 51.63L26.82 51.74Q27.75 51.9 27.75 52.4Q27.75 53 26.99 53ZM27.86 37.77H33.89Q34.77 37.77 34.6 36.84L31.1 22.15H30.82L27.15 36.84Q26.99 37.77 27.86 37.77Z"/>
|
||||
<rect width="64" height="64" fill="#0a090c"/>
|
||||
<path fill="#f5f4f1" d="M12.31 47 27.06 17H36.94L51.69 47H42.08L39.81 42.19H23.71L21.47 47ZM26.68 35.69H36.84L34.1 29.59Q33.93 29.18 33.63 28.43Q33.32 27.68 32.99 26.83Q32.65 25.98 32.37 25.25Q32.09 24.52 31.96 24.17H31.63Q31.31 24.97 30.89 26.01Q30.47 27.05 30.08 28.02Q29.7 29 29.42 29.61Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 106 KiB |
@@ -1,44 +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 { GridLines } from './components/GridLines'
|
||||
import { Hero } from './components/Hero'
|
||||
import { Marquee } from './components/Marquee'
|
||||
import { MobileBookBar } from './components/MobileBookBar'
|
||||
import { Intro } from './components/Intro'
|
||||
import { Nav } from './components/Nav'
|
||||
import { Services } from './components/Services'
|
||||
import { Team } from './components/Team'
|
||||
import { Visit } from './components/Visit'
|
||||
import { Prices } from './components/Prices'
|
||||
import { TeamList } from './components/TeamList'
|
||||
import { setLenis } from './lib/scroll'
|
||||
|
||||
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()
|
||||
setLenis(lenis)
|
||||
return () => {
|
||||
setLenis(null)
|
||||
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"
|
||||
href="#preise"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-1/2 focus:z-[100] focus:-translate-x-1/2 focus:bg-ink focus:px-4 focus:py-2 focus:text-paper"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
Zu den Preisen springen
|
||||
</a>
|
||||
<Nav />
|
||||
<main>
|
||||
<main className="relative isolate">
|
||||
<GridLines />
|
||||
<Hero />
|
||||
<Marquee />
|
||||
<Services />
|
||||
<Team />
|
||||
<Visit />
|
||||
<FinalCta />
|
||||
<Intro />
|
||||
<TeamList />
|
||||
<Prices />
|
||||
</main>
|
||||
<Footer />
|
||||
<MobileBookBar />
|
||||
<div aria-hidden="true" className="grain pointer-events-none fixed inset-0 z-[70]" />
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 229 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)]',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -54,12 +54,19 @@ export const serviceUrl = (s: Service) =>
|
||||
? `${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 type ServiceCategory = {
|
||||
key: string
|
||||
label: string
|
||||
/** wer diese Leistungen anbietet (Schlüssel aus `team`) */
|
||||
team: string[]
|
||||
items: Service[]
|
||||
}
|
||||
|
||||
export const services: ServiceCategory[] = [
|
||||
{
|
||||
key: 'herren',
|
||||
label: 'Herren',
|
||||
team: ['amin', 'ahmad', 'ali', 'younes', 'mo'],
|
||||
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' },
|
||||
@@ -71,6 +78,7 @@ export const services: ServiceCategory[] = [
|
||||
{
|
||||
key: 'damen',
|
||||
label: 'Damen',
|
||||
team: ['atena', 'amin', 'ahmad', 'ali', 'younes'],
|
||||
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 },
|
||||
@@ -86,6 +94,7 @@ export const services: ServiceCategory[] = [
|
||||
{
|
||||
key: 'farbe',
|
||||
label: 'Farbe & Form',
|
||||
team: ['atena'],
|
||||
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 },
|
||||
@@ -104,6 +113,7 @@ export const services: ServiceCategory[] = [
|
||||
{
|
||||
key: 'kinder',
|
||||
label: 'Kinder',
|
||||
team: ['amin', 'ahmad', 'ali', 'younes', 'mo', 'atena'],
|
||||
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 },
|
||||
@@ -112,6 +122,7 @@ export const services: ServiceCategory[] = [
|
||||
{
|
||||
key: 'beauty',
|
||||
label: 'Beauty',
|
||||
team: ['atena'],
|
||||
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 },
|
||||
@@ -123,16 +134,22 @@ export const services: ServiceCategory[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export type Member = { name: string; role: string; photo?: string }
|
||||
export type Member = { key: string; name: string; short: 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`]
|
||||
const images = import.meta.glob<string>(['../assets/team/*.webp', '../assets/photos/*.webp'], {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
})
|
||||
/** Bild-URL aus `src/assets`, z. B. `image('photos/emblem')` */
|
||||
export const image = (path: string) => images[`../assets/${path}.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') },
|
||||
{ key: 'amin', name: 'Amin B. Ahmadi', short: 'Amin', role: 'Inhaber · Barber', photo: image('photos/emblem-breit-900') },
|
||||
{ key: 'atena', name: 'Atena', short: 'Atena', role: 'Damen · Farbe · Beauty', photo: image('team/atena') },
|
||||
{ key: 'ahmad', name: 'Ahmad', short: 'Ahmad', role: 'Barber', photo: image('team/ahmad') },
|
||||
{ key: 'ali', name: 'Ali', short: 'Ali', role: 'Barber', photo: image('team/ali') },
|
||||
{ key: 'younes', name: 'Younes', short: 'Younes', role: 'Barber', photo: image('team/younes') },
|
||||
{ key: 'mo', name: 'Mo Bargus', short: 'Mo', role: 'Barber', photo: image('team/mo') },
|
||||
]
|
||||
|
||||
export const member = (key: string) => team.find((m) => m.key === key)!
|
||||
@@ -1,32 +1,24 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-ink: #0a0a0a;
|
||||
--color-smoke: #181716;
|
||||
--color-bone: #f2ede7;
|
||||
--color-ash: #8f8a84;
|
||||
--color-champagne: #e5cfbb;
|
||||
--color-paper: #f5f4f1;
|
||||
--color-ink: #0a090c;
|
||||
--color-stone: #8a8883;
|
||||
--color-graphite: #3f3f3f;
|
||||
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: 'Archivo 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);
|
||||
background-color: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-stretch: 100%;
|
||||
font-weight: 500;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -39,19 +31,15 @@
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-champagne);
|
||||
color: var(--color-ink);
|
||||
background: var(--color-ink);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-champagne);
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
[id] {
|
||||
scroll-margin-top: 4rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
@@ -59,69 +47,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
/* Headlines: breit, fett, Versalien */
|
||||
@utility display {
|
||||
font-family: var(--font-sans);
|
||||
font-stretch: 125%;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
/* Kleine Serifen-Labels */
|
||||
@utility mini {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.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");
|
||||
/* Feine vertikale Rasterlinien im Hintergrund */
|
||||
@utility grid-lines {
|
||||
background-image: linear-gradient(to right, var(--grid-line, rgb(10 9 12 / 0.07)) 1px, transparent 1px);
|
||||
background-size: calc(100% / var(--grid-cols, 4)) 100%;
|
||||
box-shadow: inset -1px 0 0 var(--grid-line, rgb(10 9 12 / 0.07));
|
||||
}
|
||||
|
||||
/* 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);
|
||||
font-stretch: 125%;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.legal p,
|
||||
@@ -130,17 +89,12 @@
|
||||
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);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Einstieg für Impressum & Datenschutz: nur Schriften und Styles, kein JavaScript-Ballast.
|
||||
import '@fontsource-variable/geist'
|
||||
import '@fontsource-variable/archivo/wdth.css'
|
||||
import '@fontsource/instrument-serif/400.css'
|
||||
import '@fontsource/instrument-serif/400-italic.css'
|
||||
import './index.css'
|
||||
@@ -58,3 +58,18 @@ export function getOpenStatus(date = new Date()): OpenStatus {
|
||||
}
|
||||
return { open: false, today: day, label: 'Geschlossen' }
|
||||
}
|
||||
|
||||
/** Öffnungszeiten kompakt, z. B. ["Mo – Sa 9 – 19 Uhr", "So geschlossen"] */
|
||||
export function compactHours() {
|
||||
const groups: { from: string; to: string; open: string | null; close: string | null }[] = []
|
||||
for (const h of hours) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.open === h.open && last.close === h.close) last.to = h.short
|
||||
else groups.push({ from: h.short, to: h.short, open: h.open, close: h.close })
|
||||
}
|
||||
return groups.map((g) => {
|
||||
const days = g.from === g.to ? g.from : `${g.from} – ${g.to}`
|
||||
if (!g.open || !g.close) return `${days} geschlossen`
|
||||
return `${days} ${formatTime(g.open).replace(' Uhr', '')} – ${formatTime(g.close)}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type Lenis from 'lenis'
|
||||
|
||||
// Gemeinsamer Zugriff auf das weiche Scrollen (Lenis), z. B. für das Menü-Overlay.
|
||||
let lenis: Lenis | null = null
|
||||
|
||||
export const setLenis = (instance: Lenis | null) => {
|
||||
lenis = instance
|
||||
}
|
||||
|
||||
export const lockScroll = () => {
|
||||
lenis?.stop()
|
||||
document.documentElement.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
export const unlockScroll = () => {
|
||||
document.documentElement.style.overflow = ''
|
||||
lenis?.start()
|
||||
}
|
||||
|
||||
export const scrollToHash = (hash: string) => {
|
||||
const target = document.querySelector<HTMLElement>(hash)
|
||||
if (!target) return
|
||||
if (lenis) lenis.scrollTo(target, { duration: 1.4 })
|
||||
else target.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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)
|
||||
|
||||
let frame = 0
|
||||
let lastWidth = parent.clientWidth
|
||||
const ro = new ResizeObserver(() => {
|
||||
// Höhenänderungen entstehen durch die Schriftgröße selbst — nur auf neue Breiten reagieren
|
||||
const width = parent.clientWidth
|
||||
if (width === lastWidth) return
|
||||
lastWidth = width
|
||||
// außerhalb der Observer-Runde anpassen, sonst meldet der Browser eine Resize-Schleife
|
||||
cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(fit)
|
||||
})
|
||||
ro.observe(parent)
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [max])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import '@fontsource-variable/geist'
|
||||
import '@fontsource-variable/geist-mono'
|
||||
import '@fontsource-variable/archivo/wdth.css'
|
||||
import '@fontsource/instrument-serif/400.css'
|
||||
import '@fontsource/instrument-serif/400-italic.css'
|
||||
import 'lenis/dist/lenis.css'
|
||||
import './index.css'
|
||||
|
||||
|
||||