feat(dyvolis): pixel-perfect update from Figma — video reviews, quote block, ticket redesign
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- Hero: bg #fdf2e8 → #f1fbeb (matches Figma page root)
- WhyVisit: replace auto-scroll photo gallery with single-card video-review
  carousel (poster + play btn, prev/next + dots, 5s auto-advance); add
  closing quote block per Figma node 4:202
- Tickets: first ticket relabelled "Вхід до ДиноПарку" per Figma; card bg
  #f1fbeb → #fdf2e8 (cream); combo subtitle order matches Figma node 4:160

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 12:35:46 +01:00
parent 1dab458ef9
commit 098339e23e
4 changed files with 116 additions and 77 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View file

@ -11,7 +11,7 @@ const TIPS = [
export function DyvoLisHero() {
return (
<section className="relative overflow-hidden" style={{ background: '#fdf2e8' }}>
<section className="relative overflow-hidden" style={{ background: '#f1fbeb' }}>
{/* Left column — contained within max-width */}
<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]">

View file

@ -10,7 +10,7 @@ interface TicketCard {
}
const SINGLE_TICKETS: TicketCard[] = [
{ label: 'Вхід до ДивоЛісу', price: '300 грн', per: 'за 1 людину' },
{ label: 'Вхід до ДиноПарку', price: '300 грн', per: 'за 1 людину' },
{
label: 'Звичайна екскурсія',
price: '150 грн',
@ -47,7 +47,7 @@ function Ticket({ label, price, per, note }: TicketCard) {
return (
<div
className="flex flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#f1fbeb' }}
style={{ background: '#fdf2e8' }}
>
<div className="flex flex-col gap-2 text-center">
{label && (
@ -139,7 +139,7 @@ export function DyvoLisTickets() {
className="text-[16px] leading-[1.4] font-semibold text-white/80 lg:text-[20px]"
style={FONT_MONT}
>
ДивоЛіс із казковими топіарними фігурами + ДиноПарк + Дзеркальний лабіринт
Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">

View file

@ -3,6 +3,9 @@
import { useState, useRef, useEffect } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const IMG_PLAY = '/images/figma/btn-video-play.svg'
const ITEMS = [
{
title: 'Простір для спільної фантазії',
@ -21,73 +24,59 @@ const ITEMS = [
},
]
const GALLERY = [
'/images/dyvolis/gallery-1.jpg',
'/images/dyvolis/gallery-2.jpg',
'/images/dyvolis/gallery-3.jpg',
'/images/dyvolis/gallery-4.jpg',
const VIDEO_REVIEWS = [
{ poster: '/images/review-video-poster.jpg' },
{ poster: '/images/review-video-poster.jpg' },
{ poster: '/images/review-video-poster.jpg' },
]
export function DyvoLisWhyVisit() {
const [openIndex, setOpenIndex] = useState(0)
const galleryRef = useRef<HTMLDivElement>(null)
const galleryPausedRef = useRef(false)
const galleryRafRef = useRef<number | null>(null)
const autoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const doubled = [...GALLERY, ...GALLERY]
const [videoActive, setVideoActive] = useState(0)
const videoPausedRef = useRef(false)
const accordionTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const videoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const vn = VIDEO_REVIEWS.length
useEffect(() => {
autoTimer.current = setInterval(() => {
accordionTimer.current = setInterval(() => {
setOpenIndex((prev) => (prev + 1) % ITEMS.length)
}, 4000)
return () => {
if (autoTimer.current) clearInterval(autoTimer.current)
if (accordionTimer.current) clearInterval(accordionTimer.current)
}
}, [])
useEffect(() => {
videoTimer.current = setInterval(() => {
if (!videoPausedRef.current) {
setVideoActive((prev) => (prev + 1) % vn)
}
}, 5000)
return () => {
if (videoTimer.current) clearInterval(videoTimer.current)
}
}, [vn])
function handleItemClick(i: number) {
setOpenIndex(i)
if (autoTimer.current) clearInterval(autoTimer.current)
autoTimer.current = setInterval(() => {
if (accordionTimer.current) clearInterval(accordionTimer.current)
accordionTimer.current = setInterval(() => {
setOpenIndex((prev) => (prev + 1) % ITEMS.length)
}, 4000)
}
useEffect(() => {
const el = galleryRef.current
if (!el) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
let last = performance.now()
const tick = (now: number) => {
const dt = now - last
last = now
if (!galleryPausedRef.current) {
const half = el.scrollWidth / 2
if (half > 0) {
el.scrollLeft += dt * 0.04
if (el.scrollLeft >= half) el.scrollLeft -= half
}
}
galleryRafRef.current = requestAnimationFrame(tick)
}
galleryRafRef.current = requestAnimationFrame(tick)
return () => {
if (galleryRafRef.current) cancelAnimationFrame(galleryRafRef.current)
}
}, [])
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]" style={{ background: '#f1fbeb' }}>
<div className="mx-auto max-w-[1204px] px-8">
<h2
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
Чому варто відвідати ДивоЛіс
</h2>
<div className="flex flex-col items-start gap-16 lg:flex-row lg:items-stretch">
<div className="flex flex-col items-start gap-16 lg:flex-row lg:items-start">
{/* Left: accordion */}
<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" />
@ -105,7 +94,7 @@ export function DyvoLisWhyVisit() {
<div className="flex items-center gap-5">
<span
className="flex-1 text-[20px] leading-tight font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
{item.title}
</span>
@ -144,58 +133,108 @@ export function DyvoLisWhyVisit() {
</div>
</div>
{/* Right: auto-scroll gallery — desktop */}
<div className="hidden flex-1 overflow-hidden lg:block">
{/* Right: video review carousel */}
<div className="w-full flex-1">
<div
ref={galleryRef}
className="flex gap-5 overflow-x-auto"
style={{ scrollbarWidth: 'none' }}
className="relative mx-auto max-w-[505px] overflow-hidden rounded-[20px] lg:mx-0"
style={{ aspectRatio: '505/546' }}
onMouseEnter={() => {
galleryPausedRef.current = true
videoPausedRef.current = true
}}
onMouseLeave={() => {
galleryPausedRef.current = false
videoPausedRef.current = false
}}
>
{doubled.map((src, i) => (
{VIDEO_REVIEWS.map((v, i) => (
<div
key={i}
className="relative h-[488px] w-[340px] flex-none overflow-hidden rounded-[20px]"
className={`absolute inset-0 transition-opacity duration-500 ${i === videoActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
aria-hidden={i !== videoActive}
>
<img
src={src}
alt=""
aria-hidden="true"
className="h-full w-full object-cover transition-transform duration-700 ease-out hover:scale-110"
loading={i < 2 ? 'eager' : 'lazy'}
src={v.poster}
alt={i === videoActive ? 'Відгук про ДивоЛіс' : ''}
className="h-full w-full object-cover"
loading={i === 0 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
</div>
{/* Mobile: horizontal scroll */}
<div
className="flex w-full gap-5 overflow-x-auto pb-2 lg:hidden"
style={{ scrollbarWidth: 'none' }}
>
{GALLERY.map((src, i) => (
<div
key={i}
className="flex-none overflow-hidden rounded-[20px]"
style={{ width: '280px', height: '200px' }}
>
{/* Play button overlay */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<img
src={src}
src={IMG_PLAY}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
loading="lazy"
className="h-[100px] w-[100px] drop-shadow-[0px_4px_125px_#171b24]"
/>
</div>
))}
</div>
{/* Carousel navigation */}
<div className="mt-6 flex items-center justify-center gap-6">
<button
onClick={() => setVideoActive((p) => (p - 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="Попереднє відео"
>
<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">
{VIDEO_REVIEWS.map((_, i) => (
<button
key={i}
onClick={() => setVideoActive(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}
/>
))}
</div>
<button
onClick={() => setVideoActive((p) => (p + 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>
{/* Quote block — Figma node 4:202 */}
<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"
style={{ background: '#f1fbeb' }}
>
<p
className="text-center text-[16px] font-medium leading-[1.5] text-[#272727] lg:text-[20px]"
style={FONT_MONT}
>
Це простір, де ви разом із дитиною створюєте власний магічний світ, вчитеся помічати
дива у звичайних речах та розвиваєте творчу уяву через спільну гру. Хто знає, можлива
саме ця прогулянка спонукає вже дорослу дитину написати казкову історію, яка стане
бестселером.
</p>
</div>
</div>
</section>
)