added site
82
DESIGN-SYSTEM.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Kumasy Design System — Glassmorphism
|
||||
|
||||
Dieses Dokument beschreibt den visuellen Stil der bestehenden App (`app.kumasy.de`), damit die neue Marketing-Seite (separates Next.js-Projekt) optisch als **eine Marke** erkennbar bleibt. Fokus: der durchsichtige "Glass"-Effekt, nicht die Farbpalette (die ist aktuell größtenteils neutral/grau und wird sich für Marketing ohnehin ändern).
|
||||
|
||||
Quelle der Wahrheit: [`src/app/globals.css`](src/app/globals.css) + tatsächlich verwendete Utility-Klassen in den Komponenten (siehe Beispiele unten). Es gibt **keine zentrale `.glass`-Klasse** — der Effekt wird pro Komponente aus denselben 3–4 Tailwind-Utilities zusammengesetzt.
|
||||
|
||||
## Das Grundrezept
|
||||
|
||||
Jede "Glass"-Fläche besteht immer aus derselben Kombination von vier Zutaten:
|
||||
|
||||
1. **Halbtransparenter Hintergrund** — `bg-white/XX` (Opacity meist zwischen 45–65%)
|
||||
2. **Weichzeichner dahinter** — `backdrop-blur-{sm|xl|2xl}`
|
||||
3. **Helle, dünne Kontur** — `border border-white/XX` (simuliert eine Lichtkante am Glasrand)
|
||||
4. **Weicher Schatten** statt harter Trennlinie — `shadow-sm` / `shadow-2xl` / ein custom `shadow-[...]`
|
||||
|
||||
Kein Layer nutzt schwere Farbverläufe oder Sättigung im Glas selbst — die Tiefe kommt aus Blur + Transparenz + Schatten, nicht aus Farbe.
|
||||
|
||||
## Belegte Beispiele aus dem Code
|
||||
|
||||
| Ort | Klassen | Wirkung |
|
||||
|---|---|---|
|
||||
| Dashboard-Sidebar — [`DashboardSidebar.tsx:118`](src/modules/dashboard/ui/components/DashboardSidebar.tsx:118) | `bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 shadow-[1px_0_0_0_oklch(0.85_0_0/30%)]` | Schwebende Sidebar, Content dahinter schimmert durch; Schatten ersetzt eine harte Randlinie |
|
||||
| Dashboard-Stat-Kacheln — [`DashboardStatsGrid.tsx:58`](src/modules/dashboard/ui/components/DashboardStatsGrid.tsx:58) | `border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm` | Freistehende KPI-Karten auf farbigem/strukturiertem Hintergrund |
|
||||
| Auth-Formular-Panel — [`AuthSidebarComponent.tsx:40`](src/modules/auth/ui/components/AuthSidebarComponent.tsx:40) | `border border-white/15 bg-blue/60 backdrop-blur-2xl shadow-2xl` | Stärkster Blur (2xl) + dunklere Glasfarbe (`--color-blue: #1e293b`) für Login/Register — dunkles, "Frosted-Glass"-Panel vor animiertem Gradient-Hintergrund |
|
||||
| Modal-Overlay — [`dialog.tsx:41`](src/components/ui/dialog.tsx:41) | `bg-black/50` (kein Blur) | Abdunklung hinter Dialogen — bewusst *kein* Blur, damit der Dialog-Inhalt scharf bleibt |
|
||||
| Public Business-Page Header — [`business-public-view.tsx:169`](src/modules/courses/ui/views/business-public-view.tsx:169) | `border-b border-gray-200 backdrop-blur-sm` | Leichter Blur für sticky Header, dezentester Grad der Glas-Familie |
|
||||
|
||||
## Intensitäts-Stufen (Blur-Skala)
|
||||
|
||||
Der Blur-Grad korreliert mit der visuellen Priorität der Fläche — nicht willkürlich gewählt:
|
||||
|
||||
- **`backdrop-blur-sm`** — sticky Header, unaufdringliche UI-Elemente, die kaum auffallen sollen
|
||||
- **`backdrop-blur-xl`** — Standard für Sidebars, Karten, primäre Content-Flächen
|
||||
- **`backdrop-blur-2xl`** — Hero-/Auth-Panels, wo maximale "Premium-Glas"-Wirkung gewünscht ist
|
||||
|
||||
**Empfehlung für Marketing-Seite:** Hero-Sections und Waitlist-Formular-Karte auf `2xl`, Sticky-Nav auf `sm`–`md`, normale Content-Cards auf `xl`.
|
||||
|
||||
## Transparenz-Werte (Opacity-Skala)
|
||||
|
||||
- `/15` – `/30` — sehr subtil, meist auf **dunklem** Untergrund (Auth-Panel-Border)
|
||||
- `/45` – `/60` — Standard für die meisten Flächen (Sidebar, Stat-Cards)
|
||||
- `/70` und höher — kommt vor, wirkt aber schon fast opak; für Marketing eher vermeiden, sonst geht der Glass-Effekt verloren
|
||||
|
||||
Faustregel: **je heller/aktiver der Hintergrund dahinter (Gradient, Bild, Blob-Animation), desto niedriger die Opacity** — sonst verschluckt die Fläche den Effekt, den sie eigentlich zeigen soll.
|
||||
|
||||
## Ergänzende Bewegungs-Elemente
|
||||
|
||||
Der Glass-Effekt wird im Auth-Bereich mit einem **animierten Hintergrund** kombiniert (siehe [`globals.css:127-178`](src/app/globals.css:127)):
|
||||
|
||||
```css
|
||||
.auth-animated-gradient {
|
||||
background: linear-gradient(120deg, oklch(0.97 0 0) 0%, oklch(0.9 0.05 240) 25%, ...);
|
||||
background-size: 300% 300%;
|
||||
animation: auth-gradient-shift 18s ease-in-out infinite;
|
||||
}
|
||||
.auth-gradient-blob { animation: auth-blob-float 16s ease-in-out infinite; }
|
||||
```
|
||||
|
||||
- Ein langsam wandernder Gradient (18s Loop) hinter den Glasflächen — sorgt dafür, dass der Blur überhaupt etwas "zu verwischen" hat.
|
||||
- Zwei "Blobs" mit versetzter `animation-delay` (`-8s`), die leicht schweben/skalieren — erzeugt organische Bewegung statt eines statischen Verlaufs.
|
||||
- `prefers-reduced-motion: reduce` deaktiviert beide Animationen komplett — **Accessibility-Pflicht**, für die Marketing-Seite 1:1 übernehmen.
|
||||
|
||||
**Für Parallax/Marketing relevant:** Dieses Muster (bewegter Gradient/Blobs im Hintergrund + ruhige Glasflächen im Vordergrund) ist die bestehende Design-Sprache für "besondere" Screens (Auth) und eignet sich direkt als Vorlage für eine Hero-Section mit Parallax-Tiefe.
|
||||
|
||||
## Radien & Schatten-Konventionen
|
||||
|
||||
- Basis-Radius-Variable: `--radius: 0.625rem` (10px), abgeleitet in `sm` (−4px), `md` (−2px), `lg` (Basis), `xl` (+4px) — siehe [`globals.css:40-43`](src/app/globals.css:40)
|
||||
- Glasflächen nutzen meist `rounded-2xl` (Stat-Cards) oder die Sidebar-Komponente ihren eigenen Radius
|
||||
- Schatten sind fast immer `shadow-sm` (dezent, für ruhige Flächen) oder `shadow-2xl` (für hervorgehobene Panels wie Auth) — nie mittlere Stufen, es gibt keinen `shadow-md`/`shadow-lg` im Glass-Kontext
|
||||
|
||||
## Was NICHT Teil des Glass-Systems ist
|
||||
|
||||
- Der Basis-`Card`-Component ([`card.tsx`](src/components/ui/card.tsx)) ist **opak** (`bg-card`, keine Transparenz) — Glass ist ein bewusst gewähltes Extra pro Screen, keine globale Card-Eigenschaft.
|
||||
- Modal-Overlays (`dialog.tsx`, `sheet.tsx`) nutzen `bg-black/50` **ohne** Blur — Verdunklung ja, Verwischen nein, damit der eigentliche Dialog-Inhalt scharf bleibt.
|
||||
- Die aktuelle Farbpalette (neutrale Grautöne via `oklch(... 0 0)`, siehe [`globals.css:49-116`](src/app/globals.css:49)) ist funktional/App-artig gehalten und explizit **nicht** der Teil, der für die Marketing-Seite übernommen werden soll (siehe Auftrag).
|
||||
|
||||
## Übertragung auf die Marketing-Seite (Empfehlung)
|
||||
|
||||
1. **Basis-Utility statt Copy-Paste:** In der neuen App eine `glass` bzw. `glass-{sm,xl,2xl}`-Klasse (oder CVA-Variante) zentral definieren, die die vier Zutaten (bg-opacity, blur, border, shadow) bündelt — vermeidet die Streuung, die im Hauptprojekt aktuell besteht.
|
||||
2. **Hero:** animierter Gradient/Blob-Hintergrund (Pattern aus `globals.css` wiederverwenden/weiterentwickeln) + Glass-Card (`2xl`-Blur) für Headline/CTA/Waitlist-Formular davor — erzeugt sofort Wiedererkennung mit der App.
|
||||
3. **Parallax-Layering:** Glass-Flächen eignen sich gut als "vorderste" Ebene in einem Parallax-Stack, da der Blur zusätzliche Tiefenwahrnehmung erzeugt, wenn sich der Hintergrund beim Scrollen schneller/langsamer bewegt als die Glasfläche selbst.
|
||||
4. **Reduced-Motion & Performance:** `prefers-reduced-motion`-Guard von Anfang an einbauen; `backdrop-blur` ist GPU-Kosten nicht zu unterschätzen — auf Mobile ggf. auf `blur-sm`/`md` reduzieren.
|
||||
487
DESIGN.md
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
# Portrait — Style Reference
|
||||
> polaroid memory wall on cream paper. A bright, off-white scrapbook where deep-navy ink provides the only text, the only structural lines, and the only border color — with a single hand-drawn rainbow that appears once, on the Sign up button and on the italicized word forever, and never again.
|
||||
|
||||
**Theme:** light
|
||||
|
||||
Portrait is a sunlit, deeply personal canvas for identity: a near-white page where a single deep-navy ink carries almost all text and structural lines, and color appears only as warm pastel surface washes or a signature rainbow that bleeds through italicized words, button borders, and small decorative strokes. The type system is two-voice — Switzer for steady UI, Basier Circle for giant display headlines that compress tight against each other — and the geometry favors generous rounding (24px cards, 28px pill buttons) with barely-there shadows that let content float rather than stamp itself. Everything reads like a scrapbook: scattered tilted photo cards, mint and peach and sky-blue tints, a thin rainbow border around the Sign up button, and a sticky pill nav that hovers above the page with a whisper of elevation. The overall density is comfortable, the rhythm is calm, and the brand voice is warm but restrained — let the user be loud, keep the frame quiet.
|
||||
|
||||
## Tokens — Colors
|
||||
|
||||
| Name | Value | Token | Role |
|
||||
|------|-------|-------|------|
|
||||
| Portrait Ink | `#08304c` | `--color-portrait-ink` | Primary text, heading strokes, outlined action borders — the single deep navy that holds the entire type and structural line system |
|
||||
| Nautical Teal | `#084e72` | `--color-nautical-teal` | Secondary brand ink for nav strokes, icons, and accent text where Ink feels too heavy |
|
||||
| Rainbow Spectrum | `linear-gradient(90deg, rgb(38, 192, 255), rgb(230, 0, 194) 20%, rgb(255, 73, 78) 40%, rgb(255, 161, 62) 60%, rgb(255, 200, 55) 80%, rgb(0, 204, 61))` | `--color-rainbow-spectrum` | Supporting palette color for small decorative accents when the core palette needs contrast. Do not promote it to the primary CTA color |
|
||||
| Lavender Violet | `#8e51ff` | `--color-lavender-violet` | Decorative spectrum color — appears only as part of the rainbow gradient, never as a standalone fill |
|
||||
| Cobalt Pop | `#3b82f6` | `--color-cobalt-pop` | Decorative spectrum color — appears only as part of the rainbow gradient composition |
|
||||
| Grape Vibrant | `#ad46ff` | `--color-grape-vibrant` | Decorative spectrum color — appears only as part of the rainbow gradient composition |
|
||||
| Cherry Red | `#ff4940` | `--color-cherry-red` | Decorative spectrum color — appears only as part of the rainbow gradient composition |
|
||||
| Tangerine Warm | `#ffa130` | `--color-tangerine-warm` | Decorative spectrum color — appears only as part of the rainbow gradient composition |
|
||||
| Sunflower Yellow | `#ffc837` | `--color-sunflower-yellow` | Decorative spectrum color — appears only as part of the rainbow gradient composition |
|
||||
| Leaf Green | `#00cc3d` | `--color-leaf-green` | Decorative spectrum color — appears only as part of the rainbow gradient composition |
|
||||
| Charcoal Outline | `#353535` | `--color-charcoal-outline` | Universal stroke and heavy text color — every hairline border, icon outline, and default UI line lives here |
|
||||
| Graphite Body | `#2c2c2c` | `--color-graphite-body` | Body text and icon strokes where Portrait Ink feels too cool — slightly warmer reading tone |
|
||||
| Slate Helper | `#797979` | `--color-slate-helper` | Muted helper text, secondary nav text, and decorative borders that should recede behind primary ink |
|
||||
| Iron Quiet | `#585858` | `--color-iron-quiet` | Quiet icon strokes and low-emphasis nav accents |
|
||||
| Ash Divider | `#dedede` | `--color-ash-divider` | Light dividers and structural hairlines on neutral surfaces |
|
||||
| Fog Edge | `#c7c7c7` | `--color-fog-edge` | Disabled state borders and placeholder strokes |
|
||||
| Mist Hairline | `#eeeeee` | `--color-mist-hairline` | Card surface tint and the lightest possible structural divider |
|
||||
| White Canvas | `#ffffff` | `--color-white-canvas` | Page background and the universal surface above which all color floats |
|
||||
| Mint Wash | `#d7ffe2` | `--color-mint-wash` | Soft pastel surface tint used on badge chips and small highlighted areas |
|
||||
| Sky Wash | `#e8f1ff` | `--color-sky-wash` | Soft section background, alternate surface, and quiet card fill |
|
||||
| Peach Wash | `#ffebd6` | `--color-peach-wash` | Soft pastel surface tint used on warm-accent chips and highlighted areas |
|
||||
|
||||
## Tokens — Typography
|
||||
|
||||
### Switzer — Universal body and UI face — paragraphs, buttons, nav, links, tags, helper text, and small display moments. Reads neutral, geometric, and friendly. The 0.14em tracked all-caps variant at 10–11px is reserved for tiny eyebrow labels and New badges. · `--font-switzer`
|
||||
- **Substitute:** Inter, Manrope, or General Sans
|
||||
- **Weights:** 400, 500, 540, 600, 700
|
||||
- **Sizes:** 10, 11, 12, 14, 16, 18, 20, 24, 36px
|
||||
- **Line height:** 1.00–1.50
|
||||
- **Letter spacing:** 0.0250em at small sizes, 0.1400em for all-caps eyebrow labels
|
||||
- **OpenType features:** `"liga" 0`
|
||||
- **Role:** Universal body and UI face — paragraphs, buttons, nav, links, tags, helper text, and small display moments. Reads neutral, geometric, and friendly. The 0.14em tracked all-caps variant at 10–11px is reserved for tiny eyebrow labels and New badges.
|
||||
|
||||
### Basier Circle — Headline and hero voice only — reserved for the largest statements and key brand moments. The dramatic negative tracking at 76px (about -4.25px) pulls characters together so multi-line headlines lock into a tight block rather than a loose stack. Mixed with Switzer for subhead and body. · `--font-basier-circle`
|
||||
- **Substitute:** Plus Jakarta Sans, General Sans, or Satoshi
|
||||
- **Weights:** 500, 600
|
||||
- **Sizes:** 16, 18, 20, 31, 39, 44, 49, 76px
|
||||
- **Line height:** 1.00–1.20
|
||||
- **Letter spacing:** -0.056em at 76px down to -0.013em at 16px — the display type breathes in by nearly 4px at the largest size
|
||||
- **Role:** Headline and hero voice only — reserved for the largest statements and key brand moments. The dramatic negative tracking at 76px (about -4.25px) pulls characters together so multi-line headlines lock into a tight block rather than a loose stack. Mixed with Switzer for subhead and body.
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Role | Size | Line Height | Letter Spacing | Token |
|
||||
|------|------|-------------|----------------|-------|
|
||||
| caption | 10px | 1.5 | 1.4px | `--text-caption` |
|
||||
| body | 16px | 1.5 | — | `--text-body` |
|
||||
| body-lg | 18px | 1.45 | — | `--text-body-lg` |
|
||||
| subheading | 20px | 1.43 | -0.26px | `--text-subheading` |
|
||||
| heading-sm | 31px | 1.1 | -0.4px | `--text-heading-sm` |
|
||||
| heading | 44px | 1.08 | -1.15px | `--text-heading` |
|
||||
| heading-lg | 49px | 1.04 | -1.96px | `--text-heading-lg` |
|
||||
| display | 76px | 1 | -4.25px | `--text-display` |
|
||||
|
||||
## Tokens — Spacing & Shapes
|
||||
|
||||
**Base unit:** 4px
|
||||
|
||||
**Density:** comfortable
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
| Name | Value | Token |
|
||||
|------|-------|-------|
|
||||
| 4 | 4px | `--spacing-4` |
|
||||
| 8 | 8px | `--spacing-8` |
|
||||
| 12 | 12px | `--spacing-12` |
|
||||
| 16 | 16px | `--spacing-16` |
|
||||
| 20 | 20px | `--spacing-20` |
|
||||
| 24 | 24px | `--spacing-24` |
|
||||
| 28 | 28px | `--spacing-28` |
|
||||
| 32 | 32px | `--spacing-32` |
|
||||
| 40 | 40px | `--spacing-40` |
|
||||
| 48 | 48px | `--spacing-48` |
|
||||
| 56 | 56px | `--spacing-56` |
|
||||
| 64 | 64px | `--spacing-64` |
|
||||
| 80 | 80px | `--spacing-80` |
|
||||
| 128 | 128px | `--spacing-128` |
|
||||
| 144 | 144px | `--spacing-144` |
|
||||
| 160 | 160px | `--spacing-160` |
|
||||
|
||||
### Border Radius
|
||||
|
||||
| Element | Value |
|
||||
|---------|-------|
|
||||
| nav | 28px |
|
||||
| tags | 9999px |
|
||||
| cards | 24px |
|
||||
| images | 24px |
|
||||
| inputs | 16px |
|
||||
| buttons | 28px |
|
||||
|
||||
### Shadows
|
||||
|
||||
| Name | Value | Token |
|
||||
|------|-------|-------|
|
||||
| md | `rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.0...` | `--shadow-md` |
|
||||
| subtle | `oklab(0 0 0 / 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, 0.03) ...` | `--shadow-subtle` |
|
||||
| subtle-2 | `oklab(0 0 0 / 0.04) 0px 0px 0px 1px, rgba(0, 0, 0, 0.03) ...` | `--shadow-subtle-2` |
|
||||
| subtle-3 | `oklab(0 0 0 / 0.06) 0px 0px 0px 1px, rgba(0, 0, 0, 0.07) ...` | `--shadow-subtle-3` |
|
||||
| subtle-4 | `oklab(0 0 0 / 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 1...` | `--shadow-subtle-4` |
|
||||
| subtle-5 | `oklab(0 0 0 / 0.1) 0px 0px 0px 1px` | `--shadow-subtle-5` |
|
||||
|
||||
### Layout
|
||||
|
||||
- **Page max-width:** 1200px
|
||||
- **Section gap:** 80px
|
||||
- **Card padding:** 16px
|
||||
- **Element gap:** 16px
|
||||
|
||||
## Components
|
||||
|
||||
### Floating Pill Nav
|
||||
**Role:** Primary site navigation
|
||||
|
||||
White pill-shaped bar, 28px radius, floating centered above the page with a soft 6-layer shadow. Contains the Portrait wordmark with a tiny rainbow square icon, a center 'New — Introducing Portrait Wallet' announcement pill, and Login + Sign up actions on the right. Height approximately 56px, horizontal padding 20px.
|
||||
|
||||
### Rainbow Outline CTA
|
||||
**Role:** Primary sign-up action
|
||||
|
||||
Pill button, 28px radius, transparent fill, 1.5px rainbow-gradient border (blue → magenta → red → orange → yellow → green at 90deg). Text in Portrait Ink (#08304c), 16px Switzer 500, 16px horizontal padding by 10px vertical. The most visible branded element on the page.
|
||||
|
||||
### Ghost Text Button
|
||||
**Role:** Secondary nav and inline action
|
||||
|
||||
No background, no border. Text in Portrait Ink, 14–16px Switzer 500. Used for Login and 'Already have a Portrait? Login'. Sits quietly next to the rainbow CTA.
|
||||
|
||||
### Domain Search Input
|
||||
**Role:** URL claim field in hero
|
||||
|
||||
Large pill input, ~9999px radius, white fill with a very faint border. 60–64px tall, ~600px wide. Contains light gray 'portrait.so/' prefix text and a darker typed username. Internal border-radius nested with the rainbow CTA inside it, so the two share one continuous border line.
|
||||
|
||||
### Photo Profile Card
|
||||
**Role:** Tile showing a user piece — photo, link card, or article preview
|
||||
|
||||
White card, 24px radius, 1px oklab outline at 4% opacity, optional soft multi-layer shadow. Contains an image area with 24px top radius, a title in Portrait Ink, a short meta line in Slate Helper, and sometimes a small external-link arrow. Padding 16px on text areas. Tiles appear in a varied grid — some taller (2:3 portrait), some wider (16:9).
|
||||
|
||||
### Article Snippet Card
|
||||
**Role:** Linked blog or note preview
|
||||
|
||||
White card, 24px radius, 1px outline. Compact horizontal layout: small square thumbnail, a date in Slate Helper at 12px, a two-line headline in Portrait Ink at 14–16px Switzer 500, and a Read more link with an external arrow. Tight 12–16px internal padding.
|
||||
|
||||
### Eyebrow Label Pill
|
||||
**Role:** Small status or category tag
|
||||
|
||||
Tiny pill, 9999px radius, one of the pastel wash backgrounds (#d7ffe2, #e8f1ff, #ffebd6). Text in matching darker tone, 10–11px Switzer 600 with 0.14em tracking, uppercase. Used for 'New', 'Early access', and category labels. 3px vertical, 8px horizontal padding.
|
||||
|
||||
### Wallet Feature Card
|
||||
**Role:** Product feature preview for Portrait Wallet
|
||||
|
||||
Small horizontal card with the wallet card thumbnail on the left, 'Portrait Wallet' title, a 'New' pill in pastel, and 'Get early access now' helper text. Right arrow icon in Charcoal Outline. White background, 16–24px radius, subtle outline.
|
||||
|
||||
### Display Headline Block
|
||||
**Role:** Hero and section headlines
|
||||
|
||||
Basier Circle 600 at 44–76px, line-height 1.0–1.08, negative letter-spacing up to -4.25px. Portrait Ink color. One italicized word inside the headline is filled with the rainbow gradient (currently 'forever' and 'one'). The compression of the type makes the block feel like a single sculptural mass.
|
||||
|
||||
### Hero Photo Collage
|
||||
**Role:** Scattered photo tiles around the hero headline
|
||||
|
||||
Individual photo cards at varied sizes, each 24px radius, tilted at 3–6 degrees, partially overlapping page edges. No drop shadow — they read as Polaroids pressed flat against the canvas with the same 1px hairline outline. White background bleeds through any rotation gap.
|
||||
|
||||
### Announcement Pill
|
||||
**Role:** In-nav promotional badge
|
||||
|
||||
Small pill inside the floating nav, sky-blue (#e8f1ff) background, 'New' label in pastel ink, followed by 'Introducing Portrait Wallet' in Portrait Ink, and a tiny down-arrow icon. 6–8px vertical padding, 12px horizontal.
|
||||
|
||||
### Hero Subtext
|
||||
**Role:** Supporting paragraph below the display headline
|
||||
|
||||
Switzer 400 at 18px, line-height 1.45, Portrait Ink or Graphite Body. Centered, max-width approximately 520px, sits 24–32px below the headline block.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use the full-spectrum linear-gradient (blue → magenta → red → orange → yellow → green at 90deg) as a 1.5px outline on at most one CTA per view, and on at most one italicized word per headline.
|
||||
- Set body type in Switzer 400 at 16–18px with 1.45–1.5 line-height; reserve Basier Circle for display sizes 31px and above.
|
||||
- Pull the headline tracking tight: -4.25px at 76px, -1.96px at 49px, -1.15px at 44px. The compression is what makes the display feel sculptural.
|
||||
- Use Portrait Ink (#08304c) as the single text and structural-line color across the entire interface; switch to Graphite Body (#2c2c2c) only for body copy that sits on a warm or pastel background.
|
||||
- Cap every shadow at 8% black opacity, and stack 3–6 thin layers with negative offsets rather than a single heavy drop. Add a 1px oklab hairline at 4–10% opacity instead of a shadow when the surface needs separation.
|
||||
- Use 24px radius for cards, 28px radius for buttons, 9999px for tags and small chips. The card/button differential is deliberate — buttons are slightly rounder than the cards they sit on.
|
||||
- Keep the page canvas pure white (#ffffff) and let color appear only as soft pastel washes on small surfaces, never as a full section background.
|
||||
|
||||
### Don't
|
||||
- Do not introduce a new brand hue — the palette is two navy inks plus a single rainbow gradient. Any additional chromatic color dilutes the signature.
|
||||
- Do not use filled colored buttons. The primary action is always a rainbow-outlined pill; secondary actions are ghost text only.
|
||||
- Do not add heavy drop shadows. A shadow darker than rgba(0,0,0,0.1) breaks the paper-on-paper language.
|
||||
- Do not set body or display type in the rainbow gradient. The gradient is reserved for borders and one italicized word per headline — full-text rainbow reads as a different brand entirely.
|
||||
- Do not mix Switzer and Basier Circle at the same size. Switzer owns 10–24px, Basier Circle owns 31px and up. Same-size mixing looks like a font fallback error.
|
||||
- Do not use a sans-serif fallback for Basier Circle's negative tracking. The compression is the brand — only a geometric humanist with matching proportions should substitute.
|
||||
- Do not center-align body paragraphs longer than three lines; the centered hero subtext is a one-off compositional choice, not a layout default.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Level | Name | Value | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| 1 | Canvas | `#ffffff` | Page background — the warm white paper the entire scrapbook sits on |
|
||||
| 2 | Card | `#ffffff` | Photo cards, profile tiles, and content cards float on white with a barely-perceptible 1px outline |
|
||||
| 3 | Sticky Nav | `#ffffff` | Floating pill navigation bar — same white as the canvas but elevated by a soft shadow |
|
||||
| 4 | Tinted Wash | `#e8f1ff` | Pale sky-blue background for highlighted nav items, info chips, and soft category badges |
|
||||
|
||||
## Elevation
|
||||
|
||||
- **Floating Nav Bar:** `0 16px 16px -8px rgba(0,0,0,0.03), 0 10px 10px -5px rgba(0,0,0,0.03), 0 5px 5px -2.5px rgba(0,0,0,0.03), 0 3px 3px -1.5px rgba(0,0,0,0.03), 0 2px 2px -1px rgba(0,0,0,0.03), 0 1px 1px -0.5px rgba(0,0,0,0.03)`
|
||||
- **Profile Card:** `0 0 0 1px oklab(0 0 0 / 0.08), 0 16px 16px -8px rgba(0,0,0,0.03), 0 10px 10px -5px rgba(0,0,0,0.03), 0 5px 5px -2.5px rgba(0,0,0,0.03), 0 3px 3px -1.5px rgba(0,0,0,0.03), 0 2px 2px -1px rgba(0,0,0,0.03), 0 1px 1px -0.5px rgba(0,0,0,0.03)`
|
||||
- **Elevated Card:** `0 0 0 1px oklab(0 0 0 / 0.06), 0 20px 20px -10px rgba(0,0,0,0.07), 0 10px 10px -5px rgba(0,0,0,0.04), 0 5px 5px -2.5px rgba(0,0,0,0.03), 0 3px 3px -1.5px rgba(0,0,0,0.03), 0 2px 2px -1px rgba(0,0,0,0.03), 0 1px 1px -0.5px rgba(0,0,0,0.03)`
|
||||
|
||||
## Imagery
|
||||
|
||||
Imagery is photographic, full-bleed inside Polaroid-style cards, and treated as personal memorabilia rather than product shots: outdoor portraits, candid smiles, golden retriever in a meadow, friends at a restaurant, a person on a cliffside, gap-year Indonesia travel, smoothies in a green cup. Every photo is cropped tight on its subject, sits inside a 24px-radius card with a 1px hairline outline, and floats at a slight rotation in the hero collage. No illustrations, no 3D renders, no abstract graphics. The only product visual is the orange leather wallet card mock in the Wallet section. Color treatment is natural and unfiltered — slightly warm, slightly soft, with the kind of color cast you get from a phone camera in golden hour. The orange wallet and the dark green smoothie are the only images that carry strong saturated color; everything else is desaturated enough to recede behind the rainbow accent.
|
||||
|
||||
## Layout
|
||||
|
||||
Centered, max-width 1200px contained layout with generous breathing room. The hero is a single centered stack: eyebrow text → 2–3 line display headline with one rainbow-italicized word → subtext paragraph → domain search input with embedded rainbow CTA → login link. Tilted photo cards scatter around the hero edges, partially cropped by the viewport, as if pinned to a wall. Below the hero, a profile card grid (masonry-like, 2–4 columns) shows the user's tiles at varied aspect ratios — portrait photos, horizontal link cards, article snippets. Sections are separated by 80px vertical gaps with no dividers or alternating background bands; the entire page is one continuous white canvas. A sticky floating pill nav hovers at the top, centered, with a soft drop shadow. A final 'Portrait Wallet' promotion card sits centered at the bottom of the page as a standalone product teaser. Navigation is the pill nav only — no sidebar, no mega-menu, no footer beyond minimal links.
|
||||
|
||||
## Agent Prompt Guide
|
||||
|
||||
**Quick Color Reference**
|
||||
- text: #08304c (Portrait Ink)
|
||||
- background: #ffffff
|
||||
- border: #353535 (Charcoal Outline) at 1px, or pastel wash on chips
|
||||
- accent: rainbow gradient — linear-gradient(90deg, #26c0ff, #e600c2 20%, #ff4940 40%, #ffa130 60%, #ffc837 80%, #00cc3d)
|
||||
- pastel washes: #d7ffe2, #e8f1ff, #ffebd6
|
||||
- primary action: #08304c (outlined action border)
|
||||
|
||||
**Example Component Prompts**
|
||||
|
||||
1. Create a hero headline: 76px Basier Circle weight 600, line-height 1.0, letter-spacing -4.25px, color #08304c. Insert the italicized word 'forever' and fill it with the rainbow linear-gradient(90deg, #26c0ff, #e600c2 20%, #ff4940 40%, #ffa130 60%, #ffc837 80%, #00cc3d). Center the block at max-width 900px.
|
||||
|
||||
2. Create a rainbow-outline pill button: 28px border-radius, transparent background, 1.5px border using the full rainbow gradient (90deg, six stops), 16px Switzer 500 text in #08304c, padding 10px 18px. The only saturated UI action in the system.
|
||||
|
||||
3. Create a photo profile card: white surface, 24px border-radius, 1px outline in oklab(0 0 0 / 0.04), padding 0 on the image (16px on text), Basier Circle or Switzer 500 title in #08304c at 16px, Slate Helper (#797979) meta line at 12px Switzer 400, optional 10px 16px 14px shadow at rgba(0,0,0,0.04).
|
||||
|
||||
4. Create a pastel eyebrow pill: 9999px radius, #e8f1ff background, text 'New' in matching darker tone at 10px Switzer 600 uppercase with 0.14em letter-spacing, 3px vertical and 8px horizontal padding.
|
||||
|
||||
5. Create a floating sticky nav: 28px radius, white background, soft 6-layer shadow capped at 3% opacity each, 56px height, 20px horizontal padding. Contains a small rainbow square icon + 'Portrait' wordmark, a center 'New — Introducing Portrait Wallet' pill, and a Login ghost link + a rainbow-outline Sign up pill on the right.
|
||||
|
||||
## Gradient System
|
||||
|
||||
Portrait uses exactly one signature gradient — the full-spectrum rainbow — and reuses it across three contexts only:
|
||||
|
||||
1. A 1.5px border on the primary Sign up button (90deg, evenly spaced from blue to green).
|
||||
2. A text fill on at most one italicized word per headline (90deg, same stops).
|
||||
3. A tiny 8–12px square brand icon next to the wordmark (90deg, same stops).
|
||||
|
||||
A second, much rarer gradient appears on the Wallet card mock — a 165–170deg spectrum used to simulate light bending across the leather texture, with a dark navy segment splitting the rainbow into two halves. This is product-specific, not a system token.
|
||||
|
||||
A single soft radial gradient (lavender at 78% → 44% → transparent) appears once on a hero background, used to create a subtle warm-cool falloff behind the headline. Do not reuse it elsewhere.
|
||||
|
||||
## Iconography
|
||||
|
||||
Icons are 1.5–2px stroke, rounded line caps, drawn from a single outlined set. Color is Charcoal Outline (#353535) by default, Portrait Ink (#08304c) when sitting on a pastel wash, and white when on a dark surface. No filled icons, no multicolor icons, no icons that mimic the rainbow. The only 'iconic' brand mark is the 8–12px square rainbow gradient swatch used as the Portrait favicon, nav mark, and tab indicator.
|
||||
|
||||
## Similar Brands
|
||||
|
||||
- **Linktree** — Same centered hero with a domain-claim input as the primary action, scattered card grid below, and a single CTA on each screen
|
||||
- **Bento** — Same scrapbook-card aesthetic with varied tile aspect ratios, tight hairline outlines, and one accent hue driving the brand
|
||||
- **Read.cv** — Same restrained two-voice type system, generous card radii, and warm off-white canvas treating the page as a personal portfolio
|
||||
- **Candy Mail** — Same playful use of a single rainbow gradient as the only saturated accent on a calm, light, type-led interface
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-portrait-ink: #08304c;
|
||||
--color-nautical-teal: #084e72;
|
||||
--color-rainbow-spectrum: #26c0ff;
|
||||
--gradient-rainbow-spectrum: linear-gradient(90deg, rgb(38, 192, 255), rgb(230, 0, 194) 20%, rgb(255, 73, 78) 40%, rgb(255, 161, 62) 60%, rgb(255, 200, 55) 80%, rgb(0, 204, 61));
|
||||
--color-lavender-violet: #8e51ff;
|
||||
--color-cobalt-pop: #3b82f6;
|
||||
--color-grape-vibrant: #ad46ff;
|
||||
--color-cherry-red: #ff4940;
|
||||
--color-tangerine-warm: #ffa130;
|
||||
--color-sunflower-yellow: #ffc837;
|
||||
--color-leaf-green: #00cc3d;
|
||||
--color-charcoal-outline: #353535;
|
||||
--color-graphite-body: #2c2c2c;
|
||||
--color-slate-helper: #797979;
|
||||
--color-iron-quiet: #585858;
|
||||
--color-ash-divider: #dedede;
|
||||
--color-fog-edge: #c7c7c7;
|
||||
--color-mist-hairline: #eeeeee;
|
||||
--color-white-canvas: #ffffff;
|
||||
--color-mint-wash: #d7ffe2;
|
||||
--color-sky-wash: #e8f1ff;
|
||||
--color-peach-wash: #ffebd6;
|
||||
|
||||
/* Typography — Font Families */
|
||||
--font-switzer: 'Switzer', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-basier-circle: 'Basier Circle', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Typography — Scale */
|
||||
--text-caption: 10px;
|
||||
--leading-caption: 1.5;
|
||||
--tracking-caption: 1.4px;
|
||||
--text-body: 16px;
|
||||
--leading-body: 1.5;
|
||||
--text-body-lg: 18px;
|
||||
--leading-body-lg: 1.45;
|
||||
--text-subheading: 20px;
|
||||
--leading-subheading: 1.43;
|
||||
--tracking-subheading: -0.26px;
|
||||
--text-heading-sm: 31px;
|
||||
--leading-heading-sm: 1.1;
|
||||
--tracking-heading-sm: -0.4px;
|
||||
--text-heading: 44px;
|
||||
--leading-heading: 1.08;
|
||||
--tracking-heading: -1.15px;
|
||||
--text-heading-lg: 49px;
|
||||
--leading-heading-lg: 1.04;
|
||||
--tracking-heading-lg: -1.96px;
|
||||
--text-display: 76px;
|
||||
--leading-display: 1;
|
||||
--tracking-display: -4.25px;
|
||||
|
||||
/* Typography — Weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-w540: 540;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-unit: 4px;
|
||||
--spacing-4: 4px;
|
||||
--spacing-8: 8px;
|
||||
--spacing-12: 12px;
|
||||
--spacing-16: 16px;
|
||||
--spacing-20: 20px;
|
||||
--spacing-24: 24px;
|
||||
--spacing-28: 28px;
|
||||
--spacing-32: 32px;
|
||||
--spacing-40: 40px;
|
||||
--spacing-48: 48px;
|
||||
--spacing-56: 56px;
|
||||
--spacing-64: 64px;
|
||||
--spacing-80: 80px;
|
||||
--spacing-128: 128px;
|
||||
--spacing-144: 144px;
|
||||
--spacing-160: 160px;
|
||||
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--section-gap: 80px;
|
||||
--card-padding: 16px;
|
||||
--element-gap: 16px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-2xl-2: 20px;
|
||||
--radius-3xl: 24px;
|
||||
--radius-3xl-2: 28px;
|
||||
--radius-3xl-3: 32px;
|
||||
|
||||
/* Named Radii */
|
||||
--radius-nav: 28px;
|
||||
--radius-tags: 9999px;
|
||||
--radius-cards: 24px;
|
||||
--radius-images: 24px;
|
||||
--radius-inputs: 16px;
|
||||
--radius-buttons: 28px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-md: rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.03) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle: oklab(0 0 0 / 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.03) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle-2: oklab(0 0 0 / 0.04) 0px 0px 0px 1px, rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.03) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle-3: oklab(0 0 0 / 0.06) 0px 0px 0px 1px, rgba(0, 0, 0, 0.07) 0px 20px 20px -10px, rgba(0, 0, 0, 0.04) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle-4: oklab(0 0 0 / 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 10px 16px 14px 0px;
|
||||
--shadow-subtle-5: oklab(0 0 0 / 0.1) 0px 0px 0px 1px;
|
||||
|
||||
/* Surfaces */
|
||||
--surface-canvas: #ffffff;
|
||||
--surface-card: #ffffff;
|
||||
--surface-sticky-nav: #ffffff;
|
||||
--surface-tinted-wash: #e8f1ff;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind v4
|
||||
|
||||
```css
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-portrait-ink: #08304c;
|
||||
--color-nautical-teal: #084e72;
|
||||
--color-rainbow-spectrum: #26c0ff;
|
||||
--color-lavender-violet: #8e51ff;
|
||||
--color-cobalt-pop: #3b82f6;
|
||||
--color-grape-vibrant: #ad46ff;
|
||||
--color-cherry-red: #ff4940;
|
||||
--color-tangerine-warm: #ffa130;
|
||||
--color-sunflower-yellow: #ffc837;
|
||||
--color-leaf-green: #00cc3d;
|
||||
--color-charcoal-outline: #353535;
|
||||
--color-graphite-body: #2c2c2c;
|
||||
--color-slate-helper: #797979;
|
||||
--color-iron-quiet: #585858;
|
||||
--color-ash-divider: #dedede;
|
||||
--color-fog-edge: #c7c7c7;
|
||||
--color-mist-hairline: #eeeeee;
|
||||
--color-white-canvas: #ffffff;
|
||||
--color-mint-wash: #d7ffe2;
|
||||
--color-sky-wash: #e8f1ff;
|
||||
--color-peach-wash: #ffebd6;
|
||||
|
||||
/* Typography */
|
||||
--font-switzer: 'Switzer', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-basier-circle: 'Basier Circle', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Typography — Scale */
|
||||
--text-caption: 10px;
|
||||
--leading-caption: 1.5;
|
||||
--tracking-caption: 1.4px;
|
||||
--text-body: 16px;
|
||||
--leading-body: 1.5;
|
||||
--text-body-lg: 18px;
|
||||
--leading-body-lg: 1.45;
|
||||
--text-subheading: 20px;
|
||||
--leading-subheading: 1.43;
|
||||
--tracking-subheading: -0.26px;
|
||||
--text-heading-sm: 31px;
|
||||
--leading-heading-sm: 1.1;
|
||||
--tracking-heading-sm: -0.4px;
|
||||
--text-heading: 44px;
|
||||
--leading-heading: 1.08;
|
||||
--tracking-heading: -1.15px;
|
||||
--text-heading-lg: 49px;
|
||||
--leading-heading-lg: 1.04;
|
||||
--tracking-heading-lg: -1.96px;
|
||||
--text-display: 76px;
|
||||
--leading-display: 1;
|
||||
--tracking-display: -4.25px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-4: 4px;
|
||||
--spacing-8: 8px;
|
||||
--spacing-12: 12px;
|
||||
--spacing-16: 16px;
|
||||
--spacing-20: 20px;
|
||||
--spacing-24: 24px;
|
||||
--spacing-28: 28px;
|
||||
--spacing-32: 32px;
|
||||
--spacing-40: 40px;
|
||||
--spacing-48: 48px;
|
||||
--spacing-56: 56px;
|
||||
--spacing-64: 64px;
|
||||
--spacing-80: 80px;
|
||||
--spacing-128: 128px;
|
||||
--spacing-144: 144px;
|
||||
--spacing-160: 160px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-2xl-2: 20px;
|
||||
--radius-3xl: 24px;
|
||||
--radius-3xl-2: 28px;
|
||||
--radius-3xl-3: 32px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-md: rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.03) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle: oklab(0 0 0 / 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.03) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle-2: oklab(0 0 0 / 0.04) 0px 0px 0px 1px, rgba(0, 0, 0, 0.03) 0px 16px 16px -8px, rgba(0, 0, 0, 0.03) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle-3: oklab(0 0 0 / 0.06) 0px 0px 0px 1px, rgba(0, 0, 0, 0.07) 0px 20px 20px -10px, rgba(0, 0, 0, 0.04) 0px 10px 10px -5px, rgba(0, 0, 0, 0.03) 0px 5px 5px -2.5px, rgba(0, 0, 0, 0.03) 0px 3px 3px -1.5px, rgba(0, 0, 0, 0.03) 0px 2px 2px -1px, rgba(0, 0, 0, 0.03) 0px 1px 1px -0.5px;
|
||||
--shadow-subtle-4: oklab(0 0 0 / 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 10px 16px 14px 0px;
|
||||
--shadow-subtle-5: oklab(0 0 0 / 0.1) 0px 0px 0px 1px;
|
||||
}
|
||||
```
|
||||
208
app/components/hero.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BadgeCheck, CreditCard, ShieldCheck, Server, MapPin } from "lucide-react";
|
||||
import { Eyebrow, WaitlistForm } from "./ui";
|
||||
import { KineticLine, Reveal, useReducedMotion } from "./motion";
|
||||
import { ShowcaseStage } from "./mockup";
|
||||
|
||||
const TRUST = [
|
||||
{ label: "Zahlungen über Stripe", Icon: CreditCard },
|
||||
{ label: "DSGVO-konform", Icon: ShieldCheck },
|
||||
{ label: "Hosting in Frankfurt", Icon: MapPin },
|
||||
{ label: "GoBD-konforme Rechnungen", Icon: BadgeCheck },
|
||||
{ label: "Entwickelt in Deutschland", Icon: Server },
|
||||
];
|
||||
|
||||
/**
|
||||
* Ambient backdrop: a slow aurora wash plus amber and navy blobs.
|
||||
* This is what the glass surfaces in front actually blur — without it
|
||||
* the backdrop-filter has nothing to work with.
|
||||
*/
|
||||
function Ambience() {
|
||||
const reduced = useReducedMotion();
|
||||
const { scrollY } = useScroll();
|
||||
const glowY = useTransform(scrollY, [0, 800], [0, 160]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 overflow-hidden"
|
||||
>
|
||||
<div className="aurora absolute inset-0" />
|
||||
|
||||
<motion.div
|
||||
className="absolute left-1/2 top-[-20%] size-[880px] -translate-x-1/2 rounded-full opacity-60"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(244,192,12,0.55) 0%, rgba(255,216,77,0.28) 38%, transparent 70%)",
|
||||
filter: "blur(90px)",
|
||||
...(reduced ? {} : { y: glowY }),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="blob absolute -left-40 top-1/3 size-[460px] rounded-full opacity-45"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(30,41,59,0.34) 0%, transparent 68%)",
|
||||
filter: "blur(80px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="blob absolute -right-32 top-24 size-[420px] rounded-full opacity-50"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(244,192,12,0.45) 0%, transparent 68%)",
|
||||
filter: "blur(80px)",
|
||||
animationDelay: "-8s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const REPLACED = [
|
||||
"die Excel-Tabelle",
|
||||
"die DM-Flut",
|
||||
"den Zettel am Empfang",
|
||||
"das Nachfassen per WhatsApp",
|
||||
"die Bar-Kasse",
|
||||
];
|
||||
|
||||
/** Cycles through what Kumasy replaces, one word swap at a time. */
|
||||
function ReplacedWords() {
|
||||
const reduced = useReducedMotion();
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced) return;
|
||||
const id = window.setInterval(
|
||||
() => setIndex((i) => (i + 1) % REPLACED.length),
|
||||
2600,
|
||||
);
|
||||
return () => window.clearInterval(id);
|
||||
}, [reduced]);
|
||||
|
||||
if (reduced) {
|
||||
// Static fallback: name them all rather than animating through them.
|
||||
return <span className="font-medium text-ink">{REPLACED.join(", ")}.</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
// Fixed height + overflow clip so the swap never reflows the line.
|
||||
<span className="relative inline-flex h-[1.6em] items-center overflow-hidden align-bottom">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={index}
|
||||
className="whitespace-nowrap font-medium text-ink"
|
||||
initial={{ y: "100%", opacity: 0 }}
|
||||
animate={{ y: "0%", opacity: 1 }}
|
||||
exit={{ y: "-100%", opacity: 0 }}
|
||||
transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{REPLACED[index]}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const copyY = useTransform(scrollYProgress, [0, 1], [0, -90]);
|
||||
const copyOpacity = useTransform(scrollYProgress, [0, 0.65], [1, 0]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
className="relative isolate overflow-hidden px-4 pb-24 pt-32 sm:pt-40"
|
||||
>
|
||||
<Ambience />
|
||||
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<motion.div
|
||||
className="mx-auto max-w-[860px] text-center"
|
||||
style={reduced ? undefined : { y: copyY, opacity: copyOpacity }}
|
||||
>
|
||||
<Reveal>
|
||||
<Eyebrow>Pilotphase · Zugang über Warteliste</Eyebrow>
|
||||
</Reveal>
|
||||
|
||||
{/* Per-character entrance. The <h1> carries the readable text for
|
||||
assistive tech; the animated spans are aria-hidden. */}
|
||||
<h1 className="mt-7 font-display text-[clamp(2.75rem,8.6vw,5.25rem)] font-semibold leading-[0.96] tracking-[-0.055em] text-ink">
|
||||
<span className="sr-only">
|
||||
Deine Kurse füllen und bezahlen sich von selbst.
|
||||
</span>
|
||||
<span className="block">
|
||||
<KineticLine text="Deine Kurse füllen" />
|
||||
</span>
|
||||
<span className="block">
|
||||
<KineticLine text="und bezahlen sich" delay={0.16} />
|
||||
</span>
|
||||
<span className="block">
|
||||
<KineticLine text="von " delay={0.32} />
|
||||
{/* Rises as one block so the gradient spans the word — see
|
||||
KineticLine for why this can't be per-character. The delay
|
||||
continues the stagger of "von " so the line reads as one. */}
|
||||
<KineticLine
|
||||
text="selbst."
|
||||
delay={0.42}
|
||||
gradient
|
||||
className="italic"
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<Reveal delay={0.55}>
|
||||
<p className="mx-auto mt-8 max-w-[540px] text-body-lg text-slate-body">
|
||||
Kumasy bündelt Kurse, Räume, Trainer, Buchungen und Zahlungen in
|
||||
einer Oberfläche. Deine Teilnehmer:innen buchen und bezahlen in
|
||||
unter zwei Minuten selbst — ohne DM, ohne Rückfrage, ohne Excel.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.65}>
|
||||
<div className="mx-auto mt-9 max-w-[560px]" id="warteliste">
|
||||
<WaitlistForm />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.75}>
|
||||
<ul className="mt-9 flex flex-wrap items-center justify-center gap-x-5 gap-y-2.5">
|
||||
{TRUST.map(({ label, Icon }) => (
|
||||
<li
|
||||
key={label}
|
||||
className="flex items-center gap-1.5 text-[13px] text-slate-helper"
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
|
||||
{/* One live line naming what Kumasy replaces — it keeps the hero
|
||||
moving after the entrance without adding another surface. */}
|
||||
<Reveal delay={0.85}>
|
||||
<p className="mt-10 flex flex-wrap items-center justify-center gap-x-2 text-body text-slate-helper">
|
||||
<span>Ersetzt</span>
|
||||
<ReplacedWords />
|
||||
</p>
|
||||
</Reveal>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 px-2">
|
||||
<ShowcaseStage />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
app/components/logo.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
// Imported straight from motion/react, not from ./motion, to keep this leaf
|
||||
// component free of a cycle (ui.tsx -> logo.tsx). Only used as a truthy check,
|
||||
// so the hook's `null` initial value behaves the same as `false` here.
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
/**
|
||||
* The Kumasy mark, rebuilt as vector from public/icons/icon-512.png:
|
||||
* a rounded stem plus a left-pointing triangle, forming a "k".
|
||||
* Drawn on a 0 0 512 512 grid to match the source proportions.
|
||||
*/
|
||||
export function LogoMark({
|
||||
className = "",
|
||||
/** Rides on currentColor so it inverts on dark surfaces. */
|
||||
title,
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
className={className}
|
||||
role={title ? "img" : undefined}
|
||||
aria-label={title}
|
||||
aria-hidden={title ? undefined : true}
|
||||
fill="currentColor"
|
||||
>
|
||||
{/* Geometry traced from public/icons/icon-512.png.
|
||||
Stem: x 153–224, y 118–395. */}
|
||||
<rect x="153" y="118" width="71" height="277" rx="35.5" />
|
||||
{/* Triangle: apex ~(236, 307), flat right edge at x≈356,
|
||||
spanning y≈218–396, with 20px corner and 10px apex radii. */}
|
||||
<path d="M234.6 299.6 317.4 220.5 A20 20 0 0 1 352 234.4 L352 379.6 A20 20 0 0 1 317.4 393.5 L234.6 314.4 A10 10 0 0 1 234.6 299.6 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wordmark: the mark in an amber tile plus the name. */
|
||||
export function Logo({ className = "" }: { className?: string }) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2.5 ${className}`}>
|
||||
<motion.span
|
||||
className="grid size-7 shrink-0 place-items-center rounded-[9px] text-ink shadow-amber"
|
||||
style={{ background: "var(--gradient-amber)" }}
|
||||
whileHover={reduced ? undefined : { rotate: -8, scale: 1.06 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 18 }}
|
||||
>
|
||||
<LogoMark className="size-[18px]" />
|
||||
</motion.span>
|
||||
<span className="text-subheading font-semibold tracking-[-0.4px] text-ink">
|
||||
kumasy
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
222
app/components/mockup.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import {
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useReducedMotion,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { Lock, TrendingUp, Users } from "lucide-react";
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Browser chrome — glass bar over the screenshot
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function BrowserFrame({
|
||||
children,
|
||||
url = "app.kumasy.de",
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
url?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`glass-rim overflow-hidden rounded-[16px] border border-white/60 bg-white/40 shadow-elevated backdrop-blur-xl ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-white/50 bg-white/55 px-4 py-2.5 backdrop-blur-xl">
|
||||
<span className="flex gap-1.5" aria-hidden>
|
||||
<span className="size-[9px] rounded-full bg-[#ff5f57]" />
|
||||
<span className="size-[9px] rounded-full bg-[#febc2e]" />
|
||||
<span className="size-[9px] rounded-full bg-[#28c840]" />
|
||||
</span>
|
||||
<span
|
||||
className="mx-auto flex items-center gap-1.5 rounded-full border border-white/70 bg-white/60 px-3 py-1 text-[11px] text-slate-helper"
|
||||
aria-hidden
|
||||
>
|
||||
<Lock className="size-[11px]" strokeWidth={2.2} />
|
||||
{url}
|
||||
</span>
|
||||
<span className="w-[42px]" aria-hidden />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Floating glass stat chip
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function GlassStat({
|
||||
Icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
Icon: typeof Users;
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "mint" | "amber";
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-xl glass-rim w-[196px] rounded-[20px] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`grid size-7 place-items-center rounded-full ${
|
||||
tone === "mint"
|
||||
? "bg-mint-wash text-mint-ink"
|
||||
: "bg-amber-soft text-[#8a6d00]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={2} />
|
||||
</span>
|
||||
<span className="text-caption font-semibold uppercase text-slate-helper">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2.5 font-display text-subheading font-semibold leading-tight text-ink">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ParallaxLayer
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function ParallaxLayer({
|
||||
children,
|
||||
depth = 60,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
|
||||
const y = useSpring(useTransform(scrollYProgress, [0, 1], [depth, -depth]), {
|
||||
stiffness: 120,
|
||||
damping: 28,
|
||||
mass: 0.4,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={reduced ? undefined : { y }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ShowcaseStage — one product shot, presented cleanly
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function ShowcaseStage() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start 0.95", "end 0.2"],
|
||||
});
|
||||
|
||||
// The window tips up from a shallow angle and settles flat, as if being
|
||||
// set down on the pedestal. One gesture, not five competing ones.
|
||||
const rotateX = useSpring(useTransform(scrollYProgress, [0, 0.6], [12, 0]), {
|
||||
stiffness: 90,
|
||||
damping: 26,
|
||||
});
|
||||
const scale = useSpring(useTransform(scrollYProgress, [0, 0.6], [0.94, 1]), {
|
||||
stiffness: 90,
|
||||
damping: 26,
|
||||
});
|
||||
const lift = useTransform(scrollYProgress, [0, 0.6], [50, 0]);
|
||||
const glowY = useTransform(scrollYProgress, [0, 1], [40, -40]);
|
||||
const chipY = useTransform(scrollYProgress, [0, 1], [-46, 46]);
|
||||
|
||||
const transform = useMotionTemplate`perspective(1600px) rotateX(${rotateX}deg) scale(${scale})`;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative mx-auto max-w-[1080px] px-4">
|
||||
{/* Pedestal: a warm plinth the window stands on, so it reads as
|
||||
placed rather than pasted onto the page. */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<motion.div
|
||||
className="absolute left-1/2 top-[38%] size-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-70"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(244,192,12,0.45) 0%, rgba(244,192,12,0.12) 45%, transparent 70%)",
|
||||
filter: "blur(80px)",
|
||||
...(reduced ? {} : { y: glowY }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The product shot. Full opacity, nothing covering it. */}
|
||||
<motion.div
|
||||
className="relative z-10"
|
||||
style={
|
||||
reduced
|
||||
? undefined
|
||||
: { transform, y: lift, transformOrigin: "50% 100%" }
|
||||
}
|
||||
>
|
||||
<BrowserFrame>
|
||||
<Image
|
||||
src="/dashboard.png"
|
||||
alt="Kumasy Dashboard mit Umsatzverlauf über zwölf Monate und Kurs-Kennzahlen"
|
||||
width={1899}
|
||||
height={905}
|
||||
priority
|
||||
sizes="(max-width: 1120px) 94vw, 1080px"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</BrowserFrame>
|
||||
</motion.div>
|
||||
|
||||
{/* Two chips, clear of the window on either side. They annotate the
|
||||
product; they never sit on top of the UI they point at. */}
|
||||
<motion.div
|
||||
className="absolute -left-4 top-[26%] z-20 hidden 2xl:block"
|
||||
style={reduced ? undefined : { y: chipY }}
|
||||
>
|
||||
<GlassStat
|
||||
Icon={Users}
|
||||
tone="mint"
|
||||
label="Warteliste"
|
||||
value="Platz rückt nach"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute -right-4 top-[58%] z-20 hidden 2xl:block"
|
||||
style={reduced ? undefined : { y: chipY }}
|
||||
>
|
||||
<GlassStat
|
||||
Icon={TrendingUp}
|
||||
tone="amber"
|
||||
label="Bezahlt"
|
||||
value="In Echtzeit"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
488
app/components/motion.tsx
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
useReducedMotion as useReducedMotionRaw,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
type Variants,
|
||||
} from "motion/react";
|
||||
|
||||
/**
|
||||
* Motion's hook returns `boolean | null` — null until the media query
|
||||
* resolves. Narrow it to a plain boolean so callers can pass it straight
|
||||
* into props; an unresolved preference means "motion allowed".
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
return useReducedMotionRaw() === true;
|
||||
}
|
||||
|
||||
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||
const EASE_MASK = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Reveal
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
y = 24,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
y?: number;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={reduced ? false : { opacity: 0, y }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2, margin: "0px 0px -8% 0px" }}
|
||||
transition={{ duration: 0.7, delay, ease: EASE_OUT }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** List-item variant, so Reveal never breaks <ul> semantics. */
|
||||
export function RevealItem({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
className={className}
|
||||
initial={reduced ? false : { opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2, margin: "0px 0px -8% 0px" }}
|
||||
transition={{ duration: 0.7, delay, ease: EASE_OUT }}
|
||||
>
|
||||
{children}
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* MaskedWords — words rise from behind a clipping mask
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const lineVariants: Variants = {
|
||||
hidden: {},
|
||||
shown: { transition: { staggerChildren: 0.055, delayChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const wordVariants: Variants = {
|
||||
hidden: { y: "108%", rotate: 3, skewY: 2.5 },
|
||||
shown: {
|
||||
y: "0%",
|
||||
rotate: 0,
|
||||
skewY: 0,
|
||||
transition: { duration: 0.9, ease: EASE_MASK },
|
||||
},
|
||||
};
|
||||
|
||||
export function MaskedWords({
|
||||
words,
|
||||
className = "",
|
||||
delay = 0,
|
||||
}: {
|
||||
words: ReactNode[];
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
if (reduced) {
|
||||
return (
|
||||
<span className={className}>
|
||||
{words.map((word, index) => (
|
||||
<span key={index}>
|
||||
{word}
|
||||
{index < words.length - 1 ? " " : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={className}
|
||||
variants={lineVariants}
|
||||
initial="hidden"
|
||||
whileInView="shown"
|
||||
viewport={{ once: true, amount: 0.4 }}
|
||||
transition={{ delayChildren: delay }}
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex overflow-hidden pb-[0.12em] align-bottom"
|
||||
>
|
||||
<motion.span variants={wordVariants} className="inline-block">
|
||||
{word}
|
||||
</motion.span>
|
||||
{index < words.length - 1 ? <span className="w-[0.26em]" /> : null}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* KineticHeadline — per-character entrance for the hero only
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const charVariants: Variants = {
|
||||
hidden: { opacity: 0, y: "70%", rotateX: -70 },
|
||||
shown: {
|
||||
opacity: 1,
|
||||
y: "0%",
|
||||
rotateX: 0,
|
||||
transition: { duration: 0.75, ease: EASE_MASK },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a string into characters and tips each one up into place.
|
||||
* The visible text is aria-hidden and the whole line is relabelled,
|
||||
* so screen readers get the sentence, not a stream of letters.
|
||||
*
|
||||
* `gradient` keeps `.brand-text` on a single element so the amber fill
|
||||
* spans the whole word, and animates that element as one unit — per
|
||||
* character the gradient would restart on every letter, and the clipped
|
||||
* fill drops out under a 3D rotateX.
|
||||
*/
|
||||
export function KineticLine({
|
||||
text,
|
||||
className = "",
|
||||
delay = 0,
|
||||
stagger = 0.022,
|
||||
gradient = false,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
stagger?: number;
|
||||
/** Fill the word with the amber gradient and animate it as one block. */
|
||||
gradient?: boolean;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
if (reduced) {
|
||||
return (
|
||||
<span className={`${gradient ? "brand-text" : ""} ${className}`}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (gradient) {
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
// 2D only: rotateX would break background-clip:text.
|
||||
className={`brand-text inline-block ${className}`}
|
||||
initial={{ opacity: 0, y: "55%" }}
|
||||
whileInView={{ opacity: 1, y: "0%" }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 0.75, delay, ease: EASE_MASK }}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className={`inline-block ${className}`}
|
||||
style={{ perspective: 600 }}
|
||||
variants={{
|
||||
hidden: {},
|
||||
shown: { transition: { staggerChildren: stagger, delayChildren: delay } },
|
||||
}}
|
||||
initial="hidden"
|
||||
whileInView="shown"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
{Array.from(text).map((char, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={charVariants}
|
||||
className="inline-block origin-bottom"
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{char === " " ? " " : char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ScrollTracking — letter-spacing tightens as the line scrolls through
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function ScrollTracking({
|
||||
children,
|
||||
className = "",
|
||||
from = 0.06,
|
||||
to = -0.04,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Letter-spacing in em at entry and at exit. */
|
||||
from?: number;
|
||||
to?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start end", "center center"],
|
||||
});
|
||||
const raw = useTransform(scrollYProgress, [0, 1], [from, to]);
|
||||
const spring = useSpring(raw, { stiffness: 90, damping: 24 });
|
||||
const letterSpacing = useTransform(spring, (v) => `${v}em`);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={reduced ? undefined : { letterSpacing }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ScrollStage — the section itself reacts to being scrolled past
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Wraps a section so it rises and settles on entry, then recedes slightly
|
||||
* as it leaves — the scroll becomes the animation rather than a trigger
|
||||
* for one. Kept subtle on purpose: this runs on every section, so anything
|
||||
* stronger would read as the page wobbling.
|
||||
*/
|
||||
export function ScrollStage({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
|
||||
const scale = useSpring(
|
||||
useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0.97, 1, 1, 0.97]),
|
||||
{ stiffness: 100, damping: 30, mass: 0.5 },
|
||||
);
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.14, 0.86, 1],
|
||||
[0.55, 1, 1, 0.55],
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={reduced ? undefined : { scale, opacity }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Parallax
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function Parallax({
|
||||
children,
|
||||
distance = 60,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
distance?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
|
||||
const y = useSpring(
|
||||
useTransform(scrollYProgress, [0, 1], [distance, -distance]),
|
||||
{ stiffness: 120, damping: 28, mass: 0.4 },
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} className={className} style={reduced ? undefined : { y }}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Tilt
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function Tilt({
|
||||
children,
|
||||
className,
|
||||
max = 5,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
max?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node || reduced) return;
|
||||
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = (event.clientX - rect.left) / rect.width - 0.5;
|
||||
const y = (event.clientY - rect.top) / rect.height - 0.5;
|
||||
node.style.transform = `perspective(1000px) rotateX(${(-y * max).toFixed(2)}deg) rotateY(${(x * max).toFixed(2)}deg)`;
|
||||
});
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
node.style.transform = "";
|
||||
};
|
||||
|
||||
node.addEventListener("pointermove", onMove);
|
||||
node.addEventListener("pointerleave", onLeave);
|
||||
return () => {
|
||||
node.removeEventListener("pointermove", onMove);
|
||||
node.removeEventListener("pointerleave", onLeave);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [max, reduced]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{ transition: "transform 0.4s cubic-bezier(0.22,1,0.36,1)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Counter
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function Counter({
|
||||
to,
|
||||
suffix = "",
|
||||
prefix = "",
|
||||
decimals = 0,
|
||||
duration = 1400,
|
||||
className,
|
||||
}: {
|
||||
to: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
decimals?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
const inView = useInView(ref, { once: true, amount: 0.4 });
|
||||
const [progressValue, setProgressValue] = useState(0);
|
||||
|
||||
const value = reduced ? to : progressValue;
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView || reduced) return;
|
||||
|
||||
let frame = 0;
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
|
||||
setProgressValue(to * eased);
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [inView, reduced, to, duration]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
{prefix}
|
||||
{value.toLocaleString("de-DE", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ScrollProgress
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 180,
|
||||
damping: 32,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="fixed inset-x-0 top-0 z-[60] h-[3px] origin-left"
|
||||
style={{ scaleX, background: "var(--gradient-amber)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
245
app/components/phone-showcase.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRef, useSyncExternalStore } from "react";
|
||||
import {
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
type MotionValue,
|
||||
} from "motion/react";
|
||||
import { Eyebrow } from "./ui";
|
||||
import { Reveal, RevealItem, useReducedMotion } from "./motion";
|
||||
|
||||
/** Each phone's resting place, and how it gets there. */
|
||||
const PHONES = [
|
||||
{
|
||||
src: "/phones/phone-nav.png",
|
||||
alt: "Kumasy Navigation auf dem iPhone: Dashboard, Kurse, Mitarbeiter, Kunden, Zahlungen und Räume",
|
||||
label: "Navigation",
|
||||
/** Final position, as a percentage of the stage width. */
|
||||
x: -140,
|
||||
y: 7,
|
||||
rotate: -7,
|
||||
scale: 0.86,
|
||||
/** Parallax travel in px across the scroll pass — higher is nearer. */
|
||||
depth: 30,
|
||||
z: 10,
|
||||
},
|
||||
{
|
||||
src: "/phones/phone-dashboard.png",
|
||||
alt: "Kumasy Dashboard auf dem iPhone mit Umsatz, Teilnehmerzahl und Kurs-Kennzahlen",
|
||||
label: "Dashboard",
|
||||
x: -47,
|
||||
y: -3,
|
||||
rotate: -2.5,
|
||||
scale: 1,
|
||||
depth: 55,
|
||||
z: 30,
|
||||
},
|
||||
{
|
||||
src: "/phones/phone-booking.png",
|
||||
alt: "Öffentliche Buchungsseite auf dem iPhone mit Kursfiltern und zwei buchbaren Kursen",
|
||||
label: "Buchungsseite",
|
||||
x: 47,
|
||||
y: -3,
|
||||
rotate: 2.5,
|
||||
scale: 1,
|
||||
depth: 55,
|
||||
z: 30,
|
||||
},
|
||||
{
|
||||
src: "/phones/phone-discounts.png",
|
||||
alt: "Rabattcodes auf dem iPhone mit Status, Rabatthöhe und Gültigkeitszeitraum",
|
||||
label: "Rabattcodes",
|
||||
x: 140,
|
||||
y: 7,
|
||||
rotate: 7,
|
||||
scale: 0.86,
|
||||
depth: 30,
|
||||
z: 10,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Tracks a media query, for behaviour that CSS cannot express (here: whether
|
||||
* to run the scroll-linked fan animation at all).
|
||||
*
|
||||
* Which layout is *visible* is decided by CSS classes, not by this hook —
|
||||
* server-rendering one branch and swapping it on hydration showed the wrong
|
||||
* layout on first paint.
|
||||
*/
|
||||
function useMediaQuery(query: string) {
|
||||
return useSyncExternalStore(
|
||||
(onChange: () => void) => {
|
||||
const mql = window.matchMedia(query);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
function Phone({
|
||||
phone,
|
||||
progress,
|
||||
index,
|
||||
reduced,
|
||||
animate,
|
||||
}: {
|
||||
phone: (typeof PHONES)[number];
|
||||
progress: MotionValue<number>;
|
||||
index: number;
|
||||
reduced: boolean;
|
||||
/** False below the fan breakpoint, where this markup is display:none. */
|
||||
animate: boolean;
|
||||
}) {
|
||||
// The fan opens: phones start stacked at centre and spread to their spot.
|
||||
const spread = useSpring(useTransform(progress, [0, 0.5], [0, 1]), {
|
||||
stiffness: 70,
|
||||
damping: 24,
|
||||
});
|
||||
|
||||
// This component only renders on wide screens, so the full spread and
|
||||
// tilt always apply — narrow screens get a plain grid instead.
|
||||
const x = useTransform(spread, (v) => `${phone.x * v}%`);
|
||||
const rotate = useTransform(spread, (v) => phone.rotate * v);
|
||||
const scale = useTransform(spread, (v) => 0.88 + (phone.scale - 0.88) * v);
|
||||
|
||||
// Continuous drift for depth, independent of the opening.
|
||||
const drift = useSpring(
|
||||
useTransform(progress, [0, 1], [phone.depth, -phone.depth]),
|
||||
{ stiffness: 110, damping: 26, mass: 0.4 },
|
||||
);
|
||||
const y = useMotionTemplate`calc(${phone.y}% + ${drift}px)`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute left-1/2 top-1/2 w-[27%] max-w-[300px] -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
zIndex: phone.z,
|
||||
...(reduced || !animate ? {} : { x, y, rotate, scale }),
|
||||
}}
|
||||
initial={reduced ? false : { opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.08, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Image
|
||||
src={phone.src}
|
||||
alt={phone.alt}
|
||||
width={342}
|
||||
height={692}
|
||||
sizes="300px"
|
||||
className="h-auto w-full drop-shadow-[0_28px_40px_rgba(30,41,59,0.22)]"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhoneShowcase() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
// The fan needs real width to read as a fan. Below this the phones
|
||||
// overlap so heavily that the tilt just looks like a mistake, so we
|
||||
// drop the whole arrangement and show a plain grid instead.
|
||||
const fanned = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
|
||||
return (
|
||||
<section id="mobil" className="relative overflow-hidden px-4 py-20 sm:py-[80px]">
|
||||
{/* Amber wash behind the devices, so the transparent PNGs sit on colour
|
||||
rather than floating on flat canvas. */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="aurora absolute inset-0 opacity-70" />
|
||||
<div
|
||||
className="blob absolute left-1/2 top-1/2 size-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-50"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(244,192,12,0.42) 0%, transparent 68%)",
|
||||
filter: "blur(90px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<div className="mx-auto max-w-[680px] text-center">
|
||||
<Reveal>
|
||||
<Eyebrow>Auch unterwegs</Eyebrow>
|
||||
</Reveal>
|
||||
<Reveal delay={0.08}>
|
||||
<h2 className="mt-5 font-display text-[clamp(2rem,4.8vw,2.9rem)] font-semibold leading-[1.06] tracking-[-0.032em] text-ink">
|
||||
Dein Studio passt in die{" "}
|
||||
<em className="brand-text italic">Hosentasche</em>.
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={0.16}>
|
||||
<p className="mx-auto mt-5 max-w-[540px] text-body-lg text-slate-body">
|
||||
Zwischen zwei Kursen kurz nachsehen, wer gebucht hat, einen
|
||||
Rabattcode anlegen oder den Umsatz prüfen — dieselbe Oberfläche,
|
||||
nur kleiner.
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div ref={ref}>
|
||||
{/* Both layouts render; CSS picks one. Deciding in JS meant the
|
||||
wrong layout was painted until hydration caught up. */}
|
||||
<div className="relative mx-auto mt-16 hidden h-[660px] w-full max-w-[1120px] lg:block">
|
||||
{PHONES.map((phone, index) => (
|
||||
<Phone
|
||||
key={phone.src}
|
||||
phone={phone}
|
||||
progress={scrollYProgress}
|
||||
index={index}
|
||||
reduced={reduced}
|
||||
animate={fanned}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Narrow screens: upright, evenly spaced, no overlap, no tilt. */}
|
||||
<ul className="mx-auto mt-14 grid max-w-[560px] grid-cols-2 gap-6 sm:gap-8 lg:hidden">
|
||||
{PHONES.map((phone, index) => (
|
||||
<RevealItem key={phone.src} delay={index * 0.08}>
|
||||
<figure className="flex flex-col items-center gap-3">
|
||||
<Image
|
||||
src={phone.src}
|
||||
alt={phone.alt}
|
||||
width={342}
|
||||
height={692}
|
||||
sizes="(max-width: 1024px) 42vw, 240px"
|
||||
className="h-auto w-full drop-shadow-[0_20px_30px_rgba(30,41,59,0.18)]"
|
||||
/>
|
||||
<figcaption className="glass glass-rim rounded-full px-3.5 py-1.5 text-[12px] font-medium text-slate-body">
|
||||
{phone.label}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.2}>
|
||||
<ul className="mt-6 hidden flex-wrap items-center justify-center gap-2.5 lg:flex">
|
||||
{PHONES.map((phone) => (
|
||||
<li
|
||||
key={phone.label}
|
||||
className="glass glass-rim rounded-full px-4 py-2 text-[13px] font-medium text-slate-body"
|
||||
>
|
||||
{phone.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
814
app/components/sections.tsx
Normal file
|
|
@ -0,0 +1,814 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
CalendarX2,
|
||||
Check,
|
||||
CreditCard,
|
||||
DoorOpen,
|
||||
Globe,
|
||||
Link2,
|
||||
MessageCircle,
|
||||
Settings2,
|
||||
Ticket,
|
||||
UploadCloud,
|
||||
UserPlus,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Counter,
|
||||
MaskedWords,
|
||||
Reveal,
|
||||
RevealItem,
|
||||
ScrollTracking,
|
||||
Tilt,
|
||||
} from "./motion";
|
||||
import { BrowserFrame, ParallaxLayer } from "./mockup";
|
||||
import { BrandButton, Eyebrow, Faq, GhostLink, WaitlistForm } from "./ui";
|
||||
import { Logo } from "./logo";
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Shared section scaffolding
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function Section({
|
||||
id,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
id?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={`px-4 py-20 sm:py-[80px] ${className}`}>
|
||||
<div className="mx-auto max-w-[1200px]">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
eyebrow,
|
||||
titleLines,
|
||||
lead,
|
||||
wash = "amber",
|
||||
align = "left",
|
||||
}: {
|
||||
eyebrow: string;
|
||||
/** Each entry is one visual line, split into words for the masked reveal. */
|
||||
titleLines: ReactNode[][];
|
||||
lead?: string;
|
||||
wash?: "amber" | "sky" | "mint" | "peach";
|
||||
align?: "left" | "center";
|
||||
}) {
|
||||
const centered = align === "center";
|
||||
return (
|
||||
<div className={centered ? "mx-auto max-w-[760px] text-center" : "max-w-[760px]"}>
|
||||
<Reveal>
|
||||
<Eyebrow wash={wash}>{eyebrow}</Eyebrow>
|
||||
</Reveal>
|
||||
|
||||
{/* Tracking tightens as the heading scrolls toward centre — the type
|
||||
settles into its final, compressed form. */}
|
||||
<ScrollTracking from={0.02} to={-0.032}>
|
||||
<h2 className="mt-5 font-display text-[clamp(2rem,4.8vw,2.9rem)] font-semibold leading-[1.06] text-ink">
|
||||
{titleLines.map((line, index) => (
|
||||
<MaskedWords
|
||||
key={index}
|
||||
className="block"
|
||||
words={line}
|
||||
delay={index * 0.08}
|
||||
/>
|
||||
))}
|
||||
</h2>
|
||||
</ScrollTracking>
|
||||
|
||||
{lead ? (
|
||||
<Reveal delay={0.18}>
|
||||
<p
|
||||
className={`mt-5 text-body-lg text-slate-body ${centered ? "mx-auto max-w-[560px]" : "max-w-[560px]"}`}
|
||||
>
|
||||
{lead}
|
||||
</p>
|
||||
</Reveal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Item = {
|
||||
title: string;
|
||||
body: string;
|
||||
Icon: ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Problem
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const PROBLEMS: Item[] = [
|
||||
{
|
||||
title: "Buchungen kommen per DM",
|
||||
body: "Instagram, WhatsApp, Telefon, zwischen zwei Kursen. Die Teilnehmerliste führst du im Kopf oder in einer Tabelle.",
|
||||
Icon: MessageCircle,
|
||||
},
|
||||
{
|
||||
title: "Wer hat eigentlich bezahlt?",
|
||||
body: "Bar, Überweisung, „bring ich nächstes Mal mit“. Nachfassen kostet dich jede Woche Stunden — und manchmal den Betrag.",
|
||||
Icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: "Raum doppelt belegt",
|
||||
body: "Zwei Kurse, ein Raum, eine Trainerin. Auffallen tut es oft erst am Kurstag — vor wartenden Kunden.",
|
||||
Icon: Building2,
|
||||
},
|
||||
{
|
||||
title: "Abgesagt heißt leer",
|
||||
body: "Kurzfristige Stornos bleiben unbesetzt, obwohl drei Leute gewartet hätten. Bezahlt hat am Ende niemand.",
|
||||
Icon: CalendarX2,
|
||||
},
|
||||
];
|
||||
|
||||
export function Problem() {
|
||||
return (
|
||||
<Section id="problem">
|
||||
<SectionHead
|
||||
eyebrow="Das Problem"
|
||||
wash="peach"
|
||||
titleLines={[
|
||||
["Der", "Kurs", "ist", "ausgebucht."],
|
||||
[
|
||||
"Dein",
|
||||
"Kopf",
|
||||
<em key="leider" className="brand-text italic">
|
||||
leider
|
||||
</em>,
|
||||
"auch.",
|
||||
],
|
||||
]}
|
||||
lead="Du bist Trainerin, nicht Sachbearbeiterin. Trotzdem geht jede Woche ein halber Arbeitstag für Koordination drauf."
|
||||
/>
|
||||
|
||||
<ul className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{PROBLEMS.map(({ title, body, Icon }, index) => (
|
||||
<RevealItem key={title} delay={index * 0.08}>
|
||||
<div className="h-full glass glass-rim rounded-cards p-5 transition-transform duration-500 ease-[var(--ease-out-soft)] hover:-translate-y-1">
|
||||
<span className="flex size-9 items-center justify-center rounded-full bg-peach-wash text-peach-ink">
|
||||
<Icon className="size-[18px]" strokeWidth={1.7} />
|
||||
</span>
|
||||
<h3 className="mt-4 text-body-lg font-semibold leading-snug text-ink">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-body text-slate-body">{body}</p>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Solution
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const FEATURES: Item[] = [
|
||||
{
|
||||
title: "Kurse & Serientermine",
|
||||
body: "Einzeltermin oder Dauerauftrag in wenigen Klicks. Level, Preis, Teilnehmerzahl, Raum und Trainer gehören direkt dazu.",
|
||||
Icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
title: "Öffentliche Buchungsseite",
|
||||
body: "Deine eigene Seite mit Kalender, Kursfiltern und deinem Logo. Gastbuchung ohne Account — kein Abbruch an der Registrierung.",
|
||||
Icon: Globe,
|
||||
},
|
||||
{
|
||||
title: "Zahlung läuft über Stripe",
|
||||
body: "Bezahlt wird beim Buchen, nicht beim Nachfragen. Du siehst jeden Eingang in Echtzeit, Rechnungen sind GoBD-konform und als ZIP exportierbar.",
|
||||
Icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: "Warteliste, die nachrückt",
|
||||
body: "Sagt jemand ab, rückt der nächste automatisch auf den Platz. Der Kurs bleibt voll, ohne dass du eine Nachricht schreibst.",
|
||||
Icon: UserPlus,
|
||||
},
|
||||
{
|
||||
title: "Räume & Trainer",
|
||||
body: "Wer unterrichtet wo, mit welchen Rechten. Doppelbelegungen fallen beim Anlegen auf — nicht am Kurstag.",
|
||||
Icon: DoorOpen,
|
||||
},
|
||||
{
|
||||
title: "Rabattcodes & Stornoregeln",
|
||||
body: "Prozentual oder als Fixbetrag, mit Nutzungslimit und Gültigkeitszeitraum. Stornofristen legst du einmal fest, danach gelten sie automatisch.",
|
||||
Icon: Ticket,
|
||||
},
|
||||
];
|
||||
|
||||
export function Solution() {
|
||||
return (
|
||||
<Section id="funktionen">
|
||||
<SectionHead
|
||||
eyebrow="Die Lösung"
|
||||
wash="mint"
|
||||
titleLines={[
|
||||
["Ein", "System", "statt", "drei", "Tools"],
|
||||
[
|
||||
"und",
|
||||
<em key="einer" className="brand-text italic">
|
||||
einer
|
||||
</em>,
|
||||
"Tabelle.",
|
||||
],
|
||||
]}
|
||||
lead="Alles, was ein Kursbetrieb wirklich braucht — und nichts, wofür du eine Schulung brauchst."
|
||||
/>
|
||||
|
||||
<ul className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map(({ title, body, Icon }, index) => (
|
||||
<RevealItem key={title} delay={(index % 3) * 0.08}>
|
||||
<div className="h-full glass glass-rim rounded-cards p-6 transition-transform duration-500 ease-[var(--ease-out-soft)] hover:-translate-y-1">
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-amber-soft text-[#8a6d00]">
|
||||
<Icon className="size-5" strokeWidth={1.6} />
|
||||
</span>
|
||||
<h3 className="mt-5 text-subheading font-semibold tracking-[-0.26px] text-ink">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2.5 text-body text-slate-body">{body}</p>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Showcase — alternating sticky-ish parallax rows
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const SHOTS = [
|
||||
{
|
||||
src: "/booking-page.png",
|
||||
url: "since3.kumasy.de",
|
||||
eyebrow: "Für deine Kundinnen",
|
||||
title: "Die Seite, die dein Kunde sieht",
|
||||
body: "Kalender, Kursfilter, Preis, freie Plätze — und ein Button. Buchen ohne Account möglich, damit niemand an der Registrierung abspringt.",
|
||||
alt: "Öffentliche Buchungsseite mit Monatskalender, Kursfiltern und zwei buchbaren Kursen",
|
||||
depth: 70,
|
||||
},
|
||||
{
|
||||
src: "/courses.png",
|
||||
url: "app.kumasy.de/kurse",
|
||||
eyebrow: "Dein Kursplan",
|
||||
title: "Kurse im Griff",
|
||||
body: "Nach Tag sortiert, mit Belegung, Level, Raum und Status. Entwürfe veröffentlichst du gesammelt statt einzeln.",
|
||||
alt: "Kursübersicht mit nach Datum gruppierten Kurskarten, Belegung und Veröffentlichungsstatus",
|
||||
depth: 50,
|
||||
},
|
||||
{
|
||||
src: "/payments.png",
|
||||
url: "app.kumasy.de/zahlungen",
|
||||
eyebrow: "Deine Zahlen",
|
||||
title: "Geld, nachvollziehbar",
|
||||
body: "Umsatz, offene Posten, Stornos und Teilerstattungen auf einer Seite. Alle Rechnungen auf einmal als ZIP.",
|
||||
alt: "Zahlungsübersicht mit Monatsumsatz, offenen Posten und einer Tabelle aller Transaktionen",
|
||||
depth: 70,
|
||||
},
|
||||
];
|
||||
|
||||
export function Showcase() {
|
||||
return (
|
||||
<Section id="einblick">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<SectionHead
|
||||
eyebrow="Einblick"
|
||||
titleLines={[
|
||||
["So", "sieht", "dein", "Studio"],
|
||||
[
|
||||
"von",
|
||||
<em key="innen" className="brand-text italic">
|
||||
innen
|
||||
</em>,
|
||||
"aus.",
|
||||
],
|
||||
]}
|
||||
lead="Keine Render-Bilder — das ist die Oberfläche, mit der unser Pilotstudio heute arbeitet."
|
||||
/>
|
||||
<Reveal delay={0.2}>
|
||||
<BrandButton href="#warteliste">
|
||||
Zugang sichern
|
||||
<ArrowRight className="size-4" strokeWidth={2} />
|
||||
</BrandButton>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 flex flex-col gap-20 lg:gap-28">
|
||||
{SHOTS.map((shot, index) => (
|
||||
<div key={shot.src}>
|
||||
{/* Copy sits above the shot so the screenshot gets the full
|
||||
column width — at half-width the UI text was unreadable. */}
|
||||
<Reveal className="mx-auto max-w-[620px] text-center">
|
||||
<Eyebrow wash={index % 2 === 1 ? "peach" : "sky"}>
|
||||
{shot.eyebrow}
|
||||
</Eyebrow>
|
||||
<h3 className="mt-5 font-display text-heading-sm font-semibold tracking-[-0.4px] text-ink">
|
||||
{shot.title}
|
||||
</h3>
|
||||
<p className="mx-auto mt-4 max-w-[540px] text-body-lg text-slate-body">
|
||||
{shot.body}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Tinted stage: the screenshot rests on colour rather than
|
||||
floating on the flat page. */}
|
||||
<div className="relative mt-10 overflow-hidden rounded-[32px] px-4 pb-0 pt-10 sm:px-10 sm:pt-14">
|
||||
<div aria-hidden className="absolute inset-0 -z-10">
|
||||
<div className="aurora absolute inset-0 opacity-80" />
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-1/3"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(249,250,251,0.9), transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ParallaxLayer depth={shot.depth}>
|
||||
<Tilt max={3}>
|
||||
<BrowserFrame url={shot.url} className="mx-auto max-w-[980px]">
|
||||
<Image
|
||||
src={shot.src}
|
||||
alt={shot.alt}
|
||||
width={1918}
|
||||
height={911}
|
||||
sizes="(max-width: 1024px) 94vw, 980px"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</BrowserFrame>
|
||||
</Tilt>
|
||||
</ParallaxLayer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Steps
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const STEPS: Item[] = [
|
||||
{
|
||||
title: "Daten übernehmen",
|
||||
body: "Unser Migrationstool zieht Kurse, Kundinnen und Termine aus deinem bisherigen System in Kumasy. Du tippst nichts ab.",
|
||||
Icon: UploadCloud,
|
||||
},
|
||||
{
|
||||
title: "Einmal einrichten",
|
||||
body: "Räume, Trainer, Preise, Stornoregeln und dein Branding. Danach legst du nur noch Termine an.",
|
||||
Icon: Settings2,
|
||||
},
|
||||
{
|
||||
title: "Link teilen",
|
||||
body: "Deine Buchungsseite kommt in Instagram-Bio, Website und Newsletter. Ab da buchen und bezahlen deine Kundinnen selbst.",
|
||||
Icon: Link2,
|
||||
},
|
||||
];
|
||||
|
||||
export function Steps() {
|
||||
return (
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="In drei Schritten"
|
||||
wash="mint"
|
||||
align="center"
|
||||
titleLines={[
|
||||
["Vom", "ersten", "Login"],
|
||||
[
|
||||
"bis",
|
||||
"zur",
|
||||
<em key="ersten" className="brand-text italic">
|
||||
ersten
|
||||
</em>,
|
||||
"Buchung.",
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<ol className="mt-14 grid gap-4 md:grid-cols-3">
|
||||
{STEPS.map(({ title, body, Icon }, index) => (
|
||||
<RevealItem key={title} delay={index * 0.11}>
|
||||
<div className="h-full glass glass-rim rounded-cards p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-mint-wash text-mint-ink">
|
||||
<Icon className="size-5" strokeWidth={1.6} />
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="font-display text-heading-sm font-semibold leading-none"
|
||||
style={{
|
||||
background: "var(--gradient-amber)",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
color: "transparent",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-5 text-subheading font-semibold tracking-[-0.26px] text-ink">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2.5 text-body text-slate-body">{body}</p>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Pricing
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const INCLUDED = [
|
||||
"Öffentliche Buchungsseite inklusive Gastbuchung, Warteliste und Rabattcodes",
|
||||
"Zahlungen über Stripe, GoBD-konforme Rechnungen, Sammelexport",
|
||||
"Migration deiner bestehenden Daten mit unserem Migrationstool",
|
||||
"Support von echten Menschen, erreichbar und ansprechbar — kein KI-Chatbot",
|
||||
"Keine Einrichtungsgebühr, keine Umsatzbeteiligung, keine Preisstufen",
|
||||
];
|
||||
|
||||
export function Pricing() {
|
||||
return (
|
||||
<Section id="preis">
|
||||
<SectionHead
|
||||
eyebrow="Preis"
|
||||
align="center"
|
||||
titleLines={[
|
||||
["Ein", "Preis.", "Keine", "Staffelung."],
|
||||
[
|
||||
"Keine",
|
||||
<em key="provision" className="brand-text italic">
|
||||
Provision.
|
||||
</em>,
|
||||
],
|
||||
]}
|
||||
lead="Du sollst am Monatsende wissen, was Kumasy kostet — ohne Taschenrechner."
|
||||
/>
|
||||
|
||||
<div className="mt-14 grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
<Reveal>
|
||||
<div className="flex h-full flex-col glass-xl glass-rim rounded-cards p-8">
|
||||
<p className="flex items-baseline gap-2">
|
||||
<Counter
|
||||
to={150}
|
||||
suffix=" €"
|
||||
className="font-display text-heading font-semibold tracking-[-1.15px] text-ink"
|
||||
/>
|
||||
<span className="text-body text-slate-helper">pro Monat</span>
|
||||
</p>
|
||||
<p className="mt-2 text-body-lg text-slate-body">
|
||||
Alle Funktionen. Unbegrenzt Kurse, Trainer und Kundinnen.
|
||||
</p>
|
||||
|
||||
<ul className="mt-7 flex flex-col gap-3.5">
|
||||
{INCLUDED.map((item) => (
|
||||
<li key={item} className="flex gap-3 text-body text-slate-body">
|
||||
<Check
|
||||
className="mt-1 size-4 shrink-0 text-mint-ink"
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 flex flex-col items-start gap-3">
|
||||
<BrandButton href="#warteliste">
|
||||
Platz auf der Warteliste sichern
|
||||
</BrandButton>
|
||||
<p className="text-[13px] text-slate-helper">
|
||||
[Mindestlaufzeit und Kündigungsfrist hier ergänzen]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Reveal delay={0.09}>
|
||||
<div className="rounded-cards glass glass-rim bg-sky-wash/50 p-7">
|
||||
<p className="flex items-baseline gap-2">
|
||||
<span className="font-display text-heading-sm font-semibold tracking-[-0.4px] text-ink">
|
||||
+ 0,50 €
|
||||
</span>
|
||||
<span className="text-body text-sky-ink">pro Buchung</span>
|
||||
</p>
|
||||
<p className="mt-3 text-body text-slate-body">
|
||||
Trägt deine Kundin beim Checkout, nicht du. Für dich bleibt die
|
||||
monatliche Rechnung planbar bei 150 €.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.16}>
|
||||
<div className="rounded-cards glass glass-rim bg-peach-wash/50 p-7">
|
||||
<Eyebrow wash="peach" className="!bg-white/70">
|
||||
Transparenz-Hinweis
|
||||
</Eyebrow>
|
||||
<p className="mt-3 text-body text-slate-body">
|
||||
Zusätzlich fallen die Transaktionsgebühren von Stripe bzw.
|
||||
PayPal nach deren jeweils gültiger Preisliste an. Diese rechnen
|
||||
die Zahlungsdienstleister direkt ab — sie laufen nicht über uns,
|
||||
und wir verdienen nichts daran.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Objections
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const OBJECTIONS = [
|
||||
{
|
||||
eyebrow: null,
|
||||
title: "„Ich bin zu klein dafür.“",
|
||||
body: "Es gibt keine Mindestgröße und keine Staffelpreise. Wenn du regelmäßig Kurse gibst, rechnet sich das System ab den ersten Stunden, die du nicht mehr mit Buchungs- und Zahlungs-Nachverfolgung verbringst.",
|
||||
},
|
||||
{
|
||||
eyebrow: null,
|
||||
title: "„Ich bin schon woanders.“",
|
||||
body: "Unser Migrationstool übernimmt deine bestehenden Daten automatisch — der Umzug ist kein Wochenendprojekt. Kumasy ist günstiger und aufgeräumter, komplett in Deutschland entwickelt, und im Support sitzt ein Mensch mit Namen statt eines KI-Chatbots.",
|
||||
},
|
||||
{
|
||||
eyebrow: "Auf der Roadmap",
|
||||
title: "„Und was kommt danach?“",
|
||||
body: "Wir arbeiten daran, wiederkehrende Abläufe im Studioalltag weitgehend per KI zu automatisieren. Wer jetzt auf der Warteliste steht, bekommt das zuerst zu sehen.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Objections() {
|
||||
return (
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="Ehrlich gesagt"
|
||||
wash="peach"
|
||||
align="center"
|
||||
titleLines={[["Was", "du", "jetzt"], ["wahrscheinlich", "denkst."]]}
|
||||
/>
|
||||
|
||||
<ul className="mt-14 grid gap-4 md:grid-cols-3">
|
||||
{OBJECTIONS.map((item, index) => (
|
||||
<RevealItem key={item.title} delay={index * 0.1}>
|
||||
<div className="h-full glass glass-rim rounded-cards p-6">
|
||||
{item.eyebrow ? (
|
||||
<Eyebrow wash="mint" className="mb-3">
|
||||
{item.eyebrow}
|
||||
</Eyebrow>
|
||||
) : null}
|
||||
<h3 className="text-subheading font-semibold tracking-[-0.26px] text-ink">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-body text-slate-body">{item.body}</p>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Pilot studio
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const TECH = [
|
||||
"Zahlungsabwicklung über Stripe",
|
||||
"Hosting über Vercel, Region Frankfurt",
|
||||
"DSGVO-konform, Daten bleiben in der EU",
|
||||
"GoBD-konforme Rechnungsstellung",
|
||||
];
|
||||
|
||||
export function Pilot() {
|
||||
return (
|
||||
<Section>
|
||||
<div className="grid gap-4 lg:grid-cols-[1.35fr_1fr]">
|
||||
<Reveal>
|
||||
<div className="flex h-full flex-col justify-between glass glass-rim rounded-cards p-8">
|
||||
<div>
|
||||
<Eyebrow wash="mint">Pilotstudio</Eyebrow>
|
||||
<h2 className="mt-5 font-display text-[clamp(1.75rem,3.4vw,2.25rem)] font-semibold leading-[1.08] tracking-[-0.026em] text-ink">
|
||||
Kumasy läuft im Alltag bei 120gradStudio.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[520px] text-body-lg text-slate-body">
|
||||
Wir testen jede Funktion zuerst dort, bevor sie in die
|
||||
Warteliste-Freigabe geht — an echten Kursen, echten Buchungen
|
||||
und echten Zahlungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<blockquote className="mt-8 border-l-2 border-hairline pl-5">
|
||||
<p className="text-body-lg italic text-ink">
|
||||
„[Hier das Zitat von 120gradStudio einsetzen — ein bis zwei
|
||||
Sätze, am besten mit konkreter Zahl.]“
|
||||
</p>
|
||||
<footer className="mt-3 text-body text-slate-helper">
|
||||
[Name], 120gradStudio
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div className="glass-ink glass-rim h-full rounded-cards p-8">
|
||||
<Eyebrow wash="amber" className="!border-white/25 !bg-white/15 !text-amber">
|
||||
Technik & Recht
|
||||
</Eyebrow>
|
||||
<ul className="mt-6 flex flex-col gap-4">
|
||||
{TECH.map((item) => (
|
||||
<li key={item} className="flex gap-3 text-body text-white/85">
|
||||
<Check
|
||||
className="mt-1 size-4 shrink-0 text-amber"
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* FAQ
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function FaqSection() {
|
||||
return (
|
||||
<Section id="faq">
|
||||
<div className="grid gap-10 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div>
|
||||
<Reveal>
|
||||
<Eyebrow>FAQ</Eyebrow>
|
||||
</Reveal>
|
||||
<h2 className="mt-5 font-display text-[clamp(2rem,4.6vw,2.75rem)] font-semibold leading-[1.08] tracking-[-0.032em] text-ink">
|
||||
<MaskedWords words={["Häufige", "Fragen"]} />
|
||||
</h2>
|
||||
<Reveal delay={0.15}>
|
||||
<p className="mt-4 text-body text-slate-body">
|
||||
Deine Frage ist nicht dabei? Schreib uns — es antwortet ein
|
||||
Mensch.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<GhostLink href="mailto:info@kumasy.de" className="mt-3">
|
||||
info@kumasy.de
|
||||
<ArrowRight className="size-4" strokeWidth={2} />
|
||||
</GhostLink>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div className="border-t border-hairline">
|
||||
<Faq question="Brauchen meine Kundinnen einen Account?">
|
||||
Nein. Gastbuchung ohne Registrierung ist Standard — genau da
|
||||
springen sonst die meisten ab. Wer will, legt sich trotzdem ein
|
||||
Konto an und sieht seine Buchungshistorie.
|
||||
</Faq>
|
||||
<Faq question="Was passiert mit meinen bestehenden Daten?">
|
||||
Unser Migrationstool pflegt Kurse, Kundinnen und Termine aus
|
||||
deinem alten System automatisch in Kumasy ein. Du musst nichts
|
||||
abtippen und kannst vorher prüfen, was übernommen wird.
|
||||
</Faq>
|
||||
<Faq question="Wer zahlt die 0,50 € pro Buchung?">
|
||||
Deine Kundin, beim Checkout. Dein monatlicher Betrag bleibt bei
|
||||
150 € — unabhängig davon, ob du 20 oder 2.000 Buchungen im Monat
|
||||
hast. Dazu kommen nur die Transaktionsgebühren von Stripe bzw.
|
||||
PayPal.
|
||||
</Faq>
|
||||
<Faq question="Ist Kumasy DSGVO-konform?">
|
||||
Ja. Gehostet wird über Vercel in der Region Frankfurt, Zahlungen
|
||||
laufen über Stripe, Rechnungen werden GoBD-konform erstellt.
|
||||
[Auftragsverarbeitungsvertrag und Datenschutzerklärung hier
|
||||
verlinken.]
|
||||
</Faq>
|
||||
<Faq question="Wann kann ich starten?">
|
||||
Wir sind in der Pilotphase und geben nach und nach Zugänge frei.
|
||||
Du bekommst eine E-Mail, sobald du dran bist.
|
||||
</Faq>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Closing CTA
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function ClosingCta() {
|
||||
return (
|
||||
<Section className="pb-28">
|
||||
<Reveal>
|
||||
<div className="relative isolate overflow-hidden glass-xl glass-rim rounded-cards px-6 py-16 text-center sm:px-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[3px]"
|
||||
style={{ background: "var(--gradient-amber)" }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="blob pointer-events-none absolute left-1/2 top-full size-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-25"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(38,192,255,0.55) 0%, transparent 68%)",
|
||||
filter: "blur(70px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Eyebrow>Pilotphase · Warteliste</Eyebrow>
|
||||
<h2 className="mx-auto mt-6 max-w-[620px] font-display text-[clamp(2rem,5vw,3.0625rem)] font-semibold leading-[1.04] tracking-[-0.04em] text-ink">
|
||||
<MaskedWords
|
||||
words={[
|
||||
"Sei",
|
||||
"dabei,",
|
||||
"bevor",
|
||||
"wir",
|
||||
<em key="oeffnen" className="brand-text italic">
|
||||
öffnen
|
||||
</em>,
|
||||
]}
|
||||
/>
|
||||
</h2>
|
||||
<Reveal delay={0.25}>
|
||||
<p className="mx-auto mt-5 max-w-[460px] text-body-lg text-slate-body">
|
||||
Trag dich ein und wir melden uns, sobald dein Zugang bereit ist.
|
||||
Keine Zahlungsdaten, keine Verpflichtung.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={0.32}>
|
||||
<div className="mx-auto mt-9 max-w-[520px] text-left">
|
||||
<WaitlistForm compact />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Footer
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-hairline px-4 py-10">
|
||||
<div className="mx-auto flex max-w-[1200px] flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Logo />
|
||||
|
||||
<nav aria-label="Rechtliches">
|
||||
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-body text-slate-helper">
|
||||
<li>
|
||||
<a href="mailto:info@kumasy.de" className="hover:text-ink">
|
||||
info@kumasy.de
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/impressum" className="hover:text-ink">
|
||||
Impressum
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/datenschutz" className="hover:text-ink">
|
||||
Datenschutz
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/agb" className="hover:text-ink">
|
||||
AGB
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
65
app/components/smooth-scroll.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Lenis from "lenis";
|
||||
import { useReducedMotion } from "./motion";
|
||||
|
||||
/**
|
||||
* Momentum scrolling. The wheel no longer maps 1:1 to scroll position —
|
||||
* it feeds a spring that eases toward the target, so every scroll-linked
|
||||
* animation on the page inherits the same softened motion.
|
||||
*
|
||||
* Disabled entirely under prefers-reduced-motion: hijacking the scroll is
|
||||
* exactly what that preference asks us not to do.
|
||||
*/
|
||||
export function SmoothScroll() {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced) return;
|
||||
|
||||
const lenis = new Lenis({
|
||||
// Slightly longer than default so the easing is felt, not just seen.
|
||||
duration: 1.1,
|
||||
easing: (t: number) => 1 - Math.pow(1 - t, 3),
|
||||
// Touch devices keep their native scrolling; overriding it there
|
||||
// fights the platform and breaks momentum handoff.
|
||||
smoothWheel: true,
|
||||
touchMultiplier: 1.6,
|
||||
});
|
||||
|
||||
let frame = 0;
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
frame = requestAnimationFrame(raf);
|
||||
};
|
||||
frame = requestAnimationFrame(raf);
|
||||
|
||||
// In-page anchors must go through Lenis, or they jump past the easing.
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const anchor = (event.target as HTMLElement | null)?.closest?.(
|
||||
'a[href^="#"]',
|
||||
) as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
|
||||
const id = anchor.getAttribute("href");
|
||||
if (!id || id === "#") return;
|
||||
|
||||
const target = document.querySelector(id);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
lenis.scrollTo(target as HTMLElement, { offset: -104 });
|
||||
};
|
||||
|
||||
document.addEventListener("click", onClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", onClick);
|
||||
cancelAnimationFrame(frame);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, [reduced]);
|
||||
|
||||
return null;
|
||||
}
|
||||
390
app/components/ui.tsx
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Check, Menu, Plus, X } from "lucide-react";
|
||||
import { Logo } from "./logo";
|
||||
import { useReducedMotion } from "./motion";
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Eyebrow label pill
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const WASH = {
|
||||
amber: "bg-amber-soft/70 text-[#8a6d00] border-amber/30",
|
||||
sky: "bg-sky-wash/70 text-sky-ink border-sky-ink/15",
|
||||
mint: "bg-mint-wash/70 text-mint-ink border-mint-ink/15",
|
||||
peach: "bg-peach-wash/70 text-peach-ink border-peach-ink/15",
|
||||
} as const;
|
||||
|
||||
export function Eyebrow({
|
||||
children,
|
||||
wash = "amber",
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
wash?: keyof typeof WASH;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-[4px] text-caption font-semibold uppercase backdrop-blur-sm ${WASH[wash]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Primary CTA — solid amber, the brand's one loud surface
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function BrandButton({
|
||||
children,
|
||||
href,
|
||||
type,
|
||||
className = "",
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
href?: string;
|
||||
type?: "button" | "submit";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
const classes = `group relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-buttons px-[22px] py-[11px] text-body font-semibold text-ink shadow-amber transition-shadow duration-300 disabled:pointer-events-none disabled:opacity-55 ${className}`;
|
||||
const style = { background: "var(--gradient-amber)" };
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{/* Sheen sweep on hover — the glass highlight passing over. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/55 to-transparent transition-transform duration-700 ease-out-soft group-hover:translate-x-full motion-reduce:hidden"
|
||||
/>
|
||||
<span className="relative flex items-center gap-2">{children}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<motion.a
|
||||
href={href}
|
||||
className={classes}
|
||||
style={style}
|
||||
whileHover={reduced ? undefined : { y: -2, scale: 1.015 }}
|
||||
whileTap={reduced ? undefined : { y: 0, scale: 0.985 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 24 }}
|
||||
>
|
||||
{inner}
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type={type ?? "button"}
|
||||
className={classes}
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
whileHover={reduced || disabled ? undefined : { y: -2, scale: 1.015 }}
|
||||
whileTap={reduced || disabled ? undefined : { y: 0, scale: 0.985 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 24 }}
|
||||
>
|
||||
{inner}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Secondary: glass surface, navy text. */
|
||||
export function GlassButton({
|
||||
children,
|
||||
href,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
return (
|
||||
<motion.a
|
||||
href={href}
|
||||
className={`glass glass-rim inline-flex items-center justify-center gap-2 rounded-buttons px-[20px] py-[11px] text-body font-medium text-ink ${className}`}
|
||||
whileHover={reduced ? undefined : { y: -2 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 24 }}
|
||||
>
|
||||
{children}
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
||||
export function GhostLink({
|
||||
children,
|
||||
href,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center gap-1.5 text-body font-medium text-ink transition-opacity duration-200 hover:opacity-60 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Floating glass nav
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "#funktionen", label: "Funktionen" },
|
||||
{ href: "#einblick", label: "Einblick" },
|
||||
{ href: "#preis", label: "Preis" },
|
||||
{ href: "#faq", label: "FAQ" },
|
||||
];
|
||||
|
||||
export function Nav() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<header className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
|
||||
<motion.nav
|
||||
aria-label="Hauptnavigation"
|
||||
initial={reduced ? false : { y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`glass-rim pointer-events-auto w-full max-w-[1000px] rounded-nav transition-all duration-500 ease-out-soft ${
|
||||
scrolled ? "glass shadow-elevated" : "glass-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between px-5">
|
||||
<a href="#hauptinhalt" aria-label="Kumasy — zum Seitenanfang">
|
||||
<Logo />
|
||||
</a>
|
||||
|
||||
<ul className="hidden items-center gap-1 md:flex">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="rounded-full px-3.5 py-2 text-body font-medium text-slate-body transition-colors duration-200 hover:bg-white/60 hover:text-ink"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<GhostLink href="https://app.kumasy.de" className="text-[14px]">
|
||||
Login
|
||||
</GhostLink>
|
||||
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
|
||||
Auf die Warteliste
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-ink md:hidden"
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={open ? "Menü schließen" : "Menü öffnen"}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? (
|
||||
<X className="size-5" strokeWidth={1.8} />
|
||||
) : (
|
||||
<Menu className="size-5" strokeWidth={1.8} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
id="mobile-nav"
|
||||
key="mobile-nav"
|
||||
className="overflow-hidden border-t border-white/50 px-5 md:hidden"
|
||||
initial={reduced ? false : { height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={reduced ? undefined : { height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<ul className="flex flex-col gap-1 py-3">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-2xl px-3 py-2.5 text-body font-medium transition-colors duration-200 hover:bg-white/60"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li className="mt-2 flex items-center justify-between gap-3 px-3 pb-2">
|
||||
<GhostLink href="https://app.kumasy.de">Login</GhostLink>
|
||||
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
|
||||
Auf die Warteliste
|
||||
</BrandButton>
|
||||
</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Waitlist form
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function WaitlistForm({ compact = false }: { compact?: boolean }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [state, setState] = useState<"idle" | "sending" | "done">("idle");
|
||||
const fieldId = useId();
|
||||
const statusId = `${fieldId}-status`;
|
||||
|
||||
// No backend is wired up yet — this validates and acknowledges locally.
|
||||
const onSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (state !== "idle") return;
|
||||
setState("sending");
|
||||
window.setTimeout(() => setState("done"), 700);
|
||||
};
|
||||
|
||||
if (state === "done") {
|
||||
return (
|
||||
<motion.div
|
||||
className="glass glass-rim flex items-center gap-3 rounded-inputs px-5 py-4"
|
||||
role="status"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-mint-wash">
|
||||
<Check className="size-4 text-mint-ink" strokeWidth={2.4} />
|
||||
</span>
|
||||
<p className="text-body text-ink">
|
||||
Danke — du stehst auf der Liste. Wir melden uns per E-Mail.
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="w-full">
|
||||
<label htmlFor={fieldId} className="sr-only">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={`glass glass-rim flex w-full items-center gap-2 rounded-full p-2 ${
|
||||
compact ? "" : "sm:p-2.5"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
placeholder="E-Mail-Adresse"
|
||||
aria-describedby={statusId}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent px-4 py-2.5 text-body text-ink outline-none placeholder:text-slate-quiet"
|
||||
/>
|
||||
<BrandButton type="submit" disabled={state === "sending"}>
|
||||
{state === "sending" ? "Moment…" : "Platz sichern"}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<p id={statusId} className="mt-3 text-[14px] text-slate-helper">
|
||||
Du bekommst eine E-Mail, sobald dein Zugang bereit ist. Keine
|
||||
Verpflichtung, keine Zahlungsdaten.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* FAQ accordion
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function Faq({
|
||||
question,
|
||||
children,
|
||||
}: {
|
||||
question: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
return (
|
||||
<div className="border-b border-hairline">
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex w-full items-center justify-between gap-6 py-5 text-left text-body-lg font-medium text-ink transition-colors duration-200 hover:text-slate-body"
|
||||
>
|
||||
{question}
|
||||
<Plus
|
||||
aria-hidden
|
||||
className={`size-5 shrink-0 text-slate-helper transition-transform duration-400 ease-out-soft ${
|
||||
open ? "rotate-45" : ""
|
||||
}`}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
// grid-template-rows animates to auto-height without measuring.
|
||||
className="grid transition-[grid-template-rows,opacity] duration-400 ease-out-soft"
|
||||
style={{
|
||||
gridTemplateRows: open ? "1fr" : "0fr",
|
||||
opacity: open ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pb-5 pr-10 text-body text-slate-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
app/globals.css
|
|
@ -1,26 +1,307 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* ── Brand ──────────────────────────────────────────────
|
||||
Sampled from public/icons/icon-512.png: the amber tile
|
||||
and the navy mark. These two carry the whole palette. */
|
||||
--color-amber: #f4c00c;
|
||||
--color-amber-deep: #d9a800;
|
||||
--color-amber-soft: #ffe9a3;
|
||||
--color-ink: #1e293b;
|
||||
--color-ink-soft: #334155;
|
||||
--color-slate-body: #475569;
|
||||
--color-slate-helper: #64748b;
|
||||
--color-slate-quiet: #94a3b8;
|
||||
|
||||
/* Surfaces & lines */
|
||||
--color-canvas: #f9fafb;
|
||||
--color-paper: #ffffff;
|
||||
--color-hairline: #e2e8f0;
|
||||
--color-mist: #eef2f6;
|
||||
|
||||
/* Status washes, borrowed from the app's badge colours */
|
||||
--color-mint-wash: #dcfce7;
|
||||
--color-mint-ink: #15803d;
|
||||
--color-sky-wash: #e0f2fe;
|
||||
--color-sky-ink: #0369a1;
|
||||
--color-peach-wash: #ffedd5;
|
||||
--color-peach-ink: #c2410c;
|
||||
|
||||
/* ── Typography ─────────────────────────────────────── */
|
||||
--font-switzer: var(--font-switzer-local), ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: var(--font-display-local), ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
--text-caption: 10px;
|
||||
--text-caption--line-height: 1.5;
|
||||
--text-caption--letter-spacing: 1.4px;
|
||||
--text-body: 16px;
|
||||
--text-body--line-height: 1.5;
|
||||
--text-body-lg: 18px;
|
||||
--text-body-lg--line-height: 1.45;
|
||||
--text-subheading: 20px;
|
||||
--text-subheading--line-height: 1.43;
|
||||
--text-subheading--letter-spacing: -0.26px;
|
||||
--text-heading-sm: 31px;
|
||||
--text-heading-sm--line-height: 1.1;
|
||||
--text-heading-sm--letter-spacing: -0.4px;
|
||||
--text-heading: 44px;
|
||||
--text-heading--line-height: 1.08;
|
||||
--text-heading--letter-spacing: -1.15px;
|
||||
--text-heading-lg: 49px;
|
||||
--text-heading-lg--line-height: 1.04;
|
||||
--text-heading-lg--letter-spacing: -1.96px;
|
||||
--text-display: 76px;
|
||||
--text-display--line-height: 1;
|
||||
--text-display--letter-spacing: -4.25px;
|
||||
|
||||
/* ── Radii ──────────────────────────────────────────── */
|
||||
--radius-cards: 24px;
|
||||
--radius-images: 24px;
|
||||
--radius-inputs: 16px;
|
||||
--radius-buttons: 28px;
|
||||
--radius-nav: 28px;
|
||||
|
||||
/* ── Shadows ────────────────────────────────────────── */
|
||||
--shadow-float: 0 16px 16px -8px rgba(30, 41, 59, 0.04),
|
||||
0 10px 10px -5px rgba(30, 41, 59, 0.04), 0 5px 5px -2.5px rgba(30, 41, 59, 0.03),
|
||||
0 3px 3px -1.5px rgba(30, 41, 59, 0.03), 0 1px 1px -0.5px rgba(30, 41, 59, 0.03);
|
||||
--shadow-card: 0 0 0 1px rgba(30, 41, 59, 0.06), 0 16px 16px -8px rgba(30, 41, 59, 0.04),
|
||||
0 10px 10px -5px rgba(30, 41, 59, 0.03), 0 5px 5px -2.5px rgba(30, 41, 59, 0.03),
|
||||
0 2px 2px -1px rgba(30, 41, 59, 0.03);
|
||||
--shadow-elevated: 0 0 0 1px rgba(30, 41, 59, 0.05),
|
||||
0 28px 32px -12px rgba(30, 41, 59, 0.1), 0 12px 14px -6px rgba(30, 41, 59, 0.05),
|
||||
0 5px 5px -2.5px rgba(30, 41, 59, 0.03), 0 2px 2px -1px rgba(30, 41, 59, 0.03);
|
||||
--shadow-amber: 0 18px 40px -14px rgba(217, 168, 0, 0.55);
|
||||
|
||||
/* ── Motion ─────────────────────────────────────────── */
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* The signature gradient: amber into navy, used on accents and
|
||||
as the light source behind glass. */
|
||||
--gradient-brand: linear-gradient(115deg, #f4c00c 0%, #f0a500 42%, #1e293b 100%);
|
||||
--gradient-amber: linear-gradient(120deg, #ffd84d 0%, #f4c00c 45%, #e09b00 100%);
|
||||
--gradient-ink: linear-gradient(150deg, #334155 0%, #1e293b 55%, #0f172a 100%);
|
||||
--page-max: 1200px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
html {
|
||||
/* Native smooth scrolling would fight Lenis. Under reduced motion Lenis
|
||||
never starts, and jumping instantly is the correct behaviour anyway. */
|
||||
scroll-behavior: auto;
|
||||
scroll-padding-top: 104px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Lenis drives scrolling via transform on <html>; these are its required
|
||||
host styles. Without them the page height collapses on some browsers. */
|
||||
html.lenis,
|
||||
html.lenis body {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.lenis.lenis-smooth {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.lenis.lenis-stopped {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-switzer);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-amber);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-ink);
|
||||
outline-offset: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ── Glass system ─────────────────────────────────────────
|
||||
The four ingredients from DESIGN-SYSTEM.md, bundled once:
|
||||
translucent fill, backdrop blur, light edge, soft shadow.
|
||||
Intensity scales with the surface's visual priority. */
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.65);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.glass-sm {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(10px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.glass-xl {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
/* Dark frosted panel — the auth-panel treatment from the app. */
|
||||
.glass-ink {
|
||||
background: rgba(30, 41, 59, 0.72);
|
||||
backdrop-filter: blur(40px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
/* A top light-edge, as if a highlight catches the glass rim. */
|
||||
.glass-rim {
|
||||
position: relative;
|
||||
}
|
||||
.glass-rim::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding-top: 1px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.9),
|
||||
transparent 40%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Backdrop-filter is expensive; on small screens trade blur for opacity. */
|
||||
@media (max-width: 640px) {
|
||||
.glass,
|
||||
.glass-xl {
|
||||
backdrop-filter: blur(14px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(150%);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.glass,
|
||||
.glass-sm,
|
||||
.glass-xl {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.glass-ink {
|
||||
background: rgba(30, 41, 59, 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Brand gradient primitives ────────────────────────── */
|
||||
|
||||
.brand-text {
|
||||
background: var(--gradient-amber);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
padding-inline-end: 0.06em;
|
||||
}
|
||||
|
||||
/* Amber gradient border via double-background. */
|
||||
.brand-border {
|
||||
background:
|
||||
linear-gradient(var(--color-paper), var(--color-paper)) padding-box,
|
||||
var(--gradient-amber) border-box;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
/* ── Ambient motion ───────────────────────────────────── */
|
||||
@keyframes blob-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(3%, -4%, 0) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.blob {
|
||||
animation: blob-float 18s var(--ease-out-soft) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes aurora-shift {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slow-drifting wash so the glass always has something to blur. */
|
||||
.aurora {
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(244, 192, 12, 0.22) 0%,
|
||||
rgba(255, 216, 77, 0.16) 22%,
|
||||
rgba(224, 242, 254, 0.3) 48%,
|
||||
rgba(30, 41, 59, 0.1) 74%,
|
||||
rgba(244, 192, 12, 0.2) 100%
|
||||
);
|
||||
background-size: 300% 300%;
|
||||
animation: aurora-shift 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.marquee-track {
|
||||
animation: marquee 42s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Accessibility ────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.blob,
|
||||
.marquee-track,
|
||||
.aurora {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,88 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Manrope, Plus_Jakarta_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
// DESIGN.md names Switzer / Basier Circle; neither ships on Google Fonts.
|
||||
// These are the documented substitutes — Manrope for UI, Plus Jakarta Sans for
|
||||
// display, which holds the tight negative tracking the brand depends on.
|
||||
const switzer = Manrope({
|
||||
variable: "--font-switzer-local",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const display = Plus_Jakarta_Sans({
|
||||
variable: "--font-display-local",
|
||||
subsets: ["latin"],
|
||||
weight: ["500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const SITE_URL = "https://kumasy.de";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
|
||||
template: "%s · Kumasy",
|
||||
},
|
||||
description:
|
||||
"Kumasy bündelt Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Deine Teilnehmer:innen buchen und bezahlen in unter zwei Minuten selbst — ohne DM, ohne Rückfrage, ohne Excel.",
|
||||
keywords: [
|
||||
"Kursverwaltung",
|
||||
"Buchungssystem",
|
||||
"Fitnessstudio Software",
|
||||
"Yoga Studio Verwaltung",
|
||||
"Kursbuchung",
|
||||
"Stripe Zahlungen",
|
||||
"GoBD",
|
||||
"DSGVO",
|
||||
],
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
url: SITE_URL,
|
||||
siteName: "Kumasy",
|
||||
title: "Deine Kurse füllen und bezahlen sich von selbst.",
|
||||
description:
|
||||
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Jetzt auf die Warteliste.",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
|
||||
description:
|
||||
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche.",
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
},
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
colorScheme: "light",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
lang="de"
|
||||
className={`${switzer.variable} ${display.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col bg-canvas text-ink">
|
||||
<a
|
||||
href="#hauptinhalt"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-2xl focus:bg-canvas focus:px-5 focus:py-3 focus:text-body focus:font-medium focus:shadow-elevated"
|
||||
>
|
||||
Zum Hauptinhalt springen
|
||||
</a>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
123
app/opengraph-image.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const alt =
|
||||
"Kumasy — Deine Kurse füllen und bezahlen sich von selbst.";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
// Static export: rendered once at build time.
|
||||
export default function OpengraphImage() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
background: "#f9fafb",
|
||||
padding: 72,
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Amber hairline, mirroring the site's scroll indicator. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 10,
|
||||
background:
|
||||
"linear-gradient(90deg, #ffd84d 0%, #f4c00c 45%, #e09b00 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(120deg, #ffd84d, #f4c00c 45%, #e09b00)",
|
||||
}}
|
||||
>
|
||||
<svg width="34" height="34" viewBox="0 0 512 512" fill="#1e293b">
|
||||
<rect x="153" y="118" width="71" height="277" rx="35.5" />
|
||||
<path d="M234.6 299.6 317.4 220.5 A20 20 0 0 1 352 234.4 L352 379.6 A20 20 0 0 1 317.4 393.5 L234.6 314.4 A10 10 0 0 1 234.6 299.6 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ fontSize: 40, fontWeight: 600, color: "#1e293b" }}>
|
||||
kumasy
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
color: "#1e293b",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 78,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.04,
|
||||
letterSpacing: -3,
|
||||
}}
|
||||
>
|
||||
Deine Kurse füllen und
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 78,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.04,
|
||||
letterSpacing: -3,
|
||||
}}
|
||||
>
|
||||
bezahlen sich von selbst.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 28,
|
||||
fontSize: 30,
|
||||
color: "#475569",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Kurse, Räume, Trainer, Buchungen und Zahlungen — in einer
|
||||
Oberfläche.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 14 }}>
|
||||
{["Stripe", "DSGVO-konform", "Hosting Frankfurt", "GoBD"].map(
|
||||
(chip) => (
|
||||
<div
|
||||
key={chip}
|
||||
style={{
|
||||
display: "flex",
|
||||
padding: "10px 20px",
|
||||
borderRadius: 999,
|
||||
background: "#ffffff",
|
||||
color: "#475569",
|
||||
border: "1px solid #e2e8f0",
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
{chip}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
57
app/page.tsx
|
|
@ -1,8 +1,59 @@
|
|||
import { Hero } from "./components/hero";
|
||||
import { ScrollProgress, ScrollStage } from "./components/motion";
|
||||
import { PhoneShowcase } from "./components/phone-showcase";
|
||||
import { SmoothScroll } from "./components/smooth-scroll";
|
||||
import {
|
||||
ClosingCta,
|
||||
FaqSection,
|
||||
Footer,
|
||||
Objections,
|
||||
Pilot,
|
||||
Pricing,
|
||||
Problem,
|
||||
Showcase,
|
||||
Solution,
|
||||
Steps,
|
||||
} from "./components/sections";
|
||||
import { Nav } from "./components/ui";
|
||||
|
||||
export default function Home() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<SmoothScroll />
|
||||
<ScrollProgress />
|
||||
<Nav />
|
||||
|
||||
</div>
|
||||
<main id="hauptinhalt" className="flex-1">
|
||||
{/* The hero is not staged — it is already at rest on load, and
|
||||
fading it in as the page opens would hide the headline. */}
|
||||
<Hero />
|
||||
<ScrollStage>
|
||||
<Problem />
|
||||
</ScrollStage>
|
||||
<ScrollStage>
|
||||
<Solution />
|
||||
</ScrollStage>
|
||||
<Showcase />
|
||||
<PhoneShowcase />
|
||||
<ScrollStage>
|
||||
<Steps />
|
||||
</ScrollStage>
|
||||
<ScrollStage>
|
||||
<Pricing />
|
||||
</ScrollStage>
|
||||
<ScrollStage>
|
||||
<Objections />
|
||||
</ScrollStage>
|
||||
<ScrollStage>
|
||||
<Pilot />
|
||||
</ScrollStage>
|
||||
<ScrollStage>
|
||||
<FaqSection />
|
||||
</ScrollStage>
|
||||
<ClosingCta />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import path from "node:path";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
turbopack: {
|
||||
// A stray package-lock.json sits in a parent directory; pin the root to
|
||||
// this project so Turbopack does not treat it as a workspace.
|
||||
root: path.join(__dirname),
|
||||
},
|
||||
images: {
|
||||
// Serve the source PNGs untouched. The device mockups are transparent
|
||||
// and pixel-aligned; resampling them softens the UI text inside.
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
103
package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
|||
"name": "kumasy-marketing",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-react": "^1.47.0",
|
||||
"motion": "^13.4.0",
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
|
|
@ -3780,6 +3783,29 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.0.tgz",
|
||||
"integrity": "sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^13.3.0",
|
||||
"motion-utils": "^13.3.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -4736,6 +4762,37 @@
|
|||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/lenis": {
|
||||
"version": "1.3.26",
|
||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz",
|
||||
"integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"playground",
|
||||
"playground/*"
|
||||
],
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/darkroomengineering"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": ">=3.0.0",
|
||||
"react": ">=17.0.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
@ -5057,6 +5114,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
|
||||
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -5124,6 +5190,43 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-13.4.0.tgz",
|
||||
"integrity": "sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^13.4.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.3.0.tgz",
|
||||
"integrity": "sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^13.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz",
|
||||
"integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-react": "^1.47.0",
|
||||
"motion": "^13.4.0",
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
|
|
|
|||
BIN
pictures/iphone-side-coupon.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
pictures/iphone-side-course.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
pictures/iphone-side-sidebar.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
pictures/iphone-sidebar-dashboard.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/booking-page.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/courses.png
Normal file
|
After Width: | Height: | Size: 557 KiB |
BIN
public/dashboard.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icons/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/payments.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
public/phones/phone-booking.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
public/phones/phone-dashboard.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
public/phones/phone-discounts.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
public/phones/phone-nav.png
Normal file
|
After Width: | Height: | Size: 130 KiB |