feat(dynopark): pixel-perfect Figma redesign of all 6 DinoXxx sections
- DinoHero: dark bg (#1e1e1e), white text, T-Rex from /dynopark/, orange-amber circle - DinoWheel: vertical layout (info → arch → photos), 25 dinos with real /dynopark/ images, orange-framed photo gallery at bottom, responsive wheel size via useRef - DinoGallery: updated fallback images to /dynopark/ real park photos - DinoActivities: photo-overlay portrait cards, gradient, price badges, exact UA text; sm:grid-cols-2 md:grid-cols-3 (project uses --breakpoint-lg: 1440px) - DinoWhyVisit: photo carousel (Gemini park photos) replaces video default, "ЗАРАЗ МИ ДІЗНАЄМОСЯ..." label overlay, exact accordion text from Figma - DinoTickets: dark green #396817 + wave bg pattern, large price typography (64px/900), "Забронювати пригоду" CTA, working hours banner, keeps cart functionality Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f317de7b0f
commit
b02954ef77
6 changed files with 1051 additions and 565 deletions
|
|
@ -1,10 +1,13 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
|
||||
const FONT_POP = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
|
||||
|
||||
interface Activity {
|
||||
name: string
|
||||
subtitle?: string | null
|
||||
price?: string | null
|
||||
priceUnit?: string | null
|
||||
description?: string | null
|
||||
imageUrl?: string | null
|
||||
href?: string | null
|
||||
|
|
@ -17,21 +20,48 @@ interface DinoActivitiesProps {
|
|||
}
|
||||
|
||||
const DEFAULT_ACTIVITIES: Activity[] = [
|
||||
{ name: 'Звичайна екскурсія', price: '150 грн', href: '#tickets' },
|
||||
{ name: 'Палеонтологічна екскурсія', price: '300 грн', href: '#tickets' },
|
||||
{ name: 'ДиноРодео', price: '50 грн', href: '#tickets' },
|
||||
{
|
||||
name: 'Звичайна екскурсія',
|
||||
subtitle: 'дізнайтеся більше про світ диновелетнів',
|
||||
price: '150 грн',
|
||||
priceUnit: 'за 1 людину',
|
||||
description:
|
||||
'На екскурсії ви та дітлахи дізнаєтесь, які динозаври були найбільшими та найнебезпечнішими, чим вони харчувалися, як полювали і чому зникли з лиця Землі.',
|
||||
imageUrl: '/dynopark/Gemini_Generated_Image_7b985p7b985p7b98_.jpg',
|
||||
href: '#tickets',
|
||||
},
|
||||
{
|
||||
name: 'Палеонтологічна екскурсія',
|
||||
subtitle: 'відчуйте себе першовідкривачами',
|
||||
price: '300 грн',
|
||||
priceUnit: 'за 1 людину',
|
||||
description:
|
||||
'Це справжня наукова пригода! Діти візьмуть участь у розкопках, власноруч викопають справжню скамʼянілість і почують захопливі факти про динозаврів.',
|
||||
imageUrl: '/dynopark/Gemini_Generated_Image_a10736a10736a107_.jpg',
|
||||
href: '#tickets',
|
||||
},
|
||||
{
|
||||
name: 'ДиноРодео',
|
||||
subtitle: 'прокатіться на динозаврі з вітерком',
|
||||
price: '50 грн',
|
||||
priceUnit: 'за 1 людину',
|
||||
description:
|
||||
'Безпечна пригода для дітлахів — висота фігури не більше 1,6 м. Але нудьгувати не доведеться: динозавр коливається, рухає хвостом та головою.',
|
||||
imageUrl: '/dynopark/Gemini_Generated_Image_gc7t4lgc7t4lgc7t_.jpg',
|
||||
href: '#tickets',
|
||||
},
|
||||
]
|
||||
|
||||
export function DinoActivities({
|
||||
title = 'Додаткові розваги у динопарку',
|
||||
description = 'Хочете дізнатись ще більше про динозаврів? Замовте екскурсію з гідом, поринь у світ палеонтологічних розкопок або підкорюй справжнього динозавра!',
|
||||
title = 'Додаткові розваги у ДиноПарку',
|
||||
description = 'Хочете зробити пригоду ще цікавішою? Замовте екскурсію — і дізнайтесь більше про динозаврів: їхнє походження, спосіб життя та цікаві факти.',
|
||||
activities = DEFAULT_ACTIVITIES,
|
||||
}: DinoActivitiesProps) {
|
||||
if (!activities.length) return null
|
||||
|
||||
return (
|
||||
<section className="py-14 lg:py-20" style={{ background: '#f1fbeb' }}>
|
||||
<div className="mx-auto max-w-[1204px] px-8">
|
||||
<div className="mx-auto max-w-[1204px] px-6">
|
||||
<h2
|
||||
className="mb-3 text-[24px] font-bold text-[#272727] uppercase md:text-[32px]"
|
||||
style={FONT_MONT}
|
||||
|
|
@ -40,14 +70,14 @@ export function DinoActivities({
|
|||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
className="mb-10 max-w-[700px] text-[16px] leading-[1.6] text-[#444] lg:text-[18px]"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
className="mb-10 max-w-[760px] text-[16px] leading-[1.6] font-light text-[#444]"
|
||||
style={FONT_POP}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{activities.map((act, i) => (
|
||||
<ActivityCard key={i} activity={act} />
|
||||
))}
|
||||
|
|
@ -60,9 +90,12 @@ export function DinoActivities({
|
|||
function ActivityCard({ activity }: { activity: Activity }) {
|
||||
const href = activity.href ?? '#'
|
||||
return (
|
||||
<div className="group flex flex-col overflow-hidden rounded-[20px] bg-white shadow-[0_4px_30px_rgba(57,104,23,0.14)] transition-shadow hover:shadow-[0_8px_40px_rgba(57,104,23,0.22)]">
|
||||
{/* Photo area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-[#e8f5dc]">
|
||||
<div
|
||||
className="group relative flex flex-col overflow-hidden"
|
||||
style={{ borderRadius: 20, boxShadow: '0 4px 30px rgba(57,104,23,0.18)' }}
|
||||
>
|
||||
{/* Full-height photo */}
|
||||
<div className="relative" style={{ aspectRatio: '3/4' }}>
|
||||
{activity.imageUrl ? (
|
||||
<img
|
||||
src={activity.imageUrl}
|
||||
|
|
@ -71,56 +104,83 @@ function ActivityCard({ activity }: { activity: Activity }) {
|
|||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
style={{ background: '#c8e8a0' }}
|
||||
>
|
||||
<span style={{ fontSize: 72 }} aria-hidden="true">
|
||||
🦕
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Price badge */}
|
||||
|
||||
{/* Gradient overlay — bottom-heavy for text legibility */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0) 35%, rgba(0,0,0,0.55) 70%, rgba(0,0,0,0.82) 100%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Price badge — top right */}
|
||||
{activity.price && (
|
||||
<div
|
||||
className="absolute top-3 right-3 rounded-[12px] px-3 py-1.5 text-[14px] font-bold text-white"
|
||||
className="absolute top-3 right-3 rounded-[10px] px-3 py-1.5 text-[13px] font-bold text-white"
|
||||
style={{ background: '#f28b4a', ...FONT_MONT }}
|
||||
>
|
||||
{activity.price}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||
<h3 className="text-[18px] leading-[1.3] font-bold text-[#272727]" style={FONT_MONT}>
|
||||
{activity.name}
|
||||
</h3>
|
||||
{activity.description && (
|
||||
<p
|
||||
className="flex-1 text-[14px] leading-[1.6] text-[#555]"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
>
|
||||
{activity.description}
|
||||
{/* Card text — overlaid at bottom */}
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-2 p-5">
|
||||
<p className="text-[19px] leading-tight font-bold text-white" style={FONT_MONT}>
|
||||
{activity.name}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={href}
|
||||
className="mt-auto inline-flex items-center justify-center gap-2 rounded-[56px] px-6 py-[10px] text-[14px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
|
||||
backgroundSize: '200% auto',
|
||||
...FONT_MONT,
|
||||
}}
|
||||
>
|
||||
Замовити екскурсію
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 7h10M8 3l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{activity.subtitle && (
|
||||
<p className="text-[13px] leading-snug font-light text-white/80" style={FONT_POP}>
|
||||
{activity.subtitle}
|
||||
</p>
|
||||
)}
|
||||
{activity.description && (
|
||||
<p className="line-clamp-3 text-[13px] leading-[1.5] text-white/70" style={FONT_POP}>
|
||||
{activity.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1.5 rounded-[56px] px-5 py-2 text-[13px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
|
||||
backgroundSize: '200% auto',
|
||||
...FONT_MONT,
|
||||
}}
|
||||
>
|
||||
Замовити екскурсію
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 6h8M7 2.5l3.5 3.5L7 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={href}
|
||||
className="text-[13px] font-medium text-white/70 underline-offset-2 hover:underline"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
Детальніше
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ interface DinoGalleryProps {
|
|||
}
|
||||
|
||||
const FALLBACK_GALLERY = [
|
||||
'/images/figma/2c6a3e5e-7346-4c3e-b8a0-fae1facb87ad.jpg',
|
||||
'/images/figma/2936ec5e-4f99-441e-9bf2-34f23c283170.jpg',
|
||||
'/images/figma/7a2627b2-b6ce-4325-a0b1-fbc3393aca4c.png',
|
||||
'/dynopark/50182754852060_1.jpg',
|
||||
'/dynopark/4da8605d916401919bbe9cf115d8f8a5_1.jpg',
|
||||
'/dynopark/Untitled_6_2.jpg',
|
||||
'/dynopark/if-when-jurassic-world-rebirth-gets-a-se.jpg',
|
||||
]
|
||||
|
||||
export function DinoGallery({ images }: DinoGalleryProps) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { BtnPrimary } from '@/components/ui/BtnPrimary'
|
||||
|
||||
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
|
||||
const FONT_INTER = { fontFamily: 'Inter, sans-serif' }
|
||||
|
||||
const DEFAULT_FEATURES = ['Повнорозмірні анімовані динозавра', 'Реалістичні рухи та звуки']
|
||||
|
||||
|
|
@ -15,29 +16,29 @@ interface DinoHeroProps {
|
|||
}
|
||||
|
||||
export function DinoHero({
|
||||
title = 'Динопарк — портал у світ динозаврів',
|
||||
description = 'Великі динозаври, що рухаються та гарчать, справжнє роздоволлє, цікаві екскурсії та динородео — тут є все, щоб ваша дитина не нудьгувала.',
|
||||
title = 'ДиноПарк — портал у світ динозаврів',
|
||||
description = 'Великі динозаври, що рухаються та гарчать, справжні розкопки, цікаві екскурсії та динородео — тут є все, щоб ваша дитина не нудьгувала.',
|
||||
stat = '26',
|
||||
statLabel = 'унікальних експонатів',
|
||||
features = DEFAULT_FEATURES,
|
||||
heroImageUrl,
|
||||
}: DinoHeroProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden" style={{ background: '#f1fbeb' }}>
|
||||
<section className="relative overflow-hidden" style={{ background: '#1e1e1e' }}>
|
||||
{/* Left column */}
|
||||
<div className="mx-auto max-w-[1204px] px-8">
|
||||
<div className="min-h-[600px] pt-12 pb-16 lg:min-h-[960px] lg:pt-[98px] lg:pb-[60px]">
|
||||
<div className="relative z-10 flex flex-col gap-10 lg:w-[608px] lg:gap-[60px]">
|
||||
<div className="mx-auto max-w-[1440px] px-6">
|
||||
<div className="min-h-[580px] pt-12 pb-16 lg:min-h-[780px] lg:pt-[80px] lg:pb-[60px]">
|
||||
<div className="relative z-10 flex flex-col gap-10 lg:w-[600px] lg:gap-[48px]">
|
||||
{/* Text */}
|
||||
<div className="flex flex-col gap-6 lg:gap-[38px]">
|
||||
<div className="flex flex-col gap-5 lg:gap-8">
|
||||
<h1
|
||||
className="text-[36px] leading-[1.15] font-bold text-[#272727] uppercase lg:text-[64px]"
|
||||
className="text-[36px] leading-[1.1] font-bold text-white uppercase lg:text-[64px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className="text-[16px] leading-[1.5] font-medium text-[#272727] lg:text-[24px]"
|
||||
className="text-[16px] leading-[1.6] font-medium text-white/80 lg:text-[24px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{description}
|
||||
|
|
@ -48,34 +49,35 @@ export function DinoHero({
|
|||
</div>
|
||||
|
||||
{/* Stat badge + feature list */}
|
||||
<div className="flex flex-col gap-4 lg:gap-[41px]">
|
||||
<div className="flex flex-col gap-4 lg:gap-[32px]">
|
||||
{/* "26 унікальних експонатів" pill */}
|
||||
<div className="relative flex items-center">
|
||||
<div
|
||||
className="z-10 flex flex-none items-center justify-center rounded-full text-white"
|
||||
className="relative z-10 flex flex-none items-center justify-center rounded-full text-white"
|
||||
style={{
|
||||
width: 'clamp(80px, 11.11vw, 160px)',
|
||||
height: 'clamp(80px, 11.11vw, 160px)',
|
||||
width: 'clamp(72px, 8.33vw, 120px)',
|
||||
height: 'clamp(72px, 8.33vw, 120px)',
|
||||
background: '#396817',
|
||||
border: 'clamp(10px, 1.39vw, 20px) solid #fdf2e8',
|
||||
marginRight: 'clamp(-101px, -7.01vw, -40px)',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: 'clamp(8px, 1.04vw, 15px) solid rgba(253,242,232,0.15)',
|
||||
marginRight: 'clamp(-80px, -5.21vw, -30px)',
|
||||
...FONT_INTER,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
fontSize: 'clamp(20px, 3.47vw, 50px)',
|
||||
fontSize: 'clamp(22px, 3.13vw, 45px)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{stat}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 rounded-[20px] text-[16px] leading-[1.3] font-medium text-white lg:text-[24px]"
|
||||
className="flex-1 rounded-[16px] text-[15px] leading-[1.3] font-medium text-white lg:text-[20px]"
|
||||
style={{
|
||||
background: '#396817',
|
||||
...FONT_MONT,
|
||||
paddingTop: 'clamp(24px, 2.85vw, 41px)',
|
||||
paddingBottom: 'clamp(24px, 2.85vw, 41px)',
|
||||
paddingLeft: 'clamp(56px, 10.28vw, 148px)',
|
||||
paddingRight: '24px',
|
||||
...FONT_INTER,
|
||||
fontWeight: 500,
|
||||
paddingTop: 'clamp(18px, 2.08vw, 30px)',
|
||||
paddingBottom: 'clamp(18px, 2.08vw, 30px)',
|
||||
paddingLeft: 'clamp(48px, 7.81vw, 112px)',
|
||||
paddingRight: '20px',
|
||||
}}
|
||||
>
|
||||
{statLabel}
|
||||
|
|
@ -85,11 +87,12 @@ export function DinoHero({
|
|||
{features.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-[20px] text-[16px] leading-[1.3] font-medium text-white lg:text-[24px]"
|
||||
className="rounded-[16px] text-[15px] leading-[1.3] font-medium text-white lg:text-[20px]"
|
||||
style={{
|
||||
background: '#396817',
|
||||
...FONT_MONT,
|
||||
padding: 'clamp(24px, 3vw, 41px) clamp(32px, 2.7vw, 39px)',
|
||||
...FONT_INTER,
|
||||
fontWeight: 500,
|
||||
padding: 'clamp(18px, 2.08vw, 30px) clamp(24px, 2.08vw, 30px)',
|
||||
}}
|
||||
>
|
||||
{f}
|
||||
|
|
@ -100,50 +103,50 @@ export function DinoHero({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — hero image on yellow circle */}
|
||||
{/* Right panel — hero image on orange circle */}
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 right-0 hidden h-full overflow-hidden lg:block"
|
||||
className="pointer-events-none absolute top-0 right-0 hidden h-full overflow-visible lg:flex lg:items-end"
|
||||
aria-hidden="true"
|
||||
style={{ width: '58vw' }}
|
||||
style={{ width: '52vw' }}
|
||||
>
|
||||
{/* Yellow circle background */}
|
||||
{/* Orange-amber circle background */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: '130%',
|
||||
width: '115%',
|
||||
aspectRatio: '1',
|
||||
background: 'radial-gradient(circle at 40% 40%, #f7d060 0%, #f0b429 70%)',
|
||||
left: '-15%',
|
||||
top: '-10%',
|
||||
background: 'radial-gradient(circle at 38% 38%, #fdcf54 0%, #f28b4a 55%, #e06d2a 100%)',
|
||||
right: '-10%',
|
||||
bottom: '-10%',
|
||||
}}
|
||||
/>
|
||||
{/* Hero dino image */}
|
||||
<img
|
||||
src={heroImageUrl ?? '/images/figma/081e52b5-d35a-41d2-b506-a9d751b0b563.png'}
|
||||
src={heroImageUrl ?? '/dynopark/T-Rex_2_1.jpg'}
|
||||
alt="Динозавр динопарку"
|
||||
className="absolute"
|
||||
className="relative z-10 w-full"
|
||||
style={{
|
||||
left: '5%',
|
||||
top: '-15%',
|
||||
width: '115%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'bottom',
|
||||
objectPosition: 'bottom center',
|
||||
maxHeight: '110%',
|
||||
}}
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile hero */}
|
||||
<div className="relative mx-auto max-w-[420px] px-8 pb-8 lg:hidden" aria-hidden="true">
|
||||
<div className="relative mx-auto max-w-[400px] px-6 pb-8 lg:hidden" aria-hidden="true">
|
||||
<div
|
||||
className="mx-auto flex aspect-square items-center justify-center rounded-full"
|
||||
style={{ background: 'radial-gradient(circle at 40% 40%, #f7d060 0%, #f0b429 70%)' }}
|
||||
className="mx-auto flex aspect-square items-end justify-center overflow-hidden rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at 38% 38%, #fdcf54 0%, #f28b4a 55%, #e06d2a 100%)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={heroImageUrl ?? '/images/figma/081e52b5-d35a-41d2-b506-a9d751b0b563.png'}
|
||||
src={heroImageUrl ?? '/dynopark/T-Rex_2_1.jpg'}
|
||||
alt=""
|
||||
className="w-full object-contain"
|
||||
className="w-full object-contain object-bottom"
|
||||
style={{ maxHeight: '100%' }}
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|||
import { useCart } from '@/context/CartContext'
|
||||
|
||||
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
|
||||
const FONT_POP = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
|
||||
|
||||
interface Tariff {
|
||||
id: number
|
||||
|
|
@ -12,74 +13,66 @@ interface Tariff {
|
|||
categoryTag: string
|
||||
icon?: string | null
|
||||
sort: number
|
||||
notes?: string | null
|
||||
priceUnit?: string | null
|
||||
}
|
||||
|
||||
function TicketCard({ tariff }: { tariff: Tariff }) {
|
||||
const { addItem } = useCart()
|
||||
const [count, setCount] = useState(1)
|
||||
const [added, setAdded] = useState(false)
|
||||
|
||||
function handleAdd() {
|
||||
for (let i = 0; i < count; i++) {
|
||||
addItem({
|
||||
tariffId: String(tariff.id),
|
||||
name: tariff.name,
|
||||
price: tariff.price,
|
||||
categoryTag: tariff.categoryTag,
|
||||
icon: tariff.icon ?? undefined,
|
||||
})
|
||||
}
|
||||
function handleBook() {
|
||||
addItem({
|
||||
tariffId: String(tariff.id),
|
||||
name: tariff.name,
|
||||
price: tariff.price,
|
||||
categoryTag: tariff.categoryTag,
|
||||
icon: tariff.icon ?? undefined,
|
||||
})
|
||||
setAdded(true)
|
||||
setCount(1)
|
||||
setTimeout(() => setAdded(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
|
||||
style={{ background: '#fdf2e8' }}
|
||||
className="flex flex-col rounded-[20px] p-6"
|
||||
style={{ background: '#fdf2e8', boxShadow: '0 4px 30px rgba(0,0,0,0.12)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
{tariff.icon && <span className="text-[28px]">{tariff.icon}</span>}
|
||||
<div className="border-t border-[#c8e8b0]" />
|
||||
{/* Category name */}
|
||||
<p
|
||||
className="mb-3 text-[12px] font-bold tracking-widest text-[#f28b4a] uppercase"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{tariff.name}
|
||||
</p>
|
||||
|
||||
{/* Price */}
|
||||
<p
|
||||
className="text-[52px] leading-none font-black text-[#272727] lg:text-[64px]"
|
||||
style={{ ...FONT_MONT, fontWeight: 900 }}
|
||||
>
|
||||
{tariff.price} грн
|
||||
</p>
|
||||
|
||||
{/* Unit */}
|
||||
<p className="mt-1 mb-3 text-[14px] text-[#555]" style={FONT_POP}>
|
||||
{tariff.priceUnit ?? 'за 1 людину'}
|
||||
</p>
|
||||
|
||||
{/* Notes */}
|
||||
{tariff.notes && (
|
||||
<p
|
||||
className="text-[32px] leading-[1.3] font-black text-[#272727] lg:text-[40px]"
|
||||
style={FONT_MONT}
|
||||
className="mb-4 text-[13px] leading-[1.5] whitespace-pre-line text-[#555]"
|
||||
style={FONT_POP}
|
||||
>
|
||||
{tariff.price} ₴
|
||||
{tariff.notes}
|
||||
</p>
|
||||
<p className="text-[14px] leading-[1.5] font-bold text-[#272727]" style={FONT_MONT}>
|
||||
{tariff.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-3 pt-5">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.max(1, c - 1))}
|
||||
aria-label="Зменшити кількість"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80"
|
||||
style={{ background: '#f28b4a' }}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span
|
||||
className="min-w-[28px] text-center text-[18px] font-bold text-[#272727]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.min(10, c + 1))}
|
||||
aria-label="Збільшити кількість"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80"
|
||||
style={{ background: '#f28b4a' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="w-full rounded-[56px] py-[10px] text-[15px] font-bold transition-all"
|
||||
onClick={handleBook}
|
||||
className="w-full rounded-[56px] py-[11px] text-[15px] font-bold transition-all"
|
||||
style={{
|
||||
...FONT_MONT,
|
||||
background: added
|
||||
|
|
@ -89,7 +82,7 @@ function TicketCard({ tariff }: { tariff: Tariff }) {
|
|||
backgroundSize: '200% auto',
|
||||
}}
|
||||
>
|
||||
{added ? '✓ Додано' : '+ До кошика'}
|
||||
{added ? '✓ Додано до кошика' : 'Забронювати пригоду'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,12 +92,13 @@ function TicketCard({ tariff }: { tariff: Tariff }) {
|
|||
function SkeletonCard() {
|
||||
return (
|
||||
<div
|
||||
className="flex animate-pulse flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
|
||||
style={{ background: '#fdf2e8', minHeight: 180 }}
|
||||
className="flex animate-pulse flex-col rounded-[20px] p-6"
|
||||
style={{ background: '#fdf2e8', minHeight: 200 }}
|
||||
>
|
||||
<div className="mx-auto mb-3 h-4 w-3/4 rounded bg-[#e8d8c4]" />
|
||||
<div className="mx-auto mb-2 h-8 w-1/2 rounded bg-[#e8d8c4]" />
|
||||
<div className="mx-auto mt-auto h-4 w-2/3 rounded bg-[#e8d8c4]" />
|
||||
<div className="mb-3 h-3 w-2/3 rounded bg-[#e8d8c4]" />
|
||||
<div className="mb-2 h-14 w-3/4 rounded bg-[#e8d8c4]" />
|
||||
<div className="mb-4 h-3 w-1/3 rounded bg-[#e8d8c4]" />
|
||||
<div className="mt-auto h-11 rounded-[56px] bg-[#e8d8c4]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -136,39 +130,47 @@ export function DinoTickets({
|
|||
|
||||
return (
|
||||
<section id="tickets" className="relative overflow-hidden">
|
||||
{/* Green background */}
|
||||
<div className="absolute inset-0 z-0" style={{ background: '#396817' }} />
|
||||
|
||||
{/* Topographic wave pattern */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 rounded-tl-[20px] rounded-tr-[20px]"
|
||||
style={{ background: '#396817' }}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage: `url('/images/figma/card-wave-green.svg')`,
|
||||
backgroundSize: '280px auto',
|
||||
backgroundRepeat: 'repeat',
|
||||
opacity: 0.22,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-[1204px] px-8 pt-0 pb-16">
|
||||
<div className="relative z-10 mx-auto max-w-[1204px] px-6 pt-0 pb-16">
|
||||
{/* Working hours banner */}
|
||||
<div
|
||||
className="mb-10 flex flex-col items-center justify-center gap-2 overflow-hidden rounded-b-[20px] px-8 py-5 text-center lg:flex-row lg:gap-6"
|
||||
className="mb-12 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-b-[20px] px-6 py-5 text-center lg:flex-row lg:gap-8"
|
||||
style={{ background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 50%, #f28b4a 100%)' }}
|
||||
>
|
||||
<p
|
||||
className="text-[20px] leading-[1.4] font-bold text-[#272727] uppercase lg:text-[26px]"
|
||||
className="text-[18px] font-bold tracking-wide text-[#272727] uppercase lg:text-[22px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
Час роботи
|
||||
</p>
|
||||
<p
|
||||
className="text-[16px] leading-[1.4] font-bold text-[#272727] lg:text-[22px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
<p className="text-[15px] font-medium text-[#272727] lg:text-[18px]" style={FONT_MONT}>
|
||||
{workingHours}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Single tickets */}
|
||||
<h3
|
||||
className="mb-6 text-[22px] leading-[1.4] font-bold text-white uppercase lg:text-[28px]"
|
||||
<h2
|
||||
className="mb-6 text-[22px] font-bold text-white uppercase lg:text-[32px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
Вартість квитка
|
||||
</h3>
|
||||
<div className="mb-10 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
</h2>
|
||||
<div className="mb-12 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
|
||||
: dynoTariffs.map((t) => <TicketCard key={t.id} tariff={t} />)}
|
||||
|
|
@ -177,16 +179,16 @@ export function DinoTickets({
|
|||
{/* Combo */}
|
||||
{!loading && comboTariffs.length > 0 && (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col gap-2">
|
||||
<h3
|
||||
className="text-[22px] leading-[1.4] font-bold text-white uppercase lg:text-[28px]"
|
||||
<div className="mb-4">
|
||||
<h2
|
||||
className="text-[22px] font-bold text-white uppercase lg:text-[32px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
Комбо
|
||||
</h3>
|
||||
</h2>
|
||||
<p
|
||||
className="text-[16px] leading-[1.4] font-semibold text-white/80 lg:text-[20px]"
|
||||
style={FONT_MONT}
|
||||
className="mt-2 text-[15px] leading-[1.5] text-white/80 lg:text-[18px]"
|
||||
style={FONT_POP}
|
||||
>
|
||||
{comboDescription}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
|
||||
|
||||
const WAVE_BG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Ccircle cx='80' cy='80' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='80' cy='80' r='40' fill='none' stroke='rgba(255,255,255,0.03)' stroke-width='1'/%3E%3Ccircle cx='0' cy='0' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='160' cy='0' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='0' cy='160' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='160' cy='160' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3C/svg%3E")`
|
||||
const FONT_POP = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
|
||||
|
||||
interface DinoSpec {
|
||||
name: string
|
||||
epoch?: string | null
|
||||
latinName?: string | null
|
||||
length?: string | null
|
||||
weight?: string | null
|
||||
height?: string | null
|
||||
imageUrl?: string | null
|
||||
thumbnailUrl?: string | null
|
||||
}
|
||||
|
|
@ -20,276 +19,626 @@ interface DinoWheelProps {
|
|||
dinos?: DinoSpec[]
|
||||
}
|
||||
|
||||
// 25 dinosaurs from Figma with actual /dynopark/ image paths
|
||||
const FALLBACK: DinoSpec[] = [
|
||||
{ name: 'Тиранозавр Рекс', epoch: 'Крейдяний', length: '12 м', weight: '7 т' },
|
||||
{ name: 'Карнотавр', epoch: 'Крейдяний', length: '8 м', weight: '1.5 т' },
|
||||
{ name: 'Трицератопс', epoch: 'Крейдяний', length: '9 м', weight: '12 т' },
|
||||
{ name: 'Велоцираптор', epoch: 'Крейдяний', length: '2 м', weight: '15 кг' },
|
||||
{ name: 'Спінозавр', epoch: 'Крейдяний', length: '15 м', weight: '20 т' },
|
||||
{ name: 'Птеранодон', epoch: 'Юрський', length: '2.5 м', weight: '20 кг' },
|
||||
{ name: 'Брахіозавр', epoch: 'Юрський', length: '26 м', weight: '56 т' },
|
||||
{ name: 'Анкілозавр', epoch: 'Крейдяний', length: '8 м', weight: '7 т' },
|
||||
{
|
||||
name: 'Тиранозавр Рекс',
|
||||
latinName: 'Гігантська Версія',
|
||||
length: '20 м.',
|
||||
height: '7 м.',
|
||||
imageUrl: '/dynopark/T-Rex_2_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Барионікс',
|
||||
length: '7 м.',
|
||||
height: '2,5 м.',
|
||||
imageUrl: null,
|
||||
},
|
||||
{
|
||||
name: 'Овіраптор',
|
||||
length: '3 м.',
|
||||
imageUrl: '/dynopark/Oviraptor_TD_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Спінозавр',
|
||||
length: '15 м.',
|
||||
height: '5,8 м.',
|
||||
imageUrl: '/dynopark/has-anyone-tried-incubating-and-releasin.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Карнотавр',
|
||||
length: '10 м.',
|
||||
height: '3,2 м.',
|
||||
imageUrl: '/dynopark/carnotaurus_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Ділофозавр на скелі',
|
||||
length: '6 м.',
|
||||
imageUrl: '/dynopark/dilophosaurus_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Брахіозавр',
|
||||
length: '25 м.',
|
||||
height: '10 м.',
|
||||
imageUrl: '/dynopark/brachiosaurus_2.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Пара Брахіозаврів',
|
||||
latinName: 'Закохані',
|
||||
length: '12 м.',
|
||||
height: '6 м.',
|
||||
imageUrl: '/dynopark/brachiosaurus_2.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Ураноза́вр',
|
||||
length: '6 м.',
|
||||
height: '2,2 м.',
|
||||
imageUrl: '/dynopark/ouranosaurus_nigeriensis_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Стиракозавр',
|
||||
length: '6 м.',
|
||||
height: '2,2 м.',
|
||||
imageUrl: '/dynopark/styracosaurus_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Космоцератопс',
|
||||
latinName: 'Пара Закоханих',
|
||||
length: '5 м.',
|
||||
height: '2,5 м.',
|
||||
imageUrl: null,
|
||||
},
|
||||
{
|
||||
name: 'Стегозавр',
|
||||
length: '15 м.',
|
||||
height: '7 м.',
|
||||
imageUrl: '/dynopark/stegosaurus_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Анкілозавр',
|
||||
length: '8 м.',
|
||||
height: '2,6 м.',
|
||||
imageUrl: '/dynopark/ankylosaurus_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Паразавролоф',
|
||||
length: '7 м.',
|
||||
height: '2,2 м.',
|
||||
imageUrl: '/dynopark/favorite-parasaurolophus-design-v0-0supu.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Паразавролоф',
|
||||
latinName: 'Сімейство',
|
||||
length: '6 м.',
|
||||
height: '2 м.',
|
||||
imageUrl: '/dynopark/favorite-parasaurolophus-design-v0-0supu.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Велоцираптор',
|
||||
length: '4 м.',
|
||||
height: '1,7 м.',
|
||||
imageUrl: '/dynopark/velociraptor_1_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Кетцалькоатль',
|
||||
height: '6,5 м.',
|
||||
imageUrl: null,
|
||||
},
|
||||
{
|
||||
name: 'Птеродактиль',
|
||||
latinName: 'з гніздом та яйцями',
|
||||
height: '2 м.',
|
||||
imageUrl: '/dynopark/1697108661_poknok-art-p-pteranodoni-51_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Яйця Динозаврів',
|
||||
height: '1,5 м.',
|
||||
imageUrl: '/dynopark/pngtree-3d-baby-dinosaur-nesting-out-of-.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Трицератопс',
|
||||
length: '6 м.',
|
||||
height: '2,45 м.',
|
||||
imageUrl: '/dynopark/202007_Triceratops_horridus.svg_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Тиранозавр',
|
||||
latinName: 'для ДиноРодео',
|
||||
length: '4 м.',
|
||||
height: '1,6 м.',
|
||||
imageUrl: '/dynopark/tyrannosaurus_rex_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Карнотавр',
|
||||
latinName: 'для ДиноРодео',
|
||||
length: '3,5 м.',
|
||||
height: '1,5 м.',
|
||||
imageUrl: '/dynopark/carnotaurus_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Тиранозавр',
|
||||
latinName: 'Версія 2',
|
||||
length: '4 м.',
|
||||
height: '1,8 м.',
|
||||
imageUrl: '/dynopark/tyrannosaurus_rex_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Велоцираптор',
|
||||
latinName: 'Версія 2',
|
||||
length: '4 м.',
|
||||
height: '1,8 м.',
|
||||
imageUrl: '/dynopark/velociraptor_1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Тиранозавр Рекс',
|
||||
latinName: 'Рев до Небес',
|
||||
length: '6 м.',
|
||||
height: '2,8 м.',
|
||||
imageUrl: '/dynopark/1675801348_grizly-club-p-klipart-tiranno.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
const DINO_EMOJIS = ['🦖', '🦕', '🦖', '🦕', '🦖', '🦕', '🦕', '🦖']
|
||||
const DINO_EMOJIS = [
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦕',
|
||||
'🦖',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦕',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🦅',
|
||||
'🦅',
|
||||
'🥚',
|
||||
'🦖',
|
||||
'🦖',
|
||||
'🦖',
|
||||
'🦖',
|
||||
'🦖',
|
||||
]
|
||||
|
||||
// ── Gallery photos at the bottom of the wheel section ─────────────────────
|
||||
const GALLERY_PHOTOS = [
|
||||
{ src: '/dynopark/50182754852060_1.jpg', alt: 'Динопарк — парк динозаврів' },
|
||||
{ src: '/dynopark/Untitled_6_2.jpg', alt: 'Динопарк — фото парку' },
|
||||
{ src: '/dynopark/if-when-jurassic-world-rebirth-gets-a-se.jpg', alt: 'Динопарк — відвідувачі' },
|
||||
]
|
||||
|
||||
// ── Wheel SVG ──────────────────────────────────────────────────────────────
|
||||
function DinoWheelSVG({ rotation, n, size }: { rotation: number; n: number; size: number }) {
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const outerR = size * 0.485
|
||||
const innerR = size * 0.295
|
||||
|
||||
const dividers: { x1: number; y1: number; x2: number; y2: number }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const rad = ((i / n) * 360 * Math.PI) / 180
|
||||
dividers.push({
|
||||
x1: cx + innerR * Math.cos(rad),
|
||||
y1: cy + innerR * Math.sin(rad),
|
||||
x2: cx + outerR * Math.cos(rad),
|
||||
y2: cy + outerR * Math.sin(rad),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
transition: 'transform 0.72s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
willChange: 'transform',
|
||||
display: 'block',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`wg-${n}`}
|
||||
x1="0"
|
||||
y1={cy}
|
||||
x2={size}
|
||||
y2={cy}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.029" stopColor="#F28B4A" />
|
||||
<stop offset="0.466" stopColor="#FDCF54" />
|
||||
<stop offset="1" stopColor="#F28D4B" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Outer shadow ring */}
|
||||
<circle cx={cx} cy={cy} r={outerR + 6} fill="rgba(0,0,0,0.15)" />
|
||||
{/* Donut body */}
|
||||
<circle cx={cx} cy={cy} r={outerR} fill={`url(#wg-${n})`} />
|
||||
{/* Inner hole */}
|
||||
<circle cx={cx} cy={cy} r={innerR} fill="#2a4418" />
|
||||
{/* Segment dividers */}
|
||||
{dividers.map((d, i) => (
|
||||
<line key={i} x1={d.x1} y1={d.y1} x2={d.x2} y2={d.y2} stroke="#396817" strokeWidth="2.5" />
|
||||
))}
|
||||
{/* Inner ring accent */}
|
||||
<circle cx={cx} cy={cy} r={innerR + 2} stroke="rgba(255,255,255,0.1)" strokeWidth="1.5" />
|
||||
<circle cx={cx} cy={cy} r={outerR - 2} stroke="rgba(255,255,255,0.08)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
export function DinoWheel({ dinos }: DinoWheelProps) {
|
||||
const items = dinos && dinos.length > 0 ? dinos : FALLBACK
|
||||
const n = items.length
|
||||
const [active, setActive] = useState(0)
|
||||
const [visible, setVisible] = useState(true)
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [wheelAngle, setWheelAngle] = useState<number>(270)
|
||||
const activeRef = useRef(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const goTo = useCallback((i: number) => {
|
||||
setVisible(false)
|
||||
setTimeout(() => {
|
||||
setActive(i)
|
||||
setVisible(true)
|
||||
}, 220)
|
||||
}, [])
|
||||
|
||||
const resetTimer = useCallback(
|
||||
(next: number) => {
|
||||
if (timer.current) clearInterval(timer.current)
|
||||
timer.current = setInterval(() => {
|
||||
setVisible(false)
|
||||
setTimeout(() => {
|
||||
setActive((p) => {
|
||||
const nx = (p + 1) % n
|
||||
return nx
|
||||
})
|
||||
setVisible(true)
|
||||
}, 220)
|
||||
}, 4000)
|
||||
goTo(next)
|
||||
},
|
||||
[n, goTo]
|
||||
)
|
||||
// Responsive wheel size
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [wheelSize, setWheelSize] = useState(820)
|
||||
|
||||
useEffect(() => {
|
||||
timer.current = setInterval(() => {
|
||||
setVisible(false)
|
||||
setTimeout(() => {
|
||||
setActive((p) => (p + 1) % n)
|
||||
setVisible(true)
|
||||
}, 220)
|
||||
}, 4000)
|
||||
return () => {
|
||||
if (timer.current) clearInterval(timer.current)
|
||||
function measure() {
|
||||
if (containerRef.current) {
|
||||
const w = containerRef.current.offsetWidth
|
||||
setWheelSize(Math.min(820, Math.max(340, w)))
|
||||
}
|
||||
}
|
||||
measure()
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [])
|
||||
|
||||
const WHEEL_SIZE = wheelSize
|
||||
const DINO_RADIUS = WHEEL_SIZE * 0.39
|
||||
const DINO_ACTIVE_SIZE = Math.round(WHEEL_SIZE * 0.1)
|
||||
const DINO_INACTIVE_SIZE = Math.round(WHEEL_SIZE * 0.065)
|
||||
|
||||
const angleForItem = useCallback((i: number) => 270 - (i / n) * 360, [n])
|
||||
|
||||
const goTo = useCallback(
|
||||
(nextIdx: number) => {
|
||||
setVisible(false)
|
||||
const target = angleForItem(nextIdx)
|
||||
setWheelAngle((current) => {
|
||||
const currentNorm = ((current % 360) + 360) % 360
|
||||
const targetNorm = ((target % 360) + 360) % 360
|
||||
let diff = targetNorm - currentNorm
|
||||
if (diff > 180) diff -= 360
|
||||
if (diff < -180) diff += 360
|
||||
return current + diff
|
||||
})
|
||||
setTimeout(() => {
|
||||
setActive(nextIdx)
|
||||
activeRef.current = nextIdx
|
||||
setVisible(true)
|
||||
}, 240)
|
||||
},
|
||||
[angleForItem]
|
||||
)
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = setInterval(() => {
|
||||
const next = (activeRef.current + 1) % n
|
||||
goTo(next)
|
||||
}, 4500)
|
||||
}, [n, goTo])
|
||||
|
||||
useEffect(() => {
|
||||
startTimer()
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [n])
|
||||
|
||||
function handleSelect(i: number) {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
goTo(i)
|
||||
setTimeout(startTimer, 8000)
|
||||
}
|
||||
|
||||
const current = items[active]!
|
||||
|
||||
// Arc positions: center at (50%, 80%) of wheel container, radius ~42%
|
||||
// Angles from 200° to 340° (CSS coords: 0=right, 90=down)
|
||||
// Items placed in a "smile" arc below center
|
||||
const getArcPos = (i: number) => {
|
||||
const startDeg = 200
|
||||
const spanDeg = 140
|
||||
const deg = startDeg + (i / Math.max(n - 1, 1)) * spanDeg
|
||||
const rad = (deg * Math.PI) / 180
|
||||
// Percentage offset from center
|
||||
const cx = 50 // %
|
||||
const cy = 32 // % from top — center of the circle
|
||||
const r = 38 // % radius
|
||||
const x = cx + r * Math.cos(rad)
|
||||
const y = cy + r * Math.sin(rad)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
background: `${WAVE_BG}, #2a4418`,
|
||||
backgroundSize: '160px 160px, cover',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-[1204px] px-6 py-12 lg:py-20">
|
||||
{/* Desktop: two-column layout */}
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-center lg:gap-12">
|
||||
{/* Left: info card */}
|
||||
<div className="flex-none lg:w-[380px]">
|
||||
<div
|
||||
className="rounded-[24px] p-8 shadow-[0_8px_40px_rgba(0,0,0,0.3)]"
|
||||
style={{ background: 'rgba(255,255,255,0.97)' }}
|
||||
>
|
||||
<p
|
||||
className="mb-1 text-[13px] font-semibold tracking-[0.12em] text-[#396817] uppercase"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
Мешканець парку
|
||||
</p>
|
||||
<h2
|
||||
className="mb-6 text-[24px] leading-[1.15] font-black text-[#272727] uppercase lg:text-[30px]"
|
||||
style={{
|
||||
...FONT_MONT,
|
||||
transition: 'opacity 0.22s ease',
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{current.name}
|
||||
</h2>
|
||||
<section className="relative overflow-hidden" style={{ background: '#2a4418' }}>
|
||||
{/* Topographic wave bg */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `url('/images/figma/card-wave-green.svg')`,
|
||||
backgroundSize: '320px auto',
|
||||
backgroundRepeat: 'repeat',
|
||||
opacity: 0.25,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
style={{ transition: 'opacity 0.22s ease', opacity: visible ? 1 : 0 }}
|
||||
>
|
||||
{current.epoch && <StatRow label="Епоха" value={current.epoch} />}
|
||||
{current.length && <StatRow label="Довжина" value={current.length} />}
|
||||
{current.weight && <StatRow label="Вага" value={current.weight} />}
|
||||
</div>
|
||||
|
||||
{/* Navigation dots */}
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => resetTimer(i)}
|
||||
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
|
||||
style={{ background: i === active ? '#396817' : '#c8e6b0' }}
|
||||
aria-label={items[i]!.name}
|
||||
aria-current={i === active ? true : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Info panel — centered, above the wheel ── */}
|
||||
<div className="relative pt-12 pb-0 lg:pt-16">
|
||||
<div
|
||||
className="mx-auto flex flex-col items-center gap-3 text-center"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
transition: 'opacity 0.24s ease',
|
||||
opacity: visible ? 1 : 0,
|
||||
minHeight: 90,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[26px] leading-tight font-bold text-white uppercase lg:text-[36px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{current.name}
|
||||
</p>
|
||||
{current.latinName && (
|
||||
<p className="text-[14px] text-white/65 lg:text-[16px]" style={FONT_POP}>
|
||||
({current.latinName})
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-6 text-[16px] text-white/80 lg:text-[18px]"
|
||||
style={FONT_POP}
|
||||
>
|
||||
{current.length && (
|
||||
<span>
|
||||
<span className="text-white/50">Довжина</span>{' '}
|
||||
<span className="font-bold text-white">{current.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{current.length && current.height && <span className="text-white/30">|</span>}
|
||||
{current.height && (
|
||||
<span>
|
||||
<span className="text-white/50">Висота</span>{' '}
|
||||
<span className="font-bold text-white">{current.height}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: wheel */}
|
||||
<div className="relative flex-1">
|
||||
{/* Circular wheel container */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{ width: '100%', maxWidth: 500, aspectRatio: '1' }}
|
||||
>
|
||||
{/* Outer ring decoration */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
border: '2px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
inset: '12%',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
/>
|
||||
{/* ── Wheel ── */}
|
||||
<div ref={containerRef} className="relative mx-auto w-full max-w-[820px] overflow-hidden">
|
||||
{/* Arch container: shows top half of the circle */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: WHEEL_SIZE,
|
||||
height: WHEEL_SIZE / 2,
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
aria-label="Колесо динозаврів"
|
||||
>
|
||||
{/* Full circle positioned so center is at bottom of container */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: WHEEL_SIZE,
|
||||
height: WHEEL_SIZE,
|
||||
}}
|
||||
>
|
||||
<DinoWheelSVG rotation={wheelAngle} n={n} size={WHEEL_SIZE} />
|
||||
|
||||
{/* Center: active dino image */}
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
inset: '22%',
|
||||
transition: 'opacity 0.22s ease',
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{current.imageUrl ? (
|
||||
<img
|
||||
src={current.imageUrl}
|
||||
alt={current.name}
|
||||
className="h-full w-full object-contain drop-shadow-2xl"
|
||||
style={{ filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.5))' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'clamp(64px, 12vw, 110px)',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{DINO_EMOJIS[active % DINO_EMOJIS.length]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{items.map((dino, i) => {
|
||||
const naturalDeg = (i / n) * 360
|
||||
const isActive = i === active
|
||||
const iconSize = isActive ? DINO_ACTIVE_SIZE : DINO_INACTIVE_SIZE
|
||||
|
||||
{/* Arc thumbnails */}
|
||||
{items.map((dino, i) => {
|
||||
const { x, y } = getArcPos(i)
|
||||
const isActive = i === active
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => resetTimer(i)}
|
||||
aria-label={dino.name}
|
||||
aria-current={isActive ? true : undefined}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(i)}
|
||||
aria-label={dino.name}
|
||||
aria-current={isActive ? true : undefined}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: WHEEL_SIZE / 2,
|
||||
top: WHEEL_SIZE / 2,
|
||||
width: 0,
|
||||
height: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
zIndex: isActive ? 20 : 10,
|
||||
transform: `rotate(${naturalDeg + wheelAngle}deg)`,
|
||||
transformOrigin: '0 0',
|
||||
transition: 'transform 0.72s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: isActive ? 'clamp(52px, 8vw, 72px)' : 'clamp(40px, 6vw, 56px)',
|
||||
height: isActive ? 'clamp(52px, 8vw, 72px)' : 'clamp(40px, 6vw, 56px)',
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
border: isActive ? '3px solid #f5c842' : '2px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: isActive
|
||||
? '0 0 0 3px rgba(245,200,66,0.4), 0 4px 16px rgba(0,0,0,0.4)'
|
||||
: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
background: '#1e3610',
|
||||
cursor: 'pointer',
|
||||
zIndex: isActive ? 10 : 5,
|
||||
left: -iconSize / 2,
|
||||
top: -(DINO_RADIUS + iconSize / 2),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
transform: `rotate(${-(naturalDeg + wheelAngle)}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
transition: [
|
||||
'transform 0.72s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'width 0.3s ease',
|
||||
'height 0.3s ease',
|
||||
'top 0.3s ease',
|
||||
'left 0.3s ease',
|
||||
].join(', '),
|
||||
willChange: 'transform',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{dino.thumbnailUrl ? (
|
||||
{dino.imageUrl ? (
|
||||
<img
|
||||
src={dino.thumbnailUrl}
|
||||
src={dino.imageUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
filter: isActive
|
||||
? 'drop-shadow(0 2px 8px rgba(0,0,0,0.8)) brightness(1.05)'
|
||||
: 'brightness(0.04) saturate(0)',
|
||||
opacity: 1,
|
||||
transition: 'filter 0.35s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
fontSize: isActive ? 28 : 22,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
fontSize: isActive
|
||||
? Math.round(iconSize * 0.6)
|
||||
: Math.round(iconSize * 0.55),
|
||||
lineHeight: 1,
|
||||
filter: isActive ? 'none' : 'grayscale(1) brightness(0.1)',
|
||||
transition: 'filter 0.35s ease, font-size 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{DINO_EMOJIS[i % DINO_EMOJIS.length]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile: dino name below wheel */}
|
||||
<p
|
||||
className="mt-4 text-center text-[18px] font-bold text-white uppercase lg:hidden"
|
||||
style={{ ...FONT_MONT, transition: 'opacity 0.22s ease', opacity: visible ? 1 : 0 }}
|
||||
>
|
||||
{current.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Prev / dots / Next ── */}
|
||||
<div className="relative flex items-center justify-center gap-4 py-4">
|
||||
<button
|
||||
onClick={() => handleSelect((active - 1 + n) % n)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-colors"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.25)', color: '#fff' }}
|
||||
aria-label="Попередній динозавр"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 2.5L4.5 7L9 11.5"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(i)}
|
||||
aria-label={items[i]!.name}
|
||||
aria-current={i === active ? true : undefined}
|
||||
style={{
|
||||
width: i === active ? 20 : 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
background: i === active ? '#fdcf54' : 'rgba(255,255,255,0.25)',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
transition: 'width 0.3s ease, background 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelect((active + 1) % n)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-colors"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.25)', color: '#fff' }}
|
||||
aria-label="Наступний динозавр"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5 2.5L9.5 7L5 11.5"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Photo gallery row ── */}
|
||||
<div className="relative mx-auto grid max-w-[1204px] grid-cols-3 gap-3 px-4 pb-12 lg:gap-5 lg:pb-16">
|
||||
{GALLERY_PHOTOS.map((photo, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="overflow-hidden"
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
border: '3px solid #f28b4a',
|
||||
aspectRatio: '3 / 4',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.alt}
|
||||
className="h-full w-full object-cover transition-transform duration-500 hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile name list ── */}
|
||||
<div className="relative mx-auto grid max-w-[1204px] grid-cols-2 gap-2 px-4 pb-6 sm:grid-cols-3 lg:hidden">
|
||||
{items.map((dino, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(i)}
|
||||
className="flex items-center gap-2 rounded-[10px] px-3 py-2 text-left text-[11px] font-semibold transition-all"
|
||||
style={{
|
||||
background: i === active ? 'rgba(253,207,84,0.18)' : 'rgba(255,255,255,0.06)',
|
||||
color: i === active ? '#fdcf54' : 'rgba(255,255,255,0.6)',
|
||||
border: i === active ? '1px solid rgba(253,207,84,0.6)' : '1px solid transparent',
|
||||
...FONT_MONT,
|
||||
}}
|
||||
aria-current={i === active ? true : undefined}
|
||||
>
|
||||
<span aria-hidden="true" style={{ fontSize: 13 }}>
|
||||
{DINO_EMOJIS[i % DINO_EMOJIS.length]}
|
||||
</span>
|
||||
<span className="truncate">{dino.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-[#f1fbeb] px-4 py-2.5">
|
||||
<span
|
||||
className="text-[13px] font-semibold tracking-wide text-[#396817] uppercase"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[16px] font-bold text-[#272727]" style={FONT_MONT}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
'use client'
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
|
||||
const FONT_POP = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
|
||||
|
||||
interface ReviewPhoto {
|
||||
src: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
interface ReviewVideo {
|
||||
src: string
|
||||
|
|
@ -14,40 +21,62 @@ interface DinoWhyVisitProps {
|
|||
title?: string
|
||||
items?: Array<{ title: string; description: string }>
|
||||
reviewVideos?: ReviewVideo[]
|
||||
reviewPhotos?: ReviewPhoto[]
|
||||
}
|
||||
|
||||
const DEFAULT_ITEMS = [
|
||||
{
|
||||
title: 'Навчання через гру',
|
||||
description:
|
||||
'Дітки дізнаються про стародавніх тварин через захопливі ігри та інтерактивні вправи з гідом.',
|
||||
"Через гру дитина краще запам'ятовує факти. Вона може вивчати не лише історію про динозаврів, а й нові іноземні слова, розуміти науку та захоплюватись природою.",
|
||||
},
|
||||
{
|
||||
title: 'Дитячі очі, що палають захватом',
|
||||
description:
|
||||
'Реалістичні рухи та звуки динозаврів створюють ефект повного занурення — дитина точно не забуде цього дня.',
|
||||
"Здивування та вау-ефект, коли дитина побачить цих велетнів, неможливо передати. Це щирі емоції радості та справжнього щастя, які закарбуються у пам'яті назавжди.",
|
||||
},
|
||||
{
|
||||
title: 'Неймовірні фотографії',
|
||||
description:
|
||||
'Сфотографуйтесь поруч із улюбленим динозавром або зробіть фото з екскурсоводом — тепла згадка для всієї родини.',
|
||||
'На локації багато зон, де можна зробити красиві фотографії не лише на згадку, а й для соцмереж. Кожен знімок — окремий маленький шедевр.',
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_REVIEW_PHOTOS: ReviewPhoto[] = [
|
||||
{
|
||||
src: '/dynopark/Gemini_Generated_Image_l7a8tql7a8tql7a8_.jpg',
|
||||
label: 'ЗАРАЗ МИ ДІЗНАЄМОСЯ...',
|
||||
},
|
||||
{
|
||||
src: '/dynopark/Gemini_Generated_Image_mlbfxbmlbfxbmlbf_.jpg',
|
||||
label: 'ЗАРАЗ МИ ДІЗНАЄМОСЯ...',
|
||||
},
|
||||
{
|
||||
src: '/dynopark/Gemini_Generated_Image_novmrqnovmrqnovm_.jpg',
|
||||
label: 'ЗАРАЗ МИ ДІЗНАЄМОСЯ...',
|
||||
},
|
||||
{
|
||||
src: '/dynopark/Gemini_Generated_Image_r9d5kbr9d5kbr9d5_.jpg',
|
||||
label: 'ЗАРАЗ МИ ДІЗНАЄМОСЯ...',
|
||||
},
|
||||
]
|
||||
|
||||
export function DinoWhyVisit({
|
||||
title = 'Чому варто відвідати динопарк',
|
||||
title = 'Чому варто відвідати Динопарк',
|
||||
items = DEFAULT_ITEMS,
|
||||
reviewVideos,
|
||||
reviewPhotos,
|
||||
}: DinoWhyVisitProps) {
|
||||
const videos = reviewVideos && reviewVideos.length > 0 ? reviewVideos : []
|
||||
const vn = videos.length
|
||||
const photos = reviewPhotos && reviewPhotos.length > 0 ? reviewPhotos : DEFAULT_REVIEW_PHOTOS
|
||||
const vn = photos.length
|
||||
const [openIndex, setOpenIndex] = useState(0)
|
||||
const [videoActive, setVideoActive] = useState(0)
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
|
||||
const videoPausedRef = useRef(false)
|
||||
const [photoActive, setPhotoActive] = useState(0)
|
||||
const accordionTimer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const videoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const photoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const hovering = useRef(false)
|
||||
|
||||
// Videos take precedence over photos if provided
|
||||
const hasVideos = reviewVideos && reviewVideos.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
accordionTimer.current = setInterval(() => {
|
||||
|
|
@ -59,16 +88,14 @@ export function DinoWhyVisit({
|
|||
}, [items.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (vn <= 0) return
|
||||
videoTimer.current = setInterval(() => {
|
||||
if (!videoPausedRef.current) {
|
||||
setVideoActive((prev) => (prev + 1) % vn)
|
||||
}
|
||||
}, 5000)
|
||||
if (vn <= 0 || hasVideos) return
|
||||
photoTimer.current = setInterval(() => {
|
||||
if (!hovering.current) setPhotoActive((prev) => (prev + 1) % vn)
|
||||
}, 3800)
|
||||
return () => {
|
||||
if (videoTimer.current) clearInterval(videoTimer.current)
|
||||
if (photoTimer.current) clearInterval(photoTimer.current)
|
||||
}
|
||||
}, [vn])
|
||||
}, [vn, hasVideos])
|
||||
|
||||
function handleItemClick(i: number) {
|
||||
setOpenIndex(i)
|
||||
|
|
@ -78,74 +105,60 @@ export function DinoWhyVisit({
|
|||
}, 4000)
|
||||
}
|
||||
|
||||
function handlePlayVideo(i: number) {
|
||||
if (playingIndex === i) return
|
||||
if (playingIndex !== null && videoRefs.current[playingIndex]) {
|
||||
videoRefs.current[playingIndex]!.pause()
|
||||
videoRefs.current[playingIndex]!.currentTime = 0
|
||||
}
|
||||
videoPausedRef.current = true
|
||||
setPlayingIndex(i)
|
||||
setVideoActive(i)
|
||||
setTimeout(() => {
|
||||
videoRefs.current[i]?.play()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleVideoNav(i: number) {
|
||||
if (playingIndex !== null && videoRefs.current[playingIndex]) {
|
||||
videoRefs.current[playingIndex]!.pause()
|
||||
videoRefs.current[playingIndex]!.currentTime = 0
|
||||
}
|
||||
setPlayingIndex(null)
|
||||
videoPausedRef.current = false
|
||||
setVideoActive(i)
|
||||
function handlePhotoNav(i: number) {
|
||||
setPhotoActive(i)
|
||||
if (photoTimer.current) clearInterval(photoTimer.current)
|
||||
photoTimer.current = setInterval(() => {
|
||||
if (!hovering.current) setPhotoActive((prev) => (prev + 1) % vn)
|
||||
}, 3800)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-[20px] md:py-[60px] lg:py-[40px]" style={{ background: '#f1fbeb' }}>
|
||||
<div className="mx-auto max-w-[1204px] px-8">
|
||||
<section className="py-[40px] md:py-[60px]" style={{ background: '#f1fbeb' }}>
|
||||
<div className="mx-auto max-w-[1204px] px-6">
|
||||
<h2
|
||||
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
|
||||
className="mb-8 text-[24px] font-bold text-[#272727] uppercase md:mb-12 md:text-[32px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col items-start gap-16 lg:flex-row lg:items-start">
|
||||
{/* Accordion */}
|
||||
<div className="flex flex-col items-start gap-10 lg:flex-row lg:items-start lg:gap-16">
|
||||
{/* ── Accordion (left) ── */}
|
||||
<div className="relative w-full flex-none lg:w-auto">
|
||||
<div className="absolute top-8 left-0 hidden h-[488px] w-[333px] rounded-[30px] bg-[#396817] lg:block" />
|
||||
<div className="relative flex flex-col gap-6 lg:ml-[76px] lg:min-h-[560px]">
|
||||
{/* Green decorative block behind accordion */}
|
||||
<div className="absolute top-6 left-0 hidden h-[calc(100%-32px)] w-[72px] rounded-[24px] bg-[#396817] lg:block" />
|
||||
<div className="relative flex flex-col gap-5 lg:ml-[60px] lg:min-h-[480px]">
|
||||
{items.map((item, i) => {
|
||||
const isOpen = openIndex === i
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleItemClick(i)}
|
||||
className="flex w-full flex-col gap-2.5 rounded-[10px] bg-[#f1fbeb] px-[50px] py-[20px] text-left shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] transition-all duration-200 lg:w-[628px]"
|
||||
className="flex w-full flex-col gap-2 rounded-[12px] px-5 py-4 text-left shadow-[0_4px_40px_0_rgba(242,139,74,0.22)] transition-all duration-200 lg:w-[580px] lg:px-8"
|
||||
style={{ background: '#f1fbeb' }}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="flex-1 text-[20px] leading-tight font-bold text-[#272727]"
|
||||
className="flex-1 text-[17px] leading-tight font-bold text-[#272727] lg:text-[20px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<svg
|
||||
width="19"
|
||||
width="18"
|
||||
height="10"
|
||||
viewBox="0 0 19 10"
|
||||
viewBox="0 0 18 10"
|
||||
fill="none"
|
||||
className="flex-none transition-transform duration-200"
|
||||
className="flex-none transition-transform duration-250"
|
||||
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M1 1L9.5 9L18 1"
|
||||
d="M1 1L9 9L17 1"
|
||||
stroke="#f28b4a"
|
||||
strokeWidth="2"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -155,8 +168,8 @@ export function DinoWhyVisit({
|
|||
>
|
||||
<div className="overflow-hidden">
|
||||
<p
|
||||
className="pt-2 text-[16px] leading-[1.6] font-light text-[#272727]"
|
||||
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
|
||||
className="pt-2 text-[15px] leading-[1.65] font-light text-[#444]"
|
||||
style={FONT_POP}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
|
|
@ -168,131 +181,189 @@ export function DinoWhyVisit({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video carousel (only if videos provided) */}
|
||||
{vn > 0 && (
|
||||
<div className="w-full flex-1">
|
||||
{/* ── Right side: photo carousel or video carousel ── */}
|
||||
<div className="w-full flex-1">
|
||||
{hasVideos ? (
|
||||
/* Video carousel (when provided by CMS) */
|
||||
<VideoCarousel videos={reviewVideos} />
|
||||
) : (
|
||||
/* Photo carousel with "ЗАРАЗ МИ ДІЗНАЄМОСЯ..." label */
|
||||
<div
|
||||
className="relative mx-auto max-w-[600px] overflow-hidden rounded-[20px] lg:mx-0"
|
||||
style={{ aspectRatio: '4/3' }}
|
||||
onMouseEnter={() => {
|
||||
videoPausedRef.current = true
|
||||
hovering.current = true
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (playingIndex === null) videoPausedRef.current = false
|
||||
hovering.current = false
|
||||
}}
|
||||
>
|
||||
{videos.map((v, i) => {
|
||||
const isActive = i === videoActive
|
||||
const isPlaying = i === playingIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute inset-0 transition-opacity duration-500 ${isActive ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<video
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
src={v.src}
|
||||
poster={v.poster ?? undefined}
|
||||
className="h-full w-full object-cover"
|
||||
playsInline
|
||||
controls={isPlaying}
|
||||
preload="none"
|
||||
onEnded={() => {
|
||||
setPlayingIndex(null)
|
||||
videoPausedRef.current = false
|
||||
}}
|
||||
/>
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={() => handlePlayVideo(i)}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors hover:bg-black/25"
|
||||
aria-label={`Відтворити відео ${i + 1}`}
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-white/90 shadow-xl transition-transform hover:scale-110">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="ml-1"
|
||||
>
|
||||
<path d="M5 3L19 12L5 21V3Z" fill="#396817" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-6">
|
||||
<button
|
||||
onClick={() => handleVideoNav((videoActive - 1 + vn) % vn)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
|
||||
aria-label="Попереднє відео"
|
||||
<div
|
||||
className="relative mx-auto overflow-hidden lg:mx-0"
|
||||
style={{ borderRadius: 20, aspectRatio: '4/5', maxWidth: 340 }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M11 4L6 9L11 14"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex gap-2.5">
|
||||
{videos.map((_, i) => (
|
||||
{photos.map((photo, i) => {
|
||||
const isActive = i === photoActive
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute inset-0 transition-opacity duration-500 ${isActive ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={isActive ? `Враження від динопарку ${i + 1}` : ''}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Label overlay */}
|
||||
{photo.label && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex items-center justify-center py-3"
|
||||
style={{
|
||||
background: 'rgba(57,104,23,0.82)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[12px] font-bold tracking-wider text-white uppercase lg:text-[14px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
{photo.label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Photo nav dots */}
|
||||
<div className="mt-5 flex justify-center gap-2.5 lg:justify-start">
|
||||
{photos.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleVideoNav(i)}
|
||||
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
|
||||
style={{ background: i === videoActive ? '#396817' : '#b8d8a0' }}
|
||||
aria-label={`Відео ${i + 1}`}
|
||||
aria-current={i === videoActive ? true : undefined}
|
||||
onClick={() => handlePhotoNav(i)}
|
||||
className="h-2.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: i === photoActive ? 20 : 10,
|
||||
background: i === photoActive ? '#396817' : '#b8d8a0',
|
||||
}}
|
||||
aria-label={`Фото ${i + 1}`}
|
||||
aria-current={i === photoActive ? true : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVideoNav((videoActive + 1) % vn)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
|
||||
aria-label="Наступне відео"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 4L12 9L7 14"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom quote */}
|
||||
<div
|
||||
className="mt-12 w-full rounded-[20px] px-8 py-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)] lg:mt-16 lg:px-10"
|
||||
className="mt-12 w-full rounded-[20px] border border-[#f28b4a]/40 px-6 py-5 lg:mt-16 lg:px-10"
|
||||
style={{ background: '#f1fbeb' }}
|
||||
>
|
||||
<p
|
||||
className="text-center text-[16px] leading-[1.5] font-medium text-[#272727] lg:text-[20px]"
|
||||
className="text-center text-[15px] leading-[1.6] font-medium text-[#272727] lg:text-[20px]"
|
||||
style={FONT_MONT}
|
||||
>
|
||||
Запросіть традицію щоразу знайомитись з новим динозавром або щоразу фотографуватись біля
|
||||
улюбленого динозавра. З часом ці знімки складуться у захопливий ковток улюблених / назад
|
||||
— тепла згадка для всієї родини.
|
||||
Започаткуйте традицію: щотижня знайомтеся з новим диногероєм або щороку фотографуйтесь
|
||||
біля улюбленого динозавра. З часом ці знімки складуться у захопливу колекцію — тепла
|
||||
згадка для всієї родини.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Video carousel (when CMS provides videos) ─────────────────────────────
|
||||
function VideoCarousel({
|
||||
videos,
|
||||
}: {
|
||||
videos: { src: string; poster?: string | null; label?: string | null }[]
|
||||
}) {
|
||||
const n = videos.length
|
||||
const [active, setActive] = useState(0)
|
||||
const [playing, setPlaying] = useState<number | null>(null)
|
||||
const refs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const paused = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (n <= 0) return
|
||||
const t = setInterval(() => {
|
||||
if (!paused.current) setActive((p) => (p + 1) % n)
|
||||
}, 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [n])
|
||||
|
||||
function handlePlay(i: number) {
|
||||
if (playing === i) return
|
||||
if (playing !== null && refs.current[playing]) {
|
||||
refs.current[playing]!.pause()
|
||||
refs.current[playing]!.currentTime = 0
|
||||
}
|
||||
paused.current = true
|
||||
setPlaying(i)
|
||||
setActive(i)
|
||||
setTimeout(() => refs.current[i]?.play(), 50)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mx-auto max-w-[340px] overflow-hidden lg:mx-0"
|
||||
style={{ borderRadius: 20, aspectRatio: '4/5' }}
|
||||
onMouseEnter={() => {
|
||||
paused.current = true
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (playing === null) paused.current = false
|
||||
}}
|
||||
>
|
||||
{videos.map((v, i) => {
|
||||
const isActive = i === active
|
||||
const isPlaying = i === playing
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute inset-0 transition-opacity duration-500 ${isActive ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<video
|
||||
ref={(el) => {
|
||||
refs.current[i] = el
|
||||
}}
|
||||
src={v.src}
|
||||
poster={v.poster ?? undefined}
|
||||
className="h-full w-full object-cover"
|
||||
playsInline
|
||||
controls={isPlaying}
|
||||
preload="none"
|
||||
onEnded={() => {
|
||||
setPlaying(null)
|
||||
paused.current = false
|
||||
}}
|
||||
/>
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={() => handlePlay(i)}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors hover:bg-black/25"
|
||||
aria-label={`Відтворити відео ${i + 1}`}
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-xl transition-transform hover:scale-110">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="ml-1"
|
||||
>
|
||||
<path d="M5 3L19 12L5 21V3Z" fill="#396817" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue