Compare commits

..
10 Commits
Author SHA1 Message Date
Claude 9e5ea3dfaf Projektdokumentation: Verlauf, Bild-Prompts, Logo, Körnung
PROJEKT.md beschreibt Datengrundlage, Gestaltungsverlauf, die Nano-Banana-
Prompts für Team-Porträts und Hero, die Vektorisierung des Logos, die
Körnungs-Varianten und offene Punkte vor dem Livegang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 15:18:47 +00:00
Claude 2f52ef70ff Kontakt-Foto im Menü mit feiner Körnung (Variante A)
Nur noch die Galerie nutzt Variante B, alle anderen Fotos Variante A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 15:13:28 +00:00
Claude d80e072adc Team-Porträts mit feiner Körnung (Variante A)
Porträts (groß und Thumbnails) sowie das Hero-Detail im Menü bekommen die
feine Körnung wie der Hero; die Galerie bleibt bei Variante B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 15:10:42 +00:00
Claude 734c2775b5 Filmkorn auf allen Fotos
- Hero: feine, dezente Körnung (Variante A)
- Team-Porträts, Galerie und Menü-Fotos: Film-Look ISO 800 mit leicht
  farbigem Korn, etwas angehobenem Schwarz und leicht entsättigt (Variante B)
- Unbearbeitete Originale in design/original/, scripts/grain.py wendet den
  Effekt reproduzierbar an; Vorschaubild und README aktualisiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 15:09:01 +00:00
Claude 9e22ce616d Galerie "Momente" zwischen Team und Preise
- Schwarze Section mit fünf Fotos aus dem Salon (Haarschnitte und Momente)
- Desktop: Section bleibt stehen, die Bildreihe läuft beim Scrollen
  seitlich durch, dazu ein Fortschrittsbalken
- Handy: horizontal wischen mit Einrasten, nächstes Bild lugt hervor
- Hover: Bild wird farbig und zoomt leicht; Menüpunkt "Galerie" ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 15:01:18 +00:00
Claude 97a00e508a Favicon: nur das "A" aus dem Logo
Das volle Emblem ist in Tab-Größe nicht erkennbar; das Favicon zeigt jetzt
nur das Serifen-"A" aus dem vektorisierten Logo (weiß auf dunklem Grund,
1,5 KB statt 55 KB). Das App-Icon behält das komplette Emblem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 14:50:22 +00:00
Claude d8df8f494a Logo als Favicon und App-Icon
- favicon.svg aus dem vektorisierten Emblem (weiß auf dunklem Grund)
- favicon-32.png als Fallback, apple-touch-icon.png mit dem Emblem
- Icon-Links auf allen Seiten (Start, Impressum, Datenschutz)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 14:48:27 +00:00
Claude d4a0a3856e Logo aus dem Instagram-Profilbild als SVG, in "Über uns" eingebaut
- Profilbild mit potrace vektorisiert: src/assets/logo/emblem.svg (nur
  Emblem, currentColor) und public/logo-profilbild.svg (quadratisch,
  weiß auf dunklem Grund wie das Profilbild)
- "Sechs Profis. Ein Salon.": Foto der Logo-Wand durch das Logo ersetzt;
  die Linien zeichnen sich beim Scrollen, danach folgt ein Lichtreflex
  dem Mauszeiger (ohne Maus der Scroll-Position)
- Originalbild nach src/assets/logo verschoben, ungenutztes Foto und
  ImageReveal entfernt, README ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 14:45:27 +00:00
KNSONWS 4392285570 Add files via upload 2026-09-29 16:14:13 +02:00
Claude 790e18e8a6 Neues Hero-Bild: Amin bei der Arbeit
- Hero zeigt Amin mit Schere und Kamm am Kunden (Desktop 2000/1000 px,
  eigener Hochformat-Ausschnitt fürs Handy per <picture>)
- Headline und Button auf Desktop unten links, damit Gesichter, Schere
  und Kamm frei bleiben; auf dem Handy weiterhin zentriert
- Oben stärker abgedunkelt: Navigation (mix-blend-difference) auf dem
  hellen Studiohintergrund jetzt mit ca. 6:1 statt 2:1 Kontrast
- Menü: "Preise" zeigt ein Detail (Hände, Schere, Kamm), "Team" ein
  3 × 2 Raster aller Porträts
- Nicht mehr genutzte Fotos entfernt, Vorschaubild (og.jpg) und README
  aktualisiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
