Barbier-Website für Amin B. Ahmadi (Kaiserslautern)
One-Pager mit Vite, React, Tailwind und Motion: WebGL-Seidenhintergrund, Chrom-Wortmarke, Scroll-Laufband, Preisliste mit Direktbuchung pro Leistung (Setmore-Deep-Links), Team-Raster mit Farb-Spotlight, Öffnungszeiten mit Live-Status sowie Impressum und Datenschutz. Logo ist ein Platzhalter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Domain der Website ohne abschließenden Schrägstrich, z. B. https://www.amin-ahmadi.de
|
||||
# Wird für das Vorschaubild beim Teilen (WhatsApp, Facebook …) benötigt.
|
||||
VITE_SITE_URL=
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Amin B. Ahmadi — Barber & Friseur, Kaiserslautern
|
||||
|
||||
One-Pager für den Salon: dunkel, modern, wenig Text. Jede Buchung läuft über die bestehende
|
||||
Setmore-Seite ([aminbahmadi.setmore.com](https://aminbahmadi.setmore.com)).
|
||||
|
||||
**Bereiche:** Hero mit animiertem Seidenhintergrund (WebGL) · Laufband · Leistungen & Preise
|
||||
(jede Zeile öffnet direkt die passende Setmore-Buchung) · Team · Adresse & Öffnungszeiten mit
|
||||
Live-Status „Jetzt geöffnet“ · großer Buchungs-CTA · Impressum & Datenschutz.
|
||||
|
||||
**Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Effekte nach Vorlagen von
|
||||
[React Bits](https://reactbits.dev) (Silk, Scroll Velocity, Circular Text, Chroma Grid, Magnet,
|
||||
Split Text). Schriften (Instrument Serif, Geist) liegen lokal, es gibt keine Cookies und keine
|
||||
externen Einbindungen.
|
||||
|
||||
## Starten
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Entwicklung auf http://localhost:5173
|
||||
npm run build # fertige Website in dist/
|
||||
npm run preview # Build lokal ansehen
|
||||
```
|
||||
|
||||
## Inhalte ändern
|
||||
|
||||
| Was | Wo |
|
||||
| --- | --- |
|
||||
| Preise, Dauer, Leistungen, Öffnungszeiten, Team, Kontakt | `src/data/salon.ts` |
|
||||
| Logo | `src/assets/logo.svg` |
|
||||
| Team-Fotos | `src/assets/team/*.webp` |
|
||||
| Favicon, App-Icon, Vorschaubild zum Teilen | `public/` |
|
||||
| Impressum & Datenschutz | `impressum.html`, `datenschutz.html` |
|
||||
|
||||
**Logo:** Das aktuelle Logo ist ein Platzhalter-Monogramm. Das echte Logo als SVG unter
|
||||
`src/assets/logo.svg` ablegen. Die Farbe im SVG ist egal (die Seite färbt es selbst ein), der
|
||||
Hintergrund muss aber transparent sein.
|
||||
|
||||
**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
|
||||
Setmore-Seite hinter `products=`.
|
||||
|
||||
## Vor dem Livegang
|
||||
|
||||
- [ ] Echtes Logo einsetzen (siehe oben)
|
||||
- [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen (`[…]`) und rechtlich prüfen lassen
|
||||
- [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen (für das Vorschaubild bei WhatsApp/Facebook)
|
||||
- [ ] Rollen im Team prüfen (`src/data/salon.ts`)
|
||||
|
||||
Der Live-Status „Jetzt geöffnet“ rechnet mit den Öffnungszeiten aus `src/data/salon.ts` und
|
||||
kennt keine Feiertage.
|
||||
|
||||
## Veröffentlichen
|
||||
|
||||
`npm run build` erzeugt den Ordner `dist/`. Er läuft auf jedem Webspace (z. B. IONOS, Strato,
|
||||
All-Inkl), auf Netlify oder Vercel und in Unterordnern, denn alle Pfade sind relativ.
|
||||
Einfach den Inhalt von `dist/` hochladen.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## React Bits
|
||||
|
||||
Some effects on this site are adapted from React Bits (https://reactbits.dev,
|
||||
https://github.com/DavidHDev/react-bits): Silk (`src/components/SilkBackground.tsx`),
|
||||
Scroll Velocity (`src/components/Marquee.tsx`), Circular Text, Chroma Grid, Magnet and Split Text.
|
||||
|
||||
```
|
||||
MIT + Commons Clause License Condition v1.0
|
||||
|
||||
Copyright (c) 2026 David Haz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, and distribute the Software **as part of an application, website, or product**, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
## Commons Clause Restriction
|
||||
|
||||
You may use this Software, including for any commercial purpose, **so long as you do not sell, sublicense, or redistribute the components themselves-whether alone, in a bundle, or as a ported version.**
|
||||
|
||||
## No Warranty
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Fonts
|
||||
|
||||
Instrument Serif and Geist / Geist Mono are licensed under the SIL Open Font License 1.1
|
||||
(bundled via Fontsource).
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Datenschutz · Amin B. Ahmadi Friseur Salon</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" src="/src/legal.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="legal mx-auto max-w-2xl px-5 py-16 text-bone/70 md:py-24">
|
||||
<a href="./" class="font-mono text-xs uppercase tracking-[0.25em] text-ash no-underline hover:text-bone">← Zur Startseite</a>
|
||||
<h1 class="mt-10 font-serif text-6xl leading-none text-bone md:text-7xl">Datenschutz</h1>
|
||||
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>
|
||||
Amin B. Ahmadi Friseur Salon, Inhaber: [vollständiger Name]<br />
|
||||
Richard-Wagner-Straße 10, 67655 Kaiserslautern<br />
|
||||
Telefon: 0631 31165201 · E-Mail: <a href="mailto:aminbahmadi28@gmail.com">aminbahmadi28@gmail.com</a>
|
||||
</p>
|
||||
|
||||
<h2>Auf einen Blick</h2>
|
||||
<p>
|
||||
Diese Website setzt keine Cookies, verwendet keine Analyse- oder Tracking-Dienste und bindet keine Inhalte von Drittanbietern ein.
|
||||
Schriftarten und Bilder werden direkt von unserem Server geladen.
|
||||
</p>
|
||||
|
||||
<h2>Hosting und Server-Logfiles</h2>
|
||||
<p>
|
||||
Die Website wird bei [Hosting-Anbieter, Anschrift] betrieben. Beim Aufruf werden technisch notwendige Daten (IP-Adresse, Datum und
|
||||
Uhrzeit, aufgerufene Seite, Browser und Betriebssystem) in Server-Logfiles verarbeitet, um die Website sicher und stabil
|
||||
bereitzustellen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Die Logfiles werden nach [Anzahl] Tagen gelöscht.
|
||||
</p>
|
||||
|
||||
<h2>Kontakt per Telefon oder E-Mail</h2>
|
||||
<p>
|
||||
Wenn Sie uns kontaktieren, verarbeiten wir Ihre Angaben ausschließlich zur Bearbeitung Ihrer Anfrage
|
||||
(Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO). Die Daten werden gelöscht, sobald sie nicht mehr erforderlich sind.
|
||||
</p>
|
||||
|
||||
<h2>Online-Terminbuchung über Setmore</h2>
|
||||
<p>
|
||||
Für die Terminbuchung nutzen wir den externen Dienst Setmore. Erst wenn Sie auf „Termin buchen“ oder eine Leistung klicken,
|
||||
werden Sie zu <a href="https://aminbahmadi.setmore.com" target="_blank" rel="noopener">aminbahmadi.setmore.com</a> weitergeleitet.
|
||||
Dort gelten die <a href="https://www.setmore.com/privacy" target="_blank" rel="noopener">Datenschutzhinweise von Setmore</a>.
|
||||
</p>
|
||||
|
||||
<h2>Externe Links</h2>
|
||||
<p>
|
||||
Links zu Google Maps (Routenplanung) und Facebook sind einfache Verweise. Daten werden an diese Anbieter erst übertragen,
|
||||
wenn Sie den jeweiligen Link anklicken.
|
||||
</p>
|
||||
|
||||
<h2>Ihre Rechte</h2>
|
||||
<p>
|
||||
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit sowie Widerspruch
|
||||
(Art. 15–21 DSGVO). Wenden Sie sich dazu einfach an die oben genannten Kontaktdaten. Außerdem können Sie sich bei einer
|
||||
Datenschutz-Aufsichtsbehörde beschweren, etwa beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit
|
||||
Rheinland-Pfalz.
|
||||
</p>
|
||||
|
||||
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-ash">Stand: September 2026</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Impressum · Amin B. Ahmadi Friseur Salon</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" src="/src/legal.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="legal mx-auto max-w-2xl px-5 py-16 text-bone/70 md:py-24">
|
||||
<a href="./" class="font-mono text-xs uppercase tracking-[0.25em] text-ash no-underline hover:text-bone">← Zur Startseite</a>
|
||||
<h1 class="mt-10 font-serif text-6xl leading-none text-bone md:text-7xl">Impressum</h1>
|
||||
|
||||
<h2>Angaben gemäß § 5 DDG</h2>
|
||||
<p>
|
||||
Amin B. Ahmadi Friseur Salon<br />
|
||||
Inhaber: [vollständiger Name]<br />
|
||||
Richard-Wagner-Straße 10<br />
|
||||
67655 Kaiserslautern
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>
|
||||
Telefon: <a href="tel:+4963131165201">0631 31165201</a><br />
|
||||
E-Mail: <a href="mailto:aminbahmadi28@gmail.com">aminbahmadi28@gmail.com</a>
|
||||
</p>
|
||||
|
||||
<h2>Umsatzsteuer</h2>
|
||||
<p>Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: [falls vorhanden eintragen]</p>
|
||||
|
||||
<h2>Berufsrechtliche Angaben</h2>
|
||||
<p>
|
||||
Berufsbezeichnung: [z. B. Friseurmeister/in] (verliehen in Deutschland)<br />
|
||||
Zuständige Kammer: Handwerkskammer der Pfalz, Am Altenhof 15, 67655 Kaiserslautern<br />
|
||||
Eintragung in der Handwerksrolle: [Nummer eintragen]<br />
|
||||
Berufsrechtliche Regelung: Handwerksordnung (HwO), einsehbar unter
|
||||
<a href="https://www.gesetze-im-internet.de/hwo/" target="_blank" rel="noopener">gesetze-im-internet.de</a>
|
||||
</p>
|
||||
|
||||
<h2>Verbraucherstreitbeilegung</h2>
|
||||
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Amin B. Ahmadi · Barber & Friseur in Kaiserslautern</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Barber & Friseur in Kaiserslautern: Herrenschnitt, Bart, Damen, Farbe & Beauty. Mo–Sa 9–19 Uhr, Richard-Wagner-Straße 10. Jetzt online Termin buchen."
|
||||
/>
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<meta property="og:site_name" content="Amin B. Ahmadi Friseur Salon" />
|
||||
<meta property="og:title" content="Amin B. Ahmadi · Barber & Friseur in Kaiserslautern" />
|
||||
<meta property="og:description" content="Schnitt, Bart & Farbe in Kaiserslautern. Jetzt online Termin buchen." />
|
||||
<meta property="og:image" content="%VITE_SITE_URL%/og.jpg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HairSalon",
|
||||
"name": "Amin B. Ahmadi Friseur Salon",
|
||||
"telephone": "+49 631 31165201",
|
||||
"email": "aminbahmadi28@gmail.com",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Richard-Wagner-Straße 10",
|
||||
"postalCode": "67655",
|
||||
"addressLocality": "Kaiserslautern",
|
||||
"addressRegion": "Rheinland-Pfalz",
|
||||
"addressCountry": "DE"
|
||||
},
|
||||
"geo": { "@type": "GeoCoordinates", "latitude": 49.4431737, "longitude": 7.7656361 },
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
|
||||
"opens": "09:00",
|
||||
"closes": "19:00"
|
||||
}
|
||||
],
|
||||
"priceRange": "€€",
|
||||
"currenciesAccepted": "EUR",
|
||||
"sameAs": ["https://facebook.com/amin_b_ahmadi"],
|
||||
"potentialAction": {
|
||||
"@type": "ReserveAction",
|
||||
"target": "https://aminbahmadi.setmore.com/book"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<div style="max-width: 40rem; margin: 0 auto; padding: 4rem 1.5rem; font-family: system-ui, sans-serif; color: #f2ede7; line-height: 1.6">
|
||||
<h1>Amin B. Ahmadi – Barber & Friseur in Kaiserslautern</h1>
|
||||
<p>Richard-Wagner-Straße 10, 67655 Kaiserslautern · Mo–Sa 9–19 Uhr</p>
|
||||
<p><a style="color: #e5cfbb" href="https://aminbahmadi.setmore.com/book">Jetzt online Termin buchen</a> · <a style="color: #e5cfbb" href="tel:+4963131165201">0631 31165201</a></p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "amin-b-ahmadi-website",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@fontsource/instrument-serif": "^5.3.0",
|
||||
"lenis": "^1.3.26",
|
||||
"motion": "^13.4.5",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0a0a0a"/>
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke="#e6d2bf" stroke-width="1.5"/>
|
||||
<path fill="#e6d2bf" d="M19.32 53Q18.49 53 18.49 52.45Q18.49 51.9 19.26 51.74L20.08 51.58Q21.18 51.36 21.73 50.51Q22.27 49.66 22.66 48.12L31.15 13.88Q31.26 13.33 31.51 13.16Q31.75 13 31.97 13Q32.25 13 32.49 13.16Q32.74 13.33 32.85 13.88L41.51 48.62Q41.89 50.15 42.38 50.75Q42.88 51.36 43.92 51.58L44.74 51.74Q45.51 51.9 45.51 52.45Q45.51 53 44.68 53H35.32Q34.55 53 34.55 52.4Q34.55 51.79 35.48 51.74L36.52 51.63Q37.45 51.52 37.7 51.03Q37.95 50.53 37.67 49.33L35.59 40.84Q35.26 39.41 33.78 39.41H28.03Q26.6 39.41 26.22 40.84L24.36 48.18Q23.53 51.3 26 51.63L26.82 51.74Q27.75 51.9 27.75 52.4Q27.75 53 26.99 53ZM27.86 37.77H33.89Q34.77 37.77 34.6 36.84L31.1 22.15H30.82L27.15 36.84Q26.99 37.77 27.86 37.77Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 916 B |
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,44 @@
|
||||
import Lenis from 'lenis'
|
||||
import { MotionConfig } from 'motion/react'
|
||||
import { useEffect } from 'react'
|
||||
import { FinalCta } from './components/FinalCta'
|
||||
import { Footer } from './components/Footer'
|
||||
import { Hero } from './components/Hero'
|
||||
import { Marquee } from './components/Marquee'
|
||||
import { MobileBookBar } from './components/MobileBookBar'
|
||||
import { Nav } from './components/Nav'
|
||||
import { Services } from './components/Services'
|
||||
import { Team } from './components/Team'
|
||||
import { Visit } from './components/Visit'
|
||||
|
||||
export default function App() {
|
||||
// Weiches Scrollen (Lenis) — nur, wenn keine reduzierte Bewegung gewünscht ist
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
const lenis = new Lenis({ autoRaf: true, anchors: true })
|
||||
return () => lenis.destroy()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-full focus:bg-bone focus:px-4 focus:py-2 focus:text-ink"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<Nav />
|
||||
<main>
|
||||
<Hero />
|
||||
<Marquee />
|
||||
<Services />
|
||||
<Team />
|
||||
<Visit />
|
||||
<FinalCta />
|
||||
</main>
|
||||
<Footer />
|
||||
<MobileBookBar />
|
||||
<div aria-hidden="true" className="grain pointer-events-none fixed inset-0 z-[70]" />
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="#fff">
|
||||
<!-- Platzhalter-Logo: durch das echte Logo (SVG) ersetzen. -->
|
||||
<circle cx="100" cy="100" r="97" fill="none" stroke="#fff" stroke-width="3"/>
|
||||
<circle cx="100" cy="100" r="88" fill="none" stroke="#fff" stroke-width="1"/>
|
||||
<path d="M70.82 146Q68.93 146 68.93 144.74Q68.93 143.48 70.7 143.1L72.59 142.72Q75.11 142.22 76.37 140.27Q77.63 138.31 78.51 134.78L98.05 56.02Q98.3 54.76 98.87 54.38Q99.43 54 99.94 54Q100.57 54 101.13 54.38Q101.7 54.76 101.95 56.02L121.87 135.92Q122.75 139.45 123.88 140.83Q125.02 142.22 127.41 142.72L129.3 143.1Q131.07 143.48 131.07 144.74Q131.07 146 129.18 146H107.62Q105.86 146 105.86 144.61Q105.86 143.23 108 143.1L110.4 142.85Q112.54 142.6 113.11 141.46Q113.67 140.33 113.04 137.56L108.25 118.02Q107.5 114.75 104.1 114.75H90.86Q87.59 114.75 86.7 118.02L82.42 134.91Q80.53 142.09 86.2 142.85L88.09 143.1Q90.23 143.48 90.23 144.61Q90.23 146 88.47 146ZM90.48 110.96H104.35Q106.36 110.96 105.99 108.82L97.92 75.05H97.29L88.85 108.82Q88.47 110.96 90.48 110.96Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,42 @@
|
||||
import { BOOKING_URL } from '../data/salon'
|
||||
import { cn } from '../lib/cn'
|
||||
import { ArrowUpRight } from './Icons'
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode
|
||||
href?: string
|
||||
variant?: 'solid' | 'ghost'
|
||||
size?: 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Pill-Button mit „rollendem“ Pfeil — führt standardmäßig zur Online-Buchung. */
|
||||
export function BookButton({ children = 'Termin buchen', href = BOOKING_URL, variant = 'solid', size = 'md', className }: Props) {
|
||||
const external = href.startsWith('http')
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(external ? { target: '_blank', rel: 'noopener' } : {})}
|
||||
className={cn(
|
||||
'group relative inline-flex items-center gap-3 rounded-full font-medium tracking-tight transition-colors duration-300',
|
||||
size === 'lg' ? 'h-14 pl-7 pr-2 text-base' : 'h-11 pl-5 pr-1.5 text-sm',
|
||||
variant === 'solid'
|
||||
? 'bg-bone text-ink hover:bg-champagne'
|
||||
: 'border border-white/15 text-bone backdrop-blur-md hover:border-white/40 hover:bg-white/5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>{children}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative grid place-items-center overflow-hidden rounded-full',
|
||||
size === 'lg' ? 'size-10' : 'size-8',
|
||||
variant === 'solid' ? 'bg-ink text-bone' : 'bg-bone text-ink',
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight className="size-4 transition-transform duration-500 ease-out-expo group-hover:translate-x-5 group-hover:-translate-y-5" />
|
||||
<ArrowUpRight className="absolute size-4 -translate-x-5 translate-y-5 transition-transform duration-500 ease-out-expo group-hover:translate-x-0 group-hover:translate-y-0" />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { motion, useMotionValue, useSpring } from 'motion/react'
|
||||
import { BOOKING_URL } from '../data/salon'
|
||||
import { useFitText } from '../lib/useFitText'
|
||||
import { ArrowUpRight } from './Icons'
|
||||
import { Logo } from './Logo'
|
||||
import { FadeIn } from './Reveal'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
|
||||
export function FinalCta() {
|
||||
const fit = useFitText<HTMLSpanElement>()
|
||||
// Kreis mit Pfeil folgt dem Mauszeiger über dem großen Link
|
||||
const x = useSpring(useMotionValue(0), { stiffness: 300, damping: 30 })
|
||||
const y = useSpring(useMotionValue(0), { stiffness: 300, damping: 30 })
|
||||
const onMove = (e: React.PointerEvent<HTMLAnchorElement>) => {
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
x.set(e.clientX - r.left)
|
||||
y.set(e.clientY - r.top)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="termin" className="relative isolate overflow-hidden border-t border-white/10">
|
||||
<Logo
|
||||
label=""
|
||||
className="pointer-events-none absolute top-1/2 -right-[12%] -z-10 size-[min(90vw,52rem)] -translate-y-1/2 text-white/[0.035]"
|
||||
/>
|
||||
<div className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<SectionLabel index="04">Termin</SectionLabel>
|
||||
<FadeIn>
|
||||
<p className="max-w-sm text-ash">Leistung wählen, Profi wählen, Uhrzeit wählen. Online — rund um die Uhr.</p>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={BOOKING_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onPointerMove={onMove}
|
||||
className="group relative mt-10 block cursor-pointer md:mt-16 md:cursor-none"
|
||||
>
|
||||
<span ref={fit} className="cta-text block w-max pr-[0.06em] font-serif leading-[0.9] tracking-[-0.035em]">
|
||||
Termin
|
||||
<br className="md:hidden" /> <em>buchen</em>
|
||||
</span>
|
||||
<motion.span aria-hidden="true" style={{ x, y }} className="pointer-events-none absolute top-0 left-0 hidden md:block">
|
||||
<span className="grid size-28 -translate-x-1/2 -translate-y-1/2 scale-0 place-items-center rounded-full bg-champagne text-ink transition-transform duration-500 ease-out-expo group-hover:scale-100">
|
||||
<ArrowUpRight className="size-8" />
|
||||
</span>
|
||||
</motion.span>
|
||||
<span className="mt-8 inline-flex items-center gap-2 text-sm text-bone/70 md:hidden">
|
||||
Jetzt online buchen <ArrowUpRight className="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { salon } from '../data/salon'
|
||||
import { Facebook } from './Icons'
|
||||
import { Logo } from './Logo'
|
||||
|
||||
const YEAR = new Date().getFullYear()
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-white/10">
|
||||
<div className="mx-auto flex max-w-[1600px] flex-col gap-10 px-5 pt-12 pb-28 md:flex-row md:items-end md:justify-between md:px-10 md:pb-12">
|
||||
<div className="flex items-center gap-4">
|
||||
<Logo className="size-12 text-bone" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{salon.legalName}</p>
|
||||
<p className="text-ash">
|
||||
{salon.street}, {salon.zip} {salon.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-ash">
|
||||
<a href="impressum.html" className="transition-colors hover:text-bone">
|
||||
Impressum
|
||||
</a>
|
||||
<a href="datenschutz.html" className="transition-colors hover:text-bone">
|
||||
Datenschutz
|
||||
</a>
|
||||
<a
|
||||
href={salon.facebook}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Facebook"
|
||||
className="grid size-9 place-items-center rounded-full border border-white/15 transition-colors hover:border-white/40 hover:text-bone"
|
||||
>
|
||||
<Facebook className="size-4" />
|
||||
</a>
|
||||
<span className="font-mono text-xs">© {YEAR}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { motion, useScroll, useTransform } from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
import { salon } from '../data/salon'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
import { useFitText } from '../lib/useFitText'
|
||||
import { useOpenStatus } from '../lib/useOpenStatus'
|
||||
import { BookButton } from './BookButton'
|
||||
import { ArrowDown } from './Icons'
|
||||
import { RevealChars } from './Reveal'
|
||||
import { RotatingBadge } from './RotatingBadge'
|
||||
import { SilkBackground } from './SilkBackground'
|
||||
import { StatusPill } from './StatusPill'
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 24, filter: 'blur(10px)' },
|
||||
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
|
||||
transition: { duration: 1.2, delay, ease: EASE_OUT_EXPO },
|
||||
})
|
||||
|
||||
export function Hero() {
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
const status = useOpenStatus()
|
||||
const wordmark = useFitText<HTMLSpanElement>()
|
||||
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] })
|
||||
const contentY = useTransform(scrollYProgress, [0, 1], ['0%', '18%'])
|
||||
const contentOpacity = useTransform(scrollYProgress, [0, 0.75], [1, 0])
|
||||
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15])
|
||||
|
||||
return (
|
||||
<section id="top" ref={ref} className="relative isolate flex h-svh min-h-[640px] flex-col overflow-hidden">
|
||||
<motion.div style={{ scale: bgScale }} className="absolute inset-0 -z-20 bg-[radial-gradient(120%_80%_at_70%_20%,#2a2724_0%,#0a0a0a_70%)]">
|
||||
<SilkBackground />
|
||||
</motion.div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 bg-[radial-gradient(90%_70%_at_50%_40%,transparent_0%,rgba(10,10,10,0.55)_100%),linear-gradient(to_bottom,rgba(10,10,10,0.5)_0%,transparent_30%,transparent_55%,#0a0a0a_100%)]"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
style={{ y: contentY, opacity: contentOpacity }}
|
||||
className="mx-auto flex w-full max-w-[1600px] flex-1 flex-col justify-end px-5 pb-6 md:px-10 md:pb-10"
|
||||
>
|
||||
<div className="flex items-end justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<motion.div {...fadeUp(0.5)}>
|
||||
<StatusPill status={status} />
|
||||
</motion.div>
|
||||
<motion.p {...fadeUp(0.6)} className="mt-6 font-serif text-[2.6rem] leading-[0.95] tracking-tight sm:text-6xl lg:text-7xl">
|
||||
Barber & Friseur
|
||||
<br />
|
||||
<em className="text-bone/55">in {salon.city}.</em>
|
||||
</motion.p>
|
||||
<motion.div {...fadeUp(0.75)} className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<BookButton size="lg" />
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="group inline-flex items-center gap-2 text-sm text-bone/70 transition-colors hover:text-bone"
|
||||
>
|
||||
Preise ansehen
|
||||
<ArrowDown className="size-4 transition-transform duration-500 ease-out-expo group-hover:translate-y-1" />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div {...fadeUp(0.9)} className="hidden md:block">
|
||||
<RotatingBadge />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-10 md:mt-12">
|
||||
<span className="sr-only">
|
||||
{salon.name} – Barber & Friseur in {salon.city}
|
||||
</span>
|
||||
<span ref={wordmark} className="block w-max font-serif leading-[0.82] tracking-[-0.035em]">
|
||||
<RevealChars
|
||||
charClassName="chrome-text"
|
||||
words={[{ text: 'Amin' }, { text: 'B.', className: 'italic', mobileBreak: true }, { text: 'Ahmadi' }]}
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>
|
||||
|
||||
const base = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.6,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
} as const
|
||||
|
||||
export const ArrowUpRight = (p: IconProps) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M7 17 17 7M8 7h9v9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ArrowDown = (p: IconProps) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M12 5v14M6 13l6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Phone = (p: IconProps) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M5 4h3.5l1.8 4.5-2.3 1.4a11 11 0 0 0 6.1 6.1l1.4-2.3L20 15.5V19a1.5 1.5 0 0 1-1.6 1.5A16.5 16.5 0 0 1 3.5 5.6 1.5 1.5 0 0 1 5 4Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Pin = (p: IconProps) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z" />
|
||||
<circle cx="12" cy="10" r="2.3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Mail = (p: IconProps) => (
|
||||
<svg {...base} {...p}>
|
||||
<rect x="3.5" y="5.5" width="17" height="13" rx="2" />
|
||||
<path d="m4 7 8 6 8-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Facebook = (p: IconProps) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M14.5 8.5H16V5.2a17 17 0 0 0-2.3-.2c-2.3 0-3.8 1.4-3.8 4v2.4H7.4v3.7h2.5V21h3.1v-5.9h2.5l.4-3.7H13V9.3c0-.5.3-.8 1.5-.8Z" />
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import logoUrl from '../assets/logo.svg'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
/**
|
||||
* Das Logo wird als Maske gerendert und übernimmt so die Textfarbe (bzw. einen Verlauf).
|
||||
* Zum Austauschen einfach `src/assets/logo.svg` ersetzen — die Farbe im SVG spielt keine Rolle,
|
||||
* nur der Hintergrund muss transparent sein.
|
||||
*/
|
||||
export function Logo({ className, label = 'Amin B. Ahmadi' }: { className?: string; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
{...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
|
||||
className={cn('inline-block shrink-0 bg-current', className)}
|
||||
style={{
|
||||
maskImage: `url("${logoUrl}")`,
|
||||
WebkitMaskImage: `url("${logoUrl}")`,
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Magnet-Effekt, inspiriert von „Magnet“ aus React Bits (reactbits.dev).
|
||||
import { motion, useMotionValue, useReducedMotion, useSpring } from 'motion/react'
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
type Props = { children: ReactNode; strength?: number; className?: string }
|
||||
|
||||
export function Magnetic({ children, strength = 0.35, className }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const reduce = useReducedMotion()
|
||||
const x = useSpring(useMotionValue(0), { stiffness: 220, damping: 18, mass: 0.4 })
|
||||
const y = useSpring(useMotionValue(0), { stiffness: 220, damping: 18, mass: 0.4 })
|
||||
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
if (reduce || e.pointerType !== 'mouse' || !ref.current) return
|
||||
const r = ref.current.getBoundingClientRect()
|
||||
x.set((e.clientX - (r.left + r.width / 2)) * strength)
|
||||
y.set((e.clientY - (r.top + r.height / 2)) * strength)
|
||||
}
|
||||
const reset = () => {
|
||||
x.set(0)
|
||||
y.set(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
onPointerMove={onMove}
|
||||
onPointerLeave={reset}
|
||||
style={{ x, y }}
|
||||
className={cn('inline-block', className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Laufband, das auf Scroll-Geschwindigkeit reagiert — adaptiert von „Scroll Velocity“ aus React Bits (reactbits.dev).
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
useVelocity,
|
||||
} from 'motion/react'
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
const wrap = (min: number, max: number, v: number) => {
|
||||
const range = max - min
|
||||
return ((((v - min) % range) + range) % range) + min
|
||||
}
|
||||
|
||||
function VelocityRow({ children, baseVelocity, className }: { children: ReactNode; baseVelocity: number; className?: string }) {
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const copy = useRef<HTMLSpanElement>(null)
|
||||
const [copyWidth, setCopyWidth] = useState(0)
|
||||
const inView = useInView(container)
|
||||
const reduce = useReducedMotion()
|
||||
|
||||
const baseX = useMotionValue(0)
|
||||
const { scrollY } = useScroll()
|
||||
const velocity = useSpring(useVelocity(scrollY), { damping: 50, stiffness: 400 })
|
||||
const factor = useTransform(velocity, [0, 1000], [0, 4], { clamp: false })
|
||||
const direction = useRef(1)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = copy.current
|
||||
if (!el) return
|
||||
const measure = () => setCopyWidth(el.offsetWidth)
|
||||
measure()
|
||||
document.fonts?.ready.then(measure)
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const x = useTransform(baseX, (v) => (copyWidth ? `${wrap(-copyWidth, 0, v)}px` : '0px'))
|
||||
|
||||
useAnimationFrame((_, delta) => {
|
||||
if (!inView || reduce) return
|
||||
const f = factor.get()
|
||||
if (f < 0) direction.current = -1
|
||||
else if (f > 0) direction.current = 1
|
||||
let moveBy = direction.current * baseVelocity * (delta / 1000)
|
||||
moveBy += direction.current * moveBy * f
|
||||
baseX.set(baseX.get() + moveBy)
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={container} className="overflow-hidden">
|
||||
<motion.div className={cn('flex whitespace-nowrap', className)} style={{ x }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<span key={i} ref={i === 0 ? copy : undefined} className="flex shrink-0 items-center">
|
||||
{children}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Star = () => (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="mx-[0.35em] size-[0.4em] shrink-0 fill-champagne">
|
||||
<path d="M12 0c.6 6.4 5.6 11.4 12 12-6.4.6-11.4 5.6-12 12-.6-6.4-5.6-11.4-12-12C6.4 11.4 11.4 6.4 12 0Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const row = (words: string[]) =>
|
||||
words.map((w) => (
|
||||
<span key={w} className="flex items-center">
|
||||
{w}
|
||||
<Star />
|
||||
</span>
|
||||
))
|
||||
|
||||
export function Marquee() {
|
||||
return (
|
||||
<section aria-hidden="true" className="relative -mt-px border-y border-white/10 bg-ink py-6 md:py-10">
|
||||
<VelocityRow baseVelocity={-40} className="font-serif text-6xl italic leading-none tracking-tight md:text-8xl">
|
||||
{row(['Haarschnitt', 'Bart', 'Nassrasur', 'Styling', 'Farbe', 'Balayage', 'Make-up'])}
|
||||
</VelocityRow>
|
||||
<VelocityRow
|
||||
baseVelocity={30}
|
||||
className="mt-2 font-mono text-sm uppercase tracking-[0.3em] text-ash md:mt-4 md:text-base"
|
||||
>
|
||||
{row(['Kaiserslautern', 'Mo – Sa 9 – 19 Uhr', 'Online buchen', 'Herren', 'Damen', 'Kinder'])}
|
||||
</VelocityRow>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { BOOKING_URL, salon } from '../data/salon'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
import { ArrowUpRight, Phone } from './Icons'
|
||||
|
||||
/** Fixierte Buchungsleiste am unteren Rand — nur auf dem Smartphone, sobald der Hero verlassen wurde. */
|
||||
export function MobileBookBar() {
|
||||
const { scrollY } = useScroll()
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useMotionValueEvent(scrollY, 'change', (y) => {
|
||||
const cta = document.getElementById('termin')
|
||||
const ctaVisible = cta ? cta.getBoundingClientRect().top < window.innerHeight : false
|
||||
setShow(y > window.innerHeight * 0.8 && !ctaVisible)
|
||||
})
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ y: '130%' }}
|
||||
animate={{ y: '0%' }}
|
||||
exit={{ y: '130%' }}
|
||||
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
|
||||
className="fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-40 md:hidden"
|
||||
>
|
||||
<div className="flex gap-1.5 rounded-full border border-white/10 bg-ink/75 p-1.5 shadow-2xl shadow-black/60 backdrop-blur-xl">
|
||||
<a
|
||||
href={BOOKING_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-full bg-bone font-medium text-ink"
|
||||
>
|
||||
Termin buchen
|
||||
<ArrowUpRight className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href={`tel:${salon.phone}`}
|
||||
aria-label={`Anrufen: ${salon.phoneDisplay}`}
|
||||
className="grid size-12 place-items-center rounded-full border border-white/15"
|
||||
>
|
||||
<Phone className="size-5" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { motion, useMotionValueEvent, useScroll } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '../lib/cn'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
import { BookButton } from './BookButton'
|
||||
import { Logo } from './Logo'
|
||||
|
||||
const links = [
|
||||
{ href: '#leistungen', label: 'Preise' },
|
||||
{ href: '#team', label: 'Team' },
|
||||
{ href: '#kontakt', label: 'Kontakt' },
|
||||
]
|
||||
|
||||
export function Nav() {
|
||||
const { scrollY } = useScroll()
|
||||
const [hidden, setHidden] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
useMotionValueEvent(scrollY, 'change', (y) => {
|
||||
const previous = scrollY.getPrevious() ?? 0
|
||||
setScrolled(y > 24)
|
||||
setHidden(y > previous && y > 480)
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: '-100%' }}
|
||||
animate={{ y: hidden ? '-100%' : '0%' }}
|
||||
transition={{ duration: 0.7, ease: EASE_OUT_EXPO }}
|
||||
className="fixed inset-x-0 top-0 z-50"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -bottom-6 bg-linear-to-b from-ink/90 via-ink/60 to-transparent transition-opacity duration-500',
|
||||
scrolled ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div className="relative mx-auto flex max-w-[1600px] items-center justify-between gap-4 px-5 py-4 md:px-10 md:py-5">
|
||||
<a href="#top" className="flex items-center gap-3" aria-label="Amin B. Ahmadi – zum Seitenanfang">
|
||||
<Logo className="size-10 text-bone" label="" />
|
||||
<span className="hidden text-sm font-medium tracking-tight sm:block">
|
||||
Amin B. Ahmadi
|
||||
<span className="block font-mono text-[10px] font-normal uppercase tracking-[0.2em] text-ash">Barber · Friseur</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav aria-label="Hauptnavigation" className="absolute left-1/2 hidden -translate-x-1/2 md:block">
|
||||
<ul className="flex items-center gap-1 rounded-full border border-white/10 bg-ink/40 p-1 backdrop-blur-xl">
|
||||
{links.map((l) => (
|
||||
<li key={l.href}>
|
||||
<a
|
||||
href={l.href}
|
||||
className="block rounded-full px-4 py-2 text-sm text-bone/70 transition-colors hover:bg-white/8 hover:text-bone"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<BookButton />
|
||||
</div>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Text-Reveals im Stil von „Split Text“ / „Blur Text“ aus React Bits (reactbits.dev).
|
||||
import { motion, type Variants } from 'motion/react'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import { cn } from '../lib/cn'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
|
||||
const tags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p, div: motion.div }
|
||||
|
||||
const lineVariants: Variants = {
|
||||
hidden: { y: '110%' },
|
||||
show: { y: '0%', transition: { duration: 1.1, ease: EASE_OUT_EXPO } },
|
||||
}
|
||||
|
||||
/** Zeilen gleiten nacheinander aus einer Maske nach oben — ausgelöst beim Scrollen. */
|
||||
export function RevealLines({
|
||||
lines,
|
||||
as = 'h2',
|
||||
className,
|
||||
delay = 0,
|
||||
}: {
|
||||
lines: ReactNode[]
|
||||
as?: keyof typeof tags
|
||||
className?: string
|
||||
delay?: number
|
||||
}) {
|
||||
const Tag = tags[as]
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.09, delayChildren: delay } } }}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i} className="-mb-[0.12em] block overflow-hidden pr-[0.08em] pb-[0.12em]">
|
||||
<motion.span className="block" variants={lineVariants}>
|
||||
{line}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
/** Einzelne Buchstaben steigen gestaffelt auf — für die große Wortmarke im Hero. */
|
||||
export function RevealChars({
|
||||
words,
|
||||
className,
|
||||
charClassName,
|
||||
delay = 0.15,
|
||||
stagger = 0.035,
|
||||
}: {
|
||||
/** `mobileBreak`: auf kleinen Bildschirmen nach diesem Wort umbrechen */
|
||||
words: { text: string; className?: string; mobileBreak?: boolean }[]
|
||||
className?: string
|
||||
/** wird auf jeden Buchstaben angewendet (z. B. Verlauf per background-clip) */
|
||||
charClassName?: string
|
||||
delay?: number
|
||||
stagger?: number
|
||||
}) {
|
||||
let index = 0
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={cn('inline-block whitespace-nowrap', className)}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } } }}
|
||||
>
|
||||
{words.map((word, w) => (
|
||||
<Fragment key={w}>
|
||||
<span className={cn('inline-block', word.className)}>
|
||||
{Array.from(word.text).map((char) => (
|
||||
<span key={index++} className="-mb-[0.1em] inline-block overflow-hidden pb-[0.1em] align-top">
|
||||
<motion.span
|
||||
className={cn('inline-block', charClassName)}
|
||||
variants={{
|
||||
hidden: { y: '105%', rotate: 6 },
|
||||
show: { y: '0%', rotate: 0, transition: { duration: 1.2, ease: EASE_OUT_EXPO } },
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{w < words.length - 1 && (
|
||||
<span className={cn('w-[0.2em]', word.mobileBreak ? 'hidden md:inline-block' : 'inline-block')} />
|
||||
)}
|
||||
{word.mobileBreak && <br className="md:hidden" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Sanftes Einblenden mit Unschärfe beim Scrollen. */
|
||||
export function FadeIn({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
y = 24,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
y?: number
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y, filter: 'blur(8px)' }}
|
||||
whileInView={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 1, delay, ease: EASE_OUT_EXPO }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Rotierender Kreistext, inspiriert von „Circular Text“ aus React Bits (reactbits.dev).
|
||||
import { useId } from 'react'
|
||||
import { BOOKING_URL } from '../data/salon'
|
||||
import { cn } from '../lib/cn'
|
||||
import { ArrowUpRight } from './Icons'
|
||||
import { Magnetic } from './Magnetic'
|
||||
|
||||
const RADIUS = 78
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
export function RotatingBadge({ className, text = 'Termin buchen • Online • 24/7 • ' }: { className?: string; text?: string }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<Magnetic strength={0.25} className={className}>
|
||||
<a
|
||||
href={BOOKING_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Online Termin buchen"
|
||||
className="group relative grid size-40 place-items-center rounded-full text-bone"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full animate-spin-slow group-hover:[animation-duration:7s] motion-reduce:animate-none"
|
||||
>
|
||||
<defs>
|
||||
<path id={id} d={`M100,100 m-${RADIUS},0 a${RADIUS},${RADIUS} 0 1,1 ${RADIUS * 2},0 a${RADIUS},${RADIUS} 0 1,1 -${RADIUS * 2},0`} />
|
||||
</defs>
|
||||
<text className="fill-current font-mono text-[15px] uppercase">
|
||||
<textPath href={`#${id}`} textLength={CIRCUMFERENCE - 2} lengthAdjust="spacing">
|
||||
{text}
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
<span
|
||||
className={cn(
|
||||
'grid size-[4.5rem] place-items-center rounded-full bg-bone text-ink transition-all duration-500 ease-out-expo',
|
||||
'group-hover:scale-110 group-hover:bg-champagne',
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight className="size-6 transition-transform duration-500 ease-out-expo group-hover:rotate-45" />
|
||||
</span>
|
||||
</a>
|
||||
</Magnetic>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
export function SectionLabel({ index, children, className }: { index: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<p className={cn('flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.25em] text-ash', className)}>
|
||||
<span className="text-champagne">({index})</span>
|
||||
<span aria-hidden="true" className="h-px w-8 bg-white/20" />
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { serviceUrl, services, type Service } from '../data/salon'
|
||||
import { cn } from '../lib/cn'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
import { ArrowUpRight } from './Icons'
|
||||
import { FadeIn, RevealLines } from './Reveal'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
|
||||
const rowVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: EASE_OUT_EXPO } },
|
||||
}
|
||||
|
||||
function ServiceRow({ service: s }: { service: Service }) {
|
||||
return (
|
||||
<motion.li variants={rowVariants}>
|
||||
<a
|
||||
href={serviceUrl(s)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="group relative flex items-center gap-4 border-b border-white/10 py-5 md:gap-6 md:py-6"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 origin-bottom scale-y-0 bg-linear-to-r from-white/[0.06] to-transparent transition-transform duration-500 ease-out-expo group-hover:scale-y-100"
|
||||
/>
|
||||
<span className="relative min-w-0 flex-1 md:flex md:items-center md:gap-6">
|
||||
<span className="block font-serif text-[1.7rem] leading-tight transition-transform duration-500 ease-out-expo group-hover:translate-x-2 md:w-1/2 md:text-4xl">
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-ash md:mt-0 md:flex-1">
|
||||
{s.note}
|
||||
<span className="md:hidden">
|
||||
{s.note && ' · '}
|
||||
{s.minutes} Min.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="relative hidden w-20 font-mono text-sm text-ash md:block">{s.minutes} Min.</span>
|
||||
<span className="relative whitespace-nowrap text-right font-mono text-lg md:w-28 md:text-xl">
|
||||
{s.from && <span className="mr-1 text-sm text-ash">ab</span>}
|
||||
{s.price} €
|
||||
</span>
|
||||
<span className="relative hidden size-11 shrink-0 place-items-center rounded-full border border-white/15 transition-colors duration-300 group-hover:border-bone group-hover:bg-bone group-hover:text-ink md:grid">
|
||||
<ArrowUpRight className="size-4 transition-transform duration-500 ease-out-expo group-hover:rotate-45" />
|
||||
</span>
|
||||
<span className="sr-only">– jetzt buchen</span>
|
||||
</a>
|
||||
</motion.li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Services() {
|
||||
const [active, setActive] = useState(services[0].key)
|
||||
const tabs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const category = services.find((c) => c.key === active) ?? services[0]
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent, i: number) => {
|
||||
const dir = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
|
||||
if (!dir) return
|
||||
e.preventDefault()
|
||||
const next = (i + dir + services.length) % services.length
|
||||
setActive(services[next].key)
|
||||
tabs.current[next]?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="leistungen" className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
|
||||
<SectionLabel index="01">Leistungen & Preise</SectionLabel>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between">
|
||||
<RevealLines
|
||||
className="font-serif text-6xl leading-[0.9] tracking-tight md:text-8xl lg:text-9xl"
|
||||
lines={['Klarer Schnitt.', <em key="preis" className="text-bone/45">Klarer Preis.</em>]}
|
||||
/>
|
||||
|
||||
<FadeIn>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Kategorien"
|
||||
className="flex flex-wrap gap-2 md:w-max md:gap-1 md:rounded-full md:border md:border-white/10 md:bg-white/[0.03] md:p-1"
|
||||
>
|
||||
{services.map((c, i) => {
|
||||
const selected = c.key === active
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
ref={(el) => {
|
||||
tabs.current[i] = el
|
||||
}}
|
||||
role="tab"
|
||||
id={`tab-${c.key}`}
|
||||
aria-selected={selected}
|
||||
aria-controls="leistungen-panel"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => setActive(c.key)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
className={cn(
|
||||
'relative cursor-pointer rounded-full border px-4 py-2.5 text-sm whitespace-nowrap transition-colors duration-300 md:border-transparent md:px-5',
|
||||
selected ? 'border-transparent text-ink' : 'border-white/10 text-bone/65 hover:text-bone',
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<motion.span
|
||||
layoutId="tab-pill"
|
||||
className="absolute inset-0 rounded-full bg-bone"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{c.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div id="leistungen-panel" role="tabpanel" aria-labelledby={`tab-${category.key}`} className="mt-12 border-t border-white/10 md:mt-16">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.ul
|
||||
key={category.key}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.04 } } }}
|
||||
>
|
||||
{category.items.map((s) => (
|
||||
<ServiceRow key={s.id} service={s} />
|
||||
))}
|
||||
</motion.ul>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-xl text-sm text-ash">
|
||||
„ab“-Preise je nach Haarlänge und Aufwand. Ein Klick auf eine Leistung öffnet direkt die Online-Buchung.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Animierter Seiden-Hintergrund (WebGL).
|
||||
* Shader adaptiert von „Silk“ aus React Bits — https://reactbits.dev
|
||||
* Copyright (c) 2026 David Haz — MIT + Commons Clause License
|
||||
* Hier ohne three.js umgesetzt (reines WebGL), damit die Seite leicht bleibt.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
const VERTEX = /* glsl */ `
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = position * 0.5 + 0.5;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT = /* glsl */ `
|
||||
precision highp float;
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uSpeed;
|
||||
uniform float uScale;
|
||||
uniform float uRotation;
|
||||
uniform float uNoiseIntensity;
|
||||
uniform float uAspect;
|
||||
uniform vec2 uPointer;
|
||||
|
||||
const float e = 2.71828182845904523536;
|
||||
|
||||
float noise(vec2 texCoord) {
|
||||
float G = e;
|
||||
vec2 r = (G * sin(G * texCoord));
|
||||
return fract(r.x * r.y * (1.0 + texCoord.x));
|
||||
}
|
||||
|
||||
vec2 rotateUvs(vec2 uv, float angle) {
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
return mat2(c, -s, s, c) * uv;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float rnd = noise(gl_FragCoord.xy);
|
||||
vec2 base = vec2(vUv.x * min(uAspect, 1.6), vUv.y);
|
||||
vec2 uv = rotateUvs(base * uScale, uRotation);
|
||||
vec2 tex = uv * uScale;
|
||||
float tOffset = uSpeed * uTime;
|
||||
|
||||
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
|
||||
|
||||
float pattern = 0.6 +
|
||||
0.4 * sin(5.0 * (tex.x + tex.y +
|
||||
cos(3.0 * tex.x + 5.0 * tex.y) +
|
||||
0.02 * tOffset) +
|
||||
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
|
||||
|
||||
// weiches Licht, das dem Zeiger folgt
|
||||
float glow = smoothstep(0.75, 0.0, distance(vUv * vec2(uAspect, 1.0), uPointer * vec2(uAspect, 1.0)));
|
||||
|
||||
vec3 col = uColor * pattern * (0.85 + 0.45 * glow) - vec3(rnd / 15.0 * uNoiseIntensity);
|
||||
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
type Props = {
|
||||
color?: string
|
||||
speed?: number
|
||||
scale?: number
|
||||
rotation?: number
|
||||
noiseIntensity?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const hexToRgb = (hex: string) => {
|
||||
const n = parseInt(hex.replace('#', ''), 16)
|
||||
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] as const
|
||||
}
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, source: string) {
|
||||
const shader = gl.createShader(type)!
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
return shader
|
||||
}
|
||||
|
||||
export function SilkBackground({
|
||||
color = '#5d5750',
|
||||
speed = 4,
|
||||
scale = 1,
|
||||
rotation = 0.35,
|
||||
noiseIntensity = 1.2,
|
||||
className,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, powerPreference: 'low-power' })
|
||||
if (!gl) {
|
||||
canvas.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
const program = gl.createProgram()!
|
||||
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX))
|
||||
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT))
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
canvas.style.display = 'none'
|
||||
return
|
||||
}
|
||||
gl.useProgram(program)
|
||||
|
||||
// Ein Dreieck, das den ganzen Viewport abdeckt
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
|
||||
const position = gl.getAttribLocation(program, 'position')
|
||||
gl.enableVertexAttribArray(position)
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
const u = (name: string) => gl.getUniformLocation(program, name)
|
||||
const uTime = u('uTime')
|
||||
const uAspect = u('uAspect')
|
||||
const uPointer = u('uPointer')
|
||||
gl.uniform3fv(u('uColor'), hexToRgb(color))
|
||||
gl.uniform1f(u('uSpeed'), speed)
|
||||
gl.uniform1f(u('uScale'), scale)
|
||||
gl.uniform1f(u('uRotation'), rotation)
|
||||
gl.uniform1f(u('uNoiseIntensity'), noiseIntensity)
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
// Seide ist weich — eine niedrigere Auflösung sieht gleich aus und spart Akku
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.25)
|
||||
|
||||
const resize = () => {
|
||||
const { clientWidth: w, clientHeight: h } = canvas
|
||||
canvas.width = Math.max(1, Math.round(w * dpr))
|
||||
canvas.height = Math.max(1, Math.round(h * dpr))
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.uniform1f(uAspect, w / Math.max(1, h))
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(canvas)
|
||||
|
||||
const pointer = { x: 0.7, y: 0.6, tx: 0.7, ty: 0.6 }
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
const r = canvas.getBoundingClientRect()
|
||||
pointer.tx = (e.clientX - r.left) / r.width
|
||||
pointer.ty = 1 - (e.clientY - r.top) / r.height
|
||||
}
|
||||
window.addEventListener('pointermove', onPointer, { passive: true })
|
||||
|
||||
let raf = 0
|
||||
let visible = true
|
||||
let elapsed = 0
|
||||
let last = performance.now()
|
||||
|
||||
const draw = (now: number) => {
|
||||
elapsed += Math.min(now - last, 100) / 1000
|
||||
last = now
|
||||
pointer.x += (pointer.tx - pointer.x) * 0.04
|
||||
pointer.y += (pointer.ty - pointer.y) * 0.04
|
||||
gl.uniform1f(uTime, elapsed * 0.1 + 2)
|
||||
gl.uniform2f(uPointer, pointer.x, pointer.y)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
}
|
||||
|
||||
const loop = (now: number) => {
|
||||
draw(now)
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (reduceMotion || raf || !visible || document.hidden) return
|
||||
last = performance.now()
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
const stop = () => {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
|
||||
draw(performance.now())
|
||||
start()
|
||||
|
||||
const io = new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting
|
||||
if (visible) start()
|
||||
else stop()
|
||||
})
|
||||
io.observe(canvas)
|
||||
|
||||
const onVisibility = () => (document.hidden ? stop() : start())
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
return () => {
|
||||
stop()
|
||||
ro.disconnect()
|
||||
io.disconnect()
|
||||
window.removeEventListener('pointermove', onPointer)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
}, [color, speed, scale, rotation, noiseIntensity])
|
||||
|
||||
return <canvas ref={canvasRef} aria-hidden="true" className={cn('block h-full w-full', className)} />
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { OpenStatus } from '../lib/hours'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
export function StatusPill({ status, className }: { status: OpenStatus; className?: string }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2.5 rounded-full border border-white/10 bg-ink/40 px-3.5 py-1.5 text-xs text-bone/80 backdrop-blur-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="relative flex size-2">
|
||||
{status.open && <span className="absolute inset-0 animate-ping rounded-full bg-emerald-400/70 motion-reduce:animate-none" />}
|
||||
<span className={cn('relative size-2 rounded-full', status.open ? 'bg-emerald-400' : 'bg-rose-400')} />
|
||||
</span>
|
||||
{status.label}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Team-Raster mit Farb-Spotlight — inspiriert von „Chroma Grid“ aus React Bits (reactbits.dev).
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { team, type Member } from '../data/salon'
|
||||
import { cn } from '../lib/cn'
|
||||
import { Logo } from './Logo'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
import { RevealLines } from './Reveal'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
|
||||
const SPOTLIGHT = 320
|
||||
|
||||
function MemberCard({ member, index }: { member: Member; index: number }) {
|
||||
return (
|
||||
<motion.figure
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.25 }}
|
||||
transition={{ duration: 1, delay: (index % 3) * 0.08, ease: EASE_OUT_EXPO }}
|
||||
className="group relative aspect-[4/5] overflow-hidden rounded-2xl bg-smoke md:rounded-3xl"
|
||||
>
|
||||
{member.photo ? (
|
||||
<img
|
||||
src={member.photo}
|
||||
alt={`Porträt von ${member.name}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-cover transition-transform duration-1000 ease-out-expo group-hover:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid size-full place-items-center bg-[radial-gradient(80%_60%_at_50%_35%,#34302c_0%,#141312_70%)]">
|
||||
<Logo label="" className="size-2/5 bg-linear-to-b from-white via-[#a39d96] to-[#ece6df]" />
|
||||
</div>
|
||||
)}
|
||||
<figcaption className="absolute inset-x-0 bottom-0 bg-linear-to-t from-ink/90 via-ink/50 to-transparent p-4 pt-16 md:p-6 md:pt-24">
|
||||
<span className="block font-serif text-2xl leading-none md:text-4xl">{member.name}</span>
|
||||
<span className="mt-2 block font-mono text-[10px] uppercase tracking-[0.2em] text-bone/60 md:text-[11px]">{member.role}</span>
|
||||
</figcaption>
|
||||
</motion.figure>
|
||||
)
|
||||
}
|
||||
|
||||
export function Team() {
|
||||
const grid = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Weiches Nachziehen des Spotlights (x, y, Radius) per requestAnimationFrame
|
||||
useEffect(() => {
|
||||
const el = grid.current
|
||||
if (!el || !window.matchMedia('(pointer: fine)').matches) return
|
||||
|
||||
const s = { x: 0, y: 0, r: 0, tx: 0, ty: 0, tr: 0 }
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
s.x += (s.tx - s.x) * 0.14
|
||||
s.y += (s.ty - s.y) * 0.14
|
||||
s.r += (s.tr - s.r) * 0.1
|
||||
el.style.setProperty('--x', `${s.x}px`)
|
||||
el.style.setProperty('--y', `${s.y}px`)
|
||||
el.style.setProperty('--r', `${s.r}px`)
|
||||
const settled = Math.abs(s.tx - s.x) < 0.5 && Math.abs(s.ty - s.y) < 0.5 && Math.abs(s.tr - s.r) < 0.5
|
||||
raf = settled ? 0 : requestAnimationFrame(tick)
|
||||
}
|
||||
const kick = () => {
|
||||
if (!raf) raf = requestAnimationFrame(tick)
|
||||
}
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
s.tx = e.clientX - r.left
|
||||
s.ty = e.clientY - r.top
|
||||
if (s.r < 1) {
|
||||
s.x = s.tx
|
||||
s.y = s.ty
|
||||
}
|
||||
s.tr = SPOTLIGHT
|
||||
kick()
|
||||
}
|
||||
const onLeave = () => {
|
||||
s.tr = 0
|
||||
kick()
|
||||
}
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerleave', onLeave)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerleave', onLeave)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section id="team" className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
|
||||
<SectionLabel index="02">Team</SectionLabel>
|
||||
<div className="mt-8 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<RevealLines
|
||||
className="font-serif text-6xl leading-[0.9] tracking-tight md:text-8xl lg:text-9xl"
|
||||
lines={['Sechs Profis.', <em key="anspruch" className="text-bone/45">Ein Anspruch.</em>]}
|
||||
/>
|
||||
<p className="max-w-xs text-ash md:pb-3 md:text-right">Bei der Online-Buchung wählst du einfach deinen Favoriten.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={grid}
|
||||
className="relative mt-12 grid grid-cols-2 gap-3 md:mt-16 md:grid-cols-3 md:gap-4"
|
||||
style={{ '--x': '50%', '--y': '50%', '--r': '0px' } as React.CSSProperties}
|
||||
>
|
||||
{team.map((m, i) => (
|
||||
<MemberCard key={m.name} member={m} index={i} />
|
||||
))}
|
||||
{/* Graustufen-Ebene mit Loch am Mauszeiger (nur bei Maus/Trackpad) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-10 hidden [@media(pointer:fine)]:block',
|
||||
'backdrop-grayscale backdrop-brightness-[0.8]',
|
||||
)}
|
||||
style={{
|
||||
maskImage:
|
||||
'radial-gradient(circle var(--r) at var(--x) var(--y), transparent 0%, transparent 20%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.6) 75%, #000 100%)',
|
||||
WebkitMaskImage:
|
||||
'radial-gradient(circle var(--r) at var(--x) var(--y), transparent 0%, transparent 20%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.6) 75%, #000 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { hours, salon } from '../data/salon'
|
||||
import { cn } from '../lib/cn'
|
||||
import { useOpenStatus } from '../lib/useOpenStatus'
|
||||
import { BookButton } from './BookButton'
|
||||
import { Mail, Phone } from './Icons'
|
||||
import { FadeIn, RevealLines } from './Reveal'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
import { StatusPill } from './StatusPill'
|
||||
|
||||
export function Visit() {
|
||||
const status = useOpenStatus()
|
||||
|
||||
return (
|
||||
<section id="kontakt" className="mx-auto max-w-[1600px] px-5 py-24 md:px-10 md:py-36">
|
||||
<SectionLabel index="03">Besuch</SectionLabel>
|
||||
|
||||
<div className="mt-8 grid gap-16 lg:grid-cols-[1.15fr_1fr] lg:gap-24">
|
||||
<div>
|
||||
<RevealLines
|
||||
className="font-serif text-6xl leading-[0.9] tracking-tight md:text-8xl"
|
||||
lines={['Richard-Wagner-', 'Straße 10']}
|
||||
/>
|
||||
<FadeIn>
|
||||
<p className="mt-5 text-xl text-ash md:text-2xl">
|
||||
{salon.zip} {salon.city}
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-3">
|
||||
<BookButton href={salon.mapsUrl} variant="ghost">
|
||||
Route planen
|
||||
</BookButton>
|
||||
<a
|
||||
href={`tel:${salon.phone}`}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full border border-white/15 px-5 text-sm transition-colors hover:border-white/40 hover:bg-white/5"
|
||||
>
|
||||
<Phone className="size-4" />
|
||||
{salon.phoneDisplay}
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href={`mailto:${salon.email}`}
|
||||
className="mt-6 inline-flex items-center gap-2 text-sm text-ash transition-colors hover:text-bone"
|
||||
>
|
||||
<Mail className="size-4" />
|
||||
{salon.email}
|
||||
</a>
|
||||
<p className="mt-12 font-mono text-[11px] uppercase tracking-[0.25em] text-ash">
|
||||
{salon.geo.lat.toFixed(4)}° N · {salon.geo.lng.toFixed(4)}° E
|
||||
</p>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<FadeIn delay={0.1}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h3 className="font-serif text-4xl">Öffnungszeiten</h3>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<ul className="mt-8 border-t border-white/10">
|
||||
{hours.map((h, i) => {
|
||||
const today = i === status.today
|
||||
return (
|
||||
<li
|
||||
key={h.day}
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-white/10 py-4 md:py-5',
|
||||
today ? 'text-bone' : 'text-bone/55',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
{h.day}
|
||||
{today && (
|
||||
<span className="rounded-full bg-champagne/15 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.15em] text-champagne">
|
||||
Heute
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-sm md:text-base">{h.open ? `${h.open} – ${h.close}` : 'Geschlossen'}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Alle Inhalte der Website an einem Ort.
|
||||
// Quelle: https://aminbahmadi.setmore.com — Preise/Zeiten hier anpassen, wenn sie sich bei Setmore ändern.
|
||||
|
||||
const SETMORE = 'https://aminbahmadi.setmore.com'
|
||||
|
||||
export const BOOKING_URL = `${SETMORE}/book`
|
||||
|
||||
export const salon = {
|
||||
name: 'Amin B. Ahmadi',
|
||||
legalName: 'Amin B. Ahmadi Friseur Salon',
|
||||
street: 'Richard-Wagner-Straße 10',
|
||||
zip: '67655',
|
||||
city: 'Kaiserslautern',
|
||||
phone: '+4963131165201',
|
||||
phoneDisplay: '0631 31165201',
|
||||
email: 'aminbahmadi28@gmail.com',
|
||||
facebook: 'https://facebook.com/amin_b_ahmadi',
|
||||
geo: { lat: 49.4431737, lng: 7.7656361 },
|
||||
mapsUrl:
|
||||
'https://www.google.com/maps/search/?api=1&query=Richard-Wagner-Stra%C3%9Fe+10%2C+67655+Kaiserslautern',
|
||||
} as const
|
||||
|
||||
export type DayHours = { day: string; short: string; open: string | null; close: string | null }
|
||||
|
||||
// Reihenfolge Montag → Sonntag. `null` = geschlossen.
|
||||
export const hours: DayHours[] = [
|
||||
{ day: 'Montag', short: 'Mo', open: '09:00', close: '19:00' },
|
||||
{ day: 'Dienstag', short: 'Di', open: '09:00', close: '19:00' },
|
||||
{ day: 'Mittwoch', short: 'Mi', open: '09:00', close: '19:00' },
|
||||
{ day: 'Donnerstag', short: 'Do', open: '09:00', close: '19:00' },
|
||||
{ day: 'Freitag', short: 'Fr', open: '09:00', close: '19:00' },
|
||||
{ day: 'Samstag', short: 'Sa', open: '09:00', close: '19:00' },
|
||||
{ day: 'Sonntag', short: 'So', open: null, close: null },
|
||||
]
|
||||
|
||||
// Setmore-ID von Atena — Leistungen, die nur sie anbietet, springen direkt zum nächsten Buchungsschritt.
|
||||
const ATENA = 'ab5d989c7b138460e9ef970bb2716a8a9'
|
||||
|
||||
export type Service = {
|
||||
name: string
|
||||
note?: string
|
||||
minutes: number
|
||||
price: number
|
||||
/** „ab“-Preis (abhängig von Haarlänge/Aufwand) */
|
||||
from?: boolean
|
||||
/** Setmore-Service-ID für den Direktlink */
|
||||
id: string
|
||||
/** gesetzt, wenn nur eine Person die Leistung anbietet */
|
||||
staff?: string
|
||||
}
|
||||
|
||||
export const serviceUrl = (s: Service) =>
|
||||
s.staff
|
||||
? `${SETMORE}/book?step=additional-products&products=${s.id}&type=service&staff=${s.staff}&staffSelected=false`
|
||||
: `${SETMORE}/book?step=staff&products=${s.id}&type=service`
|
||||
|
||||
export type ServiceCategory = { key: string; label: string; items: Service[] }
|
||||
|
||||
export const services: ServiceCategory[] = [
|
||||
{
|
||||
key: 'herren',
|
||||
label: 'Herren',
|
||||
items: [
|
||||
{ name: 'Maschinenschnitt', note: 'ohne Waschen', minutes: 20, price: 17, id: 'scab580064626bcfc2786e3e10e359241dc0c9d54' },
|
||||
{ name: 'Herren Basic', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 23, id: 'sc3f7ae51cdeb863047f5376611c179c78f33e84c' },
|
||||
{ name: 'Herren Basic + Bart', note: 'Basic inklusive Bart', minutes: 45, price: 37, id: 's0d0186fc2795c7e0d5af3d7dc9b64c237551f228' },
|
||||
{ name: 'Bart stutzen', note: 'mit Maschine', minutes: 15, price: 8, id: 's20de03f274a36fe115aaa5b50bae798c8f1a1a9b' },
|
||||
{ name: 'Men’s Special', note: 'Basic, Zupfen & Nassrasur', minutes: 45, price: 38, id: 'sfdd1b1071cefc7092b91ad89f4407de4f31df53c' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'damen',
|
||||
label: 'Damen',
|
||||
items: [
|
||||
{ name: 'Damen Basic · kurz', note: 'Waschen, Schneiden, Föhnen', minutes: 30, price: 26, from: true, id: 'sbadfe20c7a6179c705a30d3cf379fbc77b113b0c' },
|
||||
{ name: 'Damen Basic · lang', note: 'Waschen, Schneiden, Föhnen', minutes: 45, price: 30, from: true, id: 's6414812bfc137bb6cf41df66e12b6f5cf465b3f7', staff: ATENA },
|
||||
{ name: 'Cut & Go · kurz', minutes: 25, price: 17, id: 's555fe7169cd2e0aa9dc5f4ca72cd6f156246de3c' },
|
||||
{ name: 'Cut & Go · lang', minutes: 35, price: 20, id: 'sb23dec861932c50502b7b826e536d8d354f6a1e0' },
|
||||
{ name: 'Waschen, Pflegen & Föhnen · kurz', minutes: 25, price: 20, from: true, id: 's1f0e304b9e921797e99fff3b758dcd953bdcc8a2', staff: ATENA },
|
||||
{ name: 'Waschen, Pflegen & Föhnen · lang', minutes: 30, price: 25, from: true, id: 's5c179fe958768fa86c3594a56fd2a2679f00f115', staff: ATENA },
|
||||
{ name: 'Quickstyling · kurz', note: 'Locken oder Glätten, ohne Waschen', minutes: 20, price: 25, from: true, id: 'sd017c6bba70af3eddfa59fb7652396bf696fa45f', staff: ATENA },
|
||||
{ name: 'Quickstyling · lang', note: 'Locken oder Glätten, ohne Waschen', minutes: 30, price: 30, from: true, id: 's746e9e32c13956a9c77c7ef884e6961d556473c2', staff: ATENA },
|
||||
{ name: 'Hochsteckfrisur', note: 'je nach Aufwand', minutes: 60, price: 40, id: 's4c0d1cb0c4806c372dbd35024bcefb2a595c6853', staff: ATENA },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'farbe',
|
||||
label: 'Farbe & Form',
|
||||
items: [
|
||||
{ name: 'Farbe / Tönung · Ansatz', note: 'ab 3 cm Ansatz', minutes: 50, price: 40, from: true, id: 's44a840b1c42fdb53b6af3f1ffd24dfea6ee29a0d', staff: ATENA },
|
||||
{ name: 'Farbe / Tönung komplett · kurz', minutes: 50, price: 38, from: true, id: 'se060cd30916ae62e34540083e6c1f6fef8f45a4e', staff: ATENA },
|
||||
{ name: 'Farbe / Tönung komplett · lang', minutes: 50, price: 48, from: true, id: 's594ab068518d2d4b6b2126f7268a28de732246c5', staff: ATENA },
|
||||
{ name: 'Blondierung Ansatz · kurz', note: 'bis 3 cm Ansatz', minutes: 40, price: 40, from: true, id: 's6b2a7fb591db39072acab1aa7e7f5320a557cc3f', staff: ATENA },
|
||||
{ name: 'Blondierung Ansatz · lang', minutes: 40, price: 40, from: true, id: 's9a70e2189423d76c3f612fb38d2fe3f15f229041', staff: ATENA },
|
||||
{ name: 'Strähnen komplett · kurz', minutes: 60, price: 60, from: true, id: 's044c48784b1916f9725f95e6f281ba5c28722fd3', staff: ATENA },
|
||||
{ name: 'Strähnen komplett · lang', minutes: 90, price: 80, from: true, id: 's9ee23b0c359db87cdfcc0c6b10efd3242733760b', staff: ATENA },
|
||||
{ name: 'Balayage · kurz', minutes: 120, price: 90, from: true, id: 'sf230a3eb6df440f44051f2007a2a8148df7ad2a1', staff: ATENA },
|
||||
{ name: 'Balayage · lang', minutes: 240, price: 140, from: true, id: 's243539debed9c1b284f2b907d5103d68feea1c39', staff: ATENA },
|
||||
{ name: 'Dauerwelle · kurz', minutes: 45, price: 45, from: true, id: 's9935eeca17760c3f03800f87f1bc64a7a32de37e', staff: ATENA },
|
||||
{ name: 'Dauerwelle · lang', minutes: 60, price: 60, from: true, id: 'sd83d9fa34a16a1290e0173cfdc157dc0e70815f6' },
|
||||
{ name: 'Haarglättung', note: 'nach Aufwand', minutes: 120, price: 120, from: true, id: 's37f058393a56acd92b9a4693a5dfd2fdf1d092aa', staff: ATENA },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'kinder',
|
||||
label: 'Kinder',
|
||||
items: [
|
||||
{ name: 'Jungen', note: 'bis 12 Jahre', minutes: 20, price: 17, id: 's61b05497d001b8d46e3b8ecd88481f0836ff902d' },
|
||||
{ name: 'Mädchen', note: 'bis 12 Jahre', minutes: 30, price: 17, id: 's73cda469cc3dbd3a134ef93916d12c6a0366084a', staff: ATENA },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'beauty',
|
||||
label: 'Beauty',
|
||||
items: [
|
||||
{ name: 'Augenbrauen zupfen', note: 'mit Faden', minutes: 10, price: 8, id: 's68c001a88641dfd20758033ec42ab080eb7581a5', staff: ATENA },
|
||||
{ name: 'Augenbrauen färben', minutes: 10, price: 8, id: 's861a13b3cee5389e7b1b7bcee268234b2a0c0d96', staff: ATENA },
|
||||
{ name: 'Wimpern färben', minutes: 15, price: 8, id: 's9ae242ebb316198745180e3c6a4e24f1bd645223', staff: ATENA },
|
||||
{ name: 'Gesichtsbehandlung', minutes: 90, price: 45, id: 's9d8b1c9b46e12d30b2fc25b07768a2d3ba35dc0b', staff: ATENA },
|
||||
{ name: 'Tages-Make-up', minutes: 30, price: 20, id: 'sc4d7559daf52e84b3894519538ecbd6651a6b24b', staff: ATENA },
|
||||
{ name: 'Abend-Make-up', minutes: 45, price: 35, id: 's4d0682896c5c11245231e561bbfce08fd335f35f', staff: ATENA },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export type Member = { name: string; role: string; photo?: string }
|
||||
|
||||
const photos = import.meta.glob<string>('../assets/team/*.webp', { eager: true, import: 'default' })
|
||||
const photo = (file: string) => photos[`../assets/team/${file}.webp`]
|
||||
|
||||
export const team: Member[] = [
|
||||
{ name: 'Amin B. Ahmadi', role: 'Inhaber · Barber' },
|
||||
{ name: 'Atena', role: 'Damen · Farbe · Beauty', photo: photo('atena') },
|
||||
{ name: 'Ahmad', role: 'Barber', photo: photo('ahmad') },
|
||||
{ name: 'Ali', role: 'Barber', photo: photo('ali') },
|
||||
{ name: 'Younes', role: 'Barber', photo: photo('younes') },
|
||||
{ name: 'Mo Bargus', role: 'Barber', photo: photo('mo') },
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-ink: #0a0a0a;
|
||||
--color-smoke: #181716;
|
||||
--color-bone: #f2ede7;
|
||||
--color-ash: #8f8a84;
|
||||
--color-champagne: #e5cfbb;
|
||||
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: 'Instrument Serif', ui-serif, Georgia, serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
--animate-spin-slow: spin-slow 22s linear infinite;
|
||||
@keyframes spin-slow {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
background-color: var(--color-ink);
|
||||
color: var(--color-bone);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-champagne);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-champagne);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
[id] {
|
||||
scroll-margin-top: 4rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Chrom-Verlauf wie das Logo an der Salonwand */
|
||||
@utility chrome-text {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
#ffffff 0%,
|
||||
#efeae4 28%,
|
||||
#a8a29b 48%,
|
||||
#6f6a64 56%,
|
||||
#c9c3bc 72%,
|
||||
#fbf8f4 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Großer CTA: Kontur, die sich beim Hover von links füllt */
|
||||
.cta-text {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 1.5px color-mix(in oklab, var(--color-bone) 70%, transparent);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bone) 50%,
|
||||
color-mix(in oklab, var(--color-bone) 6%, transparent) 50%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 100% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
transition: background-position 1s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.group:hover .cta-text,
|
||||
.group:focus-visible .cta-text {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.cta-text {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Feines Filmkorn über der ganzen Seite */
|
||||
.grain {
|
||||
opacity: 0.05;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Rechtstexte (Impressum / Datenschutz) */
|
||||
@layer components {
|
||||
.legal h2 {
|
||||
margin-top: 2.5rem;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-bone);
|
||||
}
|
||||
|
||||
.legal h3 {
|
||||
margin-top: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-bone);
|
||||
}
|
||||
|
||||
.legal p,
|
||||
.legal ul {
|
||||
margin-top: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.legal ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.legal a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.legal a:hover {
|
||||
color: var(--color-bone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Einstieg für Impressum & Datenschutz: nur Schriften und Styles, kein JavaScript-Ballast.
|
||||
import '@fontsource-variable/geist'
|
||||
import '@fontsource/instrument-serif/400.css'
|
||||
import '@fontsource/instrument-serif/400-italic.css'
|
||||
import './index.css'
|
||||
@@ -0,0 +1,2 @@
|
||||
export const cn = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
@@ -0,0 +1,60 @@
|
||||
import { hours } from '../data/salon'
|
||||
|
||||
export type OpenStatus = {
|
||||
open: boolean
|
||||
/** Index in `hours` (0 = Montag) für den heutigen Tag in Kaiserslautern */
|
||||
today: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
const toMinutes = (t: string) => {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
/** „09:00“ → „9 Uhr“, „18:30“ → „18:30 Uhr“ */
|
||||
export const formatTime = (t: string) => {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
return m === 0 ? `${h} Uhr` : `${h}:${String(m).padStart(2, '0')} Uhr`
|
||||
}
|
||||
|
||||
/** Aktuelle Uhrzeit in Kaiserslautern — unabhängig von der Zeitzone des Besuchers. */
|
||||
function berlinNow(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
||||
const jsDay = WEEKDAYS.indexOf(get('weekday'))
|
||||
return { day: (jsDay + 6) % 7, minutes: Number(get('hour')) * 60 + Number(get('minute')) }
|
||||
}
|
||||
|
||||
export function getOpenStatus(date = new Date()): OpenStatus {
|
||||
const { day, minutes } = berlinNow(date)
|
||||
const todayHours = hours[day]
|
||||
|
||||
if (todayHours.open && todayHours.close) {
|
||||
const open = toMinutes(todayHours.open)
|
||||
const close = toMinutes(todayHours.close)
|
||||
if (minutes >= open && minutes < close) {
|
||||
return { open: true, today: day, label: `Jetzt geöffnet · bis ${formatTime(todayHours.close)}` }
|
||||
}
|
||||
if (minutes < open) {
|
||||
return { open: false, today: day, label: `Geschlossen · öffnet heute um ${formatTime(todayHours.open)}` }
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
const next = hours[(day + i) % 7]
|
||||
if (next.open) {
|
||||
const when = i === 1 ? 'morgen' : next.day
|
||||
return { open: false, today: day, label: `Geschlossen · öffnet ${when} um ${formatTime(next.open)}` }
|
||||
}
|
||||
}
|
||||
return { open: false, today: day, label: 'Geschlossen' }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Kurve für alle Einblend-Animationen (entspricht `ease-out-expo` in CSS). */
|
||||
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Skaliert die Schriftgröße eines einzeiligen Elements so, dass es genau die Breite
|
||||
* seines Elternelements ausfüllt (randlose Wortmarke).
|
||||
*/
|
||||
export function useFitText<T extends HTMLElement>(max = 400) {
|
||||
const ref = useRef<T>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
const parent = el?.parentElement
|
||||
if (!el || !parent) return
|
||||
|
||||
const fit = () => {
|
||||
el.style.fontSize = '100px'
|
||||
const natural = el.scrollWidth
|
||||
const available = parent.clientWidth
|
||||
if (!natural || !available) return
|
||||
el.style.fontSize = `${Math.min(max, (100 * available) / natural) * 0.995}px`
|
||||
}
|
||||
|
||||
fit()
|
||||
document.fonts?.ready.then(fit)
|
||||
const ro = new ResizeObserver(fit)
|
||||
ro.observe(parent)
|
||||
return () => ro.disconnect()
|
||||
}, [max])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getOpenStatus } from './hours'
|
||||
|
||||
export function useOpenStatus() {
|
||||
const [status, setStatus] = useState(getOpenStatus)
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setStatus(getOpenStatus()), 30_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '@fontsource-variable/geist'
|
||||
import '@fontsource-variable/geist-mono'
|
||||
import '@fontsource/instrument-serif/400.css'
|
||||
import '@fontsource/instrument-serif/400-italic.css'
|
||||
import 'lenis/dist/lenis.css'
|
||||
import './index.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { resolve } from 'node:path'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
// relative Pfade: die Seite funktioniert auf jeder Domain und in jedem Unterordner
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
input: {
|
||||
main: resolve(import.meta.dirname, 'index.html'),
|
||||
impressum: resolve(import.meta.dirname, 'impressum.html'),
|
||||
datenschutz: resolve(import.meta.dirname, 'datenschutz.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||