feat: update components and add useAutoScroll hook
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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-10 16:58:34 +01:00
parent cca4ea1d55
commit e644ee899a
9 changed files with 424 additions and 152 deletions

View file

@ -22,16 +22,46 @@ interface HeaderClientProps {
siteName: string
}
/**
* Liquid-glass header.
* - sits over the hero (page uses `-mt-[120px]` on hero so header overlays it)
* - max width 1204 (Figma frame is 1204 inside 1920 viewport, centered)
* - height 120 desktop / 60 mobile
* - rounded bottom corners 20px
* - real backdrop-filter blur + saturate + a subtle dark-green tint
* so the foliage behind shows through but text stays readable
*/
export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps) {
const [menuOpen, setMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-50 px-[10px]">
<div className="mx-auto max-w-[1204px] backdrop-blur-md bg-[rgba(34,62,13,0.85)] rounded-b-[20px]">
<div className="flex items-center justify-between h-[60px] md:h-[120px] px-[30px]">
<div
className="relative mx-auto max-w-[1204px] overflow-hidden rounded-b-[20px]"
style={{
// Liquid-glass: heavy blur + saturate + thin dark-green wash.
// Webkit prefix kept for Safari < 18.
backgroundColor: 'rgba(34, 62, 13, 0.42)',
backdropFilter: 'blur(22px) saturate(160%)',
WebkitBackdropFilter: 'blur(22px) saturate(160%)',
boxShadow:
'inset 0 1px 0 0 rgba(255,255,255,0.18), 0 1px 0 0 rgba(34,62,13,0.18)',
}}
>
{/* Highlight sheen (top edge) — gives the glass-y look from Figma */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-[34px]"
style={{
background:
'linear-gradient(to bottom, rgba(255,255,255,0.10), rgba(255,255,255,0))',
}}
/>
<div className="relative flex items-center justify-between h-[60px] lg:h-[120px] px-[20px] lg:px-[30px]">
{/* Logo */}
<Link href="/" aria-label="Шуміленд — на головну" className="shrink-0">
<div className="relative h-[50px] w-[57px] lg:h-[62px] lg:w-[71px]">
<div className="relative h-[44px] w-[50px] lg:h-[62px] lg:w-[71px]">
<div
className="absolute"
style={{ top: '91.32%', right: '21.81%', bottom: '0.97%', left: '22.26%' }}
@ -99,7 +129,14 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps)
{/* Mobile dropdown */}
{menuOpen && (
<div className="lg:hidden border-t border-white/10 px-8 py-5">
<div
className="lg:hidden mx-auto max-w-[1204px] mt-1 rounded-[20px] px-6 py-5"
style={{
backgroundColor: 'rgba(34,62,13,0.85)',
backdropFilter: 'blur(22px) saturate(160%)',
WebkitBackdropFilter: 'blur(22px) saturate(160%)',
}}
>
<ul className="flex flex-col gap-5 list-none m-0 p-0">
{navLinks.map((link) => (
<li key={link.href}>

View file

@ -2,7 +2,6 @@
import Link from 'next/link'
const IMG_CHECK = '/images/figma/check-mark.png'
const IMG_LINE = '/images/figma/line-divider.svg'
const PACKAGES = [
{
@ -43,6 +42,15 @@ const PACKAGES = [
},
]
/**
* Birthday pricing block Figma 1204×1052 (212 + 840).
* Cards holder: 3 columns at left:0 / 421 / 842 (gap 59), each 362 wide.
* Featured card sticks up by 84px (badge + slight overlap).
*
* Each card is a static stack: header (title + divider + price) features
* list CTA button. No flex-1 on the list content stacks naturally and
* any extra card height shows as bottom padding (matches Figma exactly).
*/
export function BirthdayPricing() {
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
@ -62,26 +70,9 @@ export function BirthdayPricing() {
</p>
</div>
<div className="flex flex-col lg:flex-row items-end justify-center gap-[59px]">
<div className="flex flex-col lg:flex-row lg:items-end justify-center gap-10 lg:gap-[59px]">
{PACKAGES.map((pkg) => (
<div
key={pkg.name}
className="flex flex-col items-center isolate"
style={{ paddingTop: pkg.featured ? 0 : '84px' }}
>
{pkg.featured && pkg.badge && (
<div
className="bg-[#223e0d] text-white text-[16px] font-medium px-5 h-[30px] flex items-center justify-center rounded-[24px] shadow-[0_0_12px_1px_rgba(242,139,74,0.25)] relative z-10"
style={{
fontFamily: 'var(--font-inter, Inter), sans-serif',
marginBottom: '-14px',
}}
>
{pkg.badge}
</div>
)}
<PricingCard {...pkg} />
</div>
<PricingCard key={pkg.name} {...pkg} />
))}
</div>
</div>
@ -93,68 +84,117 @@ interface PricingCardProps {
name: string
price: string
featured: boolean
badge?: string
features: string[]
}
function PricingCard({ name, price, featured, features }: PricingCardProps) {
function PricingCard({ name, price, featured, badge, features }: PricingCardProps) {
return (
<div
className="flex flex-col gap-10 w-[362px] rounded-[23.793px] pt-[60px] pb-10 px-10 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] relative z-[1]"
style={{
backgroundColor: featured ? '#f28b4a' : '#223e0d',
height: '756px',
}}
className="flex flex-col items-center w-full lg:w-[362px]"
// Featured wrapper is 772 (badge 30 + overlap -14 + card 756);
// non-featured wrapper is 840 (84 padding-top + card 756).
// The whole row is `lg:items-end`, so featured pops up.
>
{/* Header */}
<div className="flex flex-col gap-6 items-center">
<p
className="font-bold text-[40px] uppercase text-center w-full"
{/* Badge — only on featured. Sits *above* the card, overlapping by 14px. */}
{featured && badge && (
<div
className="relative z-10 hidden lg:flex items-center justify-center text-white font-medium px-5 h-[30px] rounded-[24px]"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
color: featured ? '#272727' : '#f28b4a',
backgroundColor: '#223e0d',
fontFamily: 'var(--font-inter, Inter), sans-serif',
fontSize: 16.65,
boxShadow: '0 0 11.897px 1.190px rgba(242,139,74,0.25)',
marginBottom: '-14px',
}}
>
{name}
</p>
<div className="relative w-full" style={{ height: '2px' }}>
<img src={IMG_LINE} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full" />
{badge}
</div>
<p
className="font-black text-[64px] text-center text-white leading-none"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
)}
{/* Mobile badge above the card */}
{featured && badge && (
<div
className="lg:hidden mb-3 inline-flex items-center justify-center text-white font-medium px-5 h-[30px] rounded-[24px]"
style={{
backgroundColor: '#223e0d',
fontFamily: 'var(--font-inter, Inter), sans-serif',
fontSize: 16,
}}
>
{price}
</p>
</div>
{badge}
</div>
)}
{/* Features */}
<ul className="flex flex-col gap-4 flex-1 list-none m-0 p-0">
{features.map((f) => (
<li key={f} className="flex items-start gap-6">
<div className="flex-none w-[30px] h-[30px] rounded-[24px] overflow-hidden shrink-0">
<img src={IMG_CHECK} alt="" aria-hidden="true" className="w-full h-full object-contain" />
</div>
<span
className="text-white text-[20px] font-medium leading-[1.5]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{f}
</span>
</li>
))}
</ul>
{/* CTA */}
<Link
href="/kvytky"
className="flex items-center justify-center w-full py-[10px] rounded-[56px] text-white font-bold text-[20px] text-center transition-opacity hover:opacity-90"
<div
className="relative w-full lg:w-[362px] rounded-[23.79px] overflow-hidden"
style={{
backgroundColor: featured ? '#223e0d' : '#f28b4a',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
backgroundColor: featured ? '#f28b4a' : '#223e0d',
height: '756px',
padding: '60px 40px 40px 40px',
boxShadow: '0 4px 60px 0 rgba(242,139,74,0.25)',
}}
>
Обрати пакет
</Link>
{/* Header: title + divider + price */}
<div className="flex flex-col items-center gap-6">
<p
className="font-bold uppercase text-center w-full leading-none"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 40,
color: featured ? '#272727' : '#f28b4a',
margin: 0,
}}
>
{name}
</p>
<div className="w-full h-px bg-white/100" />
<p
className="text-white text-center leading-none"
style={{
fontFamily: 'var(--font-inter, Inter), sans-serif',
fontSize: 64,
fontWeight: 400,
margin: 0,
}}
>
{price}
</p>
</div>
{/* Features — natural flow, NOT flex-1. Gap 16, top margin 40. */}
<ul className="list-none m-0 p-0 flex flex-col gap-4" style={{ marginTop: 40 }}>
{features.map((f) => (
<li key={f} className="flex items-start gap-6">
<img
src={IMG_CHECK}
alt=""
aria-hidden="true"
className="flex-none w-[30px] h-[30px] rounded-[24px] object-contain"
/>
<span
className="text-white text-[20px] font-medium leading-[1.5]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{f}
</span>
</li>
))}
</ul>
{/* CTA — sits 40px below the features list */}
<Link
href="/kvytky"
className="mt-10 flex items-center justify-center w-full rounded-[56px] text-white font-bold text-[20px] text-center transition-opacity hover:opacity-90"
style={{
backgroundColor: featured ? '#223e0d' : '#f28b4a',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
height: 44,
}}
>
Обрати пакет
</Link>
</div>
</div>
)
}

View file

@ -2,6 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import { useRef } from 'react'
import { useAutoScroll } from '@/hooks/useAutoScroll'
export interface GalleryImage {
src: string
@ -17,16 +18,16 @@ interface GallerySliderProps {
export function GallerySlider({ images }: GallerySliderProps) {
const trackRef = useRef<HTMLDivElement>(null)
useAutoScroll(trackRef, { speed: 0.5, intervalMs: 16 })
function scrollBy(dir: 1 | -1) {
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 710, behavior: 'smooth' })
}
return (
<div className="relative w-full">
{/* Prev */}
<button
onClick={() => scrollBy(-1)}
onClick={() => scrollByOne(-1)}
className="hidden lg:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Попереднє фото"
>
@ -37,7 +38,7 @@ export function GallerySlider({ images }: GallerySliderProps) {
<div
ref={trackRef}
className="flex gap-[26px] overflow-x-auto snap-x snap-mandatory scroll-smooth pb-2"
className="flex gap-[26px] overflow-x-auto scroll-smooth pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{images.map((img, i) => {
@ -47,12 +48,8 @@ export function GallerySlider({ images }: GallerySliderProps) {
return (
<div
key={i}
className="flex-none snap-start overflow-hidden shrink-0"
style={{
width: `${w}px`,
height: `${h}px`,
borderRadius: `${r}px`,
}}
className="flex-none overflow-hidden shrink-0"
style={{ width: `${w}px`, height: `${h}px`, borderRadius: `${r}px` }}
>
<img
src={img.src}
@ -64,9 +61,8 @@ export function GallerySlider({ images }: GallerySliderProps) {
})}
</div>
{/* Next */}
<button
onClick={() => scrollBy(1)}
onClick={() => scrollByOne(1)}
className="hidden lg:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Наступне фото"
>

View file

@ -5,12 +5,24 @@ import { BtnPrimary } from '@/components/ui/BtnPrimary'
const IMG_BG2 = '/images/figma/hero-bg2.png'
const IMG_BG1 = '/images/figma/hero-bg1.png'
const IMG_FAMILY = '/images/figma/hero-bg-family.png'
const IMG_BLUR = '/images/figma/hero-blur-mask.png'
interface HeroProps {
hero?: HomePageHero | null
}
/**
* Hero frame (Figma 1920 × 1080):
* - section bg #223e0d, rounded-b-20, padding 0 10 10 10
* - 3 background image layers stacked (bg2 bg1 family)
* - inner "hero-main" content frame at left:208 top:130 (1504 × 879)
* - blur layer is an L-shape SVG path with a notch in the top-right where
* the family image shows through clearly.
* - title 120px Montserrat 700, line-height 1.2, white
* - subtitle 24px Montserrat 500, max-width 629
* - btn_primary at the bottom-left of the content frame
*
* Mobile/tablet: title scales down, blur layer becomes a simple fade.
*/
export function Hero({ hero }: HeroProps) {
const subtitle =
hero?.subtitle ??
@ -19,8 +31,11 @@ export function Hero({ hero }: HeroProps) {
const ctaHref = hero?.ctaHref ?? '/kvytky'
return (
<section className="relative overflow-hidden bg-[#223e0d] min-h-[776px] md:min-h-[970px] lg:min-h-[1080px] -mt-[60px] md:-mt-[120px]">
{/* Background layers — order matters: last wins on top */}
<section
className="relative overflow-hidden bg-[#223e0d] -mt-[60px] lg:-mt-[120px] rounded-b-[20px] mx-[10px]"
style={{ minHeight: 'min(1080px, 100vh)' }}
>
{/* Background image layers */}
<img
src={IMG_BG2}
alt=""
@ -31,7 +46,7 @@ export function Hero({ hero }: HeroProps) {
src={IMG_BG1}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none opacity-70"
className="absolute inset-0 w-full h-full object-cover pointer-events-none mix-blend-overlay opacity-90"
/>
<img
src={IMG_FAMILY}
@ -39,32 +54,129 @@ export function Hero({ hero }: HeroProps) {
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
<img
src={IMG_BLUR}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full pointer-events-none object-fill"
/>
{/* Content */}
<div className="relative z-10 w-full max-w-[1504px] mx-auto px-8 pt-[120px] pb-[60px] md:pt-[180px] lg:pt-[218px] lg:pb-[40px]">
<div className="flex flex-col gap-[38px] lg:pl-[154px]">
<h1
className="text-white font-bold uppercase leading-[1.2] text-[32px] md:text-[48px] lg:text-[120px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
{/* Content + blur frame.
Container is 1504 × 879 in Figma, anchored at (208, 130) within the
1920 × 1080 hero. Ratios: left 10.83% top 12.04% w 78.33% h 81.39%. */}
<div
className="hidden lg:block absolute"
style={{
left: '10.83%',
top: '12.04%',
width: '78.33%',
height: '81.39%',
}}
>
{/* L-shaped frosted layer exact path from Figma (1504 × 879).
opacity 0.49, fill semi-white frosted; backdrop blur on shape. */}
<svg
viewBox="0 0 1504 879"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden="true"
>
<defs>
<clipPath id="hero-blur-clip">
<path d="M 1221 538.998 C 1221 550.044 1229.954 558.998 1241 558.998 L 1484 558.998 C 1495.046 558.998 1504 567.952 1504 578.998 L 1504 859 C 1504 870.046 1495.046 879 1484 879 L 20 879 C 8.954 879 0 870.046 0 859 L 0 20 C 0 8.954 8.954 0 20 0 L 1201 0 C 1212.046 0 1221 8.954 1221 20 L 1221 538.998 Z" />
</clipPath>
</defs>
<foreignObject
x="0"
y="0"
width="1504"
height="879"
clipPath="url(#hero-blur-clip)"
>
Шуміленд {' '}
<br />
<div
// @ts-expect-error xmlns on div in foreignObject is fine in JSX
xmlns="http://www.w3.org/1999/xhtml"
style={{
width: '100%',
height: '100%',
backdropFilter: 'blur(28px) saturate(120%)',
WebkitBackdropFilter: 'blur(28px) saturate(120%)',
backgroundColor: 'rgba(34, 62, 13, 0.49)',
}}
/>
</foreignObject>
</svg>
{/* Text content sits on top of the L-shape, padded to clear the
top-right notch (1186 / 1504 = ~78.86%, i.e. the title block sits
in the left ~79% of the content area). */}
<div
className="absolute"
style={{
left: '10.24%', // 154 / 1504
top: '11.15%', // 98 / 879
width: '67.59%', // 1016 / 1504
}}
>
<h1
className="text-white font-bold uppercase"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(48px, 6.25vw, 120px)',
lineHeight: 1.2,
fontWeight: 700,
margin: 0,
}}
>
Шуміленд <br />
світ, де казка оживає
</h1>
</div>
{/* Subtitle */}
<div
className="absolute"
style={{
left: '10.24%',
// Title bottom in Figma: top:98 + h:432 = 530, then gap 38 → 568.
// 568 / 879 = 64.62%
top: '64.62%',
width: '41.82%', // 629 / 1504
}}
>
<p
className="text-white font-medium text-[16px] lg:text-[24px] leading-[1.5] max-w-[629px]"
className="text-white"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 24,
fontWeight: 500,
lineHeight: 1.5,
margin: 0,
}}
>
{subtitle}
</p>
</div>
{/* CTA at left:154, top:714+98=812 within the 879 frame.
812 / 879 = 92.38%, but height of btn = 50, so anchor top ~81.23% */}
<div
className="absolute"
style={{ left: '10.24%', top: '81.23%' }}
>
<BtnPrimary href={ctaHref}>{ctaLabel}</BtnPrimary>
</div>
</div>
{/* Mobile / tablet — simple stacked layout, no blur shape */}
<div className="lg:hidden relative z-10 px-6 pt-[120px] pb-[60px] md:pt-[180px] md:pb-[80px]">
<div className="flex flex-col gap-[24px] md:gap-[32px] max-w-[680px]">
<h1
className="text-white font-bold uppercase leading-[1.15] text-[36px] md:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Шуміленд світ, де казка оживає
</h1>
<p
className="text-white font-medium text-[16px] md:text-[20px] leading-[1.5] max-w-[560px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}
</p>
<BtnPrimary href={ctaHref} className="self-start">
{ctaLabel}
</BtnPrimary>

View file

@ -2,6 +2,7 @@
import { useRef } from 'react'
import { BtnGradient } from '@/components/ui/BtnGradient'
import { useAutoScroll } from '@/hooks/useAutoScroll'
export interface LocationData {
name: string
@ -15,19 +16,26 @@ interface LocationsSliderProps {
locations: LocationData[]
}
/**
* Locations slider (Figma: 5 cards × 694w × 491h, gap 20).
* - card width 694, height 491, rounded 20
* - dark-green text panel 327 wide on the right of each card
* (rgba(34,62,13,0.8) partially transparent over the photo)
* - auto-scrolls 1px/16ms (60 fps marquee), pauses on hover
* - prev/next arrow buttons for manual nav
*/
export function LocationsSlider({ locations }: LocationsSliderProps) {
const trackRef = useRef<HTMLDivElement>(null)
useAutoScroll(trackRef, { speed: 0.6, intervalMs: 16 })
function scrollBy(dir: 1 | -1) {
if (!trackRef.current) return
trackRef.current.scrollBy({ left: dir * 580, behavior: 'smooth' })
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 714, behavior: 'smooth' })
}
return (
<div className="relative">
{/* Prev arrow */}
<button
onClick={() => scrollBy(-1)}
onClick={() => scrollByOne(-1)}
className="hidden lg:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Попередня локація"
>
@ -36,41 +44,38 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
</svg>
</button>
{/* Scrollable track */}
<div
ref={trackRef}
className="flex gap-5 overflow-x-auto pb-4 snap-x snap-mandatory scroll-smooth"
className="flex gap-5 overflow-x-auto pb-4 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{locations.map((loc) => (
<article
key={loc.name}
className="flex-none w-full md:w-[min(640px,90vw)] lg:w-[560px] rounded-[20px] overflow-hidden snap-start"
className="flex-none w-full md:w-[min(694px,90vw)] lg:w-[694px] rounded-[20px] overflow-hidden"
>
<div className="relative flex items-center h-[491px]">
{/* Background image */}
<div className="relative h-[491px]">
<img
src={loc.image}
alt={loc.name}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
{/* Dark green text overlay — positioned on the right portion */}
<div className="absolute right-0 top-0 h-full w-[327px] bg-[rgba(34,62,13,0.8)] flex flex-col justify-between py-[30px] px-[30px]">
<div className="flex flex-col gap-3 text-white overflow-hidden">
<div className="absolute right-0 top-0 h-full w-[327px] bg-[rgba(34,62,13,0.8)] flex flex-col justify-center gap-7 px-[30px]">
<div className="flex flex-col gap-3 text-white">
<h3
className="font-bold text-[24px] leading-[1.1]"
className="font-bold text-[24px] leading-[1.1] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h3>
<p
className="font-normal text-[20px] leading-[1.5]"
className="font-medium text-[16px] leading-[1.5] text-[#fdcf54]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.tagline}
</p>
<p
className="font-normal text-[16px] leading-[1.5] line-clamp-5"
className="font-normal text-[16px] leading-[1.5] line-clamp-6"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}
@ -83,9 +88,8 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
))}
</div>
{/* Next arrow */}
<button
onClick={() => scrollBy(1)}
onClick={() => scrollByOne(1)}
className="hidden lg:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Наступна локація"
>

View file

@ -3,6 +3,7 @@
import { useRef } from 'react'
import { BtnDetails } from '@/components/ui/BtnDetails'
import { useAutoScroll } from '@/hooks/useAutoScroll'
const IMG_RATE = '/images/figma/rate-stars.svg'
@ -11,26 +12,27 @@ const REVIEWS = [
initial: 'Ж',
name: 'Женя Олейник',
ago: '2 months ago',
text: 'Beautiful, interesting for children and adults. Large area and different locations.\nFew visitors on weekdays.',
text: 'Beautiful, interesting for children and adults. Large area and different locations. Few visitors on weekdays.',
},
{
initial: 'А',
name: 'Анна Калініченко',
ago: '6 months ago',
text: 'A wonderful dinosaur park, a park of figures made of grass. You can climb on the figures, the kids are delighted! The dinosaurs move, roar, everyone works. The only minus is a separate payment for the sandbox-excavation and the slide-lasagna.',
text: 'A wonderful dinosaur park, a park of figures made of grass. You can climb on the figures, the kids are delighted! The dinosaurs move, roar, everyone works.',
},
{
initial: 'V',
name: 'Volodymyr Prisajnuk',
ago: '10 months ago',
text: 'My family and I visited the open-air park at VDNH, we really liked it! It was much better than I expected. The children climbed the topiary figures, they were very interested in exploring them. The dinosaurs were very memorable — incredible!',
text: 'My family and I visited the open-air park at VDNH, we really liked it! It was much better than I expected. The dinosaurs were very memorable — incredible!',
},
]
export function Reviews() {
const trackRef = useRef<HTMLDivElement>(null)
useAutoScroll(trackRef, { speed: 0.5, intervalMs: 16 })
function scrollBy(dir: 1 | -1) {
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 611, behavior: 'smooth' })
}
@ -46,7 +48,7 @@ export function Reviews() {
<div className="relative">
<button
onClick={() => scrollBy(-1)}
onClick={() => scrollByOne(-1)}
className="hidden lg:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Попередній відгук"
>
@ -57,7 +59,7 @@ export function Reviews() {
<div
ref={trackRef}
className="flex flex-col md:flex-row gap-5 overflow-x-auto pb-2"
className="flex flex-col md:flex-row gap-5 overflow-x-auto pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{REVIEWS.map((review) => (
@ -110,7 +112,7 @@ export function Reviews() {
</div>
<button
onClick={() => scrollBy(1)}
onClick={() => scrollByOne(1)}
className="hidden lg:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Наступний відгук"
>

View file

@ -2,14 +2,15 @@
/* eslint-disable @next/next/no-img-element */
import { useState, useRef } from 'react'
import { useAutoScroll } from '@/hooks/useAutoScroll'
const IMG_GALLERY = [
'/images/figma/why-parents-1.png',
'/images/figma/why-parents-2.png',
'/images/figma/why-parents-3.png',
'/images/figma/why-parents-4.png',
'/images/figma/loc-map.jpg',
'/images/figma/loc-map.jpg',
'/images/figma/gallery-2.png',
'/images/figma/gallery-7.png',
]
const ITEMS = [
@ -42,6 +43,7 @@ const ITEMS = [
export function WhyParents() {
const [openIndex, setOpenIndex] = useState<number>(1)
const galleryRef = useRef<HTMLDivElement>(null)
useAutoScroll(galleryRef, { speed: 0.5, intervalMs: 16 })
function scrollGallery(dir: 1 | -1) {
galleryRef.current?.scrollBy({ left: dir * 525, behavior: 'smooth' })
@ -58,9 +60,7 @@ export function WhyParents() {
</h2>
<div className="flex flex-col lg:flex-row gap-16 items-start">
{/* Accordion */}
<div className="relative flex-none w-full lg:w-auto">
{/* Green decorative side bar */}
<div className="hidden lg:block absolute left-0 top-8 w-[333px] h-[488px] bg-[#223e0d] rounded-[30px]" />
<div className="flex flex-col gap-6 relative lg:ml-[76px]">
@ -89,12 +89,7 @@ export function WhyParents() {
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
aria-hidden="true"
>
<path
d="M1 1L9.5 9L18 1"
stroke="#f28b4a"
strokeWidth="2"
strokeLinecap="round"
/>
<path d="M1 1L9.5 9L18 1" stroke="#f28b4a" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<div
@ -114,7 +109,6 @@ export function WhyParents() {
</div>
</div>
{/* Gallery scroll */}
<div className="relative flex-1 min-w-0">
<button
onClick={() => scrollGallery(-1)}
@ -128,14 +122,14 @@ export function WhyParents() {
<div
ref={galleryRef}
className="flex gap-5 overflow-x-auto pb-2 snap-x snap-mandatory scroll-smooth"
className="flex gap-5 overflow-x-auto pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{IMG_GALLERY.map((src, i) => (
<div
key={i}
className="flex-none snap-start overflow-hidden rounded-[20px]"
style={{ width: '505px', height: i === 0 ? '546px' : '488px' }}
className="flex-none overflow-hidden rounded-[20px]"
style={{ width: '505px', height: '488px' }}
>
<img src={src} alt="" aria-hidden="true" className="w-full h-full object-cover" />
</div>

View file

@ -1,33 +1,45 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const LINE_ACTIVE = '/images/figma/line-nav-active.svg'
interface NavLinkProps {
href: string
children: string
className?: string
}
/**
* Header nav item.
* - default: white Montserrat 700 / 20px, opacity:1
* - active: same text + a 1px white underline directly under the baseline
* (matches /1920/components/Property1Active `border: 1px solid #fff`
* span at top:30 of the 30px-tall pill, full text width).
*/
export function NavLink({ href, children, className = '' }: NavLinkProps) {
const pathname = usePathname()
const isActive = pathname === href
// Active when path matches exactly, or when nested under the same root
// section (so /lokatsii/dinopark also lights "Локації").
const isActive =
pathname === href || (href !== '/' && pathname?.startsWith(href + '/'))
return (
<Link
href={href}
className={`flex flex-col items-center font-bold text-[20px] text-white whitespace-nowrap leading-[1.5] transition-opacity hover:opacity-80 ${isActive ? 'opacity-100' : ''} ${className}`}
className={`relative inline-flex items-center font-bold text-[20px] leading-[1.5] text-white whitespace-nowrap transition-opacity hover:opacity-80 ${className}`.trim()}
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
aria-current={isActive ? 'page' : undefined}
>
{children}
<span className="block w-full" style={{ height: '2px' }} aria-hidden="true">
{isActive && (
<img src={LINE_ACTIVE} alt="" aria-hidden="true" className="block w-full h-full" />
)}
<span className="relative">
{children}
<span
aria-hidden="true"
className="pointer-events-none absolute left-0 right-0 -bottom-[1px] block bg-white transition-opacity"
style={{
height: '1px',
opacity: isActive ? 1 : 0,
}}
/>
</span>
</Link>
)

View file

@ -0,0 +1,75 @@
import { useEffect, type RefObject } from 'react'
interface Options {
/** Pixels per tick. Default 1. Use larger value (e.g. 580) with `step: true` for snap-style. */
speed?: number
/** Tick interval in ms. Default 16 (≈60fps). */
intervalMs?: number
/** When true, treat one tick as a discrete jump (good with scroll-snap). */
step?: boolean
/** Pause when user hovers the element. Default true. */
pauseOnHover?: boolean
/** Disable entirely. */
disabled?: boolean
}
/**
* Auto-scrolls a horizontal scroll container, looping back to start when it
* reaches the end. Respects prefers-reduced-motion and pauses on hover.
*/
export function useAutoScroll<T extends HTMLElement>(
ref: RefObject<T | null>,
{
speed = 1,
intervalMs = 16,
step = false,
pauseOnHover = true,
disabled = false,
}: Options = {},
) {
useEffect(() => {
if (disabled) return
const el = ref.current
if (!el) return
const reduce =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) return
let paused = false
const onEnter = () => {
paused = true
}
const onLeave = () => {
paused = false
}
if (pauseOnHover) {
el.addEventListener('pointerenter', onEnter)
el.addEventListener('pointerleave', onLeave)
}
const id = window.setInterval(() => {
if (paused) return
const max = el.scrollWidth - el.clientWidth
if (max <= 0) return
const next = el.scrollLeft + speed
if (next >= max - 1) {
// Loop back smoothly
el.scrollTo({ left: 0, behavior: step ? 'smooth' : 'auto' })
} else if (step) {
el.scrollBy({ left: speed, behavior: 'smooth' })
} else {
el.scrollLeft = next
}
}, intervalMs)
return () => {
window.clearInterval(id)
if (pauseOnHover) {
el.removeEventListener('pointerenter', onEnter)
el.removeEventListener('pointerleave', onLeave)
}
}
}, [ref, speed, intervalMs, step, pauseOnHover, disabled])
}