michaelpeskov/scaffold.ps1

674 lines
30 KiB
PowerShell

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