674 lines
30 KiB
PowerShell
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.'
|
|
|