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

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