MVp
This commit is contained in:
85
components/AnimatedFAQ.tsx
Normal file
85
components/AnimatedFAQ.tsx
Normal 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
9
components/BadgeRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
components/DogstudioAnimations.tsx
Normal file
202
components/DogstudioAnimations.tsx
Normal 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
208
components/FeatureCards.tsx
Normal 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
36
components/Hero.tsx
Normal 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 & Pickpocket</span>
|
||||
<h1 className="h1 hero__title">{d.headline1.title}</h1>
|
||||
<p className="hero__lead">{d.headline1.sub}</p>
|
||||
<div className="h-stack mt-4">
|
||||
<Link href="/contact#booking" className="btn btn--primary">{dict.common.cta.bookNow}</Link>
|
||||
<Link href="/showreel" className="btn btn--ghost">{dict.common.cta.watchShowreel}</Link>
|
||||
</div>
|
||||
<div className="stack mt-6">
|
||||
<div className="badge badge--accent">Seen on: SAT.1 • WDR • ZDF • Amazon Prime Video</div>
|
||||
<div className="badge">5/5 stars, 12 reviews, 20+ bookings, 100% recommendation; since 12/2022 on Eventpeppers.</div>
|
||||
<div className="badge">Location: 42699 Solingen, travel radius >1000 km</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero__media sparkle">
|
||||
<Image src="https://images.eventpeppers.com/sites/default/files/imagecache/profile-picture/images/13234/michael-peskov-magier-taschendieb-453624.jpeg" alt="Michael Peskov – Magier & Taschendieb" priority width={1200} height={1200} sizes="(max-width: 960px) 100vw, 50vw" className="cover" />
|
||||
<div className="hero__badge">Live reactions • Close-up & Stage</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
components/LanguageToggle.tsx
Normal file
21
components/LanguageToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
components/MagneticButton.tsx
Normal file
109
components/MagneticButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
components/ParallaxLayer.tsx
Normal file
61
components/ParallaxLayer.tsx
Normal 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
161
components/PinnedStory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
318
components/ScrollAnimations.tsx
Normal file
318
components/ScrollAnimations.tsx
Normal 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
175
components/ScrollHero.tsx
Normal 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
200
components/SocialProof.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
components/VideoPlayer.tsx
Normal file
77
components/VideoPlayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
components/VideoShowcase.tsx
Normal file
142
components/VideoShowcase.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user