2026-09-29 13:49:45 +00:00
71 changed files with 589 additions and 77 deletions

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
# Projektdokumentation – Website Amin B. Ahmadi Friseur Salon
One-Pager für den Barber- und Friseursalon **Amin B. Ahmadi** in Kaiserslautern
(Richard-Wagner-Straße 10). Ziel: minimalistisch, modern, wenig Text, interaktive
Bild-Elemente – alle Buchungen laufen über die bestehende Setmore-Seite
([aminbahmadi.setmore.com](https://aminbahmadi.setmore.com)).
Wie man die Seite startet, Inhalte ändert und veröffentlicht, steht in der [README](README.md).
Dieses Dokument beschreibt, **wie die Seite entstanden ist** und **woher Inhalte und Bilder stammen**.
---
## 1. Datengrundlage
Alle Salon-Daten stammen aus der öffentlichen Setmore-Buchungsseite:
- 36 Leistungen mit Preisen und Dauer, sortiert in Herren, Damen, Farbe & Form, Kinder, Beauty
- Team: Amin B. Ahmadi (Inhaber), Atena, Ahmad, Ali, Younes, Mo Bargus
- Adresse, Telefon, E-Mail, Öffnungszeiten (Mo–Sa 9–19 Uhr)
- Setmore-ID jeder Leistung → jede Preiszeile öffnet direkt die passende Buchung
Bereinigt: Tippfehler („Machinenschnitt“, „Balyage“ …), ein doppeltes „Men's Special“ und ein
vermutlich falsch einsortiertes „Dauerwelle lang“ unter Kosmetik wurden nicht übernommen.
Alles steht zentral in `src/data/salon.ts`.
## 2. Verlauf der Gestaltung
| Schritt | Ergebnis |
| --- | --- |
| 1. Entwurf | Dunkle Seite mit animiertem Seidenhintergrund (WebGL), Chrom-Schriftzug, Laufband, vielen Buttons. |
| 2. Feedback | Zu viel Call-to-Action, zu viel Text, Schriftzug gefällt nicht, Hero soll ein **Foto** sein, mehr **interaktive Bild-Elemente**. Vorbild: [crispmtl.com](https://www.crispmtl.com). |
| 3. Redesign | Heller, minimalistischer Stil wie CRISP: feine Rasterlinien, breite Versal-Headlines (Archivo Expanded), kleine Serifen-Labels (Instrument Serif), eckige Buttons. „Termin buchen“ nur noch oben rechts als Textlink, im Hero und im Footer. |
| 4. Team-Porträts | Einheitliche Studio-Porträts per Bild-KI (siehe 3.1). |
| 5. Hero-Bild | Amin bei der Arbeit, per Bild-KI nach dem Vorbild des CRISP-Heros (siehe 3.2). |
| 6. Logo | Instagram-Profilbild vektorisiert und in „Über uns“ als animiertes Emblem eingebaut; das „A“ ist das Favicon (siehe 4). |
| 7. Galerie | Schwarze Section „Momente“ mit echten Fotos aus dem Salon. |
| 8. Körnung | Filmkorn auf allen Fotos, Varianten per Preview ausgewählt (siehe 5). |
### Aufbau der Seite
1. **Navigation** – Menü-Button, Name in der Mitte, „Termin buchen“. Farbe passt sich per
`mix-blend-difference` automatisch hellen und dunklen Flächen an.
2. **Menü** (Vollbild) – große Links; daneben wechselt das Bild zum Link unter der Maus
(bei „Team“ ein Raster aus allen Porträts).
3. **Hero** – Foto „Amin bei der Arbeit“, Headline „Dein Look. Unser Handwerk.“ unten links,
damit Gesichter, Schere und Kamm frei bleiben. Eigener Hochformat-Ausschnitt fürs Handy.
4. **Über uns** – „Sechs Profis. Ein Salon.“ mit dem Logo: Die Linien zeichnen sich beim
Scrollen, danach folgt ein Lichtreflex wie auf Metall dem Mauszeiger.
5. **Team** – Namen als große Liste, das Porträt schiebt sich beim Überfahren/Antippen ins Bild.
6. **Momente** – schwarze Galerie; am Desktop bleibt die Section stehen und die Bilder laufen
beim Scrollen seitlich durch, am Handy wird gewischt.
7. **Preise** – Akkordeon mit kleinen Porträts der Profis, die die Leistungen anbieten;
jede Zeile führt direkt zur Setmore-Buchung.
8. **Footer** – „Bis zum [Fotoraster] nächsten Schnitt.“, Adresse, Öffnungszeiten mit
Live-Status „Jetzt geöffnet“, Kontakt, Impressum, Datenschutz.
## 3. Bildgenerierung (Nano Banana Pro / Gemini)
Die Team-Porträts und das Hero-Bild wurden mit **Nano Banana Pro** (Google Gemini) erstellt.
Vorgehen: pro Person ein echtes Foto als Referenz hochladen, immer derselbe Prompt – so sehen
alle Bilder aus wie aus einem Shooting. Die KI-Bilder bitte vor der Veröffentlichung von den
abgebildeten Personen freigeben lassen.
### 3.1 Team-Porträts
Referenz: das jeweilige Foto der Person (für Amin ein eigenes Foto, bei Setmore gab es keins).
Einstellungen: Seitenverhältnis 4:5, mindestens 2K.
```
Use the uploaded photo only as the identity reference for this person. Create a new, photorealistic studio team portrait of exactly this person for a modern barbershop website.
Identity: keep the face 100% identical – facial structure, skin tone, eye shape and colour, eyebrows, nose, lips, ears, hairline, hairstyle and hair length, beard shape and density, visible tattoos and earrings. Keep the apparent age. Do not beautify, slim, smooth or alter the person in any way.
Framing: vertical 4:5, head-and-shoulders portrait from mid-chest up, person centred, eyes at about 38% from the top edge, generous space above the head and on both sides so the image can also be cropped to a square. Shoulders turned about 15 degrees to the left, face and gaze directly into the camera.
Expression: calm, confident and friendly, relaxed face, subtle closed-mouth smile.
Wardrobe: plain black crew-neck T-shirt, no logos, no print.
Background: seamless studio paper backdrop in warm light grey (#d6d4cf), perfectly even, no texture, no objects, very soft slightly darker falloff towards the edges.
Lighting: large softbox key light from 45 degrees front-left, slightly above eye level; soft fill from the right about 1.5 stops darker; subtle rim light from behind to separate hair and shoulders from the background; soft natural shadows, no hard highlights.
Camera: full-frame, 85 mm lens, f/5.6, eye level, tack-sharp focus on the eyes.
Look: high-end editorial portrait photography, neutral white balance, slightly muted natural colours, gentle contrast, natural skin texture with visible pores, no plastic retouching, no HDR, no heavy grain.
Do not add text, logos, watermarks, props, hands, extra jewellery or accessories that are not in the reference, or other people.
```
Tipp für Konsistenz: das erste gelungene Porträt bei allen weiteren als zweites Bild mitgeben und
anhängen: *„Match the background, lighting, framing, crop and colour grading of the second image
exactly; take only the person from the first image.“*
### 3.2 Hero-Bild „Amin bei der Arbeit“
Referenz: nur Amins Porträt. Die Szene (Pose, Hände, Schere, Kamm, Kunde, Licht) ist komplett
beschrieben, angelehnt an das Hero-Foto von crispmtl.com. Der Kunde ist eine erfundene Person.
Einstellungen: 16:9, 4K.
```
Photorealistic editorial photograph for a barbershop website hero image. Landscape 16:9. The image only – absolutely no text, letters, headline, logo, button, watermark or graphic overlay.
BARBER (identity from the attached reference photo):
The barber is exactly the man in the attached reference image. Keep his face 100% identical: face shape, skin tone, eyes, eyebrows, nose, lips, ears, beard shape and density, apparent age. Do not beautify or alter him. He wears the same black baseball cap as in the reference, but plain black without any logo or embroidery.
SCENE AND POSITIONS:
A barber at work on a seated client, captured in a calm, precise moment.
- The barber stands behind and slightly to the left of the client (as seen from the camera), upper body visible from the cap down to the hips. His head is in the upper left third (about 33% from the left edge, close to the top edge with a little headroom). His torso is turned about 30 degrees towards the client on the right. His head is tilted down about 20 degrees and his eyes look down at the client's head with a focused, calm expression, mouth closed.
- His RIGHT hand (lower hand, left of image centre, at chest height about 45% from the left and 50% from the top) holds professional hair-cutting shears: polished stainless steel, slim straight 6-inch blades, blades closed. Thumb in the lower ring, ring finger in the upper ring, index finger resting along the shank. The blade tips point diagonally up and to the right at about 45 degrees towards the side of the client's head, about 20 cm away, not touching.
- His LEFT arm is raised, elbow bent outwards to the right, forearm almost horizontal above the client's head. His LEFT hand (about 64% from the left, 25% from the top) points the index finger straight down towards the top of the client's head, checking the line. A long, slim ivory-white cutting comb (about 20 cm) sits clamped between his middle and ring fingers and sticks out horizontally to the right.
- The client sits in the foreground, right of centre, in a barber chair that is completely hidden under the cape. He faces the camera straight on, head upright and level, eyes closed, face relaxed and serene, lips closed. His head is centred about 62% from the left; the top of his head is about 35% from the top, just below the barber's pointing finger, and his chin is at about 65% of the height. He is a young man in his mid-twenties with a fresh high skin fade on the sides, a very short dark crop on top, a neat dark moustache and short goatee, and light stubble along the jaw.
WARDROBE:
- Barber: relaxed-fit sage-green long-sleeve button-up shirt with collar, cuffs buttoned, a chest pocket with a small sprig of dried flowers tucked in. A gold curb-chain bracelet on his right wrist, a gold signet ring on his left hand.
- Client: matte ivory/cream barber cape with a ribbed elastic collar snug around the neck. The cape covers his shoulders and body and fills the lower right of the frame down to the bottom edge as a large, calm, light area.
BACKGROUND:
Seamless, plain, very light grey-white studio wall (#e6e7e4), no objects, no mirror, no salon interior, no floor visible. Soft, even tone with a slight falloff from lighter upper left to slightly darker grey at the right edge. The right 15% of the frame is empty background.
LIGHT:
Soft, diffused high-key studio light from a large source at front-left, slightly above eye level. Very soft shadows on the right side of both faces, low contrast, no hard highlights, no dramatic lighting.
CAMERA:
Full-frame camera, 45 mm lens, f/4, at the client's eye level, straight-on frontal perspective, no tilt. Both faces, both hands, the shears and the comb in sharp focus.
COLOUR AND LOOK:
Muted, airy and slightly desaturated palette: pale grey-white, sage green, ivory, natural warm skin tones, polished silver steel, small gold accents. Matte, natural finish like a premium editorial campaign, natural skin texture, no HDR, no heavy grain, no plastic retouching.
AVOID:
Any text or logos (including on the cap), extra people, extra or missing fingers, distorted hands, fingers not correctly inside the scissor rings, hair clippings in the air, mirrors, salon furniture, props other than shears and comb.
```
Weiterverarbeitung: Desktop-Version (2000 px), kleinere Version (1000 px), Hochformat-Ausschnitt
fürs Handy mit Amin und Schere, Detail-Ausschnitt (Hände, Schere, Kamm) für das Menü.
## 4. Logo
- Quelle: Instagram-Profilbild des Salons (`src/assets/logo/instagram-profilbild.jpg`, 583 × 583 px).
- Vektorisiert mit **potrace** (Bild 6-fach vergrößert, geschärft, Schwellwert, Kurven-Tracing),
Koordinaten bereinigt und komprimiert.
- `src/assets/logo/emblem.svg` – nur das Emblem, Farbe `currentColor`, frei skalierbar.
- `public/logo-profilbild.svg` – wie das Profilbild: weißes Emblem auf dunklem Quadrat.
- **Favicon:** nur das Serifen-„A“ aus dem Emblem (im Tab gut erkennbar); **App-Icon** fürs
Handy: das komplette Emblem.
- Hinweis: Wegen der kleinen Vorlage ist das Ornament unten minimal kräftiger als im Original.
Liegt die Original-Vektordatei vom Designer vor, einfach `emblem.svg` ersetzen.
## 5. Körnung (Filmkorn)
Für einen analogen Nacht-Look wurden vier Varianten als Preview erstellt und verglichen:
A fein & dezent · B Film ISO 800 · C Nacht ISO 3200 · D Nacht mit Lichthof.
Ausgewählt:
- **Galerie „Momente“:** Variante B (sichtbares, leicht farbiges Korn, angehobenes Schwarz, leicht entsättigt)
- **Alle anderen Fotos** (Hero, Team-Porträts, Menü): Variante A (fein)
Die Körnung ist fest in die Bilddateien eingerechnet. Die unbearbeiteten Originale liegen in
`design/original/`; `python3 scripts/grain.py` wendet den Effekt reproduzierbar erneut an.
## 6. Technik
- **Vite + React + TypeScript**, **Tailwind CSS**, **Motion** (Animationen), **Lenis** (weiches Scrollen)
- Schriften lokal eingebunden (Archivo, Instrument Serif) – keine Google-Fonts-Anfragen
- Keine Cookies, kein Tracking, keine externen Einbindungen; Setmore, Google Maps und Facebook
nur als normale Links
- Bilder als WebP, Galerie- und Team-Bilder werden erst beim Scrollen geladen
- Rücksicht auf „Bewegung reduzieren“ in den Systemeinstellungen, Tastaturbedienung
(Menü mit Esc, Akkordeon, Team-Liste)
- Impressum und Datenschutz als eigene Seiten; SEO-Daten (Schema.org `HairSalon`, Vorschaubild)
## 7. Offen vor dem Livegang
- [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen und rechtlich prüfen lassen
- [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen
- [ ] Freigabe aller abgebildeten Personen (KI-Porträts, Hero, Kunden in der Galerie)
- [ ] Rollen im Team prüfen (`src/data/salon.ts`)
+32 -5
View File
@@ -11,6 +11,8 @@ Setmore-Buchung) · Footer mit Fotoraster, Adresse, Öffnungszeiten und Live-Sta
**Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Schriften (Archivo, Instrument Serif) **Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Schriften (Archivo, Instrument Serif)
liegen lokal, es gibt keine Cookies und keine externen Einbindungen. liegen lokal, es gibt keine Cookies und keine externen Einbindungen.
Wie die Seite entstanden ist (Verlauf, Bild-Prompts, Logo, Körnung): siehe [PROJEKT.md](PROJEKT.md).
## Starten ## Starten
```bash ```bash
@@ -25,20 +27,43 @@ 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` |
| Fotos (Hero, Über uns, Menü, Fotoraster) | `src/assets/photos/*.webp` | | Fotos (Hero, Menü) | `src/assets/photos/*.webp` |
| Logo | `src/assets/logo/emblem.svg` |
| Team-Porträts | `src/assets/team/*.webp` | | Team-Porträts | `src/assets/team/*.webp` |
| Galerie „Momente“ (Bilder & Titel) | `src/assets/gallery/*.webp`, `src/components/Gallery.tsx` |
| Schriftzug oben in der Mitte | `src/components/Nav.tsx` | | 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` |
**Fotos:** Hero, „Über uns“ und Menü nutzen die Fotos von der Logo-Wand im Salon (Setmore-Seite). Für den **Hero:** Amin bei der Arbeit (per Bild-KI erstellt), abgelegt als `hero.webp` (2000 px), `hero-1000.webp`
Hero lohnt sich ein eigenes, hochauflösendes Foto (mind. 2400 px breit), z. B. ein Barber bei der Arbeit. und `hero-mobile.webp` (Hochformat-Ausschnitt fürs Handy). Die Headline steht unten links, damit Gesichter,
Einfach `salon-wand.webp` bzw. `salon-wand-800.webp` ersetzen. Schere und Kamm frei bleiben. Beim Austauschen alle drei Dateien ersetzen, den Handy-Ausschnitt so wählen,
dass Gesicht und Hände darin liegen. `hero-detail.webp` (Hände mit Schere und Kamm) erscheint im Menü.
**Logo:** Das Instagram-Profilbild (`src/assets/logo/instagram-profilbild.jpg`) wurde mit potrace
vektorisiert:
- `src/assets/logo/emblem.svg` – nur das Emblem, Farbe über `currentColor` (standardmäßig schwarz), für Druck & Co.
- `public/logo-profilbild.svg` – wie das Profilbild: quadratisch, weißes Emblem auf dunklem Grund.
Auf der Website steht es in „Über uns“: Die Linien zeichnen sich beim Scrollen, danach folgt ein Lichtreflex
dem Mauszeiger (`src/components/Emblem.tsx`). Liegt irgendwann die Original-Vektordatei vom Designer vor,
einfach `emblem.svg` ersetzen (ein `<path>` pro Form, `fill="currentColor"`).
**Salon-Foto:** Der Menüpunkt „Kontakt“ zeigt das Logo an der Wand im Salon (`emblem-breit-900.webp`).
**Team-Porträts:** Einheitliche Studio-Porträts (per Bild-KI aus den Originalfotos erstellt), je zweimal **Team-Porträts:** Einheitliche Studio-Porträts (per Bild-KI aus den Originalfotos erstellt), je zweimal
abgelegt: `name.webp` (1000 px breit, Team-Liste) und `name-thumb.webp` (320 × 320, Fotoraster und abgelegt: `name.webp` (1000 px breit, Team-Liste) und `name-thumb.webp` (320 × 320, Fotoraster und
Preis-Thumbnails). Neue Porträts im selben Format ablegen und in `src/data/salon.ts` eintragen. Preis-Thumbnails). Neue Porträts im selben Format ablegen und in `src/data/salon.ts` eintragen.
**Galerie:** Schwarze Section zwischen Team und Preise. Auf dem Desktop bleibt sie stehen und die Bilder
laufen beim Scrollen seitlich durch, auf dem Handy wird gewischt. Neues Bild: als WebP (900 px breit) in
`src/assets/gallery/` ablegen und in `moments` in `Gallery.tsx` eintragen (Titel + Seitenverhältnis).
**Körnung:** Alle Fotos haben einen Filmkorn-Look – die Galerie wie Film ISO 800 (Variante B), alle
anderen Fotos fein (Variante A). Die unbearbeiteten Originale liegen in `design/original/`. Neue oder geänderte Fotos
dort ablegen (gleicher Pfad wie in `src/assets/`) und `python3 scripts/grain.py` ausführen – das Skript
schreibt die gekörnten Versionen nach `src/assets/`.
**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
Setmore-Seite hinter `products=`. Setmore-Seite hinter `products=`.
@@ -48,7 +73,9 @@ Setmore-Seite hinter `products=`.
- [ ] 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 - [ ] Einverständnis der Kunden auf den Galerie-Fotos einholen
- [ ] Einverständnis des Teams für die Verwendung der Fotos einholen (Porträts und Hero sind KI-Bilder
nach echten Fotos – vor der Veröffentlichung freigeben lassen)
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.
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<meta name="theme-color" content="#f5f4f1" /> <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="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<script type="module" src="/src/legal.ts"></script> <script type="module" src="/src/legal.ts"></script>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<meta name="theme-color" content="#f5f4f1" /> <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="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<script type="module" src="/src/legal.ts"></script> <script type="module" src="/src/legal.ts"></script>
</head> </head>
<body> <body>
+1
View File
@@ -10,6 +10,7 @@
/> />
<meta name="theme-color" content="#f5f4f1" /> <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="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

+1 -4
View File
@@ -1,4 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="421 531 313 313"><rect x="421" y="531" width="313" height="313" rx="50" fill="#0a090c"/><path fill="#fff" d="M574 572c-1 1-1 2-3 5c0 3-1 6-2 7c0 1-2 5-3 8c-2 4-3 7-3 8c0 1-1 2-2 4c-1 2-2 5-3 8c-1 3-3 7-3 8c-4 9-6 15-7 18c-1 2-2 5-3 6c0 0-1 3-2 4c0 2-1 4-2 6c0 1-1 2-1 3c0 0-1 2-1 4c-2 4-8 18-9 20c0 2-1 4-2 7c-1 2-2 5-3 6c-3 8-11 28-11 30c-1 1-1 2-2 3c0 0-1 2-1 4c-1 1-2 5-3 7c-1 3-2 6-3 6c0 1-1 5-3 8c-1 4-3 8-4 10c-1 2-2 6-3 9c-8 19-10 22-18 25c-3 0-4 1-4 2c0 1 2 1 8 1c7 0 13 0 18 0c4 0 5 0 5-2c0 0-1-1-2-1c-4-1-5-3-4-8c1-2 6-16 9-23c0-1 2-4 2-6c1-2 3-7 5-10c1-3 2-6 2-7c1-1 2-3 3-6c0-2 1-4 2-5c0-1 0-3 1-3c0-1 2-4 3-8c0-1 1-3 2-5c1-2 3-7 4-10c1-3 2-6 2-7c1-2 2-2 7-2c6 0 48 0 54 0c7 0 7 0 8 4c3 7 5 12 5 13c2 4 4 10 8 19c1 3 2 5 3 6c0 1 1 3 1 4c0 1 1 3 2 5c1 1 1 3 2 4c0 1 1 3 2 5c1 3 2 5 2 6c0 1 2 5 4 10c6 15 7 18 6 21c-1 1-3 3-4 3c0 0-1 1-1 2c0 1 1 1 8 1c5-1 10-1 20 0c19 0 20 0 21-1c1-1 0-1-4-3c-9-3-13-8-19-25c-1-3-3-6-3-7c-1-2-3-7-5-13c-1-1-2-5-4-9c-5-14-6-16-7-19c-1-1-2-3-2-5c-2-3-3-5-4-10c-2-4-3-7-6-14c-2-4-3-8-4-9c0-2-1-3-1-3c0-1-1-2-1-3c-1-2-4-9-8-19c-1-2-2-5-2-6c-1-1-2-4-3-7c-1-3-3-7-3-8c-1-1-2-3-2-4c0-1-1-3-2-5c0-1-2-4-2-6c-1-2-2-5-3-7c-1-1-3-7-5-12c-7-19-8-21-10-26c-2-7-4-9-7-6zM568 615c1 1 2 2 3 7c2 6 4 10 5 14c3 8 4 9 5 12c1 2 2 4 2 5c1 1 1 3 1 3c1 1 1 2 1 3c1 1 1 2 2 4c1 3 5 12 7 18c4 9 4 9-5 8c-7 0-42 0-46 0c-6 1-6 0-3-7c1-2 3-6 4-9c1-3 2-6 3-7c2-6 5-14 6-16c0-1 1-4 2-7c3-6 7-16 9-22c2-6 2-6 4-6z"/></svg>
<rect width="64" height="64" fill="#0a090c"/>
<path fill="#f5f4f1" d="M12.31 47 27.06 17H36.94L51.69 47H42.08L39.81 42.19H23.71L21.47 47ZM26.68 35.69H36.84L34.1 29.59Q33.93 29.18 33.63 28.43Q33.32 27.68 32.99 26.83Q32.65 25.98 32.37 25.25Q32.09 24.52 31.96 24.17H31.63Q31.31 24.97 30.89 26.01Q30.47 27.05 30.08 28.02Q29.7 29 29.42 29.61Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 413 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 583 583"><title>Amin B. Ahmadi – Logo</title><rect width="583" height="583" fill="#02000d"/><g transform="translate(-2.2 107.8) scale(0.5)" fill="#fff"><path d="M359 7c-11 1-20 8-21 16c-1 10 10 24 23 26c8 2 16-5 14-12c-1-5-4-6-10-4c-1 0-2 1-3 1c-2 1-4-1-4-4c0-8 16-9 24-1c10 10 3 30-12 36c-5 2-15 2-26 0c-5-1-6-1-14-1c-4 0-10-1-12-1c-7-1-30-1-38 0c-13 0-27 1-32 2c-2 0-6 1-8 1c-2 0-5 1-7 1c-2 0-4 1-5 1c-2 0-4 0-6 1c-2 0-5 1-7 1c-2 1-4 1-5 2c-2 0-3 0-5 1c-23 5-47 15-64 26c-12 8-20 14-29 24c-9 10-18 24-22 34c-3 11-5 16-6 27c-3 14-2 36 2 53c2 8 1 8-3 4c-8-8-19-9-28-3c-10 8-11 19-1 27c3 2 6 0 5-4c0-2 0-7 1-9c3-7 19 2 23 14c2 5 1 5-3 11c-5 7-7 10-10 13c-4 6-6 11-4 19c1 9 0 11-9 19c-5 4-9 9-12 14c-3 6-4 8-13 17c-17 17-24 25-26 30c0 1-1 2-1 2c-1 1-1 14 0 14c0 1 1 2 2 3c3 4 8 9 15 12c5 3 6 4 8 7c4 6 4 7 7 10c3 5 4 5 8 12c4 6 8 10 12 12c2 0 3 1 4 2c2 1 3 3 4 5c1 5 4 13 7 20c1 1 1 2 1 2c0 1 1 2 2 4c9 13 19 18 32 15c2-1 3-1 3-1c1 0 9-4 11-5c6-4 9-7 11-8c1-1 3-3 4-4c1-1 3-2 4-3c0-1 2-2 2-2c1-1 6-5 11-11c15-13 22-19 32-25c9-4 13-6 14-5c1 0 3 4 3 5c1 2 5 10 7 12c3 5 11 10 18 12c8 2 8 2 5 8c0 2-1 4-2 5c0 1-1 4-2 5c-2 4-3 8-3 12c-2 13-7 20-16 20c-10 0-18-10-12-18c2-3 5-3 5 1c-1 5 4 9 10 7c5-2 7-9 4-15c-4-9-15-12-26-7c-10 5-16 21-11 32c5 12 18 19 31 17c9-1 8-2 9 11c0 10 0 14 1 20c0 2 1 5 1 5c0 1 0 4 0 6c1 2 1 4 2 5c0 3 4 14 7 20c3 9 9 20 12 23c0 1 1 3 2 3c1 2 7 9 10 12c16 14 38 19 53 13c18-9 28-26 24-40c-3-7-10-16-16-19c-1 0-2-1-2-1c-6-4-18-5-25-1c-11 5-14 14-8 23c1 2 8 8 9 9c3 1 5-1 4-5c-2-6-2-10-1-12c7-9 23 3 20 15c-3 9-12 14-24 12c-5 0-5-1-14-5c-11-6-12-7-21-17c-4-5-10-15-12-21c-6-16-8-28-9-45c0-13 1-19 5-28c2-3 8-15 8-17c1-2 2-3 2-2c1 0 1 1 3 6c3 12 7 19 13 28c2 5 4 7 9 13c2 3 12 13 16 17c9 7 13 11 16 13c1 1 4 3 6 5c1 1 4 3 5 4c1 1 3 3 4 4c4 2 6 5 8 6c2 1 3 3 4 4c2 1 5 4 8 6c2 2 6 5 7 6c1 1 2 2 4 3c1 1 3 3 4 4c3 2 10 7 12 8c2 2 5 4 8 6c1 2 2 3 3 3c1 1 4 4 8 7c3 4 8 8 10 10c1 1 4 4 7 6c5 4 22 21 25 25c1 2 3 4 4 5c1 1 6 8 9 13c5 9 10 21 13 33c1 6 1 26-1 33c-7 29-49 37-64 13c-4-8-5-15-1-22c3-7 10-9 15-5c3 4 2 7-4 10c-6 3-7 7-2 12c4 3 5 3 9 3c13 1 20-10 16-23c-5-12-14-17-27-16c-13 2-21 14-21 30c1 11 4 18 11 25c7 6 9 8 17 10c7 1 7 2 6 5c-2 5 1 8 7 6c4-1 4 0 1 4c-4 5-4 11-1 13c4 3 7 2 10-4c2-4 3-4 3 0c0 5 2 11 4 13c1 1 2 2 2 3c5 6 16 8 22 5c3-1 7-5 8-7c2-4 9-7 14-6c2 1 5 3 5 4c0 1-2 2-3 2c-3-1-4-1-5-1c-10 4-9 17 2 18c8 1 14-5 14-13c-1-4-1-6-4-10c-2-4 0-3 6 3c6 7 9 8 13 8c5-1 9-6 7-10c-1 0-1-1-1-2c0-1-1-2-2-4c-4-4-3-4 1-4c10 0 10-11-1-17c-7-5-19-4-27 1c-2 1-3 2-3 2c-2 1-9 11-10 13c0 1 1 2 1 2c1 2 1 2-1 3c-2 0-2-1-3-4c-2-12-15-15-22-5c-2 2-2 2-2 5c-1 6 6 11 13 9c1 0 2 0 2 0c1 1-1 5-5 6c-8 5-20-5-18-15c2-13 11-21 28-23c5 0 23-1 24 0c1 0 1 0 2 0c1 0 9 0 18 0c20 0 20 0 17 4c-5 11 2 25 14 24c6-1 17 4 20 9c0 1 1 2 2 3c1 2 1 7-1 9c-4 8-13 10-18 5c-3-3-2-4 1-3c10 5 18-5 13-13c-7-10-22-7-25 5c-2 9 5 19 15 21c4 1 9-1 15-6c4-4 4-4 3 0c-2 3-3 8-3 11c1 4 2 7 7 12c3 3 5 6 5 6c1 2 3 8 3 9c0 2 2 3 4 3c1-1 2-2 2-5c1-6 2-8 5-11c8-6 11-15 8-22c-1-2-2-5-1-5c0 0 2 1 4 3c8 7 16 7 23 0c6-6 7-12 4-19c-2-5-5-7-9-8c-5-1-8 0-13 4c-4 4-4 10 0 14c3 3 6 3 11 1c4-1 5 0 2 3c-9 9-25-4-19-15c3-7 12-11 22-11c4 0 4 0 6-1c8-3 12-15 8-22c-3-5-3-5 11-5c43-1 52 0 60 2c18 7 24 28 9 37c-5 2-15-1-15-6c0 0 0 0 5 0c8-1 13-6 10-13c-5-11-22-10-23 2c-1 3-2 4-4 4c-1 0-2 0-1-2c1-1 1-2 1-3c-1 0-1-1-2-2c-4-9-13-15-23-16c-12-1-22 5-23 13c0 5 3 7 8 7c4 0 4 0 1 3c-7 10-1 20 9 15c2-1 3-2 7-5c4-5 6-6 7-6c0 0-1 4-2 4c0 1-1 2-1 3c-3 10 1 18 11 18c6 0 11-4 10-10c0-5-4-8-10-8c-5 0-6-2-1-4c5-3 12 0 16 5c5 9 17 11 25 5c7-5 10-10 10-19c0-3 0-5 0-5c1 0 2 2 3 5c3 5 6 7 10 5c4-3 3-8-1-15c-3-5-3-5 2-3c4 1 5 1 7-1c1-3 1-3 0-6c-1-3-1-3 3-4c8-3 13-6 19-12c13-15 11-43-5-51c-4-2-13-3-16-2c0 1-2 1-3 1c-10 2-19 18-15 29c3 9 16 13 24 6c6-5 5-8-2-12c-5-3-6-5-4-8c0-2 4-4 6-4c8-1 14 11 12 21c-8 29-51 30-65 1c-5-10-6-34 0-49c2-8 7-18 10-22c0-1 1-3 3-5c7-11 16-22 29-34c3-3 7-6 9-8c2-1 5-5 8-7c3-2 8-7 11-10c10-9 10-9 15-12c1-1 3-2 5-4c7-5 13-10 18-14c2-3 3-3 8-7c2-1 4-4 6-5c2-2 4-4 5-5c1 0 3-2 5-4c2-1 5-4 7-5c4-4 11-9 17-14c14-12 27-27 32-35c0-1 0-2 1-2c2-3 7-14 10-25c4-10 4-10 7-2c1 3 3 7 4 9c5 10 7 15 7 19c1 6 0 26-1 32c0 1 0 3-1 4c0 1 0 4-1 5c0 2-1 6-2 9c-2 8-7 18-11 24c-7 10-13 17-18 19c-2 1-5 3-8 4c-15 9-32 6-36-6c-4-9 3-18 12-18c7-1 9 4 6 12c-1 5-1 5 0 6c2 2 4 2 10-5c3-4 4-4 5-7c5-16-16-28-34-19c-23 12-26 35-8 52c6 6 14 10 21 11c9 2 24-1 33-7c0 0 2-1 3-1c6-4 20-17 22-23c1-1 2-2 2-3c2-3 8-15 11-22c0-2 1-4 2-6c1-4 4-12 4-16c0-1 1-3 1-3c0 0 0-2 0-4c1-8 2-14 2-23c1-14 0-13 7-12c19 3 38-13 35-29c-3-13-9-19-21-22c-11-2-22 7-19 18c2 9 16 8 15-1c0-5 6-2 7 3c2 9-10 17-20 13c-5-3-8-7-10-20c-1-4-2-7-3-9c0-1-1-4-2-6c-1-2-2-5-3-7c-2-4-2-5 3-6c15-5 23-11 28-26c2-5 3-6 11-2c10 5 17 10 23 15c8 7 8 7 18 16c8 8 13 13 16 15c2 1 4 3 5 4c7 6 16 10 25 12c9 3 21-3 27-13c1-1 1-2 2-3c2-2 7-14 8-19c3-11 3-10 7-12c8-4 10-6 16-16c3-3 6-8 7-9c1-1 2-3 3-4c0-2 1-2 4-7c2-3 3-3 7-5c23-13 25-25 9-42c-1-2-3-5-5-7c-2-2-8-8-13-13c-9-9-9-10-11-13c-1-2-3-5-3-5c-1-1-2-3-2-3c-1-2-6-7-13-14Line truncated

After

Width:  |  Height:  |  Size: 53 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 65 KiB

+74
View File
@@ -0,0 +1,74 @@
"""Filmkorn auf die Website-Fotos rechnen.
Liest die unbearbeiteten Bilder aus design/original/ und schreibt die gekörnten Versionen
nach src/assets/. Galerie: Variante B (Film, ISO 800), alle anderen Fotos (Hero, Team,
Menü): Variante A (fein).
pip install numpy pillow
python3 scripts/grain.py
"""
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / 'design' / 'original'
DST = ROOT / 'src' / 'assets'
VARIANTS = {
# fein & dezent
'A': dict(amount=0.016, size=1.2),
# Film, ISO 800: sichtbares, leicht farbiges Korn, etwas angehobenes Schwarz, leicht entsättigt
'B': dict(amount=0.028, size=1.5, color=0.15, lift=0.03, contrast=1.05, sat=0.92),
}
def variant_for(rel):
return 'B' if rel.startswith('gallery/') else 'A'
LUMA = np.array([0.2126, 0.7152, 0.0722], np.float32)
def noise(rng, h, w, size, color):
"""Gauß-Rauschen, auf Korngröße weichgezeichnet und wieder auf Standardabweichung 1 normiert."""
def one():
n = rng.normal(0, 1, (h, w)).astype(np.float32)
if size > 1:
im = Image.fromarray((n * 40 + 128).clip(0, 255).astype(np.uint8))
n = (np.asarray(im.filter(ImageFilter.GaussianBlur(size * 0.5)), np.float32) - 128) / 40
n /= n.std() + 1e-6
return n
mono = one()
if not color:
return np.stack([mono] * 3, -1)
return np.stack([mono + color * one() for _ in range(3)], -1)
def grain(img, rng, amount, size, color=0.0, lift=0.0, contrast=1.0, sat=1.0):
a = np.asarray(img.convert('RGB'), np.float32) / 255
h, w, _ = a.shape
a = (a - 0.5) * contrast + 0.5
lum = a @ LUMA
a = lum[..., None] + (a - lum[..., None]) * sat
a = lift + a * (1 - lift)
# Korn vor allem in den Mitteltönen, wie bei Film
lum = np.clip(a @ LUMA, 0, 1)
weight = 4 * lum * (1 - lum) + 0.35
a = a + noise(rng, h, w, size, color) * amount * weight[..., None]
return Image.fromarray((np.clip(a, 0, 1) * 255).astype(np.uint8))
def main():
for i, src in enumerate(sorted(SRC.rglob('*.webp'))):
rel = src.relative_to(SRC).as_posix()
variant = variant_for(rel)
out = grain(Image.open(src), np.random.default_rng(i + 1), **VARIANTS[variant])
dst = DST / rel
out.save(dst, 'WEBP', quality=82, method=6)
print(f'{variant} {rel:28} {src.stat().st_size // 1024:4} KB -> {dst.stat().st_size // 1024:4} KB')
if __name__ == '__main__':
main()
+2
View File
@@ -2,6 +2,7 @@ import Lenis from 'lenis'
import { MotionConfig } from 'motion/react' import { MotionConfig } from 'motion/react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Footer } from './components/Footer' import { Footer } from './components/Footer'
import { Gallery } from './components/Gallery'
import { GridLines } from './components/GridLines' import { GridLines } from './components/GridLines'
import { Hero } from './components/Hero' import { Hero } from './components/Hero'
import { Intro } from './components/Intro' import { Intro } from './components/Intro'
@@ -36,6 +37,7 @@ export default function App() {
<Hero /> <Hero />
<Intro /> <Intro />
<TeamList /> <TeamList />
<Gallery />
<Prices /> <Prices />
</main> </main>
<Footer /> <Footer />
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1157 913" fill="currentColor"><path d="M359 7c-11 1-20 8-21 16c-1 10 10 24 23 26c8 2 16-5 14-12c-1-5-4-6-10-4c-1 0-2 1-3 1c-2 1-4-1-4-4c0-8 16-9 24-1c10 10 3 30-12 36c-5 2-15 2-26 0c-5-1-6-1-14-1c-4 0-10-1-12-1c-7-1-30-1-38 0c-13 0-27 1-32 2c-2 0-6 1-8 1c-2 0-5 1-7 1c-2 0-4 1-5 1c-2 0-4 0-6 1c-2 0-5 1-7 1c-2 1-4 1-5 2c-2 0-3 0-5 1c-23 5-47 15-64 26c-12 8-20 14-29 24c-9 10-18 24-22 34c-3 11-5 16-6 27c-3 14-2 36 2 53c2 8 1 8-3 4c-8-8-19-9-28-3c-10 8-11 19-1 27c3 2 6 0 5-4c0-2 0-7 1-9c3-7 19 2 23 14c2 5 1 5-3 11c-5 7-7 10-10 13c-4 6-6 11-4 19c1 9 0 11-9 19c-5 4-9 9-12 14c-3 6-4 8-13 17c-17 17-24 25-26 30c0 1-1 2-1 2c-1 1-1 14 0 14c0 1 1 2 2 3c3 4 8 9 15 12c5 3 6 4 8 7c4 6 4 7 7 10c3 5 4 5 8 12c4 6 8 10 12 12c2 0 3 1 4 2c2 1 3 3 4 5c1 5 4 13 7 20c1 1 1 2 1 2c0 1 1 2 2 4c9 13 19 18 32 15c2-1 3-1 3-1c1 0 9-4 11-5c6-4 9-7 11-8c1-1 3-3 4-4c1-1 3-2 4-3c0-1 2-2 2-2c1-1 6-5 11-11c15-13 22-19 32-25c9-4 13-6 14-5c1 0 3 4 3 5c1 2 5 10 7 12c3 5 11 10 18 12c8 2 8 2 5 8c0 2-1 4-2 5c0 1-1 4-2 5c-2 4-3 8-3 12c-2 13-7 20-16 20c-10 0-18-10-12-18c2-3 5-3 5 1c-1 5 4 9 10 7c5-2 7-9 4-15c-4-9-15-12-26-7c-10 5-16 21-11 32c5 12 18 19 31 17c9-1 8-2 9 11c0 10 0 14 1 20c0 2 1 5 1 5c0 1 0 4 0 6c1 2 1 4 2 5c0 3 4 14 7 20c3 9 9 20 12 23c0 1 1 3 2 3c1 2 7 9 10 12c16 14 38 19 53 13c18-9 28-26 24-40c-3-7-10-16-16-19c-1 0-2-1-2-1c-6-4-18-5-25-1c-11 5-14 14-8 23c1 2 8 8 9 9c3 1 5-1 4-5c-2-6-2-10-1-12c7-9 23 3 20 15c-3 9-12 14-24 12c-5 0-5-1-14-5c-11-6-12-7-21-17c-4-5-10-15-12-21c-6-16-8-28-9-45c0-13 1-19 5-28c2-3 8-15 8-17c1-2 2-3 2-2c1 0 1 1 3 6c3 12 7 19 13 28c2 5 4 7 9 13c2 3 12 13 16 17c9 7 13 11 16 13c1 1 4 3 6 5c1 1 4 3 5 4c1 1 3 3 4 4c4 2 6 5 8 6c2 1 3 3 4 4c2 1 5 4 8 6c2 2 6 5 7 6c1 1 2 2 4 3c1 1 3 3 4 4c3 2 10 7 12 8c2 2 5 4 8 6c1 2 2 3 3 3c1 1 4 4 8 7c3 4 8 8 10 10c1 1 4 4 7 6c5 4 22 21 25 25c1 2 3 4 4 5c1 1 6 8 9 13c5 9 10 21 13 33c1 6 1 26-1 33c-7 29-49 37-64 13c-4-8-5-15-1-22c3-7 10-9 15-5c3 4 2 7-4 10c-6 3-7 7-2 12c4 3 5 3 9 3c13 1 20-10 16-23c-5-12-14-17-27-16c-13 2-21 14-21 30c1 11 4 18 11 25c7 6 9 8 17 10c7 1 7 2 6 5c-2 5 1 8 7 6c4-1 4 0 1 4c-4 5-4 11-1 13c4 3 7 2 10-4c2-4 3-4 3 0c0 5 2 11 4 13c1 1 2 2 2 3c5 6 16 8 22 5c3-1 7-5 8-7c2-4 9-7 14-6c2 1 5 3 5 4c0 1-2 2-3 2c-3-1-4-1-5-1c-10 4-9 17 2 18c8 1 14-5 14-13c-1-4-1-6-4-10c-2-4 0-3 6 3c6 7 9 8 13 8c5-1 9-6 7-10c-1 0-1-1-1-2c0-1-1-2-2-4c-4-4-3-4 1-4c10 0 10-11-1-17c-7-5-19-4-27 1c-2 1-3 2-3 2c-2 1-9 11-10 13c0 1 1 2 1 2c1 2 1 2-1 3c-2 0-2-1-3-4c-2-12-15-15-22-5c-2 2-2 2-2 5c-1 6 6 11 13 9c1 0 2 0 2 0c1 1-1 5-5 6c-8 5-20-5-18-15c2-13 11-21 28-23c5 0 23-1 24 0c1 0 1 0 2 0c1 0 9 0 18 0c20 0 20 0 17 4c-5 11 2 25 14 24c6-1 17 4 20 9c0 1 1 2 2 3c1 2 1 7-1 9c-4 8-13 10-18 5c-3-3-2-4 1-3c10 5 18-5 13-13c-7-10-22-7-25 5c-2 9 5 19 15 21c4 1 9-1 15-6c4-4 4-4 3 0c-2 3-3 8-3 11c1 4 2 7 7 12c3 3 5 6 5 6c1 2 3 8 3 9c0 2 2 3 4 3c1-1 2-2 2-5c1-6 2-8 5-11c8-6 11-15 8-22c-1-2-2-5-1-5c0 0 2 1 4 3c8 7 16 7 23 0c6-6 7-12 4-19c-2-5-5-7-9-8c-5-1-8 0-13 4c-4 4-4 10 0 14c3 3 6 3 11 1c4-1 5 0 2 3c-9 9-25-4-19-15c3-7 12-11 22-11c4 0 4 0 6-1c8-3 12-15 8-22c-3-5-3-5 11-5c43-1 52 0 60 2c18 7 24 28 9 37c-5 2-15-1-15-6c0 0 0 0 5 0c8-1 13-6 10-13c-5-11-22-10-23 2c-1 3-2 4-4 4c-1 0-2 0-1-2c1-1 1-2 1-3c-1 0-1-1-2-2c-4-9-13-15-23-16c-12-1-22 5-23 13c0 5 3 7 8 7c4 0 4 0 1 3c-7 10-1 20 9 15c2-1 3-2 7-5c4-5 6-6 7-6c0 0-1 4-2 4c0 1-1 2-1 3c-3 10 1 18 11 18c6 0 11-4 10-10c0-5-4-8-10-8c-5 0-6-2-1-4c5-3 12 0 16 5c5 9 17 11 25 5c7-5 10-10 10-19c0-3 0-5 0-5c1 0 2 2 3 5c3 5 6 7 10 5c4-3 3-8-1-15c-3-5-3-5 2-3c4 1 5 1 7-1c1-3 1-3 0-6c-1-3-1-3 3-4c8-3 13-6 19-12c13-15 11-43-5-51c-4-2-13-3-16-2c0 1-2 1-3 1c-10 2-19 18-15 29c3 9 16 13 24 6c6-5 5-8-2-12c-5-3-6-5-4-8c0-2 4-4 6-4c8-1 14 11 12 21c-8 29-51 30-65 1c-5-10-6-34 0-49c2-8 7-18 10-22c0-1 1-3 3-5c7-11 16-22 29-34c3-3 7-6 9-8c2-1 5-5 8-7c3-2 8-7 11-10c10-9 10-9 15-12c1-1 3-2 5-4c7-5 13-10 18-14c2-3 3-3 8-7c2-1 4-4 6-5c2-2 4-4 5-5c1 0 3-2 5-4c2-1 5-4 7-5c4-4 11-9 17-14c14-12 27-27 32-35c0-1 0-2 1-2c2-3 7-14 10-25c4-10 4-10 7-2c1 3 3 7 4 9c5 10 7 15 7 19c1 6 0 26-1 32c0 1 0 3-1 4c0 1 0 4-1 5c0 2-1 6-2 9c-2 8-7 18-11 24c-7 10-13 17-18 19c-2 1-5 3-8 4c-15 9-32 6-36-6c-4-9 3-18 12-18c7-1 9 4 6 12c-1 5-1 5 0 6c2 2 4 2 10-5c3-4 4-4 5-7c5-16-16-28-34-19c-23 12-26 35-8 52c6 6 14 10 21 11c9 2 24-1 33-7c0 0 2-1 3-1c6-4 20-17 22-23c1-1 2-2 2-3c2-3 8-15 11-22c0-2 1-4 2-6c1-4 4-12 4-16c0-1 1-3 1-3c0 0 0-2 0-4c1-8 2-14 2-23c1-14 0-13 7-12c19 3 38-13 35-29c-3-13-9-19-21-22c-11-2-22 7-19 18c2 9 16 8 15-1c0-5 6-2 7 3c2 9-10 17-20 13c-5-3-8-7-10-20c-1-4-2-7-3-9c0-1-1-4-2-6c-1-2-2-5-3-7c-2-4-2-5 3-6c15-5 23-11 28-26c2-5 3-6 11-2c10 5 17 10 23 15c8 7 8 7 18 16c8 8 13 13 16 15c2 1 4 3 5 4c7 6 16 10 25 12c9 3 21-3 27-13c1-1 1-2 2-3c2-2 7-14 8-19c3-11 3-10 7-12c8-4 10-6 16-16c3-3 6-8 7-9c1-1 2-3 3-4c0-2 1-2 4-7c2-3 3-3 7-5c23-13 25-25 9-42c-1-2-3-5-5-7c-2-2-8-8-13-13c-9-9-9-10-11-13c-1-2-3-5-3-5c-1-1-2-3-2-3c-1-2-6-7-13-14c-6-6-6-4-5-18c0-5 0-6 0-8c-1-4-1-3-10-16c-1-1-2-3-3-3c0 0-1-2-2-3c-3-4-4-6-2-10c3-10 16-19 22-15c2 1 2 6 1 10c-1 8 7 5 11-3Line truncated

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 96 KiB

+99
View File
@@ -0,0 +1,99 @@
import { useInView, useMotionValueEvent, useReducedMotion, useScroll } from 'motion/react'
import { useEffect, useId, useRef } from 'react'
import raw from '../assets/logo/emblem.svg?raw'
import { cn } from '../lib/cn'
// Das Logo (Instagram-Profilbild, vektorisiert) liegt als SVG-Datei vor und wird hier inline gezeichnet,
// damit die Linien animiert und vom Licht angestrahlt werden können.
const viewBox = raw.match(/viewBox="([^"]+)"/)![1]
const [, , VB_W, VB_H] = viewBox.split(' ').map(Number)
const paths = [...raw.matchAll(/<path d="([^"]+)"/g)].map((m) => m[1])
/**
* Logo-Emblem:
* - zeichnet sich beim ersten Erscheinen Linie für Linie, danach füllen sich die Linien
* - ein Lichtreflex wie auf Metall folgt dem Mauszeiger (ohne Maus: der Scroll-Position)
*/
export function Emblem({ className }: { className?: string }) {
const wrap = useRef<HTMLDivElement>(null)
const gradient = useRef<SVGRadialGradientElement>(null)
const id = `emblem-light-${useId().replace(/[^\w-]/g, '')}`
const reduce = useReducedMotion()
const drawn = useInView(wrap, { once: true, amount: 0.4 })
// Lichtposition (0–1 relativ zum Logo): Ziel und aktueller, weich nachgezogener Wert
const light = useRef({ x: 0.3, y: 0.25, tx: 0.3, ty: 0.25, hover: false, raf: 0 })
const render = () => {
const l = light.current
l.x += (l.tx - l.x) * 0.12
l.y += (l.ty - l.y) * 0.12
gradient.current?.setAttribute('cx', String(l.x * VB_W))
gradient.current?.setAttribute('cy', String(l.y * VB_H))
const settled = Math.abs(l.tx - l.x) < 0.001 && Math.abs(l.ty - l.y) < 0.001
l.raf = settled ? 0 : requestAnimationFrame(render)
}
const moveLight = (x: number, y: number) => {
const l = light.current
l.tx = x
l.ty = y
if (reduce) {
l.x = x
l.y = y
render()
} else if (!l.raf) l.raf = requestAnimationFrame(render)
}
const { scrollYProgress } = useScroll({ target: wrap, offset: ['start end', 'end start'] })
useMotionValueEvent(scrollYProgress, 'change', (p) => {
if (!light.current.hover) moveLight(0.1 + 0.8 * p, 0.15 + 0.5 * p)
})
useEffect(() => {
const l = light.current
return () => cancelAnimationFrame(l.raf)
}, [])
const onPointerMove = (e: React.PointerEvent) => {
const r = wrap.current!.getBoundingClientRect()
light.current.hover = true
moveLight((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height)
}
const onPointerLeave = () => {
light.current.hover = false
const p = scrollYProgress.get()
moveLight(0.1 + 0.8 * p, 0.15 + 0.5 * p)
}
return (
<div
ref={wrap}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
data-drawn={drawn || reduce ? '' : undefined}
className={cn('emblem', className)}
>
<svg viewBox={viewBox} role="img" aria-label="Logo von Amin B. Ahmadi" className="block h-auto w-full overflow-visible">
<defs>
<radialGradient ref={gradient} id={id} gradientUnits="userSpaceOnUse" cx={0.3 * VB_W} cy={0.25 * VB_H} r={VB_W * 0.55}>
<stop offset="0" stopColor="#ffffff" />
<stop offset="0.35" stopColor="#ece9e4" />
<stop offset="1" stopColor="#8d8a85" />
</radialGradient>
</defs>
{/* Konturen für die Zeichen-Animation */}
<g className="emblem-lines" aria-hidden="true">
{paths.map((d, i) => (
<path key={i} d={d} pathLength={1} style={{ '--i': i } as React.CSSProperties} />
))}
</g>
{/* Gefüllte Linien mit Lichtreflex */}
<g className="emblem-fill" fill={`url(#${id})`}>
{paths.map((d, i) => (
<path key={i} d={d} />
))}
</g>
</svg>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { motion, useScroll, useTransform } from 'motion/react'
import { useLayoutEffect, useRef, useState } from 'react'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { GridLines } from './GridLines'
import { RevealLines } from './Reveal'
const photos = import.meta.glob<string>('../assets/gallery/*.webp', { eager: true, import: 'default' })
const photo = (name: string) => photos[`../assets/gallery/${name}.webp`]
// Seitenverhältnis (Breite/Höhe) der Dateien, damit nichts beschnitten wird
const moments = [
{ src: photo('platin'), title: 'Platin Buzz', ratio: 900 / 1119 },
{ src: photo('twists'), title: 'Twists', ratio: 900 / 1068 },
{ src: photo('crew'), title: 'Nach Feierabend', ratio: 900 / 1200 },
{ src: photo('fade-bart'), title: 'Skin Fade & Bart', ratio: 900 / 1078 },
{ src: photo('blond'), title: 'Blond Crop', ratio: 900 / 1200 },
]
function Moment({ m, i }: { m: (typeof moments)[number]; i: number }) {
return (
<figure className="group w-[78vw] shrink-0 snap-center md:w-auto">
<div
className="overflow-hidden bg-paper/5 md:h-[min(56svh,38rem)]"
style={{ aspectRatio: m.ratio } as React.CSSProperties}
>
<img
src={m.src}
alt={m.title}
loading="lazy"
decoding="async"
className="size-full object-cover grayscale-[35%] transition duration-700 ease-out-expo group-hover:scale-[1.04] group-hover:grayscale-0"
/>
</div>
<figcaption className="mt-3 flex items-baseline justify-between gap-4">
<span className="display text-sm md:text-base">{m.title}</span>
<span className="mini text-paper/50">{String(i + 1).padStart(2, '0')}</span>
</figcaption>
</figure>
)
}
/**
* Galerie auf schwarzem Grund. Desktop: die Section bleibt stehen und die Bilder
* laufen beim Scrollen seitlich durch. Handy: horizontal wischen.
*/
export function Gallery() {
const section = useRef<HTMLElement>(null)
const track = useRef<HTMLDivElement>(null)
const [distance, setDistance] = useState(0)
// Wie weit die Bildreihe über den Bildschirm hinausragt (nur Desktop)
useLayoutEffect(() => {
const el = track.current
if (!el) return
const desktop = window.matchMedia('(min-width: 768px)')
const measure = () => setDistance(desktop.matches ? Math.max(0, el.scrollWidth - window.innerWidth) : 0)
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
desktop.addEventListener('change', measure)
return () => {
ro.disconnect()
desktop.removeEventListener('change', measure)
}
}, [])
const { scrollYProgress } = useScroll({ target: section, offset: ['start start', 'end end'] })
const x = useTransform(scrollYProgress, [0, 1], [0, -distance])
return (
<section
id="galerie"
ref={section}
className="relative isolate bg-ink text-paper"
style={{ height: distance ? `calc(100svh + ${distance}px)` : undefined }}
>
<GridLines tone="dark" />
<div className={cn('flex flex-col overflow-hidden py-24 md:py-0', distance > 0 && 'md:sticky md:top-0 md:h-svh md:justify-center md:pt-16')}>
<div className="px-4 md:px-8">
<div className="mini flex justify-between gap-6 text-paper/60">
<span>Galerie</span>
<span>Aus dem Salon</span>
</div>
<RevealLines className="display mt-6 text-[clamp(2.1rem,5vw,4.5rem)]" lines={['Momente.']} />
</div>
<motion.div
ref={track}
style={{ x }}
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 1.1, ease: EASE_OUT_EXPO }}
className="mt-10 flex w-full snap-x snap-mandatory gap-3 overflow-x-auto px-4 [scrollbar-width:none] md:mt-12 md:w-max md:snap-none md:gap-4 md:overflow-visible md:px-8"
>
{moments.map((m, i) => (
<Moment key={m.title} m={m} i={i} />
))}
</motion.div>
{/* Fortschritt beim seitlichen Durchlaufen */}
{distance > 0 && (
<div className="mx-8 mt-8 hidden h-px bg-paper/15 md:block">
<motion.div style={{ scaleX: scrollYProgress }} className="h-full origin-left bg-paper" />
</div>
)}
</div>
</section>
)
}
+24 -17
View File
@@ -13,36 +13,43 @@ export function Hero() {
return ( return (
<section id="top" ref={ref} className="relative isolate h-svh min-h-[560px] overflow-hidden bg-ink text-paper"> <section id="top" ref={ref} className="relative isolate h-svh min-h-[560px] overflow-hidden bg-ink text-paper">
<motion.div style={{ y }} className="absolute inset-0 -z-20"> <motion.div style={{ y }} className="absolute inset-0 -z-20">
<motion.img {/* Handy: eigener Hochformat-Ausschnitt mit Amin und der Schere */}
src={image('photos/salon-wand')} <picture>
srcSet={`${image('photos/salon-wand-800')} 800w, ${image('photos/salon-wand')} 1317w`} <source media="(max-width: 767px)" srcSet={image('photos/hero-mobile')} />
sizes="100vw" <motion.img
alt={`Barber vor dem Logo an der Wand im Salon ${salon.name}`} src={image('photos/hero')}
fetchPriority="high" srcSet={`${image('photos/hero-1000')} 1000w, ${image('photos/hero')} 2000w`}
initial={{ scale: 1.15 }} sizes="100vw"
animate={{ scale: 1 }} alt={`${salon.name} schneidet einem Kunden mit Schere und Kamm die Haare`}
transition={{ duration: 2.2, ease: EASE_OUT_EXPO }} fetchPriority="high"
className="size-full object-cover object-[50%_60%] md:object-[50%_100%]" initial={{ scale: 1.15 }}
/> animate={{ scale: 1 }}
transition={{ duration: 2.2, ease: EASE_OUT_EXPO }}
className="size-full object-cover md:object-[50%_25%]"
/>
</picture>
</motion.div> </motion.div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-ink/35" /> <div aria-hidden="true" className="absolute inset-0 -z-10 bg-ink/30" />
<div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-40 bg-linear-to-b from-ink/60 to-transparent" /> {/* oben dunkler, damit die Navigation auf dem hellen Studiohintergrund lesbar bleibt */}
<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" /> <div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-44 bg-linear-to-b from-ink/80 to-transparent" />
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 -z-10 h-3/5 bg-linear-to-t from-ink/75 to-transparent" />
<div aria-hidden="true" className="absolute inset-y-0 left-0 -z-10 hidden w-3/5 bg-linear-to-r from-ink/35 to-transparent md:block" />
<div className="flex h-full flex-col items-center justify-end px-4 pb-10 text-center md:pb-14"> {/* Text unten links: Gesichter, Schere und Kamm bleiben frei */}
<div className="flex h-full flex-col items-center justify-end px-4 pb-10 text-center md:items-start md:px-8 md:pb-12 md:text-left">
<RevealLines <RevealLines
as="h1" as="h1"
onLoad onLoad
delay={0.3} delay={0.3}
srLabel={`${salon.name} – Barber & Friseur in ${salon.city}. Dein Look, unser Handwerk.`} srLabel={`${salon.name} – Barber & Friseur in ${salon.city}. Dein Look, unser Handwerk.`}
className="display text-[clamp(1.55rem,6.4vw,5.75rem)]" className="display text-[clamp(1.55rem,4.2vw,4.5rem)]"
lines={['Dein Look.', 'Unser Handwerk.']} lines={['Dein Look.', 'Unser Handwerk.']}
/> />
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.9, ease: EASE_OUT_EXPO }} transition={{ duration: 1, delay: 0.9, ease: EASE_OUT_EXPO }}
className="mt-6 md:mt-8" className="mt-6 md:mt-7"
> >
<Button tone="light" /> <Button tone="light" />
</motion.div> </motion.div>
+7 -7
View File
@@ -1,15 +1,15 @@
import { image, salon } from '../data/salon' import { salon } from '../data/salon'
import { FadeIn, ImageReveal, RevealLines } from './Reveal' import { Emblem } from './Emblem'
import { FadeIn, RevealLines } from './Reveal'
export function Intro() { export function Intro() {
return ( return (
<section id="salon" className="px-4 py-24 md:px-8 md:py-36"> <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"> <div className="grid gap-10 md:grid-cols-12 md:gap-8">
<ImageReveal {/* Logo wie auf Instagram: helle Linien auf dunklem Grund */}
src={image('photos/emblem')} <div className="grid aspect-square place-items-center bg-ink md:col-span-4">
alt={`Das Logo von ${salon.name} aus Edelstahl an der Schieferwand im Salon`} <Emblem className="w-[84%]" />
className="aspect-[4/5] md:col-span-4" </div>
/>
<div className="flex flex-col justify-between gap-12 md:col-span-8"> <div className="flex flex-col justify-between gap-12 md:col-span-8">
<div className="mini flex justify-between"> <div className="mini flex justify-between">
+15 -9
View File
@@ -1,15 +1,18 @@
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { BOOKING_URL, image, salon } from '../data/salon' import { BOOKING_URL, image, salon, team } from '../data/salon'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion' import { EASE_OUT_EXPO } from '../lib/motion'
import { scrollToHash, unlockScroll } from '../lib/scroll' import { scrollToHash, unlockScroll } from '../lib/scroll'
import { GridLines } from './GridLines' import { GridLines } from './GridLines'
import galleryCover from '../assets/gallery/twists.webp'
// Ein Bild oder – beim Team – ein 3 × 2 Raster aus allen Porträts
const items = [ const items = [
{ href: '#preise', label: 'Preise', photo: image('photos/emblem-quadrat') }, { href: '#preise', label: 'Preise', photos: [image('photos/hero-detail')] },
{ href: '#team', label: 'Team', photo: image('photos/mo-wand') }, { href: '#team', label: 'Team', photos: team.map((m) => m.thumb) },
{ href: '#kontakt', label: 'Kontakt', photo: image('photos/emblem-breit-900') }, { href: '#galerie', label: 'Galerie', photos: [galleryCover] },
{ href: '#kontakt', label: 'Kontakt', photos: [image('photos/emblem-breit-900')] },
] ]
/** Vollbild-Menü: große Links, daneben wechselt das Bild zum Link unter dem Mauszeiger. */ /** Vollbild-Menü: große Links, daneben wechselt das Bild zum Link unter dem Mauszeiger. */
@@ -72,15 +75,18 @@ export function Menu({ open, onClose }: { open: boolean; onClose: () => void })
<div className="relative hidden md:block"> <div className="relative hidden md:block">
{items.map((item, i) => ( {items.map((item, i) => (
<img <div
key={item.href} key={item.href}
src={item.photo}
alt=""
className={cn( className={cn(
'absolute inset-0 size-full object-cover transition-all duration-700 ease-out-expo', 'absolute inset-0 grid gap-[2px] transition-all duration-700 ease-out-expo',
item.photos.length > 1 && 'grid-cols-3',
active === i ? 'scale-100 opacity-100' : 'scale-105 opacity-0', active === i ? 'scale-100 opacity-100' : 'scale-105 opacity-0',
)} )}
/> >
{item.photos.map((src) => (
<img key={src} src={src} alt="" className="size-full min-h-0 object-cover" />
))}
</div>
))} ))}
</div> </div>
</div> </div>
-34
View File
@@ -1,6 +1,5 @@
import { motion, type Variants } from 'motion/react' import { motion, type Variants } from 'motion/react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
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 }
@@ -62,36 +61,3 @@ export function FadeIn({ children, className, delay = 0 }: { children: ReactNode
</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>
)
}
+1 -1
View File
@@ -149,7 +149,7 @@ const images = import.meta.glob<string>(['../assets/team/*.webp', '../assets/pho
eager: true, eager: true,
import: 'default', import: 'default',
}) })
/** Bild-URL aus `src/assets`, z. B. `image('photos/emblem')` */ /** Bild-URL aus `src/assets`, z. B. `image('photos/hero')` */
export const image = (path: string) => images[`../assets/${path}.webp`] export const image = (path: string) => images[`../assets/${path}.webp`]
export const team: Member[] = [ export const team: Member[] = [
+39
View File
@@ -72,6 +72,45 @@
box-shadow: inset -1px 0 0 var(--grid-line, rgb(10 9 12 / 0.07)); box-shadow: inset -1px 0 0 var(--grid-line, rgb(10 9 12 / 0.07));
} }
/* Logo-Emblem: Linien zeichnen sich, danach füllen sie sich (siehe Emblem.tsx) */
.emblem .emblem-lines path {
fill: none;
stroke: var(--color-paper);
stroke-width: 2;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
}
.emblem .emblem-fill {
opacity: 0;
}
.emblem[data-drawn] .emblem-lines path {
stroke-dashoffset: 0;
transition: stroke-dashoffset 2.6s var(--ease-out-expo) calc(var(--i) * 30ms);
}
.emblem[data-drawn] .emblem-lines {
opacity: 0;
transition: opacity 1s ease 2.6s;
}
.emblem[data-drawn] .emblem-fill {
opacity: 1;
transition: opacity 1.4s ease 1.4s;
}
@media (prefers-reduced-motion: reduce) {
.emblem .emblem-lines {
display: none;
}
.emblem .emblem-fill {
opacity: 1;
transition: none;
}
}
/* Rechtstexte (Impressum / Datenschutz) */ /* Rechtstexte (Impressum / Datenschutz) */
@layer components { @layer components {
.legal h2 { .legal h2 {