MVp
This commit is contained in:
commit
c988d51438
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"codium.codeCompletion.enable": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# 1) Base
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# 2) Deps
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 3) Builder
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# falls public nicht existiert -> anlegen, damit COPY später nicht crasht
|
||||||
|
RUN mkdir -p public
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
# 4) Runner
|
||||||
|
FROM node:18-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||||
|
|
||||||
|
# Assets & Build-Output kopieren
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node_modules/.bin/next", "start", "-p", "3000"]
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Immersive Scroll Experience Setup
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
First, install the required dependencies for the scroll experience:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @studio-freight/lenis gsap @types/gsap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 🎭 Dogstudio-Inspired Scroll Experience
|
||||||
|
- **Smooth Scroll**: Lenis integration with custom easing
|
||||||
|
- **Pinned Storytelling**: Problem → Promise → Solution narrative
|
||||||
|
- **Split Text Animations**: Character-by-character reveals
|
||||||
|
- **Parallax Layers**: Multi-depth background elements
|
||||||
|
- **Magnetic Buttons**: Cursor-following interactive elements
|
||||||
|
- **Video Showcase**: Scroll-triggered media with play overlay
|
||||||
|
- **Count-up Stats**: Animated numbers on scroll
|
||||||
|
- **Feature Cards**: 3D perspective reveals with clip-mask animations
|
||||||
|
|
||||||
|
### 🎨 Visual Effects
|
||||||
|
- **Noise Texture**: Subtle grain overlay for premium feel
|
||||||
|
- **Gradient Backgrounds**: Dynamic color transitions
|
||||||
|
- **Glass Morphism**: Backdrop blur effects on cards
|
||||||
|
- **Hover Animations**: Tilt and scale effects
|
||||||
|
- **Scroll Hints**: Animated scroll indicators
|
||||||
|
|
||||||
|
### ♿ Accessibility
|
||||||
|
- **Reduced Motion**: Respects `prefers-reduced-motion`
|
||||||
|
- **Keyboard Navigation**: Full keyboard support
|
||||||
|
- **Focus Management**: Visible focus states
|
||||||
|
- **Screen Reader**: Proper ARIA labels and live regions
|
||||||
|
|
||||||
|
### 📱 Performance
|
||||||
|
- **Optimized Animations**: RequestAnimationFrame-based
|
||||||
|
- **Lazy Loading**: Images load on demand
|
||||||
|
- **Code Splitting**: Dynamic imports for heavy components
|
||||||
|
- **Smooth 60fps**: Optimized for mobile devices
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Install dependencies** (see above)
|
||||||
|
2. **Visit the experience**: Navigate to `/scroll` in your browser
|
||||||
|
3. **Enjoy the journey**: Scroll through the immersive experience
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `/` - Original premium homepage
|
||||||
|
- `/scroll` - New immersive scroll experience
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Core Animation Components
|
||||||
|
- `ScrollProvider` - Lenis smooth scroll integration
|
||||||
|
- `ParallaxLayer` - Multi-depth parallax effects
|
||||||
|
- `PinnedStory` - Scroll-triggered narrative sections
|
||||||
|
- `MagneticButton` - Cursor-following interactive buttons
|
||||||
|
|
||||||
|
### Content Components
|
||||||
|
- `ScrollHero` - Animated hero with split text
|
||||||
|
- `FeatureCards` - 3D perspective card reveals
|
||||||
|
- `VideoShowcase` - Scroll-triggered video player
|
||||||
|
- `SocialProof` - Animated stats and logos
|
||||||
|
|
||||||
|
### Animation Utilities
|
||||||
|
- `animateSplit()` - Character-by-character text reveals
|
||||||
|
- `animateWords()` - Word-by-word text animations
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
The experience uses a dark theme with accent colors:
|
||||||
|
- Background: `#0f1220` (slate-900)
|
||||||
|
- Text: `#ffffff` (white)
|
||||||
|
- Accent: `#7c5cff` (purple) to `#7cf4e2` (cyan)
|
||||||
|
|
||||||
|
### Animation Timing
|
||||||
|
- Split text stagger: `0.015s`
|
||||||
|
- Card reveals: `0.8s` duration
|
||||||
|
- Parallax speed: `0.5x` to `1.5x`
|
||||||
|
- Magnetic strength: `0.3` to `0.4`
|
||||||
|
|
||||||
|
### Performance Settings
|
||||||
|
- Mobile pixel ratio: Capped at `1.25`
|
||||||
|
- Animation budget: `<8ms` per frame
|
||||||
|
- Bundle size: `<220KB` gzipped
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Chrome 90+
|
||||||
|
- Safari 14+
|
||||||
|
- Firefox 88+
|
||||||
|
- Edge 90+
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit:
|
||||||
|
- http://localhost:3000 - Original homepage
|
||||||
|
- http://localhost:3000/scroll - Immersive experience
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] Lighthouse score >90 on mobile
|
||||||
|
- [ ] Core Web Vitals passing
|
||||||
|
- [ ] Reduced motion tested
|
||||||
|
- [ ] Cross-browser compatibility
|
||||||
|
- [ ] Touch device optimization
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function AboutPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="h1">Über Michael Peskov</h1>
|
||||||
|
<div className="grid grid--2 mt-4">
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="h3">Michael Peskov – Deutschlands jüngster Profi‑Zauberkünstler & Taschendieb</h3>
|
||||||
|
<p>
|
||||||
|
Aus Solingen stammend, ist Michael bereits eines der bekanntesten magischen Talente Deutschlands – gezeigt in Formaten von SAT.1, WDR, ZDF und Amazon Prime Video. Schon mit sechs Jahren von der Magie gepackt, verzaubert er heute mit tänzerischer Leichtigkeit und jugendlicher Dynamik Kunden verschiedenster Events.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Interaktiv, witzig, verändernd – sein Ziel: Langweile eliminieren, Gespräche entfachen und unvergessliche Momente schaffen. Ob hautnah bei kleinen Gruppen oder groß inszeniert auf der Bühne – Michael passt sich jeder Veranstaltung flexibel an und bringt nicht nur Magie, sondern echte Emotion. Profi‑Organisation, offene Kommunikation und maximale Zuverlässigkeit inklusive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<aside className="card">
|
||||||
|
<div className="badge">5 von 5 Sternen • 100% Weiterempfehlung</div>
|
||||||
|
<div className="badge">42699 Solingen • Aktionsradius > 1000 km</div>
|
||||||
|
<div className="badge">SAT.1 • WDR • ZDF • Amazon Prime Video</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Kontakt – Michael Peskov | Zauberer Solingen & NRW – Magier buchen für Hochzeit & Firmenfeier',
|
||||||
|
description:
|
||||||
|
'Planen Sie Ihr Event mit Michael Peskov: Close-up Magie, Taschendieb‑Show, Walking Act & Bühnenzauber. 5/5 Sterne (12), 100% Empfehlung. Solingen, Düsseldorf, Köln, NRW – europaweit (>1000 km). Antwort innerhalb von 24h.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="container">
|
||||||
|
{/* 1. Headline with main keyword */}
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="h1">Zauberer Solingen – Kontakt & Angebot</h1>
|
||||||
|
{/* 2. Intro paragraph (USP + 24h response, natural keywords) */}
|
||||||
|
<p>
|
||||||
|
Magier buchen leicht gemacht: Innerhalb von 24 Stunden erhalten Sie ein maßgeschneidertes Konzept –
|
||||||
|
unverbindlich und passgenau für Ihren Anlass. Ob <strong>Close‑up Magie</strong> als <strong>Walking Act</strong>,
|
||||||
|
interaktiver <strong>Bühnenzauber</strong> oder eine verblüffende <strong>Taschendieb‑Show</strong> –
|
||||||
|
Michael Peskov ist Ihr <strong>Zauberer in Solingen</strong> und <strong>Zauberkünstler NRW</strong> für
|
||||||
|
<strong> Firmenfeier</strong>, <strong>Hochzeit</strong> und private Feiern. Profitieren Sie von moderner Präsentation,
|
||||||
|
klarer Kommunikation und einer reibungslosen Organisation – damit Ihre Gäste noch lange über Ihr Event sprechen.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid--2">
|
||||||
|
{/* 3. Booking form section (conversational labels) */}
|
||||||
|
<div className="card" id="booking">
|
||||||
|
<h2 className="h2">Anfrage – in 60 Sekunden</h2>
|
||||||
|
<form className="bookingForm" style={{ marginTop: 16 }}>
|
||||||
|
<div className="grid" style={{ display:'grid', gridTemplateColumns:'repeat(2, minmax(0,1fr))', gap:'16px' }}>
|
||||||
|
<label className="label">Wann findet Ihr Event statt?
|
||||||
|
<input className="input" placeholder="TT.MM.JJJJ" type="date" required />
|
||||||
|
</label>
|
||||||
|
<label className="label">Wo findet es statt?
|
||||||
|
<input className="input" placeholder="Stadt / Location" required />
|
||||||
|
</label>
|
||||||
|
<label className="label">Wie viele Gäste werden erwartet?
|
||||||
|
<input className="input" type="number" min={1} placeholder="z. B. 120" required />
|
||||||
|
</label>
|
||||||
|
<label className="label">Anlass
|
||||||
|
<select className="select" defaultValue="Firmenfeier">
|
||||||
|
<option>Firmenfeier</option>
|
||||||
|
<option>Hochzeit</option>
|
||||||
|
<option>Geburtstag</option>
|
||||||
|
<option>Gala</option>
|
||||||
|
<option>Messe</option>
|
||||||
|
<option>Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="label" style={{ display:'block', marginTop: 12 }}>Erzählen Sie kurz Details (Timing, Location, Wünsche)
|
||||||
|
<textarea className="textarea" placeholder="z. B. Dinner 19:00, Outdoor‑Bühne, zweisprachige Moderation" />
|
||||||
|
</label>
|
||||||
|
{/* 4. Outcome-driven CTA buttons */}
|
||||||
|
<div className="h-stack mt-4">
|
||||||
|
<button className="btn btn--primary" type="submit">Termin prüfen</button>
|
||||||
|
<button className="btn btn--ghost" type="button">Event‑Plan anfordern</button>
|
||||||
|
<button className="btn btn--ghost" type="button">Datum reservieren</button>
|
||||||
|
</div>
|
||||||
|
{/* 7. Privacy reassurance microcopy */}
|
||||||
|
<p className="muted mt-2">Kein Spam – Ihre Angaben nutzen wir ausschließlich zur Planung Ihres Events.</p>
|
||||||
|
|
||||||
|
{/* 5. Alternative contact options (clickable) */}
|
||||||
|
<div className="h-stack mt-6" style={{ flexWrap:'wrap' as const }}>
|
||||||
|
<a className="btn btn--ghost" href="tel:+491234567890" aria-label="Anrufen">📞 Anruf: +49 XXX XXX XXXX</a>
|
||||||
|
<a className="btn btn--ghost" href="https://wa.me/491234567890" target="_blank" rel="noopener">💬 WhatsApp: Jetzt chatten</a>
|
||||||
|
<a className="btn btn--ghost" href="mailto:booking@michaelpeskov.de">📧 E‑Mail: booking@michaelpeskov.de</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Trust & speed elements + photo */}
|
||||||
|
<aside>
|
||||||
|
<div className="sparkle ratio-4x5" style={{ marginBottom: 16 }}>
|
||||||
|
<Image
|
||||||
|
src="https://images.eventpeppers.com/sites/default/files/imagecache/lightbox/images/13234/michael-peskov-magier-taschendieb-450252.jpeg"
|
||||||
|
alt="Michael Peskov – Close‑Up Magie mit Gästen"
|
||||||
|
width={1200}
|
||||||
|
height={1500}
|
||||||
|
className="cover"
|
||||||
|
sizes="(max-width: 960px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul className="stack" style={{ listStyle:'none', padding:0, margin:0 }}>
|
||||||
|
<li className="badge">✅ Antwort innerhalb von 24 Stunden</li>
|
||||||
|
<li className="badge">📍 Solingen (NRW) • europaweit buchbar (>1000 km Aktionsradius)</li>
|
||||||
|
<li className="badge">⭐ 5/5 Sterne (12), 100% Empfehlung – Eventpeppers</li>
|
||||||
|
<li className="badge badge--accent">Verfügbar in: DE, AT, CH, EU</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Service area list for local SEO */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="h3">Einsatzgebiete & lokale Keywords</h2>
|
||||||
|
<p>
|
||||||
|
Michael ist als <strong>Magier NRW</strong> in <strong>Solingen</strong>, <strong>Zauberer Düsseldorf</strong>,
|
||||||
|
<strong> Zauberer Köln</strong> und ganz NRW unterwegs – ebenso deutschland‑ und europaweit. Ideal für
|
||||||
|
<strong> Zauberer Firmenfeier</strong>, <strong>Zauberer für Hochzeit</strong>, private Feiern und
|
||||||
|
<strong> Event Entertainment</strong>. Buchen Sie moderne <strong>Close‑up Magie</strong>,
|
||||||
|
eine verblüffende <strong>Taschendieb‑Show</strong> oder interaktiven <strong>Bühnenzauber</strong> –
|
||||||
|
auf Wunsch als flexibler <strong>Walking Act</strong>.
|
||||||
|
</p>
|
||||||
|
<ul className="grid grid--3 mt-4" style={{ listStyle:'none', padding:0, margin:0 }}>
|
||||||
|
<li className="badge">Solingen</li>
|
||||||
|
<li className="badge">Düsseldorf</li>
|
||||||
|
<li className="badge">Köln</li>
|
||||||
|
<li className="badge">Wuppertal</li>
|
||||||
|
<li className="badge">Essen</li>
|
||||||
|
<li className="badge">Dortmund</li>
|
||||||
|
<li className="badge">Bonn</li>
|
||||||
|
<li className="badge">Bochum</li>
|
||||||
|
<li className="badge">NRW & Ruhrgebiet</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 8. Closing CTA with internal links */}
|
||||||
|
<section className="cta mt-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="h2">Jetzt Verfügbarkeit prüfen – sichern Sie Ihren Wunschtermin</h2>
|
||||||
|
<p>
|
||||||
|
Buchen Sie <strong>Close‑Up Magie</strong>, <strong>Walking Act</strong>, <strong>Bühnenzauber</strong> oder die
|
||||||
|
<strong> Taschendieb‑Show</strong> von Michael Peskov. Schauen Sie sich auch die{' '}
|
||||||
|
<Link href="/services">Leistungen</Link> und das <Link href="/showreel">Showreel</Link> an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-stack">
|
||||||
|
<a className="btn btn--primary" href="#booking">Termin anfragen</a>
|
||||||
|
<Link className="btn btn--ghost" href="/services">Zu den Leistungen</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="muted mt-2"><em>P.S.</em> Beliebte Termine sind schnell vergeben – frühe Anfrage erhöht die Chance auf Ihren Wunschslot.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default function LegalPage() {
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>Impressum / Datenschutz</h1>
|
||||||
|
<p style={{ color: 'var(--muted)' }}>Legal content placeholder.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function ServicesPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="h1">Leistungen</h1>
|
||||||
|
<div className="grid grid--2 mt-4">
|
||||||
|
<article className="card">
|
||||||
|
<h3 className="h3">1. Close-Up & Tischzauberei (Walking Act)</h3>
|
||||||
|
<p>
|
||||||
|
„Perfekt für Hochzeiten, Firmen‑ und Familienfeiern. Michael bewegt sich von Tisch zu Tisch und verzaubert die Gäste hautnah mit Gabeln, die sich verbiegen, Gegenständen, die verschwinden oder erscheinen, und spektakulären Pickpocket‑Tricks.“
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="card">
|
||||||
|
<h3 className="h3">2. Bühnenshow (interaktiv & humorvoll)</h3>
|
||||||
|
<p>
|
||||||
|
„Eine energiegeladene, interaktive Show, bei der Gäste aktiver Teil der Magie werden. Mit Auszeichnungen, z. B. „punkthöchste Darbietung“ bei der Aufnahmeprüfung im Magischen Zirkel Deutschlands.“
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="card">
|
||||||
|
<h3 className="h3">3. Taschendieb‑Darbietung (Pickpocketing)</h3>
|
||||||
|
<p>
|
||||||
|
„Spontane Besitzübertragung mit Stil – Handy, Portemonnaie oder Armbanduhr werden mit Witz und Präzision ‘gestohlen’ und danach … natürlich zurückgegeben. Kann auch als Lerneffekt gegen echte Taschendiebe genutzt werden.“
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article className="card">
|
||||||
|
<h3 className="h3">4. Gabelbieger (Signature Act)</h3>
|
||||||
|
<p>
|
||||||
|
„Steinhartes Metall wird in Sekundenschnelle verformt – unnachahmlich, selbst im Vergleich mit Uri Geller. Ein bleibendes Souvenir für Gäste und der magische Höhepunkt jeder Veranstaltung.“
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function ShowreelPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="h1">Showreel</h1>
|
||||||
|
<p className="muted">WDR Lokalzeit Düsseldorf, Showreel – Michael Peskov, Promo Video, Zauberkunst – Vielfalt & Stimmung in Clips.</p>
|
||||||
|
<div className="card mt-4">
|
||||||
|
<div className="ratio-16x9 sparkle">
|
||||||
|
<iframe title="WDR Lokalzeit Düsseldorf - Michael Peskov" src="https://www.youtube-nocookie.com/embed/BGAXMeIzu3Q" loading="lazy" width="100%" height="100%" allow="autoplay; encrypted-media; picture-in-picture" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function TestimonialsPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="h1">Stimmen</h1>
|
||||||
|
<div className="grid grid--2 mt-4">
|
||||||
|
<blockquote className="testimonial">
|
||||||
|
<p className="testimonial__quote"><strong>„Ein absolut perfekt gelungenes Zauber Event – jeder unserer Gäste war fasziniert … professionell … absolut weiterzuempfehlen!“</strong></p>
|
||||||
|
<div className="testimonial__meta">
|
||||||
|
<div className="avatar" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<div className="muted">Familienfeier, Februar 2023 — Solingen</div>
|
||||||
|
<div className="badge">5 von 5 Sternen • 100% Weiterempfehlung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</blockquote>
|
||||||
|
<blockquote className="testimonial">
|
||||||
|
<p className="testimonial__quote"><strong>„Seine Shows sind genial! Seine Energie und Leidenschaft … unvergleichlich. Ich würde jederzeit wiederkommen!“</strong></p>
|
||||||
|
<div className="testimonial__meta">
|
||||||
|
<div className="avatar" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<div className="muted">Bewertung bei ProvenExpert (11/2024)</div>
|
||||||
|
<div className="badge">5 von 5 Sternen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
<div className="hr" />
|
||||||
|
<p className="muted">Durchschnitt: 5 von 5 Sternen • 100% Weiterempfehlung</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(59, 130, 246, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import './globals.css'
|
||||||
|
import '@/styles/premium.css'
|
||||||
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Michael Peskov Magic – Moderne Magie für Events | Zauberer NRW',
|
||||||
|
description: 'Bekannt aus SAT.1, WDR, ZDF & Amazon Prime Video. 5★ Zauberer für Corporate Events, Hochzeiten & private Feiern. Deutschlandweit buchbar.',
|
||||||
|
metadataBase: new URL('http://localhost:3000'),
|
||||||
|
openGraph: {
|
||||||
|
title: 'Michael Peskov Magic – Moderne Magie für Events',
|
||||||
|
description: 'Bekannt aus SAT.1, WDR, ZDF & Amazon Prime Video. 5★ Zauberer für Corporate Events, Hochzeiten & private Feiern.',
|
||||||
|
url: '/',
|
||||||
|
siteName: 'Michael Peskov Magic',
|
||||||
|
images: [{
|
||||||
|
url: '/michael-peskov-magier-taschendieb-453624.jpeg',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'Michael Peskov performing magic'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
keywords: [
|
||||||
|
'Zauberer NRW',
|
||||||
|
'Magier Solingen',
|
||||||
|
'Corporate Entertainment',
|
||||||
|
'Hochzeitszauberer',
|
||||||
|
'Close-up Magic',
|
||||||
|
'Taschendieb Act',
|
||||||
|
'Firmenfeier Entertainment'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: '#0F1220',
|
||||||
|
colorScheme: 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link rel="icon" href="/icon.png" type="image/png" />
|
||||||
|
<link rel="shortcut icon" href="/icon.png" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon.png" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" media="print" onLoad="this.media='all'" />
|
||||||
|
<noscript>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
|
</noscript>
|
||||||
|
</head>
|
||||||
|
<body className="font-sans antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,797 @@
|
||||||
|
'use client'
|
||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { Check, Star, BadgeCheck, Sparkles, Play, Mail, Phone, Calendar, Building2, ShieldCheck, Clock4, Users2, Zap, Award, Heart } from "lucide-react"
|
||||||
|
import Image from 'next/image'
|
||||||
|
import {
|
||||||
|
FadeUp,
|
||||||
|
StaggerContainer,
|
||||||
|
StaggerItem,
|
||||||
|
ScaleIn,
|
||||||
|
SlideInLeft,
|
||||||
|
SlideInRight,
|
||||||
|
HoverCard,
|
||||||
|
MagneticButton,
|
||||||
|
CountUp,
|
||||||
|
ParallaxElement
|
||||||
|
} from '@/components/ScrollAnimations'
|
||||||
|
import { AnimatedFAQ } from '@/components/AnimatedFAQ'
|
||||||
|
import { VideoPlayer } from '@/components/VideoPlayer'
|
||||||
|
|
||||||
|
// ---- Main Component ----
|
||||||
|
export default function MagicianLandingPage() {
|
||||||
|
const [active, setActive] = useState("home")
|
||||||
|
|
||||||
|
// Simple scroll function
|
||||||
|
const scrollToSection = (sectionId: string) => {
|
||||||
|
console.log('Scrolling to:', sectionId) // Debug log
|
||||||
|
const element = document.getElementById(sectionId)
|
||||||
|
if (element) {
|
||||||
|
const headerOffset = 100
|
||||||
|
const elementPosition = element.getBoundingClientRect().top
|
||||||
|
const offsetPosition = elementPosition + window.pageYOffset - headerOffset
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('Element not found:', sectionId) // Debug log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe sections to highlight active link
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = ["home", "proof", "showreel", "acts", "usecases", "testimonials", "about", "faq", "contact"]
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]
|
||||||
|
if (visible) setActive(visible.target.id)
|
||||||
|
},
|
||||||
|
{ rootMargin: "-50% 0px -45% 0px", threshold: [0.15, 0.35, 0.6] }
|
||||||
|
)
|
||||||
|
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (el) obs.observe(el)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const proof = useMemo(
|
||||||
|
() => ({
|
||||||
|
rating: 5,
|
||||||
|
reviews: 12,
|
||||||
|
bookings: 20,
|
||||||
|
travel: ">1000 km",
|
||||||
|
logos: ["SAT.1", "WDR", "ZDF", "Amazon Prime Video"],
|
||||||
|
clients: [
|
||||||
|
"Mercedes-Benz AG",
|
||||||
|
"Materna TMT",
|
||||||
|
"VIVA Cruises",
|
||||||
|
"AMADA",
|
||||||
|
"IHK",
|
||||||
|
"Lexus",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const acts = [
|
||||||
|
{
|
||||||
|
title: "Close-up Magic (Walking)",
|
||||||
|
desc: "Hautnah am Tisch – perfekter Eisbrecher & Gesprächszünder für Empfänge und Dinner.",
|
||||||
|
points: ["Interaktiv mit Gästen", "Flexibel zwischen den Gängen", "Keine Technik nötig"],
|
||||||
|
icon: <Sparkles className="w-8 h-8 text-blue-300" />,
|
||||||
|
gradient: "from-blue-500/20 to-purple-500/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bühnenshow",
|
||||||
|
desc: "Humorvoll, modern und gemeinschaftsstiftend – ideal für Kickoffs, Galas & Weihnachtsfeiern.",
|
||||||
|
points: ["20–40 Minuten", "Publikumsbeteiligung", "Skalierbar für kleine & große Bühnen"],
|
||||||
|
icon: <Zap className="w-8 h-8 text-yellow-300" />,
|
||||||
|
gradient: "from-yellow-500/20 to-orange-500/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Taschendieb-Act",
|
||||||
|
desc: "Respektvoll, sicher und spektakulär: scheinbarer Diebstahl von Uhr, Krawatte oder Handy – mit Lerneffekt.",
|
||||||
|
points: ["Publikum lacht & lernt", "Immer mit Einverständnis", "Sofortige Wow-Momente"],
|
||||||
|
icon: <Award className="w-8 h-8 text-green-300" />,
|
||||||
|
gradient: "from-green-500/20 to-emerald-500/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Signature: Gabelbiegen",
|
||||||
|
desc: "Metall wird zur Erinnerung – bleibende Souvenirs für Ihre Gäste.",
|
||||||
|
points: ["Starkes visuelles Finale", "Mitnahme-Souvenir", "Perfekt für Fotos"],
|
||||||
|
icon: <Heart className="w-8 h-8 text-pink-300" />,
|
||||||
|
gradient: "from-pink-500/20 to-rose-500/20"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const testimonials = [
|
||||||
|
{
|
||||||
|
name: "Corporate Event",
|
||||||
|
quote: "Perfekter Eisbrecher, professionell und absolut verblüffend – unsere Gäste sprechen heute noch davon.",
|
||||||
|
avatar: "🏢"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hochzeit",
|
||||||
|
quote: "Familienfreundlich, charmant und mega eindrucksvoll. Die Fotos vom Gabelbiegen lieben wir!",
|
||||||
|
avatar: "💒"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Weihnachtsfeier",
|
||||||
|
quote: "Zero Cringe, 100% Wow. Super zuverlässig, großartige Kommunikation und pünktlich auf die Minute.",
|
||||||
|
avatar: "🎄"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
q: "Welche Programmlängen sind möglich?",
|
||||||
|
a: "Close-up 60–90 Minuten (walking/tischweise) je nach Gästezahl; Bühne 20–40 Minuten – wir planen es passend zur Agenda.",
|
||||||
|
},
|
||||||
|
{ q: "Welche Technik wird benötigt?", a: "Close-up: keine. Bühne: Licht/PA nach Raumgröße – kann organisiert werden." },
|
||||||
|
{ q: "Wo trittst du auf?", a: "NRW, deutschlandweit und international – Reise >1000 km möglich." },
|
||||||
|
{ q: "Ist der Taschendieb-Act sicher?", a: "Ja – immer respektvoll, nur mit Einverständnis, haftpflichtversichert." },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="site-bg" aria-hidden="true" />
|
||||||
|
|
||||||
|
{/* NAVBAR */}
|
||||||
|
<nav className="onepage-nav">
|
||||||
|
<div className="onepage-nav-row">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => scrollToSection("home")}
|
||||||
|
className="onepage-brand"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-brand-logo"
|
||||||
|
whileHover={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<Image src="/icon.png" alt="Michael Peskov Magic Logo" width={20} height={20} />
|
||||||
|
</motion.div>
|
||||||
|
<span>Michael Peskov Magic</span>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="onepage-nav-links">
|
||||||
|
{[
|
||||||
|
["Proof", "proof"],
|
||||||
|
["Showreel", "showreel"],
|
||||||
|
["Acts", "acts"],
|
||||||
|
["Use Cases", "usecases"],
|
||||||
|
["Testimonials", "testimonials"],
|
||||||
|
["About", "about"],
|
||||||
|
["FAQ", "faq"],
|
||||||
|
].map(([label, id]) => (
|
||||||
|
<motion.button
|
||||||
|
key={id}
|
||||||
|
onClick={() => {
|
||||||
|
const element = document.getElementById(id)
|
||||||
|
if (element) {
|
||||||
|
const y = element.getBoundingClientRect().top + window.scrollY - 88
|
||||||
|
window.scrollTo({ top: y, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`onepage-link ${active === id ? "active" : ""}`}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
|
padding: '8px 16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="onepage-nav-cta">
|
||||||
|
<MagneticButton>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const element = document.getElementById('contact')
|
||||||
|
if (element) {
|
||||||
|
const y = element.getBoundingClientRect().top + window.scrollY - 88
|
||||||
|
window.scrollTo({ top: y, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="onepage-btn onepage-btn--primary"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verfügbarkeit prüfen
|
||||||
|
</button>
|
||||||
|
</MagneticButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* HERO */}
|
||||||
|
<section id="home" className="onepage-hero">
|
||||||
|
<ParallaxElement speed={0.3} className="spotlight" />
|
||||||
|
<div className="onepage-container">
|
||||||
|
<div className="onepage-hero-grid">
|
||||||
|
<div>
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity:0,y:50}}
|
||||||
|
animate={{opacity:1,y:0}}
|
||||||
|
transition={{duration:0.8, ease: "easeOut"}}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="onepage-eyebrow"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
Premium • Modern • Live
|
||||||
|
</motion.span>
|
||||||
|
<motion.h1
|
||||||
|
className="onepage-h1"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.8 }}
|
||||||
|
>
|
||||||
|
Moderne Magie, die Menschen verbindet.
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className="onepage-lead"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.6, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
Bekannt aus {proof.logos.join(" • ")}. <CountUp end={proof.rating} />★ bewertet, <CountUp end={proof.bookings} />+ erfolgreiche Buchungen. Europaweit im Einsatz.
|
||||||
|
</motion.p>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-flex-wrap onepage-gap-3 onepage-mt-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<MagneticButton>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToSection("contact")}
|
||||||
|
className="onepage-btn onepage-btn--primary"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', font: 'inherit' }}
|
||||||
|
>
|
||||||
|
Verfügbarkeit prüfen
|
||||||
|
</button>
|
||||||
|
</MagneticButton>
|
||||||
|
<MagneticButton>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToSection("showreel")}
|
||||||
|
className="onepage-btn onepage-btn--ghost"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', font: 'inherit', display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4"/> Showreel ansehen
|
||||||
|
</button>
|
||||||
|
</MagneticButton>
|
||||||
|
</motion.div>
|
||||||
|
<StaggerContainer className="onepage-flex onepage-flex-wrap onepage-gap-2 onepage-mt-6" staggerDelay={0.1}>
|
||||||
|
{proof.clients.map((c) => (
|
||||||
|
<StaggerItem key={c}>
|
||||||
|
<motion.span
|
||||||
|
className="onepage-badge"
|
||||||
|
whileHover={{ scale: 1.1, y: -2 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Building2 className="w-3 h-3"/> {c}
|
||||||
|
</motion.span>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SlideInRight delay={0.4}>
|
||||||
|
<HoverCard className="onepage-card">
|
||||||
|
<VideoPlayer
|
||||||
|
posterImage="/michael-peskov-magier-taschendieb-453624.jpeg"
|
||||||
|
videoUrl="https://www.youtube-nocookie.com/embed/BGAXMeIzu3Q?autoplay=1"
|
||||||
|
title="SAT.1 Showreel"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-gap-2 onepage-mt-4 onepage-text"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4 text-yellow-300"/>
|
||||||
|
<span><CountUp end={proof.rating} />.0 / 5 · <CountUp end={proof.reviews} />+ Bewertungen · <CountUp end={proof.bookings} />+ Buchungen</span>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-gap-2 onepage-mt-4 onepage-text"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1.2, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4"/> <span>Haftpflicht · Vertrag · Pünktlich</span>
|
||||||
|
</motion.div>
|
||||||
|
</HoverCard>
|
||||||
|
</SlideInRight>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* PROOF */}
|
||||||
|
<section id="proof" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">As seen on & trusted by</span>
|
||||||
|
<h2 className="onepage-h2">TV & Marken, die Vertrauen schaffen</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<StaggerContainer className="onepage-grid onepage-grid--3" staggerDelay={0.1}>
|
||||||
|
{proof.logos.map((l) => (
|
||||||
|
<StaggerItem key={l}>
|
||||||
|
<HoverCard className="onepage-card onepage-text-center">
|
||||||
|
<div className="onepage-h3">{l}</div>
|
||||||
|
</HoverCard>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
{proof.clients.map((l) => (
|
||||||
|
<StaggerItem key={l}>
|
||||||
|
<HoverCard className="onepage-card onepage-text-center">
|
||||||
|
<div className="onepage-h3">{l}</div>
|
||||||
|
</HoverCard>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SHOWREEL */}
|
||||||
|
<section id="showreel" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">Erleben</span>
|
||||||
|
<h2 className="onepage-h2">Showreel & Momente des Staunens</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<ScaleIn delay={0.2}>
|
||||||
|
<HoverCard className="onepage-card">
|
||||||
|
<VideoPlayer
|
||||||
|
posterImage="/michael-peskov-magier-taschendieb-453624.jpeg"
|
||||||
|
videoUrl="https://www.youtube-nocookie.com/embed/BGAXMeIzu3Q?autoplay=1"
|
||||||
|
title="SAT.1 Showreel"
|
||||||
|
/>
|
||||||
|
</HoverCard>
|
||||||
|
</ScaleIn>
|
||||||
|
<StaggerContainer className="onepage-grid onepage-grid--3 onepage-mt-4" staggerDelay={0.1}>
|
||||||
|
<StaggerItem>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-gap-3 onepage-text"
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<BadgeCheck className="w-5 h-5 text-blue-300"/> Eisbrecher für Gespräche
|
||||||
|
</motion.div>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-gap-3 onepage-text"
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<BadgeCheck className="w-5 h-5 text-blue-300"/> Null Fremdscham, 100% Wow
|
||||||
|
</motion.div>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-gap-3 onepage-text"
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<BadgeCheck className="w-5 h-5 text-blue-300"/> Planbar & professionell
|
||||||
|
</motion.div>
|
||||||
|
</StaggerItem>
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ACTS / PACKAGES */}
|
||||||
|
<section id="acts" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">Programme</span>
|
||||||
|
<h2 className="onepage-h2">Acts & Pakete</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<StaggerContainer className="onepage-grid onepage-grid--4" staggerDelay={0.15}>
|
||||||
|
{acts.map((a, index) => (
|
||||||
|
<StaggerItem key={a.title}>
|
||||||
|
<HoverCard className={`onepage-card onepage-card-hover bg-gradient-to-br ${a.gradient}`}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ delay: 0.5 + index * 0.1, duration: 0.5, type: "spring" }}
|
||||||
|
className="onepage-mb-4"
|
||||||
|
>
|
||||||
|
{a.icon}
|
||||||
|
</motion.div>
|
||||||
|
<h3 className="onepage-h3">{a.title}</h3>
|
||||||
|
<p className="onepage-text onepage-mb-4">{a.desc}</p>
|
||||||
|
<ul className="onepage-mb-6" style={{listStyle: 'none', padding: 0}}>
|
||||||
|
{a.points.map((p, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={p}
|
||||||
|
className="onepage-flex onepage-gap-2 onepage-text"
|
||||||
|
style={{marginBottom: '8px'}}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.8 + index * 0.1 + i * 0.1 }}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-blue-300 flex-shrink-0" style={{marginTop: '2px'}}/> {p}
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<MagneticButton
|
||||||
|
className="onepage-btn onepage-btn--ghost"
|
||||||
|
style={{width: '100%', justifyContent: 'center'}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToSection("contact")}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', font: 'inherit' }}
|
||||||
|
>
|
||||||
|
Termin anfragen
|
||||||
|
</button>
|
||||||
|
</MagneticButton>
|
||||||
|
</HoverCard>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* USE CASES */}
|
||||||
|
<section id="usecases" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">Für welche Anlässe?</span>
|
||||||
|
<h2 className="onepage-h2">Corporate · Hochzeit · Private Feiern</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<StaggerContainer className="onepage-grid onepage-grid--3" staggerDelay={0.2}>
|
||||||
|
{[{
|
||||||
|
title: "Corporate Events",
|
||||||
|
points: ["Kickoff, Gala, Kunden-Dinner", "Bühne + Close-up kombinierbar", "Seriös, vertraglich & pünktlich"],
|
||||||
|
emoji: "🏢"
|
||||||
|
}, {
|
||||||
|
title: "Hochzeiten",
|
||||||
|
points: ["Sektempfang & zwischen den Gängen", "Foto-Momente für das Paar", "Familienfreundlich"],
|
||||||
|
emoji: "💒"
|
||||||
|
}, {
|
||||||
|
title: "Private Feiern",
|
||||||
|
points: ["30./40./50. Geburtstag", "Packages & WhatsApp-Kontakt", "Schnelle Antwort <24h"],
|
||||||
|
emoji: "🎉"
|
||||||
|
}].map((u) => (
|
||||||
|
<StaggerItem key={u.title}>
|
||||||
|
<HoverCard className="onepage-card">
|
||||||
|
<motion.div
|
||||||
|
className="text-4xl onepage-mb-4"
|
||||||
|
animate={{ rotate: [0, 10, -10, 0] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{u.emoji}
|
||||||
|
</motion.div>
|
||||||
|
<h3 className="onepage-h3">{u.title}</h3>
|
||||||
|
<ul style={{listStyle: 'none', padding: 0}}>
|
||||||
|
{u.points.map((p) => (
|
||||||
|
<motion.li
|
||||||
|
key={p}
|
||||||
|
className="onepage-flex onepage-gap-2 onepage-text"
|
||||||
|
style={{marginBottom: '8px'}}
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-blue-300 flex-shrink-0" style={{marginTop: '2px'}}/> {p}
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</HoverCard>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* TESTIMONIALS */}
|
||||||
|
<section id="testimonials" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">Bewertungen</span>
|
||||||
|
<h2 className="onepage-h2">Vertrauen, das man lesen kann</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<StaggerContainer className="onepage-grid onepage-grid--3" staggerDelay={0.2}>
|
||||||
|
{testimonials.map((t, i) => (
|
||||||
|
<StaggerItem key={i}>
|
||||||
|
<HoverCard className="onepage-testimonial">
|
||||||
|
<div className="quote">"</div>
|
||||||
|
<motion.div
|
||||||
|
className="text-4xl onepage-mb-4"
|
||||||
|
animate={{ scale: [1, 1.1, 1] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, delay: i * 0.5 }}
|
||||||
|
>
|
||||||
|
{t.avatar}
|
||||||
|
</motion.div>
|
||||||
|
<div className="onepage-flex onepage-gap-1 onepage-mb-4">
|
||||||
|
{Array.from({length:5}).map((_,j)=>(
|
||||||
|
<motion.div
|
||||||
|
key={j}
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.5 + j * 0.1 }}
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4 onepage-stars" fill="currentColor"/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="onepage-text" style={{fontStyle: 'italic'}}>"{t.quote}"</p>
|
||||||
|
<div className="onepage-mt-4 onepage-text" style={{fontWeight: '600'}}>— {t.name}</div>
|
||||||
|
</HoverCard>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ABOUT */}
|
||||||
|
<section id="about" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">Über Michael</span>
|
||||||
|
<h2 className="onepage-h2">Jung, modern, TV-bekannt</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<div className="onepage-grid onepage-grid--2">
|
||||||
|
<SlideInLeft delay={0.2}>
|
||||||
|
<p className="onepage-text" style={{lineHeight: '1.7'}}>
|
||||||
|
Michael Peskov gehört zu den jüngsten professionellen Magiern Deutschlands. Seine Auftritte verbinden modernes
|
||||||
|
Entertainment mit interaktiver Zauberei und einem einzigartigen Taschendieb-Act – immer respektvoll und sicher.
|
||||||
|
Er ist europaweit im Einsatz ({proof.travel}) und wurde u. a. bei {proof.logos.join(", ")} gezeigt.
|
||||||
|
</p>
|
||||||
|
</SlideInLeft>
|
||||||
|
<SlideInRight delay={0.4}>
|
||||||
|
<HoverCard className="onepage-card">
|
||||||
|
<ul style={{listStyle: 'none', padding: 0}}>
|
||||||
|
{[
|
||||||
|
{ icon: <ShieldCheck className="w-4 h-4 flex-shrink-0" />, text: "Vertrag & Haftpflicht inklusive" },
|
||||||
|
{ icon: <Clock4 className="w-4 h-4 flex-shrink-0" />, text: "Pünktlich, sauberer Ablauf" },
|
||||||
|
{ icon: <Users2 className="w-4 h-4 flex-shrink-0" />, text: "Kleine & große Events" }
|
||||||
|
].map((item, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
className="onepage-flex onepage-gap-2 onepage-text"
|
||||||
|
style={{marginBottom: '12px'}}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.6 + i * 0.2 }}
|
||||||
|
whileHover={{ x: 5, color: 'var(--primary-400)' }}
|
||||||
|
>
|
||||||
|
<motion.div style={{marginTop: '2px'}} whileHover={{ scale: 1.2 }}>
|
||||||
|
{item.icon}
|
||||||
|
</motion.div>
|
||||||
|
{item.text}
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</HoverCard>
|
||||||
|
</SlideInRight>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<section id="faq" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">FAQ</span>
|
||||||
|
<h2 className="onepage-h2">Häufige Fragen</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<FadeUp delay={0.2}>
|
||||||
|
<AnimatedFAQ faqs={faqs} />
|
||||||
|
</FadeUp>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CONTACT / CTA */}
|
||||||
|
<section id="contact" className="onepage-section">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<span className="onepage-eyebrow">Jetzt anfragen</span>
|
||||||
|
<h2 className="onepage-h2">Verfügbarkeit & Angebot</h2>
|
||||||
|
</FadeUp>
|
||||||
|
<ScaleIn delay={0.3}>
|
||||||
|
<HoverCard className="onepage-card">
|
||||||
|
<form className="onepage-grid onepage-grid--2" onSubmit={(e)=>{e.preventDefault(); alert("Danke! Wir melden uns i. d. R. innerhalb von 24 Stunden.");}}>
|
||||||
|
<StaggerContainer className="contents" staggerDelay={0.1}>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Datum</label>
|
||||||
|
<motion.input
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
className="onepage-input"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Ort</label>
|
||||||
|
<motion.input
|
||||||
|
required
|
||||||
|
placeholder="Stadt / Location"
|
||||||
|
className="onepage-input"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Anlass</label>
|
||||||
|
<motion.select
|
||||||
|
className="onepage-select"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
>
|
||||||
|
<option>Corporate Event</option>
|
||||||
|
<option>Hochzeit</option>
|
||||||
|
<option>Private Feier</option>
|
||||||
|
</motion.select>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Gästezahl</label>
|
||||||
|
<motion.input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="z. B. 120"
|
||||||
|
className="onepage-input"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group" style={{gridColumn: '1 / -1'}}>
|
||||||
|
<label className="onepage-label">Programm</label>
|
||||||
|
<div className="onepage-grid onepage-grid--2">
|
||||||
|
{acts.map((a)=> (
|
||||||
|
<motion.label
|
||||||
|
key={a.title}
|
||||||
|
className="onepage-flex onepage-gap-2"
|
||||||
|
style={{padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', cursor: 'pointer'}}
|
||||||
|
whileHover={{ scale: 1.02, backgroundColor: 'rgba(255,255,255,0.08)' }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<input type="checkbox" className="onepage-checkbox"/> <span className="onepage-text">{a.title}</span>
|
||||||
|
</motion.label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Budget (Richtwert)</label>
|
||||||
|
<motion.select
|
||||||
|
className="onepage-select"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
>
|
||||||
|
<option>auf Anfrage</option>
|
||||||
|
<option>bis 1.000 €</option>
|
||||||
|
<option>1.000 – 2.000 €</option>
|
||||||
|
<option>2.000 – 4.000 €</option>
|
||||||
|
<option>4.000 € +</option>
|
||||||
|
</motion.select>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Firma (optional)</label>
|
||||||
|
<motion.input
|
||||||
|
placeholder="Firmenname"
|
||||||
|
className="onepage-input"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">E-Mail</label>
|
||||||
|
<motion.input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
placeholder="name@firma.de"
|
||||||
|
className="onepage-input"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-form-group">
|
||||||
|
<label className="onepage-label">Telefon / WhatsApp</label>
|
||||||
|
<motion.input
|
||||||
|
placeholder="+49 …"
|
||||||
|
className="onepage-input"
|
||||||
|
whileFocus={{ scale: 1.02, borderColor: 'var(--primary-400)' }}
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem style={{gridColumn: '1 / -1'}}>
|
||||||
|
<motion.label
|
||||||
|
className="onepage-flex onepage-gap-2 onepage-text"
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
>
|
||||||
|
<input type="checkbox" className="onepage-checkbox"/> Rückruf per WhatsApp gewünscht
|
||||||
|
</motion.label>
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem className="onepage-flex onepage-flex-wrap onepage-gap-3" style={{gridColumn: '1 / -1'}}>
|
||||||
|
<MagneticButton>
|
||||||
|
<button type="submit" className="onepage-btn onepage-btn--primary onepage-flex onepage-gap-2" style={{border: 'none', background: 'none', color: 'inherit'}}>
|
||||||
|
<Calendar className="w-4 h-4"/> Anfrage senden
|
||||||
|
</button>
|
||||||
|
</MagneticButton>
|
||||||
|
<MagneticButton>
|
||||||
|
<a href="mailto:booking@michaelpeskov.de" className="onepage-btn onepage-btn--ghost onepage-flex onepage-gap-2">
|
||||||
|
<Mail className="w-4 h-4"/> booking@michaelpeskov.de
|
||||||
|
</a>
|
||||||
|
</MagneticButton>
|
||||||
|
<MagneticButton>
|
||||||
|
<a href="tel:+491234567890" className="onepage-btn onepage-btn--ghost onepage-flex onepage-gap-2">
|
||||||
|
<Phone className="w-4 h-4"/> +49 123 456 7890
|
||||||
|
</a>
|
||||||
|
</MagneticButton>
|
||||||
|
</StaggerItem>
|
||||||
|
</StaggerContainer>
|
||||||
|
</form>
|
||||||
|
</HoverCard>
|
||||||
|
</ScaleIn>
|
||||||
|
<FadeUp delay={0.5}>
|
||||||
|
<p className="onepage-text" style={{fontSize: '0.75rem', marginTop: '12px', opacity: 0.7}}>
|
||||||
|
Antwort in der Regel innerhalb von 24 Stunden. Ihre Daten werden nur zur Bearbeitung der Anfrage verwendet.
|
||||||
|
</p>
|
||||||
|
</FadeUp>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<footer className="onepage-footer">
|
||||||
|
<div className="onepage-container">
|
||||||
|
<FadeUp>
|
||||||
|
<div className="onepage-footer-content">
|
||||||
|
<motion.div
|
||||||
|
className="onepage-flex onepage-gap-2"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<Image src="/icon.png" alt="Michael Peskov Magic Logo" width={16} height={16} />
|
||||||
|
</motion.div>
|
||||||
|
<span>Michael Peskov Magic</span>
|
||||||
|
</motion.div>
|
||||||
|
<div className="onepage-footer-text">© {new Date().getFullYear()} – All rights reserved.</div>
|
||||||
|
</div>
|
||||||
|
</FadeUp>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* JSON-LD */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Michael Peskov Magic",
|
||||||
|
url: "https://michaelpeskov.de",
|
||||||
|
sameAs: ["https://instagram.com/", "https://www.youtube.com/"],
|
||||||
|
aggregateRating: {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
ratingValue: proof.rating,
|
||||||
|
reviewCount: proof.reviews,
|
||||||
|
},
|
||||||
|
areaServed: "Europe",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
// Dynamic imports to avoid SSR issues
|
||||||
|
let gsap: any
|
||||||
|
let ScrollTrigger: any
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap = require('gsap').gsap
|
||||||
|
ScrollTrigger = require('gsap/ScrollTrigger').ScrollTrigger
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if GSAP is available
|
||||||
|
if (!gsap || !ScrollTrigger) return
|
||||||
|
|
||||||
|
// Just refresh ScrollTrigger on resize - no smooth scrolling
|
||||||
|
const handleResize = () => {
|
||||||
|
ScrollTrigger.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
ScrollTrigger.killAll()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
interface FAQItem {
|
||||||
|
q: string
|
||||||
|
a: stringß
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedFAQ({ faqs }: { faqs: FAQItem[] }) {
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const toggleFAQ = (index: number) => {
|
||||||
|
setOpenIndex(openIndex === index ? null : index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: '16px' }}>
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="onepage-faq-item"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.02,
|
||||||
|
transition: { duration: 0.2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.button
|
||||||
|
className="onepage-faq-question"
|
||||||
|
onClick={() => toggleFAQ(index)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
whileHover={{ color: 'var(--primary-400)' }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{faq.q}
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: openIndex === index ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{openIndex === index && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
opacity: { duration: 0.2 }
|
||||||
|
}}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -10 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: -10 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
className="onepage-faq-answer"
|
||||||
|
style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}
|
||||||
|
>
|
||||||
|
{faq.a}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default function BadgeRow({ badges }: { badges: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="badgeRow" role="list" aria-label="Badges">
|
||||||
|
{badges.map((b, i) => (
|
||||||
|
<span role="listitem" className={`badge ${i === 2 ? 'primary' : ''}`} key={i}>{b}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { animateSplit, animateWords } from '@/lib/animateSplit'
|
||||||
|
|
||||||
|
// Dynamic imports to avoid SSR issues
|
||||||
|
let gsap: any
|
||||||
|
let ScrollTrigger: any
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap = require('gsap').gsap
|
||||||
|
ScrollTrigger = require('gsap/ScrollTrigger').ScrollTrigger
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DogstudioAnimations() {
|
||||||
|
const initialized = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current) return
|
||||||
|
initialized.current = true
|
||||||
|
|
||||||
|
// Check if GSAP is available
|
||||||
|
if (!gsap || !ScrollTrigger) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (prefersReducedMotion) return
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
// Animate main headline with character split
|
||||||
|
const mainHeadline = document.querySelector('.h1')
|
||||||
|
if (mainHeadline) {
|
||||||
|
gsap.set(mainHeadline, { opacity: 1 })
|
||||||
|
animateSplit(mainHeadline as HTMLElement, {
|
||||||
|
delay: 0.5,
|
||||||
|
stagger: 0.02,
|
||||||
|
duration: 0.8
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate section headings as they come into view
|
||||||
|
const sectionHeadings = gsap.utils.toArray<HTMLElement>('.h2')
|
||||||
|
sectionHeadings.forEach((heading) => {
|
||||||
|
gsap.set(heading, { opacity: 0 })
|
||||||
|
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: heading,
|
||||||
|
start: 'top 80%',
|
||||||
|
onEnter: () => {
|
||||||
|
gsap.set(heading, { opacity: 1 })
|
||||||
|
animateWords(heading, { stagger: 0.05 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate cards with stagger
|
||||||
|
const cards = gsap.utils.toArray<HTMLElement>('.card, .gallery-item, .testimonial')
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
gsap.from(card, {
|
||||||
|
y: 60,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power2.out',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: card,
|
||||||
|
start: 'top 85%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate chips with stagger
|
||||||
|
const chips = gsap.utils.toArray<HTMLElement>('.chip')
|
||||||
|
if (chips.length > 0) {
|
||||||
|
gsap.from(chips, {
|
||||||
|
scale: 0,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.4,
|
||||||
|
stagger: 0.1,
|
||||||
|
ease: 'back.out(1.7)',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: chips[0],
|
||||||
|
start: 'top 80%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate gallery items with hover effects
|
||||||
|
const galleryItems = gsap.utils.toArray<HTMLElement>('.gallery-item')
|
||||||
|
galleryItems.forEach((item) => {
|
||||||
|
const img = item.querySelector('img')
|
||||||
|
if (!img) return
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
gsap.to(img, {
|
||||||
|
scale: 1.05,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
gsap.to(img, {
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
item.addEventListener('mouseenter', handleMouseEnter)
|
||||||
|
item.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate buttons with magnetic effect
|
||||||
|
const buttons = gsap.utils.toArray<HTMLElement>('.btn')
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = button.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left - rect.width / 2
|
||||||
|
const y = e.clientY - rect.top - rect.height / 2
|
||||||
|
|
||||||
|
gsap.to(button, {
|
||||||
|
x: x * 0.1,
|
||||||
|
y: y * 0.1,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
gsap.to(button, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'elastic.out(1, 0.3)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('mousemove', handleMouseMove)
|
||||||
|
button.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parallax effect on hero image
|
||||||
|
const heroImage = document.querySelector('.hero__media')
|
||||||
|
if (heroImage) {
|
||||||
|
gsap.to(heroImage, {
|
||||||
|
yPercent: -20,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: heroImage,
|
||||||
|
start: 'top bottom',
|
||||||
|
end: 'bottom top',
|
||||||
|
scrub: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth reveal for lead text
|
||||||
|
const leadText = document.querySelector('.lead')
|
||||||
|
if (leadText) {
|
||||||
|
gsap.from(leadText, {
|
||||||
|
y: 30,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
delay: 1.2,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate eyebrow
|
||||||
|
const eyebrow = document.querySelector('.eyebrow')
|
||||||
|
if (eyebrow) {
|
||||||
|
gsap.from(eyebrow, {
|
||||||
|
y: 20,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate hero buttons
|
||||||
|
const heroButtons = document.querySelectorAll('.hero .btn')
|
||||||
|
if (heroButtons.length > 0) {
|
||||||
|
gsap.from(heroButtons, {
|
||||||
|
y: 20,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
stagger: 0.1,
|
||||||
|
delay: 1.5,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Feature {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const features: Feature[] = [
|
||||||
|
{
|
||||||
|
title: "Close-Up Magic",
|
||||||
|
description: "Intimate table-to-table performances that break the ice and create unforgettable moments for your guests.",
|
||||||
|
image: "https://images.eventpeppers.com/sites/default/files/imagecache/lightbox-preview/images/13234/michael-peskov-magier-taschendieb-450253.jpeg",
|
||||||
|
icon: "✨"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Stage Shows",
|
||||||
|
description: "Grand illusions and interactive performances that captivate entire audiences with wonder and amazement.",
|
||||||
|
image: "https://images.eventpeppers.com/sites/default/files/imagecache/lightbox-preview/images/13234/michael-peskov-magier-taschendieb-450255.jpeg",
|
||||||
|
icon: "🎭"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pickpocket Act",
|
||||||
|
description: "Masterful sleight of hand that entertains while teaching guests how to protect themselves from real pickpockets.",
|
||||||
|
image: "https://images.eventpeppers.com/sites/default/files/imagecache/lightbox-preview/images/13234/michael-peskov-magier-taschendieb-450254.jpeg",
|
||||||
|
icon: "🎩"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Corporate Events",
|
||||||
|
description: "Professional entertainment that elevates business gatherings and creates memorable experiences for clients.",
|
||||||
|
image: "https://images.eventpeppers.com/sites/default/files/imagecache/lightbox-preview/images/13234/michael-peskov-magier-taschendieb-450256.jpeg",
|
||||||
|
icon: "💼"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FeatureCards() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const cards = gsap.utils.toArray<HTMLElement>('.feature-card')
|
||||||
|
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
const image = card.querySelector('.feature-image')
|
||||||
|
const content = card.querySelector('.feature-content')
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
// Simple fade for reduced motion
|
||||||
|
gsap.from(card, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 30,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: i * 0.1,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: card,
|
||||||
|
start: 'top 80%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Full animation with perspective and clip-mask
|
||||||
|
gsap.set(card, {
|
||||||
|
rotateX: 15,
|
||||||
|
rotateY: 5,
|
||||||
|
transformPerspective: 1000,
|
||||||
|
transformOrigin: 'center center'
|
||||||
|
})
|
||||||
|
|
||||||
|
gsap.set(image, {
|
||||||
|
clipPath: 'inset(0 0 100% 0)'
|
||||||
|
})
|
||||||
|
|
||||||
|
gsap.set(content, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: card,
|
||||||
|
start: 'top 75%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tl.to(card, {
|
||||||
|
rotateX: 0,
|
||||||
|
rotateY: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
.to(image, {
|
||||||
|
clipPath: 'inset(0 0 0% 0)',
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, '-=0.6')
|
||||||
|
.to(content, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, '-=0.4')
|
||||||
|
|
||||||
|
// Hover effects
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
gsap.to(card, {
|
||||||
|
rotateX: -5,
|
||||||
|
rotateY: 5,
|
||||||
|
scale: 1.02,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
|
||||||
|
gsap.to(image, {
|
||||||
|
scale: 1.1,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
gsap.to(card, {
|
||||||
|
rotateX: 0,
|
||||||
|
rotateY: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
|
||||||
|
gsap.to(image, {
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
card.addEventListener('mouseenter', handleMouseEnter)
|
||||||
|
card.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, container)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={containerRef} className="py-32 px-6 bg-slate-900">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center mb-20">
|
||||||
|
<h2 className="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||||
|
Magical Experiences
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-300 max-w-3xl mx-auto">
|
||||||
|
From intimate gatherings to grand celebrations, discover the perfect magical experience for your event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{features.map((feature, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="feature-card group relative bg-slate-800/50 rounded-2xl overflow-hidden backdrop-blur-sm border border-slate-700/50"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
<div className="feature-image relative h-64 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={feature.image}
|
||||||
|
alt={feature.title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-300"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 to-transparent" />
|
||||||
|
<div className="absolute top-4 left-4 text-3xl">
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feature-content p-8">
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-4">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client"
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type Dict = Awaited<ReturnType<typeof import('@/lib/i18n').getDictionary>>
|
||||||
|
|
||||||
|
export default function Hero({ dict }: { dict: Dict }) {
|
||||||
|
const d = dict.home.hero
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="site-bg" aria-hidden="true" />
|
||||||
|
<section className="section hero" aria-label="Hero">
|
||||||
|
<div className="container hero__grid">
|
||||||
|
<div>
|
||||||
|
<span className="kicker">Modern Magician & Pickpocket</span>
|
||||||
|
<h1 className="h1 hero__title">{d.headline1.title}</h1>
|
||||||
|
<p className="hero__lead">{d.headline1.sub}</p>
|
||||||
|
<div className="h-stack mt-4">
|
||||||
|
<Link href="/contact#booking" className="btn btn--primary">{dict.common.cta.bookNow}</Link>
|
||||||
|
<Link href="/showreel" className="btn btn--ghost">{dict.common.cta.watchShowreel}</Link>
|
||||||
|
</div>
|
||||||
|
<div className="stack mt-6">
|
||||||
|
<div className="badge badge--accent">Seen on: SAT.1 • WDR • ZDF • Amazon Prime Video</div>
|
||||||
|
<div className="badge">5/5 stars, 12 reviews, 20+ bookings, 100% recommendation; since 12/2022 on Eventpeppers.</div>
|
||||||
|
<div className="badge">Location: 42699 Solingen, travel radius >1000 km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero__media sparkle">
|
||||||
|
<Image src="https://images.eventpeppers.com/sites/default/files/imagecache/profile-picture/images/13234/michael-peskov-magier-taschendieb-453624.jpeg" alt="Michael Peskov – Magier & Taschendieb" priority width={1200} height={1200} sizes="(max-width: 960px) 100vw, 50vw" className="cover" />
|
||||||
|
<div className="hero__badge">Live reactions • Close-up & Stage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client"
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = { initial: 'en' | 'de'; labels: { en: string; de: string } }
|
||||||
|
|
||||||
|
export default function LanguageToggle({ initial, labels }: Props) {
|
||||||
|
const [lang, setLang] = useState<'en' | 'de'>(initial)
|
||||||
|
useEffect(() => { setLang(initial) }, [initial])
|
||||||
|
function setCookieLang(next: 'en' | 'de') {
|
||||||
|
document.cookie = `lang=${next}; path=/; max-age=31536000; SameSite=Lax`
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
if (next === 'de') url.searchParams.set('lang', 'de')
|
||||||
|
else url.searchParams.delete('lang')
|
||||||
|
window.location.replace(url.toString())
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className="btn ghost" aria-pressed={lang === 'de'} aria-label="Switch language" onClick={() => setCookieLang(lang === 'en' ? 'de' : 'en')}>
|
||||||
|
{lang === 'en' ? labels.de : labels.en}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
'use client'
|
||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
|
||||||
|
interface MagneticButtonProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
href?: string
|
||||||
|
onClick?: () => void
|
||||||
|
strength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MagneticButton({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
strength = 0.3
|
||||||
|
}: MagneticButtonProps) {
|
||||||
|
const buttonRef = useRef<HTMLElement>(null)
|
||||||
|
const textRef = useRef<HTMLSpanElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const button = buttonRef.current
|
||||||
|
const text = textRef.current
|
||||||
|
if (!button || !text) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (prefersReducedMotion) return
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = button.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left - rect.width / 2
|
||||||
|
const y = e.clientY - rect.top - rect.height / 2
|
||||||
|
|
||||||
|
gsap.to(button, {
|
||||||
|
x: x * strength,
|
||||||
|
y: y * strength,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
|
||||||
|
gsap.to(text, {
|
||||||
|
x: x * strength * 0.5,
|
||||||
|
y: y * strength * 0.5,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
gsap.to([button, text], {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'elastic.out(1, 0.3)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
gsap.to(button, {
|
||||||
|
scale: 0.98,
|
||||||
|
duration: 0.1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
gsap.to(button, {
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('mousemove', handleMouseMove)
|
||||||
|
button.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
button.addEventListener('mousedown', handleMouseDown)
|
||||||
|
button.addEventListener('mouseup', handleMouseUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
button.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
button.removeEventListener('mouseleave', handleMouseLeave)
|
||||||
|
button.removeEventListener('mousedown', handleMouseDown)
|
||||||
|
button.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [strength])
|
||||||
|
|
||||||
|
const Component = href ? 'a' : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
ref={buttonRef as any}
|
||||||
|
href={href}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`relative inline-block cursor-pointer ${className}`}
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className="relative inline-block"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParallaxLayerProps {
|
||||||
|
depth?: number
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
speed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParallaxLayer({
|
||||||
|
depth = 1,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
speed = 0.5
|
||||||
|
}: ParallaxLayerProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (prefersReducedMotion) return
|
||||||
|
|
||||||
|
const yMovement = -100 * depth * speed
|
||||||
|
|
||||||
|
const tl = gsap.to(element, {
|
||||||
|
yPercent: yMovement,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: element,
|
||||||
|
start: 'top bottom',
|
||||||
|
end: 'bottom top',
|
||||||
|
scrub: true,
|
||||||
|
invalidateOnRefresh: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tl.kill()
|
||||||
|
}
|
||||||
|
}, [depth, speed])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`will-change-transform ${className}`}
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
'use client'
|
||||||
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
import { animateWords } from '@/lib/animateSplit'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryStep {
|
||||||
|
title: string
|
||||||
|
copy: string
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PinnedStoryProps {
|
||||||
|
steps: StoryStep[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PinnedStory({ steps, className = '' }: PinnedStoryProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const content = contentRef.current
|
||||||
|
if (!container || !content) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (!prefersReducedMotion) {
|
||||||
|
// Pin the section
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: container,
|
||||||
|
pin: content,
|
||||||
|
pinSpacing: true,
|
||||||
|
start: 'top top',
|
||||||
|
end: '+=150%',
|
||||||
|
invalidateOnRefresh: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate each step
|
||||||
|
const stepElements = gsap.utils.toArray<HTMLElement>('.story-step')
|
||||||
|
|
||||||
|
stepElements.forEach((step, i) => {
|
||||||
|
const title = step.querySelector('.story-title') as HTMLElement
|
||||||
|
const copy = step.querySelector('.story-copy') as HTMLElement
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
// Simple fade for reduced motion
|
||||||
|
gsap.from(step, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
duration: 0.6,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: step,
|
||||||
|
start: 'top 80%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Full animation
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: step,
|
||||||
|
start: 'top 70%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tl.from(step, {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
tl.add(() => animateWords(title, { stagger: 0.05 }), '-=0.4')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copy) {
|
||||||
|
tl.from(copy, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 30,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, '-=0.2')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Background color transitions
|
||||||
|
if (!prefersReducedMotion) {
|
||||||
|
steps.forEach((step, i) => {
|
||||||
|
if (step.highlight) {
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: `.story-step:nth-child(${i + 1})`,
|
||||||
|
start: 'top 60%',
|
||||||
|
end: 'bottom 40%',
|
||||||
|
onEnter: () => {
|
||||||
|
gsap.to('body', {
|
||||||
|
backgroundColor: 'rgba(124, 244, 226, 0.05)',
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onLeave: () => {
|
||||||
|
gsap.to('body', {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onEnterBack: () => {
|
||||||
|
gsap.to('body', {
|
||||||
|
backgroundColor: 'rgba(124, 244, 226, 0.05)',
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onLeaveBack: () => {
|
||||||
|
gsap.to('body', {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, container)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [steps])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={containerRef} className={`min-h-[250vh] ${className}`}>
|
||||||
|
<div ref={contentRef} className="sticky top-0 h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 space-y-24">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} className="story-step text-center">
|
||||||
|
<h2 className="story-title text-4xl md:text-6xl font-bold mb-8 text-white">
|
||||||
|
{step.title}
|
||||||
|
</h2>
|
||||||
|
<p className="story-copy text-lg md:text-xl opacity-80 text-gray-300 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{step.copy}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, useInView, useAnimation } from 'framer-motion'
|
||||||
|
|
||||||
|
// Scroll-triggered animation hook
|
||||||
|
export function useScrollAnimation(threshold = 0.1, once = true) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { threshold, once })
|
||||||
|
const controls = useAnimation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInView) {
|
||||||
|
controls.start('visible')
|
||||||
|
}
|
||||||
|
}, [isInView, controls])
|
||||||
|
|
||||||
|
return { ref, controls }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade up animation component
|
||||||
|
export function FadeUp({ children, delay = 0, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
delay?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { ref, controls } = useScrollAnimation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
animate={controls}
|
||||||
|
initial="hidden"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 40 },
|
||||||
|
visible: { opacity: 1, y: 0 }
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay, ease: "easeOut" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stagger children animation
|
||||||
|
export function StaggerContainer({ children, className = '', staggerDelay = 0.1 }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
staggerDelay?: number
|
||||||
|
}) {
|
||||||
|
const { ref, controls } = useScrollAnimation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
animate={controls}
|
||||||
|
initial="hidden"
|
||||||
|
variants={{
|
||||||
|
hidden: {},
|
||||||
|
visible: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: staggerDelay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual stagger item
|
||||||
|
export function StaggerItem({ children, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 30 },
|
||||||
|
visible: { opacity: 1, y: 0 }
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale in animation
|
||||||
|
export function ScaleIn({ children, delay = 0, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
delay?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { ref, controls } = useScrollAnimation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
animate={controls}
|
||||||
|
initial="hidden"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, scale: 0.8 },
|
||||||
|
visible: { opacity: 1, scale: 1 }
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay, ease: "backOut" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide in from left
|
||||||
|
export function SlideInLeft({ children, delay = 0, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
delay?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { ref, controls } = useScrollAnimation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
animate={controls}
|
||||||
|
initial="hidden"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, x: -50 },
|
||||||
|
visible: { opacity: 1, x: 0 }
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay, ease: "easeOut" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide in from right
|
||||||
|
export function SlideInRight({ children, delay = 0, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
delay?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { ref, controls } = useScrollAnimation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
animate={controls}
|
||||||
|
initial="hidden"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, x: 50 },
|
||||||
|
visible: { opacity: 1, x: 0 }
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.6, delay, ease: "easeOut" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover card with micro-interactions
|
||||||
|
export function HoverCard({ children, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{
|
||||||
|
y: -8,
|
||||||
|
scale: 1.02,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" }
|
||||||
|
}}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magnetic button effect
|
||||||
|
export function MagneticButton({ children, className = '', strength = 0.3 }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
strength?: number
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!ref.current) return
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left - rect.width / 2
|
||||||
|
const y = e.clientY - rect.top - rect.height / 2
|
||||||
|
|
||||||
|
ref.current.style.transform = `translate(${x * strength}px, ${y * strength}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!ref.current) return
|
||||||
|
ref.current.style.transform = 'translate(0px, 0px)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
|
className={className}
|
||||||
|
style={{ transition: 'transform 0.3s cubic-bezier(0.23, 1, 0.320, 1)' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter animation
|
||||||
|
export function CountUp({ end, duration = 2, suffix = '' }: {
|
||||||
|
end: number
|
||||||
|
duration?: number
|
||||||
|
suffix?: string
|
||||||
|
}) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { threshold: 0.1, once: true })
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInView) return
|
||||||
|
|
||||||
|
let startTime: number
|
||||||
|
let animationFrame: number
|
||||||
|
|
||||||
|
const animate = (timestamp: number) => {
|
||||||
|
if (!startTime) startTime = timestamp
|
||||||
|
const progress = Math.min((timestamp - startTime) / (duration * 1000), 1)
|
||||||
|
|
||||||
|
setCount(Math.floor(progress * end))
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrame = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrame = requestAnimationFrame(animate)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrame) {
|
||||||
|
cancelAnimationFrame(animationFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isInView, end, duration])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{count}{suffix}
|
||||||
|
</motion.span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallax effect
|
||||||
|
export function ParallaxElement({ children, speed = 0.5, className = '' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
speed?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrolled = window.pageYOffset
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const elementTop = rect.top + scrolled
|
||||||
|
const elementHeight = rect.height
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
|
||||||
|
if (scrolled + windowHeight > elementTop && scrolled < elementTop + elementHeight) {
|
||||||
|
const yPos = -(scrolled - elementTop) * speed
|
||||||
|
element.style.transform = `translateY(${yPos}px)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticking = false
|
||||||
|
const throttledScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
handleScroll()
|
||||||
|
ticking = false
|
||||||
|
})
|
||||||
|
ticking = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', throttledScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', throttledScroll)
|
||||||
|
}, [speed])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className} style={{ willChange: 'transform' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
import { animateSplit } from '@/lib/animateSplit'
|
||||||
|
import ParallaxLayer from './ParallaxLayer'
|
||||||
|
import MagneticButton from './MagneticButton'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScrollHero() {
|
||||||
|
const heroRef = useRef<HTMLDivElement>(null)
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||||
|
const subtitleRef = useRef<HTMLParagraphElement>(null)
|
||||||
|
const scrollHintRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hero = heroRef.current
|
||||||
|
const title = titleRef.current
|
||||||
|
const subtitle = subtitleRef.current
|
||||||
|
const scrollHint = scrollHintRef.current
|
||||||
|
|
||||||
|
if (!hero || !title || !subtitle || !scrollHint) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
// Initial setup
|
||||||
|
gsap.set([title, subtitle, scrollHint], { opacity: 0 })
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
// Simple fade in for reduced motion
|
||||||
|
const tl = gsap.timeline({ delay: 0.5 })
|
||||||
|
tl.to(title, { opacity: 1, y: 0, duration: 0.8, ease: 'power2.out' })
|
||||||
|
.to(subtitle, { opacity: 1, y: 0, duration: 0.6, ease: 'power2.out' }, '-=0.4')
|
||||||
|
.to(scrollHint, { opacity: 1, duration: 0.4 }, '-=0.2')
|
||||||
|
} else {
|
||||||
|
// Full animation
|
||||||
|
const tl = gsap.timeline({ delay: 0.8 })
|
||||||
|
|
||||||
|
tl.add(() => {
|
||||||
|
gsap.set(title, { opacity: 1 })
|
||||||
|
animateSplit(title, { stagger: 0.02, duration: 1 })
|
||||||
|
})
|
||||||
|
.to(subtitle, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, '-=0.5')
|
||||||
|
.to(scrollHint, {
|
||||||
|
opacity: 1,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'power2.out'
|
||||||
|
}, '-=0.3')
|
||||||
|
|
||||||
|
// Scroll hint animation
|
||||||
|
gsap.to(scrollHint, {
|
||||||
|
y: 10,
|
||||||
|
duration: 1.5,
|
||||||
|
ease: 'power2.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hero parallax on scroll
|
||||||
|
gsap.to(hero, {
|
||||||
|
yPercent: -50,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: hero,
|
||||||
|
start: 'top top',
|
||||||
|
end: 'bottom top',
|
||||||
|
scrub: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, hero)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={heroRef}
|
||||||
|
className="relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Background layers */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900" />
|
||||||
|
|
||||||
|
{/* Noise texture */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.03] mix-blend-soft-light"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Parallax background elements */}
|
||||||
|
<ParallaxLayer depth={0.2} className="absolute inset-0">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" />
|
||||||
|
</ParallaxLayer>
|
||||||
|
|
||||||
|
<ParallaxLayer depth={0.4} className="absolute inset-0">
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl" />
|
||||||
|
</ParallaxLayer>
|
||||||
|
|
||||||
|
{/* Hero image */}
|
||||||
|
<ParallaxLayer depth={0.6} className="absolute inset-0 flex items-center justify-end pr-12">
|
||||||
|
<div className="relative w-96 h-96 opacity-20">
|
||||||
|
<Image
|
||||||
|
src="/michael-peskov-magier-taschendieb-453624.jpeg"
|
||||||
|
alt="Michael Peskov performing magic"
|
||||||
|
fill
|
||||||
|
className="object-cover rounded-2xl"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ParallaxLayer>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 text-center px-6 max-w-6xl mx-auto">
|
||||||
|
<h1
|
||||||
|
ref={titleRef}
|
||||||
|
className="text-6xl md:text-8xl lg:text-9xl font-bold text-white mb-8 leading-none"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
Magic That
|
||||||
|
<br />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400">
|
||||||
|
Mesmerizes
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
ref={subtitleRef}
|
||||||
|
className="text-xl md:text-2xl text-gray-300 mb-12 max-w-3xl mx-auto leading-relaxed"
|
||||||
|
style={{ transform: 'translateY(30px)', willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
Experience the wonder of modern magic with Michael Peskov.
|
||||||
|
From intimate close-up performances to grand stage illusions,
|
||||||
|
every moment is crafted to leave your audience spellbound.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
|
||||||
|
<MagneticButton className="bg-gradient-to-r from-purple-600 to-cyan-600 text-white px-8 py-4 rounded-full text-lg font-semibold hover:shadow-2xl transition-shadow">
|
||||||
|
Book a Show
|
||||||
|
</MagneticButton>
|
||||||
|
|
||||||
|
<MagneticButton className="border border-white/30 text-white px-8 py-4 rounded-full text-lg font-semibold hover:bg-white/10 transition-colors">
|
||||||
|
Watch Showreel
|
||||||
|
</MagneticButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll hint */}
|
||||||
|
<div
|
||||||
|
ref={scrollHintRef}
|
||||||
|
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 text-white/60"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-sm mb-2">Scroll to explore</span>
|
||||||
|
<div className="w-6 h-10 border border-white/30 rounded-full flex justify-center">
|
||||||
|
<div className="w-1 h-3 bg-white/60 rounded-full mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ number: 5, suffix: '/5', label: 'Star Rating' },
|
||||||
|
{ number: 100, suffix: '%', label: 'Recommendation' },
|
||||||
|
{ number: 20, suffix: '+', label: 'Bookings' },
|
||||||
|
{ number: 1000, suffix: '+', label: 'KM Travel Radius' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const logos = [
|
||||||
|
'SAT.1',
|
||||||
|
'WDR',
|
||||||
|
'ZDF',
|
||||||
|
'Amazon Prime Video',
|
||||||
|
'Mercedes-Benz AG',
|
||||||
|
'Materna TMT',
|
||||||
|
'IHK',
|
||||||
|
'Lexus'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SocialProof() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const logosRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const statsContainer = statsRef.current
|
||||||
|
const logosContainer = logosRef.current
|
||||||
|
|
||||||
|
if (!container || !statsContainer || !logosContainer) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
// Stats animation
|
||||||
|
const statElements = gsap.utils.toArray<HTMLElement>('.stat-item')
|
||||||
|
|
||||||
|
statElements.forEach((stat, i) => {
|
||||||
|
const numberEl = stat.querySelector('.stat-number') as HTMLElement
|
||||||
|
const labelEl = stat.querySelector('.stat-label') as HTMLElement
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
// Simple fade for reduced motion
|
||||||
|
gsap.from(stat, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: i * 0.1,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: statsContainer,
|
||||||
|
start: 'top 80%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Count-up animation
|
||||||
|
const targetNumber = parseInt(numberEl.textContent || '0')
|
||||||
|
const suffix = numberEl.dataset.suffix || ''
|
||||||
|
|
||||||
|
gsap.set(stat, { opacity: 0, y: 30 })
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: statsContainer,
|
||||||
|
start: 'top 70%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tl.to(stat, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: i * 0.1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
})
|
||||||
|
.to({ value: 0 }, {
|
||||||
|
value: targetNumber,
|
||||||
|
duration: 1.5,
|
||||||
|
ease: 'power2.out',
|
||||||
|
onUpdate: function() {
|
||||||
|
const currentValue = Math.round(this.targets()[0].value)
|
||||||
|
numberEl.textContent = currentValue + suffix
|
||||||
|
}
|
||||||
|
}, '-=0.3')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Logos animation
|
||||||
|
const logoElements = gsap.utils.toArray<HTMLElement>('.logo-item')
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
gsap.from(logoElements, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
stagger: 0.1,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: logosContainer,
|
||||||
|
start: 'top 80%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
gsap.from(logoElements, {
|
||||||
|
opacity: 0,
|
||||||
|
x: -30,
|
||||||
|
duration: 0.6,
|
||||||
|
stagger: 0.1,
|
||||||
|
ease: 'power2.out',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: logosContainer,
|
||||||
|
start: 'top 80%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Continuous marquee effect
|
||||||
|
const marqueeWidth = logosContainer.scrollWidth
|
||||||
|
const containerWidth = logosContainer.offsetWidth
|
||||||
|
|
||||||
|
if (marqueeWidth > containerWidth) {
|
||||||
|
gsap.to('.logos-track', {
|
||||||
|
x: -(marqueeWidth - containerWidth),
|
||||||
|
duration: 20,
|
||||||
|
ease: 'none',
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, container)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={containerRef} className="py-32 px-6 bg-slate-900">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Stats */}
|
||||||
|
<div ref={statsRef} className="mb-20">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||||
|
Trusted by Many
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-300">
|
||||||
|
Numbers that speak for themselves
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
|
{stats.map((stat, i) => (
|
||||||
|
<div key={i} className="stat-item text-center">
|
||||||
|
<div
|
||||||
|
className="stat-number text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400 mb-2"
|
||||||
|
data-suffix={stat.suffix}
|
||||||
|
>
|
||||||
|
0{stat.suffix}
|
||||||
|
</div>
|
||||||
|
<div className="stat-label text-gray-300 text-sm md:text-base">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logos */}
|
||||||
|
<div ref={logosRef} className="overflow-hidden">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h3 className="text-2xl font-semibold text-white mb-4">
|
||||||
|
As Seen On & Trusted By
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="logos-track flex items-center justify-center gap-12 md:gap-16">
|
||||||
|
{logos.map((logo, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="logo-item flex-shrink-0 text-gray-400 hover:text-white transition-colors duration-300 text-lg md:text-xl font-medium"
|
||||||
|
>
|
||||||
|
{logo}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Play } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
posterImage: string
|
||||||
|
videoUrl: string
|
||||||
|
title: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPlayer({ posterImage, videoUrl, title, className = '' }: VideoPlayerProps) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setIsPlaying(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`onepage-media ${className}`}>
|
||||||
|
{!isPlaying ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={posterImage}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-play-overlay"
|
||||||
|
whileHover={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
onClick={handlePlay}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="onepage-play-btn"
|
||||||
|
whileHover={{ scale: 1.2 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
animate={{
|
||||||
|
boxShadow: [
|
||||||
|
'0 0 0 0 rgba(122,92,255,0.15)',
|
||||||
|
'0 0 0 20px rgba(122,92,255,0)',
|
||||||
|
'0 0 0 0 rgba(122,92,255,0)'
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="w-6 h-6"/> Play {title}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src={videoUrl}
|
||||||
|
title={title}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
'use client'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoShowcase() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const videoRef = useRef<HTMLDivElement>(null)
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const video = videoRef.current
|
||||||
|
const overlay = overlayRef.current
|
||||||
|
|
||||||
|
if (!container || !video || !overlay) return
|
||||||
|
|
||||||
|
// Check for reduced motion
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
// Simple fade for reduced motion
|
||||||
|
gsap.from(video, {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.9,
|
||||||
|
duration: 1,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: container,
|
||||||
|
start: 'top 60%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Video scale animation on scroll
|
||||||
|
gsap.fromTo(video,
|
||||||
|
{
|
||||||
|
scale: 0.8,
|
||||||
|
borderRadius: '2rem'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: 1,
|
||||||
|
borderRadius: '1rem',
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.out',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: container,
|
||||||
|
start: 'top 70%',
|
||||||
|
end: 'center center',
|
||||||
|
scrub: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parallax effect on video
|
||||||
|
gsap.to(video, {
|
||||||
|
yPercent: -20,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: container,
|
||||||
|
start: 'top bottom',
|
||||||
|
end: 'bottom top',
|
||||||
|
scrub: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Overlay fade out on scroll
|
||||||
|
gsap.to(overlay, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: container,
|
||||||
|
start: 'top 50%',
|
||||||
|
toggleActions: 'play none none reverse'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, container)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePlayClick = () => {
|
||||||
|
// Replace with actual video player logic
|
||||||
|
console.log('Play video')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={containerRef} className="py-32 px-6 bg-slate-800">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||||
|
See the Magic
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||||
|
Watch Michael Peskov in action as he creates moments of wonder and amazement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={videoRef}
|
||||||
|
className="relative aspect-video rounded-2xl overflow-hidden shadow-2xl"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/michael-peskov-magier-taschendieb-453624.jpeg"
|
||||||
|
alt="Michael Peskov showreel preview"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Video overlay */}
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer group"
|
||||||
|
onClick={handlePlayClick}
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 bg-white/90 rounded-full flex items-center justify-center group-hover:bg-white group-hover:scale-110 transition-all duration-300">
|
||||||
|
<div className="w-0 h-0 border-l-[16px] border-l-slate-800 border-y-[12px] border-y-transparent ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/50 to-transparent pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video info */}
|
||||||
|
<div className="text-center mt-8">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
As seen on <span className="text-white font-semibold">SAT.1, WDR, ZDF & Amazon Prime Video</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: michaelpeskov_web
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./web:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Config } from 'jest'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{ tsconfig: 'tsconfig.json', isolatedModules: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
testMatch: ['**/tests/unit/**/*.test.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||||
|
'^@/lib/(.*)$': '<rootDir>/lib/$1',
|
||||||
|
'^@/locales/(.*)$': '<rootDir>/locales/$1',
|
||||||
|
'^@/styles/(.*)$': '<rootDir>/styles/$1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default config
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Dynamic import to avoid SSR issues
|
||||||
|
let gsap: any
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gsap = require('gsap').gsap
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitTextIntoChars(element: HTMLElement): HTMLElement[] {
|
||||||
|
const text = element.textContent || ''
|
||||||
|
const chars: HTMLElement[] = []
|
||||||
|
|
||||||
|
element.innerHTML = ''
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i]
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.textContent = char === ' ' ? '\u00A0' : char
|
||||||
|
span.setAttribute('data-char', i.toString())
|
||||||
|
span.style.display = 'inline-block'
|
||||||
|
span.style.willChange = 'transform'
|
||||||
|
element.appendChild(span)
|
||||||
|
chars.push(span)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateSplit(element: HTMLElement, options: {
|
||||||
|
delay?: number
|
||||||
|
stagger?: number
|
||||||
|
duration?: number
|
||||||
|
ease?: string
|
||||||
|
} = {}) {
|
||||||
|
if (!gsap) return
|
||||||
|
|
||||||
|
const {
|
||||||
|
delay = 0,
|
||||||
|
stagger = 0.015,
|
||||||
|
duration = 0.8,
|
||||||
|
ease = 'power2.out'
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const chars = splitTextIntoChars(element)
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
gsap.set(chars, {
|
||||||
|
yPercent: 110,
|
||||||
|
rotateZ: 5,
|
||||||
|
opacity: 0,
|
||||||
|
transformOrigin: 'center bottom'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
return gsap.to(chars, {
|
||||||
|
yPercent: 0,
|
||||||
|
rotateZ: 0,
|
||||||
|
opacity: 1,
|
||||||
|
stagger,
|
||||||
|
duration,
|
||||||
|
ease,
|
||||||
|
delay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateWords(element: HTMLElement, options: {
|
||||||
|
delay?: number
|
||||||
|
stagger?: number
|
||||||
|
duration?: number
|
||||||
|
ease?: string
|
||||||
|
} = {}) {
|
||||||
|
if (!gsap) return
|
||||||
|
|
||||||
|
const {
|
||||||
|
delay = 0,
|
||||||
|
stagger = 0.08,
|
||||||
|
duration = 0.6,
|
||||||
|
ease = 'power2.out'
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const text = element.textContent || ''
|
||||||
|
const words = text.split(' ')
|
||||||
|
|
||||||
|
element.innerHTML = ''
|
||||||
|
|
||||||
|
const wordElements = words.map(word => {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.textContent = word + ' '
|
||||||
|
span.style.display = 'inline-block'
|
||||||
|
span.style.willChange = 'transform'
|
||||||
|
element.appendChild(span)
|
||||||
|
return span
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
gsap.set(wordElements, {
|
||||||
|
yPercent: 100,
|
||||||
|
opacity: 0,
|
||||||
|
transformOrigin: 'center bottom'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
return gsap.to(wordElements, {
|
||||||
|
yPercent: 0,
|
||||||
|
opacity: 1,
|
||||||
|
stagger,
|
||||||
|
duration,
|
||||||
|
ease,
|
||||||
|
delay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
export const supportedLocales = ['en', 'de'] as const;
|
||||||
|
export type Locale = typeof supportedLocales[number];
|
||||||
|
|
||||||
|
export function getInitialLocale(cookieStore: any, _hdrs: any): Locale {
|
||||||
|
const cookie = cookieStore?.get?.('lang')?.value as Locale | undefined;
|
||||||
|
if (cookie && (supportedLocales as readonly string[]).includes(cookie)) return cookie;
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDictionary(locale: Locale) {
|
||||||
|
// Use relative imports to ensure resolution in both server and edge runtimes
|
||||||
|
const common = await import(`../locales/${locale}/common.json`).then((m) => m.default);
|
||||||
|
const home = await import(`../locales/${locale}/home.json`).then((m) => m.default);
|
||||||
|
return { common, home, ...home } as any;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export function supportsScrollTimeline(): boolean {
|
||||||
|
// @ts-ignore
|
||||||
|
return typeof (CSS as any)?.scrollTimeline !== 'undefined'
|
||||||
|
}
|
||||||
|
export function initScrollFallback() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (prefersReduced) return
|
||||||
|
}
|
||||||
|
export function disableAnimations() { }
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"nav": {"home": "Start", "services": "Leistungen", "about": "Über mich", "showreel": "Showreel", "testimonials": "Stimmen", "contact": "Kontakt"},
|
||||||
|
"cta": {"bookNow": "Jetzt anfragen", "bookNowAlt": "Book Now", "watchShowreel": "Showreel ansehen", "sendRequest": "Anfrage senden"},
|
||||||
|
"a11y": {"skipAnimation": "Animation überspringen"}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"headline1": {"title": "Moderne Magie für außergewöhnliche Events", "sub": "Von Boardrooms bis Ballsälen Michael Peskov schafft interaktive Momente, über die Gäste noch Jahre sprechen."},
|
||||||
|
"headline2": {"title": "Bekannt aus SAT.1, WDR, ZDF & Amazon Prime Video", "sub": "Einer der jüngsten professionellen Zauberkünstler Deutschlands jetzt live auf Ihrem Event."},
|
||||||
|
"headline3": {"title": "Eis brechen. Gespräche entfachen. Erinnerungen schaffen.", "sub": "Close-up-Wunder und Bühnenmomente, die Gäste fesseln null Langeweile."}
|
||||||
|
},
|
||||||
|
"about": {"title": "Über Michael Peskov", "blurb": "Michael Peskov ist ein moderner Magier & Taschendieb aus Solingen. Bekannt aus SAT.1, WDR, ZDF & Amazon Prime Video, deutschlandweit und darüber hinaus (>1000 km) unterwegs."},
|
||||||
|
"services": {"title": "Leistungen", "closeup": {"title": "Close-Up Magie (Tischzauberei)", "desc": "Das meistgebuchte Format: Michael bewegt sich von Tisch zu Tisch, schafft sofortige Gesprächsanlässe und eliminiert Leerlauf."}, "stage": {"title": "Bühnenshow", "desc": "Interaktiv, humorvoll und vom MZvD als punkthöchste Darbietung ausgezeichnet."}, "pickpocket": {"title": "Taschendieb-Act", "desc": "Handys, Geldbörsen, sogar Krawatten geliehen und sicher zurückgegeben plus praktische Tipps gegen echte Taschendiebe."}, "fork": {"title": "Gabelbiegen", "desc": "Stahl wird in Sekunden zu Kunst Gäste behalten eine gebogene Gabel als Souvenir. Weiterentwickelt über den klassischen Uri-Geller-Stil hinaus."}},
|
||||||
|
"social": {"ratingBadge": "5/5 Sterne, 12 Bewertungen, 20+ Buchungen, 100% Empfehlung; seit 12/2022 auf Eventpeppers.", "seenOn": "Bekannt aus: SAT.1 WDR ZDF Amazon Prime Video."},
|
||||||
|
"showreel": {"title": "Showreel", "note": "Datenschutzfreundliche Einbettung."},
|
||||||
|
"testimonials": {"title": "Stimmen"},
|
||||||
|
"contact": {"title": "Kontakt", "subtitle": "Erzählen Sie mir von Ihrem Event Datum, Stadt, Gäste, Anlass. Ich schlage innerhalb von 24 h das passende Format vor.", "date": "Datum", "city": "Stadt", "guests": "Gäste", "occasion": "Anlass", "message": "Nachricht", "travelNote": "Reiseradius >1000 km mit Sitz in Solingen"}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"nav": {"home": "Home", "services": "Services", "about": "About", "showreel": "Showreel", "testimonials": "Testimonials", "contact": "Contact"},
|
||||||
|
"cta": {"bookNow": "Book Now", "bookNowAlt": "Jetzt anfragen", "watchShowreel": "Watch Showreel", "sendRequest": "Send Request"},
|
||||||
|
"a11y": {"skipAnimation": "Skip animation"}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"headline1": {"title": "Modern Magic for Events That Dont Feel Average", "sub": "From boardrooms to ballrooms, Michael Peskov creates interactive moments guests talk about for years."},
|
||||||
|
"headline2": {"title": "Seen on SAT.1, WDR, ZDF & Amazon Prime Video", "sub": "One of Germanys youngest professional magiciansnow live at your event."},
|
||||||
|
"headline3": {"title": "Break the Ice. Spark Conversations. Create Memories.", "sub": "Close-up miracles and stage moments that keep guests engagedzero boredom."}
|
||||||
|
},
|
||||||
|
"about": {"title": "About Michael Peskov", "blurb": "Michael Peskov is a modern magician & pickpocket from Solingen. Known from SAT.1, WDR, ZDF & Amazon Prime Video, he performs Germany-wide and beyond (>1000 km)."},
|
||||||
|
"services": {"title": "Services", "closeup": {"title": "Close-Up Magic (Tischzauberei)", "desc": "The most booked format: Michael moves table-to-table, creating instant conversation starters and eliminating downtime."}, "stage": {"title": "Stage Show (Bühnenshow)", "desc": "Interactive, humorous, and award-recognized by the MZvD as a punkthöchste Darbietung."}, "pickpocket": {"title": "Pickpocket Act (Taschendieb)", "desc": "Phones, wallets, even tiesborrowed and safely returnedplus practical tips to avoid real pickpockets."}, "fork": {"title": "Fork-Bending (Gabelbieger)", "desc": "Steel turns to art within secondsguests keep a bent fork as a memorable souvenir. Developed beyond the classic Uri Geller style."}},
|
||||||
|
"social": {"ratingBadge": "5/5 stars, 12 reviews, 20+ bookings, 100% recommendation; since 12/2022 on Eventpeppers.", "seenOn": "Seen on: SAT.1 WDR ZDF Amazon Prime Video."},
|
||||||
|
"showreel": {"title": "Showreel", "note": "Privacy-enhanced embed."},
|
||||||
|
"testimonials": {"title": "Testimonials"},
|
||||||
|
"contact": {"title": "Contact", "subtitle": "Tell me about your event date, city, guests, occasion. Ill suggest the perfect format within 24 h.", "date": "Date", "city": "City", "guests": "Guests", "occasion": "Occasion", "message": "Message", "travelNote": "Travel radius >1000 km based in Solingen"}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
typedRoutes: true,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
formats: ['image/avif', 'image/webp'],
|
||||||
|
dangerouslyAllowSVG: true,
|
||||||
|
contentDispositionType: 'attachment',
|
||||||
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'images.eventpeppers.com',
|
||||||
|
pathname: '/**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'api.dicebear.com',
|
||||||
|
pathname: '/**'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Permissions-Policy', value: 'geolocation=(), microphone=(), camera=()' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Security & privacy headers
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()";
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
# HSTS (only effective over HTTPS; harmless over HTTP)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Content Security Policy:
|
||||||
|
# - Allow self-hosted assets
|
||||||
|
# - Inline styles allowed for critical CSS
|
||||||
|
# - Allow privacy-enhanced embeds
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' https://*.youtube-nocookie.com https://*.vimeo.com; frame-src https://www.youtube-nocookie.com https://player.vimeo.com; connect-src 'self'; base-uri 'self'; form-action 'self' https://wa.me https://api.whatsapp.com; upgrade-insecure-requests";
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml application/rss+xml font/woff2;
|
||||||
|
|
||||||
|
# HTML: no cache to ensure updates are seen
|
||||||
|
location ~* \.(?:html)$ {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets: long cache
|
||||||
|
location ~* \.(?:css|js)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
location ~* \.(?:png|jpg|jpeg|gif|webp|avif|svg|ico)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
location ~* \.(?:mp4|webm|ogv|mp3|wav)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
location ~* \.(?:woff2?|ttf|otf|eot)$ {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "michaelpeskov-site",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "jest --runInBand",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@studio-freight/lenis": "^1.0.42",
|
||||||
|
"@types/gsap": "^1.20.2",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
|
"next": "14.2.5",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.1",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^20.11.30",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "14.2.5",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
timeout: 30000,
|
||||||
|
testDir: 'tests/e2e',
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
viewport: { width: 1280, height: 800 }
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
placeholder poster; replace with a real WebP/AVIF image of the hero.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
|
|
@ -0,0 +1,673 @@
|
||||||
|
# Save this as scaffold.ps1, then run:
|
||||||
|
# pwsh -NoProfile -ExecutionPolicy Bypass -File ./scaffold.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Write-Utf8([string]$Path, [string]$Content) {
|
||||||
|
$parent = Split-Path -Parent $Path
|
||||||
|
if ($parent -and -not (Test-Path $parent)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
||||||
|
}
|
||||||
|
$Content | Set-Content -Encoding UTF8 -Path $Path
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create folders
|
||||||
|
$dirs = @(
|
||||||
|
'app','app/(routes)','app/(routes)/about','app/(routes)/services','app/(routes)/showreel','app/(routes)/testimonials','app/(routes)/contact','app/(routes)/legal',
|
||||||
|
'components','styles','locales','locales/en','locales/de','lib','tests','tests/unit','tests/e2e','public'
|
||||||
|
)
|
||||||
|
foreach($d in $dirs){ if(!(Test-Path $d)){ New-Item -ItemType Directory -Path $d | Out-Null } }
|
||||||
|
|
||||||
|
# .gitignore
|
||||||
|
$gitignore = @'
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
'@
|
||||||
|
Write-Utf8 '.gitignore' $gitignore
|
||||||
|
|
||||||
|
# package.json
|
||||||
|
$packageJson = @'
|
||||||
|
{
|
||||||
|
"name": "michaelpeskov-site",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "jest --runInBand",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.5",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.1",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^20.11.30",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "14.2.5",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'package.json' $packageJson
|
||||||
|
|
||||||
|
# tsconfig.json
|
||||||
|
$tsconfig = @'
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/components/*": ["components/*"],
|
||||||
|
"@/styles/*": ["styles/*"],
|
||||||
|
"@/lib/*": ["lib/*"],
|
||||||
|
"@/locales/*": ["locales/*"]
|
||||||
|
},
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'tsconfig.json' $tsconfig
|
||||||
|
|
||||||
|
# next.config.ts
|
||||||
|
$nextConfig = @'
|
||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: { typedRoutes: true },
|
||||||
|
images: { formats: ['image/avif', 'image/webp'], remotePatterns: [] },
|
||||||
|
headers: async () => [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Permissions-Policy', value: 'geolocation=(), microphone=(), camera=()' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
export default nextConfig
|
||||||
|
'@
|
||||||
|
Write-Utf8 'next.config.ts' $nextConfig
|
||||||
|
|
||||||
|
# jest.config.ts
|
||||||
|
$jestConfig = @'
|
||||||
|
import type { Config } from 'jest'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{ tsconfig: 'tsconfig.json', isolatedModules: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
testMatch: ['**/tests/unit/**/*.test.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||||
|
'^@/lib/(.*)$': '<rootDir>/lib/$1',
|
||||||
|
'^@/locales/(.*)$': '<rootDir>/locales/$1',
|
||||||
|
'^@/styles/(.*)$': '<rootDir>/styles/$1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default config
|
||||||
|
'@
|
||||||
|
Write-Utf8 'jest.config.ts' $jestConfig
|
||||||
|
|
||||||
|
# playwright.config.ts
|
||||||
|
$pwConfig = @'
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
timeout: 30000,
|
||||||
|
testDir: 'tests/e2e',
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
viewport: { width: 1280, height: 800 }
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
'@
|
||||||
|
Write-Utf8 'playwright.config.ts' $pwConfig
|
||||||
|
|
||||||
|
# app/layout.tsx
|
||||||
|
$appLayout = @'
|
||||||
|
import './globals.css'
|
||||||
|
import '@/styles/tokens.css'
|
||||||
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, Locale, getInitialLocale } from '@/lib/i18n'
|
||||||
|
import LanguageToggle from '@/components/LanguageToggle'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Michael Peskov Magician & Pickpocket | Zauberer Solingen & NRW',
|
||||||
|
description: 'Seen on SAT.1, WDR, ZDF & Amazon Prime Video. 5 rated magician for corporate events, weddings & more. Based in Solingen, travels >1000 km.',
|
||||||
|
metadataBase: new URL('http://localhost:3000'),
|
||||||
|
alternates: { languages: { en: '/', de: '/?lang=de' } },
|
||||||
|
openGraph: {
|
||||||
|
title: 'Michael Peskov Modern Magician & Pickpocket',
|
||||||
|
description: '5/5 stars, 12 reviews, 20+ bookings, 100% recommendation; since 12/2022 on Eventpeppers.',
|
||||||
|
url: '/', siteName: 'Michael Peskov',
|
||||||
|
images: [{ url: '/hero-poster.webp', width: 1200, height: 630, alt: 'Michael performing' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = { themeColor: '#0B0C10', colorScheme: 'dark' }
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const initialLocale = getInitialLocale(cookieStore, hdrs) as Locale
|
||||||
|
const dict = await getDictionary(initialLocale)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={initialLocale} suppressHydrationWarning>
|
||||||
|
<body className="bg">
|
||||||
|
<a href="#main" className="skip">Skip to content</a>
|
||||||
|
<header className="siteHeader">
|
||||||
|
<div className="container nav">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="mark" aria-hidden></span>
|
||||||
|
<span>Michael Peskov</span>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<ul>
|
||||||
|
<li><Link href="/">{dict.common.nav.home}</Link></li>
|
||||||
|
<li><Link href="/services">{dict.common.nav.services}</Link></li>
|
||||||
|
<li><Link href="/about">{dict.common.nav.about}</Link></li>
|
||||||
|
<li><Link href="/showreel">{dict.common.nav.showreel}</Link></li>
|
||||||
|
<li><Link href="/testimonials">{dict.common.nav.testimonials}</Link></li>
|
||||||
|
<li><Link href="/contact">{dict.common.nav.contact}</Link></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="cta">
|
||||||
|
<LanguageToggle initial={initialLocale} labels={{ en: 'EN', de: 'DE' }} />
|
||||||
|
<Link className="btn primary" href="/contact#booking">{dict.common.cta.bookNow}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main" className="main">{children}</main>
|
||||||
|
<a className="floatingCTA" href="/contact#booking">{dict.common.cta.bookNow} / {dict.common.cta.bookNowAlt}</a>
|
||||||
|
<footer className="siteFooter">
|
||||||
|
<div className="container foot">
|
||||||
|
<div>
|
||||||
|
<strong>Michael Peskov</strong>
|
||||||
|
<div className="muted">Modern Magician & Pickpocket Solingen</div>
|
||||||
|
<div className="badges">
|
||||||
|
<span className="badge">SAT.1</span>
|
||||||
|
<span className="badge">WDR</span>
|
||||||
|
<span className="badge">ZDF</span>
|
||||||
|
<span className="badge">Amazon Prime Video</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><Link href="/services">Services</Link></div>
|
||||||
|
<div><Link href="/showreel">Showreel</Link></div>
|
||||||
|
<div><Link href="/testimonials">Testimonials</Link></div>
|
||||||
|
<div><Link href="/about">About</Link></div>
|
||||||
|
<div><Link href="/contact">Contact</Link></div>
|
||||||
|
<div><Link href="/legal">Impressum / Datenschutz</Link></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><a href="https://www.youtube.com/" rel="me noopener" target="_blank">YouTube</a></div>
|
||||||
|
<div><a href="https://www.instagram.com/" rel="me noopener" target="_blank">Instagram</a></div>
|
||||||
|
<div><a href="https://www.tiktok.com/" rel="me noopener" target="_blank">TikTok</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="container" style={{ marginTop: 8, color: 'var(--muted)', fontSize: '.9rem' }}>
|
||||||
|
{new Date().getFullYear()} Michael Peskov
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/layout.tsx' $appLayout
|
||||||
|
|
||||||
|
# app/page.tsx
|
||||||
|
$homePage = @'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
import Hero from '@/components/Hero'
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (<><Hero dict={dict} /></>)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/page.tsx' $homePage
|
||||||
|
|
||||||
|
# routes pages
|
||||||
|
$about = @'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function AboutPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>{dict.about.title}</h1>
|
||||||
|
<p>{dict.about.blurb}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/(routes)/about/page.tsx' $about
|
||||||
|
|
||||||
|
$services = @'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function ServicesPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>{dict.services.title}</h1>
|
||||||
|
<ul className="serviceList">
|
||||||
|
<li><strong>{dict.services.closeup.title}</strong> {dict.services.closeup.desc}</li>
|
||||||
|
<li><strong>{dict.services.stage.title}</strong> {dict.services.stage.desc}</li>
|
||||||
|
<li><strong>{dict.services.pickpocket.title}</strong> {dict.services.pickpocket.desc}</li>
|
||||||
|
<li><strong>{dict.services.fork.title}</strong> {dict.services.fork.desc}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/(routes)/services/page.tsx' $services
|
||||||
|
|
||||||
|
$showreel = @'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function ShowreelPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>{dict.showreel.title}</h1>
|
||||||
|
<p className="muted">{dict.showreel.note}</p>
|
||||||
|
<div className="embed">
|
||||||
|
<iframe title="Showreel" src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" loading="lazy" width="100%" height="420" allow="autoplay; encrypted-media; picture-in-picture" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/(routes)/showreel/page.tsx' $showreel
|
||||||
|
|
||||||
|
$testimonials = @'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
export default async function TestimonialsPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>{dict.testimonials.title}</h1>
|
||||||
|
<p className="muted">{dict.social.ratingBadge}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/(routes)/testimonials/page.tsx' $testimonials
|
||||||
|
|
||||||
|
$contact = @'
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
import { getDictionary, getInitialLocale } from '@/lib/i18n'
|
||||||
|
import Link from 'next/link'
|
||||||
|
export default async function ContactPage() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const hdrs = headers()
|
||||||
|
const locale = getInitialLocale(cookieStore, hdrs)
|
||||||
|
const dict = await getDictionary(locale)
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>{dict.contact.title}</h1>
|
||||||
|
<p className="muted">{dict.contact.subtitle}</p>
|
||||||
|
<div className="card" id="booking" style={{ marginTop: 24 }}>
|
||||||
|
<form className="bookingForm" onSubmit={(e) => { e.preventDefault() }}>
|
||||||
|
<div className="grid">
|
||||||
|
<label>{dict.contact.date}<input type="date" required /></label>
|
||||||
|
<label>{dict.contact.city}<input placeholder="Solingen" required /></label>
|
||||||
|
<label>{dict.contact.guests}<input type="number" min={1} placeholder="100" required /></label>
|
||||||
|
<label>{dict.contact.occasion}<input placeholder="Corporate / Wedding / " /></label>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'block', marginTop: 12 }}>{dict.contact.message}<textarea placeholder="Timing, venue, special requests" /></label>
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn primary" type="submit">{dict.common.cta.sendRequest}</button>
|
||||||
|
<Link className="btn ghost" href="tel:+491234567890">Phone</Link>
|
||||||
|
<Link className="btn ghost" href="https://wa.me/491234567890" target="_blank">WhatsApp</Link>
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ marginTop: 6 }}>{dict.contact.travelNote}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/(routes)/contact/page.tsx' $contact
|
||||||
|
|
||||||
|
$legal = @'
|
||||||
|
export default function LegalPage() {
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ paddingBlock: '64px' }}>
|
||||||
|
<h1>Impressum / Datenschutz</h1>
|
||||||
|
<p style={{ color: 'var(--muted)' }}>Legal content placeholder.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/(routes)/legal/page.tsx' $legal
|
||||||
|
|
||||||
|
# components
|
||||||
|
$hero = @'
|
||||||
|
"use client"
|
||||||
|
import Image from 'next/image'
|
||||||
|
import BadgeRow from '@/components/BadgeRow'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type Dict = Awaited<ReturnType<typeof import('@/lib/i18n').getDictionary>>
|
||||||
|
|
||||||
|
export default function Hero({ dict }: { dict: Dict }) {
|
||||||
|
const d = dict.home.hero
|
||||||
|
return (
|
||||||
|
<section className="scene hero" aria-label="Hero">
|
||||||
|
<div className="heroMedia" aria-hidden="true">
|
||||||
|
<Image src="/hero-poster.webp" alt="Michael performing close-up magic under spotlight" priority width={2400} height={1350} sizes="100vw" className="poster" />
|
||||||
|
<div className="veil" />
|
||||||
|
</div>
|
||||||
|
<div className="container content">
|
||||||
|
<div className="twoCol">
|
||||||
|
<div className="pin">
|
||||||
|
<h1 className="headline">{d.headline1.title}</h1>
|
||||||
|
<p className="sub">{d.headline1.sub}</p>
|
||||||
|
<BadgeRow badges={[
|
||||||
|
'Location: 42699 Solingen, travel radius >1000 km.',
|
||||||
|
'Seen on: SAT.1 WDR ZDF Amazon Prime Video.',
|
||||||
|
'5/5 stars, 12 reviews, 20+ bookings, 100% recommendation; since 12/2022 on Eventpeppers.'
|
||||||
|
]} />
|
||||||
|
<div className="row">
|
||||||
|
<Link href="/contact#booking" className="btn primary">{dict.common.cta.bookNow}</Link>
|
||||||
|
<Link href="/showreel" className="btn ghost">{dict.common.cta.watchShowreel}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a className="skipAnim" href="/#scene-6">{dict.common.a11y.skipAnimation}</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'components/Hero.tsx' $hero
|
||||||
|
|
||||||
|
$badgeRow = @'
|
||||||
|
export default function BadgeRow({ badges }: { badges: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="badgeRow" role="list" aria-label="Badges">
|
||||||
|
{badges.map((b, i) => (
|
||||||
|
<span role="listitem" className={`badge ${i === 2 ? 'primary' : ''}`} key={i}>{b}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'components/BadgeRow.tsx' $badgeRow
|
||||||
|
|
||||||
|
$languageToggle = @'
|
||||||
|
"use client"
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = { initial: 'en' | 'de'; labels: { en: string; de: string } }
|
||||||
|
|
||||||
|
export default function LanguageToggle({ initial, labels }: Props) {
|
||||||
|
const [lang, setLang] = useState<'en' | 'de'>(initial)
|
||||||
|
useEffect(() => { setLang(initial) }, [initial])
|
||||||
|
function setCookieLang(next: 'en' | 'de') {
|
||||||
|
document.cookie = `lang=${next}; path=/; max-age=31536000; SameSite=Lax`
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
if (next === 'de') url.searchParams.set('lang', 'de')
|
||||||
|
else url.searchParams.delete('lang')
|
||||||
|
window.location.replace(url.toString())
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className="btn ghost" aria-pressed={lang === 'de'} aria-label="Switch language" onClick={() => setCookieLang(lang === 'en' ? 'de' : 'en')}>
|
||||||
|
{lang === 'en' ? labels.de : labels.en}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'components/LanguageToggle.tsx' $languageToggle
|
||||||
|
|
||||||
|
# styles
|
||||||
|
$tokens = @'
|
||||||
|
:root{--bg-1:#0B0C10;--bg-2:#15171E;--bg-3:#262A33;--text:#F2F5F7;--muted:#C2C6CC;--accent-1:#9B87F5;--accent-2:#F8D37A;--danger:#FF647C;--h1:clamp(2.2rem,4vw + 1rem,4rem);--h2:clamp(1.6rem,2.2vw + .8rem,2.6rem);--h3:clamp(1.2rem,1.2vw + .6rem,1.6rem);--body:clamp(1rem,.3vw + .9rem,1.1rem);--mono:"Space Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--serif:"Playfair Display","Cinzel",Georgia,"Times New Roman",serif;--sans:Inter,Roboto,"Segoe UI",Helvetica,Arial,system-ui,-apple-system,sans-serif;--s2:8px;--s3:12px;--s4:16px;--s5:24px;--s6:40px;--s7:64px;--s8:96px;--radius:12px;--shadow:0 20px 60px rgba(0,0,0,.45)}
|
||||||
|
.btn{appearance:none;border:0;border-radius:var(--radius);padding:10px 16px;font-weight:600;cursor:pointer}.btn.primary{background:linear-gradient(180deg,color-mix(in oklch,var(--accent-1) 90%,white 10%),var(--accent-1));color:#0b0c10}.btn.ghost{background:transparent;outline:1px solid rgba(255,255,255,.18);color:var(--text)}.badge{border-radius:999px;padding:6px 10px;font-size:.9rem;background:color-mix(in oklab,var(--bg-3) 88%,black 12%);outline:1px solid rgba(255,255,255,.12)}.badge.primary{background:linear-gradient(180deg,var(--accent-1),color-mix(in oklch,var(--accent-1) 80%,black 20%));color:#0b0c10}
|
||||||
|
.container{width:min(1200px,92vw);margin-inline:auto}.muted{color:var(--muted)}.card{background:color-mix(in oklab,var(--bg-3) 92%,black 8%);border-radius:var(--radius);padding:var(--s5);box-shadow:var(--shadow);outline:1px solid rgba(255,255,255,.06)}.row{display:flex;gap:12px;flex-wrap:wrap}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'styles/tokens.css' $tokens
|
||||||
|
|
||||||
|
$globals = @'
|
||||||
|
*{box-sizing:border-box}html,body{height:100%}html{background:var(--bg-1);color:var(--text);scroll-behavior:smooth}body{margin:0;font-family:var(--sans);font-size:var(--body);line-height:1.6}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline;text-underline-offset:3px}
|
||||||
|
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;background-image:radial-gradient(rgba(255,255,255,.04),rgba(255,255,255,0) 40%),repeating-linear-gradient(90deg,rgba(255,255,255,.015),rgba(255,255,255,.015) 1px,transparent 1px,transparent 2px);mix-blend-mode:soft-light;opacity:.8}
|
||||||
|
.bg{background:linear-gradient(180deg,var(--bg-2),var(--bg-1))}
|
||||||
|
.siteHeader{position:sticky;top:0;z-index:50;backdrop-filter:saturate(120%) blur(8px);background:color-mix(in oklab,var(--bg-1) 92%,transparent);border-bottom:1px solid rgba(255,255,255,.06)}.nav{display:flex;align-items:center;justify-content:space-between;padding:var(--s4) 0}.brand{display:flex;align-items:center;gap:var(--s3);font-family:var(--serif);font-weight:700;letter-spacing:.4px}.brand .mark{width:36px;height:36px;display:grid;place-items:center;border-radius:50%;background:linear-gradient(145deg,var(--bg-3),#0f1015);outline:1px solid rgba(255,255,255,.06);box-shadow:var(--shadow)}nav ul{display:flex;gap:var(--s5);list-style:none;margin:0;padding:0}nav a{opacity:.9}nav a:hover,nav a:focus{opacity:1}.cta{display:flex;gap:var(--s3);align-items:center}.skip{position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden}.skip:focus{position:fixed;left:12px;top:12px;width:auto;height:auto;z-index:70;background:var(--bg-3);padding:6px 10px;border-radius:8px}
|
||||||
|
.siteFooter{padding:var(--s7) 0;background:linear-gradient(180deg,var(--bg-2),var(--bg-1));border-top:1px solid rgba(255,255,255,.06);margin-top:var(--s8)}.foot{display:grid;gap:24px;grid-template-columns:2fr 1fr 1fr}.badges{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px}
|
||||||
|
.floatingCTA{position:fixed;right:16px;bottom:16px;z-index:60;display:flex;align-items:center;gap:10px;background:linear-gradient(180deg,var(--accent-2),color-mix(in oklch,var(--accent-2) 90%,black 10%));color:#141414;border-radius:999px;padding:12px 16px;box-shadow:var(--shadow)}
|
||||||
|
.main{scroll-snap-type:y proximity}.scene{min-height:100svh;position:relative;display:grid;align-items:center;border-bottom:1px solid rgba(255,255,255,.06)}.scene{scroll-snap-align:start}.content{position:relative;z-index:2}.pin{position:sticky;top:96px;align-self:start}.twoCol{display:grid;grid-template-columns:1.1fr .9fr;gap:var(--s7)}@media (max-width:900px){.twoCol{grid-template-columns:1fr}}.badgeRow{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}
|
||||||
|
.hero{background:radial-gradient(1200px 600px at 50% -20%,rgba(155,135,245,.22),transparent 60%),radial-gradient(800px 400px at 50% 120%,rgba(248,211,122,.14),transparent 60%),linear-gradient(180deg,var(--bg-2),var(--bg-1))}.hero .headline{font-family:var(--serif);font-size:var(--h1);margin:0 0 var(--s4)}.hero .sub{color:var(--muted);max-width:60ch}.heroMedia{position:absolute;inset:0;overflow:hidden;z-index:1}.poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;filter:contrast(1.1) brightness(.9) saturate(1.1)}.veil{position:absolute;inset:0;background:radial-gradient(60% 40% at 50% 40%,rgba(0,0,0,.2),rgba(0,0,0,.75));z-index:1}.skipAnim{position:fixed;left:12px;bottom:12px;z-index:60;font-size:.9rem;opacity:.9}
|
||||||
|
.bookingForm .grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--s4)}@media (max-width:900px){.bookingForm .grid{grid-template-columns:1fr 1fr}}@media (max-width:600px){.bookingForm .grid{grid-template-columns:1fr}}input,textarea{width:100%;background:#0e1017;color:var(--text);border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:12px 12px;font:inherit}textarea{min-height:120px;resize:vertical}
|
||||||
|
@media (prefers-reduced-motion: reduce){.poster{opacity:1 !important}}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'app/globals.css' $globals
|
||||||
|
|
||||||
|
# locales
|
||||||
|
$enCommon = @'
|
||||||
|
{
|
||||||
|
"nav": {"home": "Home", "services": "Services", "about": "About", "showreel": "Showreel", "testimonials": "Testimonials", "contact": "Contact"},
|
||||||
|
"cta": {"bookNow": "Book Now", "bookNowAlt": "Jetzt anfragen", "watchShowreel": "Watch Showreel", "sendRequest": "Send Request"},
|
||||||
|
"a11y": {"skipAnimation": "Skip animation"}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'locales/en/common.json' $enCommon
|
||||||
|
|
||||||
|
$enHome = @'
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"headline1": {"title": "Modern Magic for Events That Dont Feel Average", "sub": "From boardrooms to ballrooms, Michael Peskov creates interactive moments guests talk about for years."},
|
||||||
|
"headline2": {"title": "Seen on SAT.1, WDR, ZDF & Amazon Prime Video", "sub": "One of Germanys youngest professional magiciansnow live at your event."},
|
||||||
|
"headline3": {"title": "Break the Ice. Spark Conversations. Create Memories.", "sub": "Close-up miracles and stage moments that keep guests engagedzero boredom."}
|
||||||
|
},
|
||||||
|
"about": {"title": "About Michael Peskov", "blurb": "Michael Peskov is a modern magician & pickpocket from Solingen. Known from SAT.1, WDR, ZDF & Amazon Prime Video, he performs Germany-wide and beyond (>1000 km)."},
|
||||||
|
"services": {"title": "Services", "closeup": {"title": "Close-Up Magic (Tischzauberei)", "desc": "The most booked format: Michael moves table-to-table, creating instant conversation starters and eliminating downtime."}, "stage": {"title": "Stage Show (Bühnenshow)", "desc": "Interactive, humorous, and award-recognized by the MZvD as a punkthöchste Darbietung."}, "pickpocket": {"title": "Pickpocket Act (Taschendieb)", "desc": "Phones, wallets, even tiesborrowed and safely returnedplus practical tips to avoid real pickpockets."}, "fork": {"title": "Fork-Bending (Gabelbieger)", "desc": "Steel turns to art within secondsguests keep a bent fork as a memorable souvenir. Developed beyond the classic Uri Geller style."}},
|
||||||
|
"social": {"ratingBadge": "5/5 stars, 12 reviews, 20+ bookings, 100% recommendation; since 12/2022 on Eventpeppers.", "seenOn": "Seen on: SAT.1 WDR ZDF Amazon Prime Video."},
|
||||||
|
"showreel": {"title": "Showreel", "note": "Privacy-enhanced embed."},
|
||||||
|
"testimonials": {"title": "Testimonials"},
|
||||||
|
"contact": {"title": "Contact", "subtitle": "Tell me about your event date, city, guests, occasion. Ill suggest the perfect format within 24 h.", "date": "Date", "city": "City", "guests": "Guests", "occasion": "Occasion", "message": "Message", "travelNote": "Travel radius >1000 km based in Solingen"}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'locales/en/home.json' $enHome
|
||||||
|
|
||||||
|
$deCommon = @'
|
||||||
|
{
|
||||||
|
"nav": {"home": "Start", "services": "Leistungen", "about": "Über mich", "showreel": "Showreel", "testimonials": "Stimmen", "contact": "Kontakt"},
|
||||||
|
"cta": {"bookNow": "Jetzt anfragen", "bookNowAlt": "Book Now", "watchShowreel": "Showreel ansehen", "sendRequest": "Anfrage senden"},
|
||||||
|
"a11y": {"skipAnimation": "Animation überspringen"}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'locales/de/common.json' $deCommon
|
||||||
|
|
||||||
|
$deHome = @'
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"headline1": {"title": "Moderne Magie für außergewöhnliche Events", "sub": "Von Boardrooms bis Ballsälen Michael Peskov schafft interaktive Momente, über die Gäste noch Jahre sprechen."},
|
||||||
|
"headline2": {"title": "Bekannt aus SAT.1, WDR, ZDF & Amazon Prime Video", "sub": "Einer der jüngsten professionellen Zauberkünstler Deutschlands jetzt live auf Ihrem Event."},
|
||||||
|
"headline3": {"title": "Eis brechen. Gespräche entfachen. Erinnerungen schaffen.", "sub": "Close-up-Wunder und Bühnenmomente, die Gäste fesseln null Langeweile."}
|
||||||
|
},
|
||||||
|
"about": {"title": "Über Michael Peskov", "blurb": "Michael Peskov ist ein moderner Magier & Taschendieb aus Solingen. Bekannt aus SAT.1, WDR, ZDF & Amazon Prime Video, deutschlandweit und darüber hinaus (>1000 km) unterwegs."},
|
||||||
|
"services": {"title": "Leistungen", "closeup": {"title": "Close-Up Magie (Tischzauberei)", "desc": "Das meistgebuchte Format: Michael bewegt sich von Tisch zu Tisch, schafft sofortige Gesprächsanlässe und eliminiert Leerlauf."}, "stage": {"title": "Bühnenshow", "desc": "Interaktiv, humorvoll und vom MZvD als punkthöchste Darbietung ausgezeichnet."}, "pickpocket": {"title": "Taschendieb-Act", "desc": "Handys, Geldbörsen, sogar Krawatten geliehen und sicher zurückgegeben plus praktische Tipps gegen echte Taschendiebe."}, "fork": {"title": "Gabelbiegen", "desc": "Stahl wird in Sekunden zu Kunst Gäste behalten eine gebogene Gabel als Souvenir. Weiterentwickelt über den klassischen Uri-Geller-Stil hinaus."}},
|
||||||
|
"social": {"ratingBadge": "5/5 Sterne, 12 Bewertungen, 20+ Buchungen, 100% Empfehlung; seit 12/2022 auf Eventpeppers.", "seenOn": "Bekannt aus: SAT.1 WDR ZDF Amazon Prime Video."},
|
||||||
|
"showreel": {"title": "Showreel", "note": "Datenschutzfreundliche Einbettung."},
|
||||||
|
"testimonials": {"title": "Stimmen"},
|
||||||
|
"contact": {"title": "Kontakt", "subtitle": "Erzählen Sie mir von Ihrem Event Datum, Stadt, Gäste, Anlass. Ich schlage innerhalb von 24 h das passende Format vor.", "date": "Datum", "city": "Stadt", "guests": "Gäste", "occasion": "Anlass", "message": "Nachricht", "travelNote": "Reiseradius >1000 km mit Sitz in Solingen"}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'locales/de/home.json' $deHome
|
||||||
|
|
||||||
|
# lib
|
||||||
|
$i18n = @'
|
||||||
|
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies'
|
||||||
|
import type { Headers } from 'next/dist/compiled/@edge-runtime/primitives'
|
||||||
|
export const supportedLocales = ['en', 'de'] as const
|
||||||
|
export type Locale = typeof supportedLocales[number]
|
||||||
|
export function getInitialLocale(cookieStore: ReadonlyRequestCookies, _hdrs: Headers): Locale {
|
||||||
|
const cookie = cookieStore.get('lang')?.value as Locale | undefined
|
||||||
|
if (cookie && (supportedLocales as readonly string[]).includes(cookie)) return cookie
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
export async function getDictionary(locale: Locale) {
|
||||||
|
const common = await import(`@/locales/${locale}/common.json`).then(m => m.default)
|
||||||
|
const home = await import(`@/locales/${locale}/home.json`).then(m => m.default)
|
||||||
|
return { common, home, ...home } as any
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Write-Utf8 'lib/i18n.ts' $i18n
|
||||||
|
|
||||||
|
$scroll = @'
|
||||||
|
export function supportsScrollTimeline(): boolean {
|
||||||
|
// @ts-ignore
|
||||||
|
return typeof (CSS as any)?.scrollTimeline !== 'undefined'
|
||||||
|
}
|
||||||
|
export function initScrollFallback() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (prefersReduced) return
|
||||||
|
}
|
||||||
|
export function disableAnimations() { }
|
||||||
|
'@
|
||||||
|
Write-Utf8 'lib/scroll.ts' $scroll
|
||||||
|
|
||||||
|
# tests
|
||||||
|
$unitTest = @'
|
||||||
|
import { supportedLocales } from '@/lib/i18n'
|
||||||
|
|
||||||
|
describe('i18n', () => {
|
||||||
|
it('supports en and de', () => {
|
||||||
|
expect(supportedLocales).toContain('en')
|
||||||
|
expect(supportedLocales).toContain('de')
|
||||||
|
})
|
||||||
|
it('has hero strings in both locales', async () => {
|
||||||
|
const enHome = (await import('@/locales/en/home.json')).default as any
|
||||||
|
const deHome = (await import('@/locales/de/home.json')).default as any
|
||||||
|
expect(enHome.hero.headline1.title.length).toBeGreaterThan(10)
|
||||||
|
expect(deHome.hero.headline1.title.length).toBeGreaterThan(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
'@
|
||||||
|
Write-Utf8 'tests/unit/i18n.test.ts' $unitTest
|
||||||
|
|
||||||
|
$e2eTest = @'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('sticky CTA present and i18n toggle works', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const cta = page.locator('.floatingCTA')
|
||||||
|
await expect(cta).toBeVisible()
|
||||||
|
const toggle = page.getByRole('button', { name: /switch language/i })
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await toggle.click()
|
||||||
|
await page.waitForURL(/lang=de/)
|
||||||
|
await expect(page.locator('text=Jetzt anfragen').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CLS guard on hero: cumulative layout shift stays under 0.05', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
(window as any).__cls = 0
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries() as PerformanceEntry[] & any) {
|
||||||
|
if (!entry.hadRecentInput) {
|
||||||
|
;(window as any).__cls += (entry as any).value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe({ type: 'layout-shift', buffered: true })
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
const cls = await page.evaluate(() => (window as any).__cls || 0)
|
||||||
|
expect(cls).toBeLessThan(0.05)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.skip('FAQ a11y keyboard toggling (deferred)', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
})
|
||||||
|
'@
|
||||||
|
Write-Utf8 'tests/e2e/home.spec.ts' $e2eTest
|
||||||
|
|
||||||
|
# public placeholder image
|
||||||
|
Write-Utf8 'public/hero-poster.webp' 'placeholder poster; replace with a real WebP/AVIF image of the hero.'
|
||||||
|
|
||||||
|
Write-Output 'Scaffold complete.'
|
||||||
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
/* Premium Dark/Purple Design System */
|
||||||
|
:root{
|
||||||
|
--bg-900:#0F1220; --bg-800:#13162B;
|
||||||
|
--surface-700:#1A1F3A; --surface-600:#20264A;
|
||||||
|
--text-100:#F5F7FF; --text-300:#BDC3EA; --text-500:#9AA1D2;
|
||||||
|
--primary-400:#7C5CFF; --primary-500:#6A4DFF; --primary-600:#5A3FFF;
|
||||||
|
--accent-emerald:#3CE0A0; --warning-amber:#FFC857; --error-rose:#FF5C8A;
|
||||||
|
--glass:rgba(255,255,255,0.06); --shadow:0 10px 30px rgba(0,0,0,0.35);
|
||||||
|
--radius-lg:16px; --radius-xl:22px; --radius-pill:999px;
|
||||||
|
--maxw:1200px; --px:clamp(16px,3.6vw,28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base */
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{height:100%; overflow-x:hidden}
|
||||||
|
html{scroll-behavior:smooth}
|
||||||
|
body{margin:0; background:var(--bg-900); color:var(--text-100); font-family:Inter,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6}
|
||||||
|
a{color:inherit; text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline; text-underline-offset:3px}
|
||||||
|
img{display:block; max-width:100%; height:auto}
|
||||||
|
|
||||||
|
/* Subtle noise/texture */
|
||||||
|
.site-bg{position:fixed; inset:0; pointer-events:none; z-index:-1;
|
||||||
|
background-image:radial-gradient(rgba(255,255,255,.03), transparent 40%),
|
||||||
|
repeating-linear-gradient(90deg, rgba(255,255,255,.01), rgba(255,255,255,.01) 1px, transparent 1px, transparent 2px);
|
||||||
|
mix-blend-mode:soft-light; opacity:.7}
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
:focus-visible{outline:3px solid var(--primary-500); outline-offset:2px; border-radius:10px}
|
||||||
|
|
||||||
|
/* Premium One-Page Styles */
|
||||||
|
.onepage-container{width:min(100%, var(--maxw)); margin-inline:auto; padding-inline:var(--px)}
|
||||||
|
.onepage-section{padding-block:clamp(88px, 10vw, 120px); border-top:1px solid rgba(255,255,255,.06)}
|
||||||
|
.onepage-section:first-child{border-top:none}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.onepage-nav{position:sticky; top:0; z-index:40; backdrop-filter:blur(8px) saturate(130%);
|
||||||
|
background:linear-gradient(180deg, rgba(19,22,43,.7), rgba(19,22,43,.4)); border-bottom:1px solid rgba(255,255,255,.06)}
|
||||||
|
.onepage-nav-row{display:flex; align-items:center; justify-content:space-between; height:68px; width:min(100%, var(--maxw)); margin-inline:auto; padding-inline:var(--px)}
|
||||||
|
.onepage-brand{display:flex; align-items:center; gap:12px; font-family:Poppins,system-ui,sans-serif; font-weight:800; letter-spacing:.02em}
|
||||||
|
.onepage-brand-logo{width:34px; height:34px; border-radius:10px; background:linear-gradient(135deg, var(--primary-400), var(--primary-600)); box-shadow:0 10px 24px rgba(122,92,255,.35); display:flex; align-items:center; justify-content:center; color:white; font-weight:bold}
|
||||||
|
.onepage-nav-links{display:none; gap:18px}
|
||||||
|
.onepage-nav-cta{display:none}
|
||||||
|
@media(min-width:920px){ .onepage-nav-links{display:flex} .onepage-nav-cta{display:flex; align-items:center; gap:10px} }
|
||||||
|
.onepage-link{position:relative; padding:8px 10px; border-radius:10px; color:var(--text-300); transition:color 0.2s ease}
|
||||||
|
.onepage-link:hover{color:var(--text-100); text-decoration:none}
|
||||||
|
.onepage-link.active{color:var(--primary-400)}
|
||||||
|
.onepage-link.active::after{content:""; position:absolute; left:10px; right:10px; bottom:2px; height:2px; background:var(--primary-500); border-radius:var(--radius-pill)}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.onepage-btn{display:inline-flex; align-items:center; justify-content:center; gap:10px; font-weight:700; border:1px solid rgba(255,255,255,.1); border-radius:var(--radius-pill); padding:12px 18px; cursor:pointer; transition:transform .18s ease, box-shadow .18s ease, background .18s ease; text-decoration:none}
|
||||||
|
.onepage-btn:hover{text-decoration:none}
|
||||||
|
.onepage-btn--primary{background:linear-gradient(90deg, var(--primary-500), var(--primary-400)); color:#0b0d1a; box-shadow:0 0 0 6px rgba(122,92,255,0.12)}
|
||||||
|
.onepage-btn--primary:hover{transform:translateY(-1px); box-shadow:0 0 0 6px rgba(122,92,255,0.18)}
|
||||||
|
.onepage-btn--ghost{background:transparent; color:var(--text-100); border:1px solid rgba(255,255,255,.2)}
|
||||||
|
.onepage-btn--ghost:hover{background:rgba(255,255,255,.1)}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.onepage-hero{position:relative; padding-block:clamp(88px, 12vw, 140px); isolation:isolate;}
|
||||||
|
.onepage-hero .spotlight{position:absolute; inset:-10% -10% auto -10%; height:66vh; z-index:-1;
|
||||||
|
background:radial-gradient(900px 460px at 50% 0%, rgba(122,92,255,.25), transparent 60%),
|
||||||
|
radial-gradient(780px 400px at 10% 20%, rgba(122,92,255,.12), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg-800), var(--bg-900))}
|
||||||
|
.onepage-hero-grid{display:grid; gap:clamp(20px, 4vw, 36px)}
|
||||||
|
@media(min-width:980px){ .onepage-hero-grid{grid-template-columns: 1.1fr .9fr; align-items:center} }
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
.onepage-eyebrow{display:inline-flex; gap:8px; align-items:center; padding:6px 10px; background:var(--glass); border:1px solid rgba(255,255,255,.08); border-radius:var(--radius-pill); color:var(--text-500); font-size:0.75rem; font-weight:600; text-transform:uppercase; letter-spacing:0.1em}
|
||||||
|
.onepage-h1{font-family:Poppins,system-ui,sans-serif; font-weight:800; font-size:clamp(2.8rem, 5vw + 1rem, 4rem); line-height:1.06; margin:10px 0; color:var(--text-100)}
|
||||||
|
.onepage-h2{font-family:Poppins,system-ui,sans-serif; font-weight:800; font-size:clamp(1.8rem, 2.2vw + .8rem, 2.4rem); margin:0 0 16px; color:var(--text-100)}
|
||||||
|
.onepage-h3{font-family:Poppins,system-ui,sans-serif; font-weight:700; font-size:clamp(1.2rem, 1.5vw + .5rem, 1.5rem); margin:0 0 12px; color:var(--text-100)}
|
||||||
|
.onepage-lead{max-width:60ch; color:var(--text-300); font-size:1.125rem; line-height:1.7}
|
||||||
|
.onepage-text{color:var(--text-300); line-height:1.6}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.onepage-card{background:var(--glass); border:1px solid rgba(255,255,255,.08); box-shadow:var(--shadow); border-radius:var(--radius-lg); padding:clamp(18px, 2.6vw, 26px)}
|
||||||
|
.onepage-card-hover{transition:transform 0.2s ease, box-shadow 0.2s ease}
|
||||||
|
.onepage-card-hover:hover{transform:translateY(-2px); box-shadow:0 20px 40px rgba(0,0,0,0.4)}
|
||||||
|
|
||||||
|
/* Media */
|
||||||
|
.onepage-media{position:relative; aspect-ratio:16/9; border-radius:var(--radius-xl); overflow:hidden; background:var(--surface-700);
|
||||||
|
border:1px solid rgba(255,255,255,.08); box-shadow:var(--shadow)}
|
||||||
|
.onepage-media img{width:100%; height:100%; object-fit:cover}
|
||||||
|
.onepage-play-overlay{position:absolute; inset:0; display:grid; place-items:center; background:linear-gradient(180deg, rgba(0,0,0,.25), rgba(0,0,0,.5)); cursor:pointer; transition:background 0.2s ease}
|
||||||
|
.onepage-play-overlay:hover{background:linear-gradient(180deg, rgba(0,0,0,.15), rgba(0,0,0,.4))}
|
||||||
|
.onepage-play-btn{display:inline-flex; align-items:center; gap:12px; background:linear-gradient(90deg, var(--primary-500), var(--primary-400)); color:#080a16; font-weight:800; border-radius:var(--radius-pill); padding:12px 18px; box-shadow:0 0 0 6px rgba(122,92,255,.15)}
|
||||||
|
|
||||||
|
/* Badges & Chips */
|
||||||
|
.onepage-badge{display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:var(--radius-pill); background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); color:var(--text-300); font-size:0.75rem; font-weight:500}
|
||||||
|
.onepage-chip{display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:var(--radius-pill); background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); color:var(--text-300); font-size:0.75rem; font-weight:500}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.onepage-grid{display:grid; gap:clamp(16px, 2.4vw, 24px)}
|
||||||
|
.onepage-grid--2{grid-template-columns:1fr}
|
||||||
|
.onepage-grid--3{grid-template-columns:1fr}
|
||||||
|
.onepage-grid--4{grid-template-columns:repeat(2, 1fr)}
|
||||||
|
@media(min-width:768px){ .onepage-grid--2{grid-template-columns:repeat(2, 1fr)} }
|
||||||
|
@media(min-width:900px){ .onepage-grid--3{grid-template-columns:repeat(3, 1fr)} .onepage-grid--4{grid-template-columns:repeat(4, 1fr)} }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.onepage-form-group{display:flex; flex-direction:column; gap:6px}
|
||||||
|
.onepage-label{color:var(--text-300); font-size:.95rem; font-weight:500}
|
||||||
|
.onepage-input, .onepage-select, .onepage-textarea{width:100%; border-radius:12px; border:1px solid rgba(255,255,255,.12); background:#151935; color:var(--text-100); padding:12px 12px; font:inherit; transition:border-color 0.2s ease, box-shadow 0.2s ease}
|
||||||
|
.onepage-input:focus, .onepage-select:focus, .onepage-textarea:focus{outline:none; border-color:var(--primary-400); box-shadow:0 0 0 3px rgba(122,92,255,0.1)}
|
||||||
|
.onepage-textarea{min-height:120px; resize:vertical}
|
||||||
|
.onepage-checkbox{accent-color:var(--primary-500)}
|
||||||
|
|
||||||
|
/* Testimonials */
|
||||||
|
.onepage-testimonial{position:relative; background:var(--glass); border:1px solid rgba(255,255,255,.08); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:18px}
|
||||||
|
.onepage-testimonial .quote{position:absolute; right:12px; top:12px; opacity:.15; font-size:24px; color:var(--text-300)}
|
||||||
|
.onepage-stars{color:var(--warning-amber)}
|
||||||
|
.onepage-rating{display:inline-flex; align-items:center; gap:8px; background:rgba(60,224,160,.16); color:var(--accent-emerald); border:1px solid rgba(60,224,160,.35); padding:4px 8px; border-radius:var(--radius-pill); font-weight:700; font-size:0.75rem}
|
||||||
|
|
||||||
|
/* FAQ */
|
||||||
|
.onepage-faq-item{background:var(--glass); border:1px solid rgba(255,255,255,.08); border-radius:var(--radius-lg); padding:20px}
|
||||||
|
.onepage-faq-question{font-weight:700; color:var(--text-100); margin-bottom:12px; cursor:pointer; display:flex; align-items:center; justify-content:space-between}
|
||||||
|
.onepage-faq-answer{color:var(--text-300); line-height:1.6}
|
||||||
|
.onepage-faq-toggle{transition:transform 0.2s ease; color:var(--text-500)}
|
||||||
|
.onepage-faq-item[open] .onepage-faq-toggle{transform:rotate(180deg)}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.onepage-footer{padding-block:56px; border-top:1px solid rgba(255,255,255,.06); background:linear-gradient(180deg, var(--bg-800), var(--bg-900))}
|
||||||
|
.onepage-footer-content{display:flex; flex-direction:column; gap:20px; align-items:center; justify-content:center; text-align:center}
|
||||||
|
@media(min-width:768px){ .onepage-footer-content{flex-direction:row; justify-content:space-between; text-align:left} }
|
||||||
|
.onepage-footer-text{color:var(--text-500); font-size:.9rem}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.onepage-text-center{text-align:center}
|
||||||
|
.onepage-text-gradient{background:linear-gradient(135deg, var(--primary-400), var(--accent-emerald)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text}
|
||||||
|
.onepage-flex{display:flex}
|
||||||
|
.onepage-flex-center{display:flex; align-items:center; justify-content:center}
|
||||||
|
.onepage-flex-between{display:flex; align-items:center; justify-content:space-between}
|
||||||
|
.onepage-flex-wrap{flex-wrap:wrap}
|
||||||
|
.onepage-gap-2{gap:8px}
|
||||||
|
.onepage-gap-3{gap:12px}
|
||||||
|
.onepage-gap-4{gap:16px}
|
||||||
|
.onepage-gap-6{gap:24px}
|
||||||
|
.onepage-mt-4{margin-top:16px}
|
||||||
|
.onepage-mt-6{margin-top:24px}
|
||||||
|
.onepage-mb-4{margin-bottom:16px}
|
||||||
|
.onepage-mb-6{margin-bottom:24px}
|
||||||
|
.onepage-mb-8{margin-bottom:32px}
|
||||||
|
.onepage-mb-12{margin-bottom:48px}
|
||||||
|
.onepage-mb-16{margin-bottom:64px}
|
||||||
|
|
||||||
|
/* Responsive utilities */
|
||||||
|
@media(max-width:767px){
|
||||||
|
.onepage-nav-links, .onepage-nav-cta{display:none}
|
||||||
|
.onepage-hero-grid{text-align:center}
|
||||||
|
.onepage-grid--mobile-1{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*{animation-duration:0.01ms!important; animation-iteration-count:1!important; transition-duration:0.01ms!important}
|
||||||
|
html{scroll-behavior:auto}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('sticky CTA present and i18n toggle works', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const cta = page.locator('.floatingCTA')
|
||||||
|
await expect(cta).toBeVisible()
|
||||||
|
const toggle = page.getByRole('button', { name: /switch language/i })
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await toggle.click()
|
||||||
|
await page.waitForURL(/lang=de/)
|
||||||
|
await expect(page.locator('text=Jetzt anfragen').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CLS guard on hero: cumulative layout shift stays under 0.05', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
(window as any).__cls = 0
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries() as PerformanceEntry[] & any) {
|
||||||
|
if (!entry.hadRecentInput) {
|
||||||
|
;(window as any).__cls += (entry as any).value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe({ type: 'layout-shift', buffered: true })
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
const cls = await page.evaluate(() => (window as any).__cls || 0)
|
||||||
|
expect(cls).toBeLessThan(0.05)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.skip('FAQ a11y keyboard toggling (deferred)', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { supportedLocales } from '@/lib/i18n'
|
||||||
|
|
||||||
|
describe('i18n', () => {
|
||||||
|
it('supports en and de', () => {
|
||||||
|
expect(supportedLocales).toContain('en')
|
||||||
|
expect(supportedLocales).toContain('de')
|
||||||
|
})
|
||||||
|
it('has hero strings in both locales', async () => {
|
||||||
|
const enHome = (await import('@/locales/en/home.json')).default as any
|
||||||
|
const deHome = (await import('@/locales/de/home.json')).default as any
|
||||||
|
expect(enHome.hero.headline1.title.length).toBeGreaterThan(10)
|
||||||
|
expect(deHome.hero.headline1.title.length).toBeGreaterThan(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/components/*": [
|
||||||
|
"components/*"
|
||||||
|
],
|
||||||
|
"@/styles/*": [
|
||||||
|
"styles/*"
|
||||||
|
],
|
||||||
|
"@/lib/*": [
|
||||||
|
"lib/*"
|
||||||
|
],
|
||||||
|
"@/locales/*": [
|
||||||
|
"locales/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"jest",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue