feat: update components and add useAutoScroll hook
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cca4ea1d55
commit
e644ee899a
9 changed files with 424 additions and 152 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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="Наступне фото"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="Наступна локація"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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="Наступний відгук"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
75
src/hooks/useAutoScroll.ts
Normal file
75
src/hooks/useAutoScroll.ts
Normal 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])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue