Redesign im Stil von crispmtl.com: minimalistisch, Foto-Hero, weniger CTAs

- Hell mit feinen Rasterlinien, breite Versal-Headlines (Archivo Expanded),
  kleine Serifen-Labels, eckige Buttons mit Füll-/Text-Roll-Hover
- Hero: Foto aus dem Salon statt animiertem Hintergrund, eine Headline,
  ein Button; "Termin buchen" oben rechts nur noch als Textlink
- Chrom-Wortmarke, Seidenhintergrund, Laufband, Kreis-Badge, großer
  Schluss-CTA und mobile Buchungsleiste entfernt; deutlich weniger Text
- Neue interaktive Elemente: Menü-Overlay mit Bildwechsel pro Link,
  Team als Namensliste mit Porträt-Wisch-Übergang, Preise als Akkordeon
  mit Team-Thumbnails, Footer mit Fotoraster zwischen den Wörtern
- Bilder aus den echten Salon-Fotos (Logo-Wand), neues Favicon/OG-Bild

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
This commit is contained in:
Claude committed 2026-09-29 12:41:03 +00:00
1 parent f21d79c08d
commit c25a3f9381
49 files changed
+815 -1494

No files matched your search

+15 -15
View File
@@ -1,16 +1,15 @@
# Amin B. Ahmadi — Barber & Friseur, Kaiserslautern # Amin B. Ahmadi — Barber & Friseur, Kaiserslautern
One-Pager für den Salon: dunkel, modern, wenig Text. Jede Buchung läuft über die bestehende Minimalistischer One-Pager für den Salon, gestalterisch angelehnt an [crispmtl.com](https://www.crispmtl.com):
Setmore-Seite ([aminbahmadi.setmore.com](https://aminbahmadi.setmore.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 **Bereiche:** Menü-Overlay (Bild wechselt je Link) · Hero mit Foto aus dem Salon · Über uns mit Bild-Reveal ·
(jede Zeile öffnet direkt die passende Setmore-Buchung) · Team · Adresse & Öffnungszeiten mit Team als Namensliste mit Porträtwechsel · Preise als Akkordeon (jede Zeile öffnet direkt die passende
Live-Status „Jetzt geöffnet“ · großer Buchungs-CTA · Impressum & Datenschutz. 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 **Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Schriften (Archivo, Instrument Serif)
[React Bits](https://reactbits.dev) (Silk, Scroll Velocity, Circular Text, Chroma Grid, Magnet, liegen lokal, es gibt keine Cookies und keine externen Einbindungen.
Split Text). Schriften (Instrument Serif, Geist) liegen lokal, es gibt keine Cookies und keine
externen Einbindungen.
## Starten ## Starten
@@ -26,14 +25,15 @@ npm run preview # Build lokal ansehen
| Was | Wo | | Was | Wo |
| --- | --- | | --- | --- |
| Preise, Dauer, Leistungen, Öffnungszeiten, Team, Kontakt | `src/data/salon.ts` | | Preise, Dauer, Leistungen, Öffnungszeiten, Team, Kontakt | `src/data/salon.ts` |
| Logo | `src/assets/logo.svg` | | Fotos (Hero, Über uns, Menü, Fotoraster) | `src/assets/photos/*.webp` |
| Team-Fotos | `src/assets/team/*.webp` | | Team-Porträts | `src/assets/team/*.webp` |
| Schriftzug oben in der Mitte | `src/components/Nav.tsx` |
| Favicon, App-Icon, Vorschaubild zum Teilen | `public/` | | Favicon, App-Icon, Vorschaubild zum Teilen | `public/` |
| Impressum & Datenschutz | `impressum.html`, `datenschutz.html` | | Impressum & Datenschutz | `impressum.html`, `datenschutz.html` |
**Logo:** Das aktuelle Logo ist ein Platzhalter-Monogramm. Das echte Logo als SVG unter **Fotos:** Alle Bilder stammen aus den Team-Fotos der Setmore-Seite (Logo-Wand im Salon). Für den
`src/assets/logo.svg` ablegen. Die Farbe im SVG ist egal (die Seite färbt es selbst ein), der Hero lohnt sich ein eigenes, hochauflösendes Foto (mind. 2400 px breit), z. B. ein Barber bei der Arbeit.
Hintergrund muss aber transparent sein. Einfach `salon-wand.webp` bzw. `salon-wand-800.webp` ersetzen.
**Direktbuchung:** Jede Leistung trägt ihre Setmore-ID (`id`). Wird bei Setmore eine Leistung **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 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 ## Vor dem Livegang
- [ ] Echtes Logo einsetzen (siehe oben)
- [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen (`[…]`) und rechtlich prüfen lassen - [ ] 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) - [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen (für das Vorschaubild bei WhatsApp/Facebook)
- [ ] Rollen im Team prüfen (`src/data/salon.ts`) - [ ] 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 Der Live-Status „Jetzt geöffnet“ rechnet mit den Öffnungszeiten aus `src/data/salon.ts` und
kennt keine Feiertage. kennt keine Feiertage.
+6 -36
View File
@@ -1,40 +1,10 @@
# Third-Party Notices # 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 ## Fonts
Instrument Serif and Geist / Geist Mono are licensed under the SIL Open Font License 1.1 Archivo and Instrument Serif are licensed under the SIL Open Font License 1.1
(bundled via Fontsource). and are bundled via Fontsource.
## Libraries
React, Motion and Lenis are MIT-licensed; their license texts ship with the packages in `node_modules`.
+5 -5
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Datenschutz · Amin B. Ahmadi Friseur Salon</title> <title>Datenschutz · Amin B. Ahmadi Friseur Salon</title>
<meta name="robots" content="noindex" /> <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" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" src="/src/legal.ts"></script> <script type="module" src="/src/legal.ts"></script>
</head> </head>
<body> <body>
<main class="legal mx-auto max-w-2xl px-5 py-16 text-bone/70 md:py-24"> <main class="legal mx-auto max-w-2xl px-5 py-16 text-ink/75 md:py-24">
<a href="./" class="font-mono text-xs uppercase tracking-[0.25em] text-ash no-underline hover:text-bone">← Zur Startseite</a> <a href="./" class="mini text-ink no-underline">← Zur Startseite</a>
<h1 class="mt-10 font-serif text-6xl leading-none text-bone md:text-7xl">Datenschutz</h1> <h1 class="display mt-10 text-4xl text-ink md:text-6xl">Datenschutz</h1>
<h2>Verantwortlicher</h2> <h2>Verantwortlicher</h2>
<p> <p>
@@ -61,7 +61,7 @@
Rheinland-Pfalz. Rheinland-Pfalz.
</p> </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> </main>
</body> </body>
</html> </html>
+4 -4
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Impressum · Amin B. Ahmadi Friseur Salon</title> <title>Impressum · Amin B. Ahmadi Friseur Salon</title>
<meta name="robots" content="noindex" /> <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" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" src="/src/legal.ts"></script> <script type="module" src="/src/legal.ts"></script>
</head> </head>
<body> <body>
<main class="legal mx-auto max-w-2xl px-5 py-16 text-bone/70 md:py-24"> <main class="legal mx-auto max-w-2xl px-5 py-16 text-ink/75 md:py-24">
<a href="./" class="font-mono text-xs uppercase tracking-[0.25em] text-ash no-underline hover:text-bone">← Zur Startseite</a> <a href="./" class="mini text-ink no-underline">← Zur Startseite</a>
<h1 class="mt-10 font-serif text-6xl leading-none text-bone md:text-7xl">Impressum</h1> <h1 class="display mt-10 text-4xl text-ink md:text-6xl">Impressum</h1>
<h2>Angaben gemäß § 5 DDG</h2> <h2>Angaben gemäß § 5 DDG</h2>
<p> <p>
+3 -3
View File
@@ -8,7 +8,7 @@
name="description" 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." 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="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
@@ -57,10 +57,10 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<noscript> <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> <h1>Amin B. Ahmadi – Barber & Friseur in Kaiserslautern</h1>
<p>Richard-Wagner-Straße 10, 67655 Kaiserslautern · Mo–Sa 9–19 Uhr</p> <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> </div>
</noscript> </noscript>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
+4 -14
View File
@@ -8,8 +8,7 @@
"name": "amin-b-ahmadi-website", "name": "amin-b-ahmadi-website",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@fontsource/instrument-serif": "^5.3.0", "@fontsource/instrument-serif": "^5.3.0",
"lenis": "^1.3.26", "lenis": "^1.3.26",
"motion": "^13.4.5", "motion": "^13.4.5",
@@ -28,19 +27,10 @@
"vite": "^8.3.0" "vite": "^8.3.0"
} }
}, },
"node_modules/@fontsource-variable/geist": { "node_modules/@fontsource-variable/archivo": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==",
"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==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
+1 -2
View File
@@ -10,8 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@fontsource/instrument-serif": "^5.3.0", "@fontsource/instrument-serif": "^5.3.0",
"lenis": "^1.3.26", "lenis": "^1.3.26",
"motion": "^13.4.5", "motion": "^13.4.5",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+2 -3
View File
@@ -1,5 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0a0a0a"/> <rect width="64" height="64" fill="#0a090c"/>
<circle cx="32" cy="32" r="26" fill="none" stroke="#e6d2bf" stroke-width="1.5"/> <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"/>
<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"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 916 B

After

Width:  |  Height:  |  Size: 413 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 106 KiB

+18 -18
View File
@@ -1,44 +1,44 @@
import Lenis from 'lenis' import Lenis from 'lenis'
import { MotionConfig } from 'motion/react' import { MotionConfig } from 'motion/react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { FinalCta } from './components/FinalCta'
import { Footer } from './components/Footer' import { Footer } from './components/Footer'
import { GridLines } from './components/GridLines'
import { Hero } from './components/Hero' import { Hero } from './components/Hero'
import { Marquee } from './components/Marquee' import { Intro } from './components/Intro'
import { MobileBookBar } from './components/MobileBookBar'
import { Nav } from './components/Nav' import { Nav } from './components/Nav'
import { Services } from './components/Services' import { Prices } from './components/Prices'
import { Team } from './components/Team' import { TeamList } from './components/TeamList'
import { Visit } from './components/Visit' import { setLenis } from './lib/scroll'
export default function App() { export default function App() {
// Weiches Scrollen (Lenis) — nur, wenn keine reduzierte Bewegung gewünscht ist // Weiches Scrollen (Lenis) — nur, wenn keine reduzierte Bewegung gewünscht ist
useEffect(() => { useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const lenis = new Lenis({ autoRaf: true, anchors: true }) const lenis = new Lenis({ autoRaf: true, anchors: true })
return () => lenis.destroy() setLenis(lenis)
return () => {
setLenis(null)
lenis.destroy()
}
}, []) }, [])
return ( return (
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
<a <a
href="#leistungen" href="#preise"
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" 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> </a>
<Nav /> <Nav />
<main> <main className="relative isolate">
<GridLines />
<Hero /> <Hero />
<Marquee /> <Intro />
<Services /> <TeamList />
<Team /> <Prices />
<Visit />
<FinalCta />
</main> </main>
<Footer /> <Footer />
<MobileBookBar />
<div aria-hidden="true" className="grain pointer-events-none fixed inset-0 z-[70]" />
</MotionConfig> </MotionConfig>
) )
} }
-6
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

