This commit is contained in:
Timo Knuth 2025-08-17 13:21:01 +02:00
commit c988d51438
51 changed files with 5742 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -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*

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"codium.codeCompletion.enable": false
}

42
Dockerfile Normal file
View File

@ -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"]

113
SCROLL_SETUP.md Normal file
View File

@ -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

View File

@ -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 ProfiZauberkünstler &amp; 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. ProfiOrganisation, 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 &gt; 1000 km</div>
<div className="badge">SAT.1 WDR ZDF Amazon Prime Video</div>
</aside>
</div>
</div>
</section>
)
}

View File

@ -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, TaschendiebShow, 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>Closeup Magie</strong> als <strong>Walking Act</strong>,
interaktiver <strong>Bühnenzauber</strong> oder eine verblüffende <strong>TaschendiebShow</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, OutdoorBü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">EventPlan 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">📧 EMail: 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 CloseUp 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 (&gt;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>Closeup Magie</strong>,
eine verblüffende <strong>TaschendiebShow</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>CloseUp Magie</strong>, <strong>Walking Act</strong>, <strong>Bühnenzauber</strong> oder die
<strong> TaschendiebShow</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>
)
}

View File

@ -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>
)
}

View File

@ -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 &amp; 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 PickpocketTricks.
</p>
</article>
<article className="card">
<h3 className="h3">2. Bühnenshow (interaktiv &amp; 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. TaschendiebDarbietung (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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

50
app/globals.css Normal file
View File

@ -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;
}
}

60
app/layout.tsx Normal file
View File

@ -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>
)
}

797
app/page.tsx Normal file
View File

@ -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: ["2040 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 6090 Minuten (walking/tischweise) je nach Gästezahl; Bühne 2040 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>
)
}

View File

@ -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}</>
}

View File

@ -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>
)
}

9
components/BadgeRow.tsx Normal file
View File

@ -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>
)
}

View File

@ -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
}

208
components/FeatureCards.tsx Normal file
View File

@ -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>
)
}

36
components/Hero.tsx Normal file
View File

@ -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 &amp; 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 &gt;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 &amp; Stage</div>
</div>
</div>
</section>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

161
components/PinnedStory.tsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

175
components/ScrollHero.tsx Normal file
View File

@ -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>
)
}

200
components/SocialProof.tsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

18
docker-compose.yml Normal file
View File

@ -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

19
jest.config.ts Normal file
View File

@ -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

110
lib/animateSplit.ts Normal file
View File

@ -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
})
}

15
lib/i18n.ts Normal file
View File

@ -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;
}

10
lib/scroll.ts Normal file
View File

@ -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() { }

5
locales/de/common.json Normal file
View File

@ -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"}
}

13
locales/de/home.json Normal file
View File

@ -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"}
}

5
locales/en/common.json Normal file
View File

@ -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"}
}

13
locales/en/home.json Normal file
View File

@ -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"}
}

5
next-env.d.ts vendored Normal file
View File

@ -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.

37
next.config.mjs Normal file
View File

@ -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;

52
nginx/default.conf Normal file
View File

@ -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;
}
}

36
package.json Normal file
View File

@ -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"
}
}

19
playwright.config.ts Normal file
View File

@ -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'] } }
]
})

1
public/hero-poster.webp Normal file
View File

@ -0,0 +1 @@
placeholder poster; replace with a real WebP/AVIF image of the hero.

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

673
scaffold.ps1 Normal file
View File

@ -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.'

157
styles/premium.css Normal file
View File

@ -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}
}

33
tests/e2e/home.spec.ts Normal file
View File

@ -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('/')
})

14
tests/unit/i18n.test.ts Normal file
View File

@ -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)
})
})

55
tsconfig.json Normal file
View File

@ -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"
]
}

1246
web/index.html Normal file

File diff suppressed because it is too large Load Diff