feat: refactor frontend components and add PageHero
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:
parent
86debfcdb1
commit
3226789bd1
19 changed files with 263 additions and 257 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -53,3 +53,9 @@ playwright-report/
|
|||
# AI tooling runtime files
|
||||
agentdb.rvf
|
||||
agentdb.rvf.lock
|
||||
.playwright-mcp/
|
||||
|
||||
# Debug screenshots (root-level)
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей.
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
32
src/components/ui/PageHero.tsx
Normal file
32
src/components/ui/PageHero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue