feat: refactor frontend components and add PageHero
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

Refactored slider components (Gallery, Locations), Hero section with data-driven approach, WhyParents layout, and frontend page layouts. Added PageHero reusable component and ignored debug screenshots/playwright runtime in .gitignore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-10 18:41:46 +01:00
parent 86debfcdb1
commit 3226789bd1
19 changed files with 263 additions and 257 deletions

6
.gitignore vendored
View file

@ -53,3 +53,9 @@ playwright-report/
# AI tooling runtime files
agentdb.rvf
agentdb.rvf.lock
.playwright-mcp/
# Debug screenshots (root-level)
*.png
*.jpg
*.jpeg

View file

@ -4,6 +4,13 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
reactStrictMode: true,
webpack: (config) => {
config.watchOptions = {
...config.watchOptions,
ignored: /node_modules|importMap\.js|\.next/,
}
return config
},
images: {
remotePatterns: [
{

View file

@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import Link from 'next/link'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Блог — Шуміленд',
@ -39,22 +40,7 @@ export default async function BlogPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="bg-[#223e0d] py-16 px-8">
<div className="max-w-[1204px] mx-auto">
<h1
className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Блог
</h1>
<p
className="text-white/80 text-[18px] mt-4"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Новини та статті від Шуміленду
</p>
</div>
</div>
<PageHero title="Блог" subtitle="Новини та статті від Шуміленду" />
<div className="max-w-[1204px] mx-auto px-8 py-16">
{posts.length === 0 ? (

View file

@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Дні народження — Шуміленд',
@ -30,22 +31,10 @@ const PACKAGES = [
export default function BirthdayPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="bg-[#223e0d] py-16 px-8">
<div className="max-w-[1204px] mx-auto">
<h1
className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Дні народження
</h1>
<p
className="text-white/80 text-[18px] mt-4 max-w-[600px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв&apos;яжуться з вами для уточнення деталей.
</p>
</div>
</div>
<PageHero
title="Дні народження"
subtitle="Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей."
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">

View file

@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Групові відвідування — Шуміленд',
@ -33,22 +34,10 @@ const GROUPS = [
export default function GroupVisitsPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="bg-[#223e0d] py-16 px-8">
<div className="max-w-[1204px] mx-auto">
<h1
className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Групові відвідування
</h1>
<p
className="text-white/80 text-[18px] mt-4 max-w-[600px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Спеціальні умови для організованих груп. Мінімум 10 осіб максимум вражень.
</p>
</div>
</div>
<PageHero
title="Групові відвідування"
subtitle="Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень."
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">

View file

@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Купити квиток — Шуміленд',
@ -53,23 +54,10 @@ export default async function TicketsPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
{/* Hero */}
<div className="bg-[#223e0d] py-16 px-8">
<div className="max-w-[1204px] mx-auto">
<h1
className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Купити квиток
</h1>
<p
className="text-white/80 text-[18px] mt-4"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Оберіть квиток та придбайте онлайн без черги на касі
</p>
</div>
</div>
<PageHero
title="Купити квиток"
subtitle="Оберіть квиток та придбайте онлайн — без черги на касі"
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
{hasWarning && (

View file

@ -1,6 +1,7 @@
import React from 'react'
import type { Metadata } from 'next'
import { Montserrat, Inter, Poppins } from 'next/font/google'
import '@/app/globals.css'
import { Header } from '@/components/layout/Header'
import { Footer } from '@/components/layout/Footer'

View file

@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Локації — Шуміленд',
@ -36,22 +37,10 @@ const LOCATIONS = [
export default function LocationsPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="bg-[#223e0d] py-16 px-8">
<div className="max-w-[1204px] mx-auto">
<h1
className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Локації
</h1>
<p
className="text-white/80 text-[18px] mt-4"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Три неповторні зони розваг для всієї родини
</p>
</div>
</div>
<PageHero
title="Локації"
subtitle="Три неповторні зони розваг для всієї родини"
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="flex flex-col gap-10">

View file

@ -27,7 +27,8 @@ export default async function HomePage() {
// Fail gracefully — DB may not be available during static build
}
const hero = home?.hero ?? STATIC_HERO
const heroData = home?.hero
const hero = heroData?.title ? heroData : STATIC_HERO
return (
<div className="bg-[#fdf2e8]">

View file

@ -1,3 +1,4 @@
import '@payloadcms/next/css'
import React from 'react'
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts'
import type { ServerFunctionClient } from 'payload'
@ -19,11 +20,7 @@ const serverFunction: ServerFunctionClient = async function (args) {
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
return (
<RootLayout
config={configPromise}
importMap={importMap}
serverFunction={serverFunction}
>
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)

View file

@ -1,6 +1,5 @@
import React from 'react'
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Shumiland',
@ -8,7 +7,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="uk">
<html lang="uk" data-theme="light" dir="LTR" suppressHydrationWarning>
<head>
<style>{`@layer payload-default, payload;`}</style>
</head>
<body>{children}</body>
</html>
)

View file

@ -2,15 +2,16 @@ import { GallerySlider } from './GallerySlider'
import type { GalleryImage } from './GallerySlider'
const IMAGES: GalleryImage[] = [
{ src: '/images/figma/gallery-2.png', alt: 'Шуміленд — фото 1' },
{ src: '/images/figma/gallery-4.png', alt: 'Шуміленд — фото 2' },
{ src: '/images/figma/gallery-6.png', alt: 'Шуміленд — фото 3' },
{ src: '/images/figma/gallery-7.png', alt: 'Шуміленд — фото 4' },
{ src: '/images/figma/loc-map.jpg', alt: 'Шуміленд — фото 5', width: 592, height: 427, radius: 30 },
{ src: '/images/figma/gallery-8.png', alt: 'Шуміленд — фото 6' },
{ src: '/images/figma/hero-bg-family.png', alt: 'Шуміленд — фото 7' },
{ src: '/images/figma/why-parents-1.png', alt: 'Шуміленд — фото 8' },
{ src: '/images/figma/video-preview.png', alt: 'Шуміленд — фото 9' },
{ src: '/images/figma/why-parents-1.png', alt: 'Шуміленд — сімейні враження', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-6.png', alt: 'Шуміленд — атракціони', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-7.png', alt: 'Шуміленд — фото 3', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-2.png', alt: 'Шуміленд — прогулянка', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/news-bg2.png', alt: 'Шуміленд — зони парку', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-4.png', alt: 'Шуміленд — фото 5', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-3.png', alt: 'Шуміленд — відпочинок', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-2.png', alt: 'Шуміленд — фото 7', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/news-bg6.png', alt: 'Шуміленд — Шумі Кафе', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-4.png', alt: 'Шуміленд — карта парку', width: 380, height: 420, radius: 20 },
]
export function Gallery() {
@ -26,7 +27,7 @@ export function Gallery() {
</div>
<div className="max-w-[1204px] mx-auto px-8 relative">
<GallerySlider images={IMAGES} />
<GallerySlider images={IMAGES} speed={40} />
</div>
</section>
)

View file

@ -1,8 +1,7 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useRef } from 'react'
import { useAutoScroll } from '@/hooks/useAutoScroll'
import { useState } from 'react'
export interface GalleryImage {
src: string
@ -14,62 +13,65 @@ export interface GalleryImage {
interface GallerySliderProps {
images: GalleryImage[]
speed?: number
}
export function GallerySlider({ images }: GallerySliderProps) {
const trackRef = useRef<HTMLDivElement>(null)
useAutoScroll(trackRef, { speed: 0.5, intervalMs: 16 })
export function GallerySlider({ images, speed = 35 }: GallerySliderProps) {
const [paused, setPaused] = useState(false)
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 710, behavior: 'smooth' })
}
// Duplicate for seamless infinite loop
const doubled = [...images, ...images]
return (
<div className="relative w-full">
<button
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="Попереднє фото"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M13 4L7 10L13 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
className="relative w-full overflow-hidden"
style={{
maskImage:
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<style>{`
@keyframes shumiland-marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.shumiland-marquee-track {
animation: shumiland-marquee ${speed}s linear infinite;
will-change: transform;
}
.shumiland-marquee-track.paused {
animation-play-state: paused;
}
`}</style>
<div
ref={trackRef}
className="flex gap-[26px] overflow-x-auto scroll-smooth pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
className={`shumiland-marquee-track flex gap-[26px] pb-2${paused ? ' paused' : ''}`}
style={{ width: 'max-content' }}
>
{images.map((img, i) => {
const w = img.width ?? 684
const h = img.height ?? 494
const r = img.radius ?? 35
{doubled.map((img, i) => {
const w = img.width ?? 384
const h = img.height ?? 280
const r = img.radius ?? 20
return (
<div
key={i}
className="flex-none overflow-hidden shrink-0"
className="flex-none overflow-hidden shrink-0 group cursor-pointer"
style={{ width: `${w}px`, height: `${h}px`, borderRadius: `${r}px` }}
>
<img
src={img.src}
alt={img.alt}
className="w-full h-full object-cover transition-transform duration-500 hover:scale-105"
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
loading="lazy"
/>
</div>
)
})}
</div>
<button
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="Наступне фото"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M7 4L13 10L7 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
)
}

View file

@ -60,7 +60,9 @@ describe('Hero', () => {
it('renders title when provided', () => {
const hero: HomePageHero = { title: 'Welcome to Shumiland' }
render(<Hero hero={hero} />)
expect(screen.getByRole('heading', { level: 1, name: 'Welcome to Shumiland' })).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 1, name: 'Welcome to Shumiland' })
).toBeInTheDocument()
})
it('does not render a heading when title is not provided', () => {

View file

@ -3,7 +3,6 @@ import type { HomePageHero, Media } from '@/types/globals'
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'
interface HeroProps {
@ -27,7 +26,7 @@ export function Hero({ hero }: HeroProps) {
return (
<section
className="relative overflow-hidden bg-[#223e0d] -mt-[60px] lg:-mt-[120px] rounded-b-[20px] mx-[10px]"
className="relative mx-[10px] -mt-[60px] overflow-hidden rounded-b-[20px] bg-black lg:-mt-[120px]"
style={{ minHeight: 'min(1080px, 100vh)' }}
>
{/* Background layers */}
@ -39,19 +38,28 @@ export function Hero({ hero }: HeroProps) {
muted
playsInline
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : bgMedia ? (
<img
src={bgMedia.url ?? ''}
alt={bgMedia.alt ?? ''}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<>
<img src={IMG_BG2} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none" />
<img src={IMG_BG1} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none mix-blend-overlay opacity-90" />
<img src={IMG_FAMILY} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none" />
<img
src={IMG_BG2}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<img
src={IMG_FAMILY}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
</>
)}
@ -59,14 +67,14 @@ export function Hero({ hero }: HeroProps) {
The mobile layout is always in the DOM; CSS hides it visually on desktop. */}
<div
aria-hidden="true"
className="hidden lg:block absolute"
className="absolute hidden lg:block"
style={{ left: '10.83%', top: '12.04%', width: '78.33%', height: '81.39%' }}
>
{/* L-shaped frosted layer */}
<svg
viewBox="0 0 1504 879"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full pointer-events-none"
className="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
>
<defs>
@ -90,12 +98,9 @@ export function Hero({ hero }: HeroProps) {
</svg>
{title && (
<div
className="absolute"
style={{ left: '10.24%', top: '11.15%', width: '67.59%' }}
>
<div className="absolute" style={{ left: '10.24%', top: '11.15%', width: '67.59%' }}>
<div
className="text-white font-bold uppercase"
className="font-bold text-white uppercase"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(48px, 6.25vw, 120px)',
@ -110,10 +115,7 @@ export function Hero({ hero }: HeroProps) {
)}
{subtitle && (
<div
className="absolute"
style={{ left: '10.24%', top: '64.62%', width: '41.82%' }}
>
<div className="absolute" style={{ left: '10.24%', top: '64.62%', width: '41.82%' }}>
<p
className="text-white"
style={{
@ -137,11 +139,11 @@ export function Hero({ hero }: HeroProps) {
</div>
{/* Mobile / tablet — semantic layout (always in DOM, provides accessible content) */}
<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]">
<div className="relative z-10 px-6 pt-[120px] pb-[60px] md:pt-[180px] md:pb-[80px] lg:hidden">
<div className="flex max-w-[680px] flex-col gap-[24px] md:gap-[32px]">
{title && (
<h1
className="text-white font-bold uppercase leading-[1.15] text-[36px] md:text-[64px]"
className="text-[36px] leading-[1.15] font-bold text-white uppercase md:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
@ -149,7 +151,7 @@ export function Hero({ hero }: HeroProps) {
)}
{subtitle && (
<p
className="text-white font-medium text-[16px] md:text-[20px] leading-[1.5] max-w-[560px]"
className="max-w-[560px] text-[16px] leading-[1.5] font-medium text-white md:text-[20px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}

View file

@ -1,17 +1,13 @@
import { LocationsSlider } from './LocationsSlider'
import type { LocationData } from './LocationsSlider'
const IMG_DINOPARK = '/images/figma/loc-dinopark.jpg'
const IMG_DIVO_LIS = '/images/figma/loc-divo-lis.png'
const IMG_MAP = '/images/figma/loc-map.jpg'
const LOCATIONS: LocationData[] = [
{
name: 'ДиноПарк',
tagline: 'портал у світ динозаврів',
description:
'Ви бачили їх у фільмах та мультиках, а тепер час зустріти в реальному житті та роздивитися їх зблизька! Найбільші динозаври України, які гарчать і рухаються, як справжні, захопливі відкриття та атмосфера справжньої наукової експедиції.',
image: IMG_DINOPARK,
image: '/images/figma/loc-dinopark.jpg',
href: '/lokatsii',
},
{
@ -19,7 +15,7 @@ const LOCATIONS: LocationData[] = [
tagline: 'зона казкових топіарних фігур',
description:
'Тут на лісових стежках оселилися єдинороги, дракони та добрі лісові звірята. Це ідеальне місце, щоб пофантазувати разом із дитиною та придумати спільну яскраву історію.',
image: IMG_DIVO_LIS,
image: '/images/figma/loc-divo-lis.png',
href: '/lokatsii',
},
{
@ -27,7 +23,7 @@ const LOCATIONS: LocationData[] = [
tagline: 'справжній виклик кмітливості',
description:
'Чи зможете ви разом знайти вихід? Це справжній пригодницький виклик для всієї родини! Тут діти вчаться бути уважними та впевненими у собі, адже вони стають героями справжнього квесту.',
image: IMG_MAP,
image: '/images/figma/news-bg3.jpg',
href: '/lokatsii',
},
{
@ -35,7 +31,7 @@ const LOCATIONS: LocationData[] = [
tagline: 'перемога, яку ви розділите разом',
description:
'Для дітей це не просто гра, а можливість проявити себе та "заробити" подарунок. Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.',
image: IMG_MAP,
image: '/images/figma/news-bg4.png',
href: '/lokatsii',
},
{
@ -43,7 +39,7 @@ const LOCATIONS: LocationData[] = [
tagline: 'територія забав і нових друзів',
description:
'Поки малеча підкорює гірки, випробовує безпечні лазанки та знаходить перших друзів, ви можете нарешті зробити паузу та просто спостерігати за їхніми розвагами.',
image: IMG_MAP,
image: '/images/figma/news-bg5.png',
href: '/lokatsii',
},
]

View file

@ -16,17 +16,9 @@ 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 })
useAutoScroll(trackRef, { speed: 0.5, intervalMs: 16 })
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 714, behavior: 'smooth' })
@ -36,7 +28,7 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
<div className="relative">
<button
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"
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] hover:scale-110 transition-all duration-200"
aria-label="Попередня локація"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
@ -45,52 +37,62 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
</button>
<div
ref={trackRef}
className="flex gap-5 overflow-x-auto pb-4 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
className="relative overflow-hidden"
style={{
maskImage:
'linear-gradient(90deg, transparent 0%, black 5%, black 95%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(90deg, transparent 0%, black 5%, black 95%, transparent 100%)',
}}
>
{locations.map((loc) => (
<article
key={loc.name}
className="flex-none w-full md:w-[min(694px,90vw)] lg:w-[694px] rounded-[20px] overflow-hidden"
>
<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"
/>
<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] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h3>
<p
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-6"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}
</p>
<div
ref={trackRef}
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(694px,90vw)] lg:w-[694px] rounded-[20px] overflow-hidden group"
>
<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 transition-transform duration-700 ease-out group-hover:scale-105"
/>
<div className="absolute right-0 top-0 h-full w-[327px] bg-[rgba(34,62,13,0.85)] backdrop-blur-[2px] flex flex-col justify-center gap-7 px-[30px] transition-all duration-300 group-hover:bg-[rgba(34,62,13,0.92)]">
<div className="flex flex-col gap-3 text-white">
<h3
className="font-bold text-[24px] leading-[1.1] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h3>
<p
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-[15px] leading-[1.5] line-clamp-6 text-white/90"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}
</p>
</div>
<BtnGradient href={loc.href}>Купити квиток</BtnGradient>
</div>
<BtnGradient href={loc.href}>Купити квиток</BtnGradient>
</div>
</div>
</article>
))}
</article>
))}
</div>
</div>
<button
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"
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] hover:scale-110 transition-all duration-200"
aria-label="Наступна локація"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">

View file

@ -1,16 +1,17 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState, useRef } from 'react'
import { useAutoScroll } from '@/hooks/useAutoScroll'
import { useState } from 'react'
const IMG_GALLERY = [
'/images/figma/why-parents-1.png',
'/images/figma/gallery-6.png',
'/images/figma/why-parents-2.png',
'/images/figma/why-parents-3.png',
'/images/figma/why-parents-4.png',
'/images/figma/gallery-2.png',
'/images/figma/gallery-7.png',
'/images/figma/why-parents-3.png',
'/images/figma/news-bg2.png',
'/images/figma/why-parents-4.png',
'/images/figma/gallery-4.png',
]
const ITEMS = [
@ -42,15 +43,24 @@ 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' })
}
const doubled = [...IMG_GALLERY, ...IMG_GALLERY]
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<style>{`
@keyframes shumiland-marquee-vertical {
0% { transform: translateY(0); }
100% { transform: translateY(-50%); }
}
.why-marquee-track {
animation: shumiland-marquee-vertical 28s linear infinite;
will-change: transform;
}
.why-marquee-wrap:hover .why-marquee-track {
animation-play-state: paused;
}
`}</style>
<div className="max-w-[1204px] mx-auto px-8">
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-[40px] md:mb-[60px]"
@ -109,42 +119,46 @@ export function WhyParents() {
</div>
</div>
<div className="relative flex-1 min-w-0">
<button
onClick={() => scrollGallery(-1)}
className="hidden lg:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-5 z-10 w-10 h-10 items-center justify-center rounded-full bg-[#223e0d] text-white shadow hover:bg-[#f28b4a] transition-colors"
aria-label="Попереднє фото"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M10 3L5 8L10 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
ref={galleryRef}
className="flex gap-5 overflow-x-auto pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{IMG_GALLERY.map((src, i) => (
{/* Vertical marquee gallery */}
<div
className="why-marquee-wrap relative flex-none hidden lg:block overflow-hidden rounded-[30px] cursor-pointer"
style={{
width: '505px',
height: '488px',
maskImage: 'linear-gradient(180deg, transparent 0%, black 12%, black 88%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, black 12%, black 88%, transparent 100%)',
}}
>
<div className="why-marquee-track flex flex-col gap-5" style={{ width: '505px' }}>
{doubled.map((src, i) => (
<div
key={i}
className="flex-none overflow-hidden rounded-[20px]"
style={{ width: '505px', height: '488px' }}
className="flex-none overflow-hidden rounded-[20px] group"
style={{ width: '505px', height: '340px' }}
>
<img src={src} alt="" aria-hidden="true" className="w-full h-full object-cover" />
<img
src={src}
alt=""
aria-hidden="true"
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
loading="lazy"
/>
</div>
))}
</div>
</div>
<button
onClick={() => scrollGallery(1)}
className="hidden lg:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-5 z-10 w-10 h-10 items-center justify-center rounded-full bg-[#223e0d] text-white shadow hover:bg-[#f28b4a] transition-colors"
aria-label="Наступне фото"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6 3L11 8L6 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* Mobile: horizontal scroll */}
<div className="lg:hidden w-full flex gap-5 overflow-x-auto pb-2" style={{ scrollbarWidth: 'none' }}>
{IMG_GALLERY.map((src, i) => (
<div
key={i}
className="flex-none overflow-hidden rounded-[20px]"
style={{ width: '280px', height: '200px' }}
>
<img src={src} alt="" aria-hidden="true" className="w-full h-full object-cover" loading="lazy" />
</div>
))}
</div>
</div>
</div>

View file

@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
interface PageHeroProps {
title: string
subtitle?: string
children?: ReactNode
}
/**
* Inner-page hero banner. Slides under the sticky header the same way the home hero does.
* Header height: 60px mobile / 120px desktop negative top margin pulls this section up.
*/
export function PageHero({ title, subtitle, children }: PageHeroProps) {
return (
<div
className="bg-[#223e0d] -mt-[60px] lg:-mt-[120px] pt-[calc(60px+48px)] lg:pt-[calc(120px+64px)] pb-16 px-8"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
<div className="max-w-[1204px] mx-auto">
<h1 className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase leading-tight">
{title}
</h1>
{subtitle && (
<p className="text-white/80 text-[18px] mt-4 max-w-[600px] leading-relaxed">
{subtitle}
</p>
)}
{children}
</div>
</div>
)
}