-42
View File
@@ -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>
)
}
+45
View File
@@ -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>
)
}
-57
View File
@@ -1,57 +0,0 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { BOOKING_URL } from '../data/salon'
import { useFitText } from '../lib/useFitText'
import { ArrowUpRight } from './Icons'
import { Logo } from './Logo'
import { FadeIn } from './Reveal'
import { SectionLabel } from './SectionLabel'
export function FinalCta() {
const fit = useFitText<HTMLSpanElement>()
// Kreis mit Pfeil folgt dem Mauszeiger über dem großen Link
const x = useSpring(useMotionValue(0), { stiffness: 300, damping: 30 })
const y = useSpring(useMotionValue(0), { stiffness: 300, damping: 30 })
const onMove = (e: React.PointerEvent<HTMLAnchorElement>) => {
const r = e.currentTarget.getBoundingClientRect()
x.set(e.clientX - r.left)
y.set(e.clientY - r.top)
}
return (
<section id="termin" className="relative isolate overflow-hidden border-t border-white/10">
<Logo
label=""
className="pointer-events-none absolute top-1/2 -right-[12%] -z-10 size-[min(90vw,52rem)] -translate-y-1/2 text-white/[0.035]"
/>
<div className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionLabel index="04">Termin</SectionLabel>
<FadeIn>
<p className="max-w-sm text-ash">Leistung wählen, Profi wählen, Uhrzeit wählen. Online — rund um die Uhr.</p>
</FadeIn>
</div>
<a
href={BOOKING_URL}
target="_blank"
rel="noopener"
onPointerMove={onMove}
className="group relative mt-10 block cursor-pointer md:mt-16 md:cursor-none"
>
<span ref={fit} className="cta-text block w-max pr-[0.06em] font-serif leading-[0.9] tracking-[-0.035em]">
Termin
<br className="md:hidden" /> <em>buchen</em>
</span>
<motion.span aria-hidden="true" style={{ x, y }} className="pointer-events-none absolute top-0 left-0 hidden md:block">
<span className="grid size-28 -translate-x-1/2 -translate-y-1/2 scale-0 place-items-center rounded-full bg-champagne text-ink transition-transform duration-500 ease-out-expo group-hover:scale-100">
<ArrowUpRight className="size-8" />
</span>
</motion.span>
<span className="mt-8 inline-flex items-center gap-2 text-sm text-bone/70 md:hidden">
Jetzt online buchen <ArrowUpRight className="size-4" />
</span>
</a>
</div>
</section>
)
}
+109 -28
View File
@@ -1,41 +1,122 @@
import { salon } from '../data/salon' import { motion } from 'motion/react'
import { Facebook } from './Icons' import { image, salon, team } from '../data/salon'
import { Logo } from './Logo' 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() const YEAR = new Date().getFullYear()
export function Footer() { // 3 × 2 Fotoraster zwischen den Wörtern: fünf Porträts und das Logo an der Wand
return ( const tiles = [
<footer className="border-t border-white/10"> ...team.filter((m) => m.key !== 'amin').map((m) => ({ src: m.photo, alt: m.name })),
<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"> { src: image('photos/emblem-quadrat'), alt: 'Logo an der Salonwand' },
<div className="flex items-center gap-4"> ]
<Logo className="size-12 text-bone" />
<div className="text-sm">
<p className="font-medium">{salon.legalName}</p>
<p className="text-ash">
{salon.street}, {salon.zip} {salon.city}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-ash"> function PhotoGrid() {
<a href="impressum.html" className="transition-colors hover:text-bone"> return (
Impressum <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">
</a> {tiles.map((t, i) => (
<a href="datenschutz.html" className="transition-colors hover:text-bone"> <motion.span
Datenschutz key={t.alt}
</a> 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 <a
href={salon.facebook} href={salon.mapsUrl}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
aria-label="Facebook" className="mt-2 inline-flex items-center gap-1 underline underline-offset-4 hover:opacity-60"
className="grid size-9 place-items-center rounded-full border border-white/15 transition-colors hover:border-white/40 hover:text-bone"
> >
<Facebook className="size-4" /> Route planen <ArrowUpRight className="size-3.5" />
</a> </a>
<span className="font-mono text-xs">© {YEAR}</span>
</div> </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> </div>
</footer> </footer>
) )
+14
View File
@@ -0,0 +1,14 @@
import { cn } from '../lib/cn'
/** Feine vertikale Rasterlinien hinter dem Inhalt (4 Spalten mobil, 8 ab Tablet). */
export function GridLines({ tone = 'light' }: { tone?: 'light' | 'dark' }) {
return (
<div
aria-hidden="true"
className={cn(
'grid-lines pointer-events-none absolute inset-y-0 inset-x-4 -z-10 [--grid-cols:4] md:inset-x-8 md:[--grid-cols:8]',
tone === 'dark' && '[--grid-line:rgb(245_244_241/0.08)]',
)}
/>
)
}
+38 -70
View File
@@ -1,84 +1,52 @@
import { motion, useScroll, useTransform } from 'motion/react' import { motion, useScroll, useTransform } from 'motion/react'
import { useRef } from 'react' import { useRef } from 'react'
import { salon } from '../data/salon' import { image, salon } from '../data/salon'
import { EASE_OUT_EXPO } from '../lib/motion' import { EASE_OUT_EXPO } from '../lib/motion'
import { useFitText } from '../lib/useFitText' import { Button } from './Button'
import { useOpenStatus } from '../lib/useOpenStatus' import { RevealLines } from './Reveal'
import { BookButton } from './BookButton'
import { ArrowDown } from './Icons'
import { RevealChars } from './Reveal'
import { RotatingBadge } from './RotatingBadge'
import { SilkBackground } from './SilkBackground'
import { StatusPill } from './StatusPill'
const fadeUp = (delay: number) => ({
initial: { opacity: 0, y: 24, filter: 'blur(10px)' },
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
transition: { duration: 1.2, delay, ease: EASE_OUT_EXPO },
})
export function Hero() { export function Hero() {
const ref = useRef<HTMLElement>(null) const ref = useRef<HTMLElement>(null)
const status = useOpenStatus()
const wordmark = useFitText<HTMLSpanElement>()
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] }) const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] })
const contentY = useTransform(scrollYProgress, [0, 1], ['0%', '18%']) const y = useTransform(scrollYProgress, [0, 1], ['0%', '20%'])
const contentOpacity = useTransform(scrollYProgress, [0, 0.75], [1, 0])
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15])
return ( return (
<section id="top" ref={ref} className="relative isolate flex h-svh min-h-[640px] flex-col overflow-hidden"> <section id="top" ref={ref} className="relative isolate h-svh min-h-[560px] overflow-hidden bg-ink text-paper">
<motion.div style={{ scale: bgScale }} className="absolute inset-0 -z-20 bg-[radial-gradient(120%_80%_at_70%_20%,#2a2724_0%,#0a0a0a_70%)]"> <motion.div style={{ y }} className="absolute inset-0 -z-20">
<SilkBackground /> <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> </motion.div>
<div <div aria-hidden="true" className="absolute inset-0 -z-10 bg-ink/35" />
aria-hidden="true" <div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-40 bg-linear-to-b from-ink/60 to-transparent" />
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-x-0 bottom-0 -z-10 h-2/3 bg-linear-to-t from-ink/70 to-transparent" />
/>
<motion.div <div className="flex h-full flex-col items-center justify-end px-4 pb-10 text-center md:pb-14">
style={{ y: contentY, opacity: contentOpacity }} <RevealLines
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" as="h1"
> onLoad
<div className="flex items-end justify-between gap-8"> delay={0.3}
<div className="max-w-2xl"> srLabel={`${salon.name} – Barber & Friseur in ${salon.city}. Dein Look, unser Handwerk.`}
<motion.div {...fadeUp(0.5)}> className="display text-[clamp(1.55rem,6.4vw,5.75rem)]"
<StatusPill status={status} /> lines={['Dein Look.', 'Unser Handwerk.']}
</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"> <motion.div
Barber & Friseur initial={{ opacity: 0, y: 12 }}
<br /> animate={{ opacity: 1, y: 0 }}
<em className="text-bone/55">in {salon.city}.</em> transition={{ duration: 1, delay: 0.9, ease: EASE_OUT_EXPO }}
</motion.p> className="mt-6 md:mt-8"
<motion.div {...fadeUp(0.75)} className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-4"> >
<BookButton size="lg" /> <Button tone="light" />
<a </motion.div>
href="#leistungen" </div>
className="group inline-flex items-center gap-2 text-sm text-bone/70 transition-colors hover:text-bone"
>
Preise ansehen
<ArrowDown className="size-4 transition-transform duration-500 ease-out-expo group-hover:translate-y-1" />
</a>
</motion.div>
</div>
<motion.div {...fadeUp(0.9)} className="hidden md:block">
<RotatingBadge />
</motion.div>
</div>
<h1 className="mt-10 md:mt-12">
<span className="sr-only">
{salon.name} – Barber & Friseur in {salon.city}
</span>
<span ref={wordmark} className="block w-max font-serif leading-[0.82] tracking-[-0.035em]">
<RevealChars
charClassName="chrome-text"
words={[{ text: 'Amin' }, { text: 'B.', className: 'italic', mobileBreak: true }, { text: 'Ahmadi' }]}
/>
</span>
</h1>
</motion.div>
</section> </section>
) )
} }
+5 -31
View File
@@ -8,9 +8,9 @@ const base = {
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
fill: 'none', fill: 'none',
stroke: 'currentColor', stroke: 'currentColor',
strokeWidth: 1.6, strokeWidth: 1.8,
strokeLinecap: 'round', strokeLinecap: 'square',
strokeLinejoin: 'round', strokeLinejoin: 'miter',
'aria-hidden': true, 'aria-hidden': true,
} as const } as const
@@ -20,34 +20,8 @@ export const ArrowUpRight = (p: IconProps) => (
</svg> </svg>
) )
export const ArrowDown = (p: IconProps) => ( export const Plus = (p: IconProps) => (
<svg {...base} {...p}> <svg {...base} {...p}>
<path d="M12 5v14M6 13l6 6 6-6" /> <path d="M12 4v16M4 12h16" />
</svg>
)
export const Phone = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M5 4h3.5l1.8 4.5-2.3 1.4a11 11 0 0 0 6.1 6.1l1.4-2.3L20 15.5V19a1.5 1.5 0 0 1-1.6 1.5A16.5 16.5 0 0 1 3.5 5.6 1.5 1.5 0 0 1 5 4Z" />
</svg>
)
export const Pin = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z" />
<circle cx="12" cy="10" r="2.3" />
</svg>
)
export const Mail = (p: IconProps) => (
<svg {...base} {...p}>
<rect x="3.5" y="5.5" width="17" height="13" rx="2" />
<path d="m4 7 8 6 8-6" />
</svg>
)
export const Facebook = (p: IconProps) => (
<svg {...base} {...p}>
<path d="M14.5 8.5H16V5.2a17 17 0 0 0-2.3-.2c-2.3 0-3.8 1.4-3.8 4v2.4H7.4v3.7h2.5V21h3.1v-5.9h2.5l.4-3.7H13V9.3c0-.5.3-.8 1.5-.8Z" />
</svg> </svg>
) )
+36
View File
@@ -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>
)
}
-26
View File
@@ -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',
}}
/>
)
}
-36
View File
@@ -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>
)
}
-98
View File
@@ -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>
)
}
+109
View File
@@ -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>
)
}
-50
View File
@@ -1,50 +0,0 @@
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from 'motion/react'
import { useState } from 'react'
import { BOOKING_URL, salon } from '../data/salon'
import { EASE_OUT_EXPO } from '../lib/motion'
import { ArrowUpRight, Phone } from './Icons'
/** Fixierte Buchungsleiste am unteren Rand — nur auf dem Smartphone, sobald der Hero verlassen wurde. */
export function MobileBookBar() {
const { scrollY } = useScroll()
const [show, setShow] = useState(false)
useMotionValueEvent(scrollY, 'change', (y) => {
const cta = document.getElementById('termin')
const ctaVisible = cta ? cta.getBoundingClientRect().top < window.innerHeight : false
setShow(y > window.innerHeight * 0.8 && !ctaVisible)
})
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ y: '130%' }}
animate={{ y: '0%' }}
exit={{ y: '130%' }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-40 md:hidden"
>
<div className="flex gap-1.5 rounded-full border border-white/10 bg-ink/75 p-1.5 shadow-2xl shadow-black/60 backdrop-blur-xl">
<a
href={BOOKING_URL}
target="_blank"
rel="noopener"
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-full bg-bone font-medium text-ink"
>
Termin buchen
<ArrowUpRight className="size-4" />
</a>
<a
href={`tel:${salon.phone}`}
aria-label={`Anrufen: ${salon.phoneDisplay}`}
className="grid size-12 place-items-center rounded-full border border-white/15"
>
<Phone className="size-5" />
</a>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+60 -56
View File
@@ -1,67 +1,71 @@
import { motion, useMotionValueEvent, useScroll } from 'motion/react' import { useEffect, useRef, useState } from 'react'
import { useState } from 'react' import { BOOKING_URL } from '../data/salon'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion' import { lockScroll, unlockScroll } from '../lib/scroll'
import { BookButton } from './BookButton' import { Menu } from './Menu'
import { Logo } from './Logo'
const links = [
{ href: '#leistungen', label: 'Preise' },
{ href: '#team', label: 'Team' },
{ href: '#kontakt', label: 'Kontakt' },
]
export function Nav() { export function Nav() {
const { scrollY } = useScroll() const [open, setOpen] = useState(false)
const [hidden, setHidden] = useState(false) const toggle = useRef<HTMLButtonElement>(null)
const [scrolled, setScrolled] = useState(false)
useMotionValueEvent(scrollY, 'change', (y) => { useEffect(() => {
const previous = scrollY.getPrevious() ?? 0 if (!open) return
setScrolled(y > 24) const button = toggle.current
setHidden(y > previous && y > 480) lockScroll()
}) const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
unlockScroll()
button?.focus()
}
}, [open])
return ( return (
<motion.header <>
initial={{ y: '-100%' }} {/* mix-blend-difference: die Leiste bleibt auf hellen, dunklen und Bild-Flächen lesbar */}
animate={{ y: hidden ? '-100%' : '0%' }} <header className="fixed inset-x-0 top-0 z-50 text-paper mix-blend-difference">
transition={{ duration: 0.7, ease: EASE_OUT_EXPO }} <div className="grid grid-cols-[1fr_auto_1fr] items-center px-4 py-5 md:px-8 md:py-6">
className="fixed inset-x-0 top-0 z-50" <button
> ref={toggle}
<div type="button"
aria-hidden="true" onClick={() => setOpen((o) => !o)}
className={cn( aria-expanded={open}
'pointer-events-none absolute inset-0 -bottom-6 bg-linear-to-b from-ink/90 via-ink/60 to-transparent transition-opacity duration-500', aria-controls="menu"
scrolled ? 'opacity-100' : 'opacity-0', aria-label={open ? 'Menü schließen' : 'Menü öffnen'}
)} className="relative -m-2 size-10 cursor-pointer justify-self-start"
/> >
<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"> <span
<a href="#top" className="flex items-center gap-3" aria-label="Amin B. Ahmadi – zum Seitenanfang"> className={cn(
<Logo className="size-10 text-bone" label="" /> 'absolute top-1/2 left-2 h-[2px] w-6 bg-current transition-transform duration-500 ease-out-expo',
<span className="hidden text-sm font-medium tracking-tight sm:block"> 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 Amin B. Ahmadi
<span className="block font-mono text-[10px] font-normal uppercase tracking-[0.2em] text-ash">Barber · Friseur</span> </a>
</span>
</a>
<nav aria-label="Hauptnavigation" className="absolute left-1/2 hidden -translate-x-1/2 md:block"> <a
<ul className="flex items-center gap-1 rounded-full border border-white/10 bg-ink/40 p-1 backdrop-blur-xl"> href={BOOKING_URL}
{links.map((l) => ( target="_blank"
<li key={l.href}> rel="noopener"
<a className="display justify-self-end text-[13px] transition-opacity hover:opacity-60 md:text-[15px]"
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" <span className="md:hidden">Termin</span>
> <span className="hidden md:inline">Termin buchen</span>
{l.label} </a>
</a> </div>
</li> </header>
))}
</ul>
</nav>
<BookButton /> <Menu open={open} onClose={() => setOpen(false)} />
</div> </>
</motion.header>
) )
} }
+118
View File
@@ -0,0 +1,118 @@
import { AnimatePresence, motion } from 'motion/react'
import { useState } from 'react'
import { member, serviceUrl, services, type Service, type ServiceCategory } from '../data/salon'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { ArrowUpRight, Plus } from './Icons'
import { RevealLines } from './Reveal'
/** Kleine, überlappende Porträts: wer diese Leistungen anbietet. */
function Faces({ keys, className }: { keys: string[]; className?: string }) {
return (
<span className={cn('flex -space-x-1.5', className)}>
{keys.map((key) => {
const m = member(key)
return (
<img
key={key}
src={m.photo}
alt={m.name}
title={m.name}
loading="lazy"
className="size-8 border-2 border-paper object-cover grayscale transition duration-300 hover:relative hover:z-10 hover:scale-125 hover:grayscale-0"
/>
)
})}
</span>
)
}
function Row({ service: s }: { service: Service }) {
return (
<a
href={serviceUrl(s)}
target="_blank"
rel="noopener"
className="group flex items-baseline gap-3 border-t border-ink/10 py-3.5 transition-colors hover:bg-ink hover:text-paper md:px-2"
>
<span className="min-w-0 flex-1">
<span className="font-semibold">{s.name}</span>
{s.note && <span className="ml-2 hidden text-sm text-stone group-hover:text-paper/60 sm:inline">{s.note}</span>}
</span>
<span className="text-sm text-stone tabular-nums group-hover:text-paper/60">{s.minutes} Min.</span>
<span className="w-20 text-right font-bold tabular-nums">
{s.from && <span className="mr-1 text-xs font-medium">ab</span>}
{s.price} €
</span>
<ArrowUpRight className="size-4 shrink-0 self-center opacity-0 transition-opacity group-hover:opacity-100" />
<span className="sr-only">– online buchen</span>
</a>
)
}
function Category({ category, open, onToggle }: { category: ServiceCategory; open: boolean; onToggle: () => void }) {
const id = `preise-${category.key}`
return (
<div className="border-b border-ink">
<h3>
<button
type="button"
id={`${id}-button`}
aria-expanded={open}
aria-controls={id}
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between gap-6 py-5 text-left md:py-7"
>
<span className="display text-[clamp(1.6rem,3.6vw,3.25rem)]">{category.label}</span>
<span className="flex items-center gap-5">
<Faces keys={category.team} className="hidden md:flex" />
<Plus className={cn('size-6 transition-transform duration-500 ease-out-expo md:size-7', open && 'rotate-45')} />
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={id}
role="region"
aria-labelledby={`${id}-button`}
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="overflow-hidden"
>
<div className="pb-8 md:pb-10">
<Faces keys={category.team} className="mb-4 md:hidden" />
<div className="grid md:grid-cols-2 md:gap-x-10">
{category.items.map((s) => (
<Row key={s.id} service={s} />
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function Prices() {
const [open, setOpen] = useState<string | null>(services[0].key)
return (
<section id="preise" className="border-t border-ink/10 px-4 py-24 md:px-8 md:py-36">
<div className="mini flex justify-between gap-6">
<span>Leistungen</span>
<span className="text-right">„ab“ = je nach Haarlänge</span>
</div>
<RevealLines className="display mt-8 text-[clamp(2.1rem,5vw,4.5rem)]" lines={['Preise.']} />
<div className="mt-10 border-t border-ink md:mt-14">
{services.map((c) => (
<Category key={c.key} category={c} open={open === c.key} onToggle={() => setOpen(open === c.key ? null : c.key)} />
))}
</div>
</section>
)
}
+49 -73
View File
@@ -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 { motion, type Variants } from 'motion/react'
import { Fragment, type ReactNode } from 'react' import type { ReactNode } from 'react'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion' 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 tags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p, div: motion.div }
const lineVariants: Variants = { const lineVariants: Variants = {
hidden: { y: '110%' }, hidden: { y: '105%' },
show: { y: '0%', transition: { duration: 1.1, ease: EASE_OUT_EXPO } }, 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({ export function RevealLines({
lines, lines,
as = 'h2', as = 'h2',
className, className,
delay = 0, delay = 0,
onLoad = false,
srLabel,
}: { }: {
lines: ReactNode[] lines: ReactNode[]
as?: keyof typeof tags as?: keyof typeof tags
className?: string className?: string
delay?: number delay?: number
onLoad?: boolean
/** Text für Screenreader, wenn die sichtbaren Zeilen nur ein Ausschnitt sind */
srLabel?: string
}) { }) {
const Tag = tags[as] const Tag = tags[as]
const trigger = onLoad ? { animate: 'show' } : { whileInView: 'show', viewport: { once: true, amount: 0.5 } }
return ( return (
<Tag <Tag
className={className} className={className}
initial="hidden" initial="hidden"
whileInView="show" {...trigger}
viewport={{ once: true, amount: 0.5 }}
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.09, delayChildren: delay } } }} variants={{ hidden: {}, show: { transition: { staggerChildren: 0.09, delayChildren: delay } } }}
> >
{srLabel && <span className="sr-only">{srLabel}</span>}
{lines.map((line, i) => ( {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}> <motion.span className="block" variants={lineVariants}>
{line} {line}
</motion.span> </motion.span>
@@ -43,75 +48,13 @@ export function RevealLines({
) )
} }
/** Einzelne Buchstaben steigen gestaffelt auf — für die große Wortmarke im Hero. */ /** Sanftes Einblenden beim Scrollen. */
export function RevealChars({ export function FadeIn({ children, className, delay = 0 }: { children: ReactNode; className?: string; delay?: number }) {
words,
className,
charClassName,
delay = 0.15,
stagger = 0.035,
}: {
/** `mobileBreak`: auf kleinen Bildschirmen nach diesem Wort umbrechen */
words: { text: string; className?: string; mobileBreak?: boolean }[]
className?: string
/** wird auf jeden Buchstaben angewendet (z. B. Verlauf per background-clip) */
charClassName?: string
delay?: number
stagger?: number
}) {
let index = 0
return (
<motion.span
aria-hidden="true"
className={cn('inline-block whitespace-nowrap', className)}
initial="hidden"
animate="show"
variants={{ hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } } }}
>
{words.map((word, w) => (
<Fragment key={w}>
<span className={cn('inline-block', word.className)}>
{Array.from(word.text).map((char) => (
<span key={index++} className="-mb-[0.1em] inline-block overflow-hidden pb-[0.1em] align-top">
<motion.span
className={cn('inline-block', charClassName)}
variants={{
hidden: { y: '105%', rotate: 6 },
show: { y: '0%', rotate: 0, transition: { duration: 1.2, ease: EASE_OUT_EXPO } },
}}
>
{char}
</motion.span>
</span>
))}
</span>
{w < words.length - 1 && (
<span className={cn('w-[0.2em]', word.mobileBreak ? 'hidden md:inline-block' : 'inline-block')} />
)}
{word.mobileBreak && <br className="md:hidden" />}
</Fragment>
))}
</motion.span>
)
}
/** Sanftes Einblenden mit Unschärfe beim Scrollen. */
export function FadeIn({
children,
className,
delay = 0,
y = 24,
}: {
children: ReactNode
className?: string
delay?: number
y?: number
}) {
return ( return (
<motion.div <motion.div
className={className} className={className}
initial={{ opacity: 0, y, filter: 'blur(8px)' }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0, filter: 'blur(0px)' }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }} viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 1, delay, ease: EASE_OUT_EXPO }} transition={{ duration: 1, delay, ease: EASE_OUT_EXPO }}
> >
@@ -119,3 +62,36 @@ export function FadeIn({
</motion.div> </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>
)
}
-47
View File
@@ -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>
)
}
-11
View File
@@ -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>
)
}
-140
View File
@@ -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>
)
}
-250
View File
@@ -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)} />
}
-19
View File
@@ -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>
)
}
-126
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
-85
View File
@@ -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>
)
}
+27 -10
View File
@@ -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=additional-products&products=${s.id}&type=service&staff=${s.staff}&staffSelected=false`
: `${SETMORE}/book?step=staff&products=${s.id}&type=service` : `${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[] = [ export const services: ServiceCategory[] = [
{ {
key: 'herren', key: 'herren',
label: 'Herren', label: 'Herren',
team: ['amin', 'ahmad', 'ali', 'younes', 'mo'],
items: [ items: [
{ name: 'Maschinenschnitt', note: 'ohne Waschen', minutes: 20, price: 17, id: 'scab580064626bcfc2786e3e10e359241dc0c9d54' }, { name: 'Maschinenschnitt', note: 'ohne Waschen', minutes: 20, price: 17, id: 'scab580064626bcfc2786e3e10e359241dc0c9d54' },
{ name: 'Herren Basic', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 23, id: 'sc3f7ae51cdeb863047f5376611c179c78f33e84c' }, { name: 'Herren Basic', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 23, id: 'sc3f7ae51cdeb863047f5376611c179c78f33e84c' },
@@ -71,6 +78,7 @@ export const services: ServiceCategory[] = [
{ {
key: 'damen', key: 'damen',
label: 'Damen', label: 'Damen',
team: ['atena', 'amin', 'ahmad', 'ali', 'younes'],
items: [ items: [
{ name: 'Damen Basic · kurz', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 26, from: true, id: 'sbadfe20c7a6179c705a30d3cf379fbc77b113b0c' }, { name: 'Damen Basic · kurz', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 26, from: true, id: 'sbadfe20c7a6179c705a30d3cf379fbc77b113b0c' },
{ name: 'Damen Basic · lang', note: 'Waschen, Schneiden, Föhnen', minutes: 45, price: 30, from: true, id: 's6414812bfc137bb6cf41df66e12b6f5cf465b3f7', staff: ATENA }, { name: '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', key: 'farbe',
label: 'Farbe & Form', label: 'Farbe & Form',
team: ['atena'],
items: [ 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 · Ansatz', note: 'ab 3 cm Ansatz', minutes: 50, price: 40, from: true, id: 's44a840b1c42fdb53b6af3f1ffd24dfea6ee29a0d', staff: ATENA },
{ name: 'Farbe / Tönung komplett · kurz', minutes: 50, price: 38, from: true, id: 'se060cd30916ae62e34540083e6c1f6fef8f45a4e', staff: ATENA }, { name: 'Farbe / Tönung komplett · kurz', minutes: 50, price: 38, from: true, id: 'se060cd30916ae62e34540083e6c1f6fef8f45a4e', staff: ATENA },
@@ -104,6 +113,7 @@ export const services: ServiceCategory[] = [
{ {
key: 'kinder', key: 'kinder',
label: 'Kinder', label: 'Kinder',
team: ['amin', 'ahmad', 'ali', 'younes', 'mo', 'atena'],
items: [ items: [
{ name: 'Jungen', note: 'bis 12 Jahre', minutes: 20, price: 17, id: 's61b05497d001b8d46e3b8ecd88481f0836ff902d' }, { 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 }, { 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', key: 'beauty',
label: 'Beauty', label: 'Beauty',
team: ['atena'],
items: [ items: [
{ name: 'Augenbrauen zupfen', note: 'mit Faden', minutes: 10, price: 8, id: 's68c001a88641dfd20758033ec42ab080eb7581a5', staff: ATENA }, { name: 'Augenbrauen zupfen', note: 'mit Faden', minutes: 10, price: 8, id: 's68c001a88641dfd20758033ec42ab080eb7581a5', staff: ATENA },
{ name: 'Augenbrauen färben', minutes: 10, price: 8, id: 's861a13b3cee5389e7b1b7bcee268234b2a0c0d96', staff: ATENA }, { name: '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 images = import.meta.glob<string>(['../assets/team/*.webp', '../assets/photos/*.webp'], {
const photo = (file: string) => photos[`../assets/team/${file}.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[] = [ export const team: Member[] = [
{ name: 'Amin B. Ahmadi', role: 'Inhaber · Barber' }, { key: 'amin', name: 'Amin B. Ahmadi', short: 'Amin', role: 'Inhaber · Barber', photo: image('photos/emblem-breit-900') },
{ name: 'Atena', role: 'Damen · Farbe · Beauty', photo: photo('atena') }, { key: 'atena', name: 'Atena', short: 'Atena', role: 'Damen · Farbe · Beauty', photo: image('team/atena') },
{ name: 'Ahmad', role: 'Barber', photo: photo('ahmad') }, { key: 'ahmad', name: 'Ahmad', short: 'Ahmad', role: 'Barber', photo: image('team/ahmad') },
{ name: 'Ali', role: 'Barber', photo: photo('ali') }, { key: 'ali', name: 'Ali', short: 'Ali', role: 'Barber', photo: image('team/ali') },
{ name: 'Younes', role: 'Barber', photo: photo('younes') }, { key: 'younes', name: 'Younes', short: 'Younes', role: 'Barber', photo: image('team/younes') },
{ name: 'Mo Bargus', role: 'Barber', photo: photo('mo') }, { key: 'mo', name: 'Mo Bargus', short: 'Mo', role: 'Barber', photo: image('team/mo') },
] ]
export const member = (key: string) => team.find((m) => m.key === key)!
+37 -83
View File
@@ -1,32 +1,24 @@
@import 'tailwindcss'; @import 'tailwindcss';
@theme { @theme {
--color-ink: #0a0a0a; --color-paper: #f5f4f1;
--color-smoke: #181716; --color-ink: #0a090c;
--color-bone: #f2ede7; --color-stone: #8a8883;
--color-ash: #8f8a84; --color-graphite: #3f3f3f;
--color-champagne: #e5cfbb;
--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-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); --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 { @layer base {
html { html {
color-scheme: dark; background-color: var(--color-paper);
background-color: var(--color-ink); color: var(--color-ink);
color: var(--color-bone);
font-family: var(--font-sans); font-family: var(--font-sans);
font-stretch: 100%;
font-weight: 500;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@@ -39,19 +31,15 @@
} }
::selection { ::selection {
background: var(--color-champagne); background: var(--color-ink);
color: var(--color-ink); color: var(--color-paper);
} }
:focus-visible { :focus-visible {
outline: 2px solid var(--color-champagne); outline: 2px solid currentColor;
outline-offset: 3px; outline-offset: 3px;
} }
[id] {
scroll-margin-top: 4rem;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { html {
scroll-behavior: auto; scroll-behavior: auto;
@@ -59,69 +47,40 @@
} }
} }
/* Chrom-Verlauf wie das Logo an der Salonwand */ /* Headlines: breit, fett, Versalien */
@utility chrome-text { @utility display {
background-image: linear-gradient( font-family: var(--font-sans);
180deg, font-stretch: 125%;
#ffffff 0%, font-weight: 800;
#efeae4 28%, text-transform: uppercase;
#a8a29b 48%, line-height: 0.95;
#6f6a64 56%, letter-spacing: -0.01em;
#c9c3bc 72%,
#fbf8f4 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
/* Großer CTA: Kontur, die sich beim Hover von links füllt */ /* Kleine Serifen-Labels */
.cta-text { @utility mini {
color: transparent; font-family: var(--font-serif);
-webkit-text-stroke: 1.5px color-mix(in oklab, var(--color-bone) 70%, transparent); font-size: 15px;
background-image: linear-gradient( line-height: 1.3;
90deg, font-weight: 400;
var(--color-bone) 50%,
color-mix(in oklab, var(--color-bone) 6%, transparent) 50%
);
background-size: 200% 100%;
background-position: 100% 0;
-webkit-background-clip: text;
background-clip: text;
transition: background-position 1s var(--ease-out-expo);
} }
.group:hover .cta-text, /* Feine vertikale Rasterlinien im Hintergrund */
.group:focus-visible .cta-text { @utility grid-lines {
background-position: 0 0; 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));
@media (hover: none) {
.cta-text {
background-position: 0 0;
}
}
/* Feines Filmkorn über der ganzen Seite */
.grain {
opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
} }
/* Rechtstexte (Impressum / Datenschutz) */ /* Rechtstexte (Impressum / Datenschutz) */
@layer components { @layer components {
.legal h2 { .legal h2 {
margin-top: 2.5rem; margin-top: 2.5rem;
font-family: var(--font-serif); font-stretch: 125%;
font-size: 1.875rem; font-weight: 800;
line-height: 1.1; text-transform: uppercase;
color: var(--color-bone); font-size: 1.25rem;
} line-height: 1.2;
.legal h3 {
margin-top: 1.5rem;
font-weight: 500;
color: var(--color-bone);
} }
.legal p, .legal p,
@@ -130,17 +89,12 @@
line-height: 1.7; line-height: 1.7;
} }
.legal ul {
list-style: disc;
padding-left: 1.25rem;
}
.legal a { .legal a {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
} }
.legal a:hover { .legal a:hover {
color: var(--color-bone); opacity: 0.6;
} }
} }
+1 -2
View File
@@ -1,5 +1,4 @@
// Einstieg für Impressum & Datenschutz: nur Schriften und Styles, kein JavaScript-Ballast. // 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.css'
import '@fontsource/instrument-serif/400-italic.css'
import './index.css' import './index.css'
+15
View File
@@ -58,3 +58,18 @@ export function getOpenStatus(date = new Date()): OpenStatus {
} }
return { open: false, today: day, label: 'Geschlossen' } 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)}`
})
}
+25
View File
@@ -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' })
}
-45
View File
@@ -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 -3
View File
@@ -1,7 +1,5 @@
import '@fontsource-variable/geist' import '@fontsource-variable/archivo/wdth.css'
import '@fontsource-variable/geist-mono'
import '@fontsource/instrument-serif/400.css' import '@fontsource/instrument-serif/400.css'
import '@fontsource/instrument-serif/400-italic.css'
import 'lenis/dist/lenis.css' import 'lenis/dist/lenis.css'
import './index.css' import './index.css'