diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bc23375..4eb163b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,7 @@ services: postgres: image: postgres:16-alpine + container_name: shumiland-postgres environment: POSTGRES_USER: shumiland POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -13,40 +14,27 @@ services: timeout: 5s retries: 5 restart: unless-stopped + networks: + - internal app: - image: ghcr.io/aimpress/shumiland:${TAG:-latest} + build: . + container_name: shumiland-app env_file: .env.production depends_on: postgres: condition: service_healthy restart: unless-stopped - - nginx: - image: nginx:alpine - ports: - - '80:80' - - '443:443' - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./certbot/conf:/etc/letsencrypt:ro - - ./certbot/www:/var/www/certbot:ro - depends_on: - - app - restart: unless-stopped - - certbot: - image: certbot/certbot - volumes: - - ./certbot/conf:/etc/letsencrypt - - ./certbot/www:/var/www/certbot - command: >- - certonly --webroot - --webroot-path=/var/www/certbot - --email ${CERTBOT_EMAIL} - --agree-tos - --no-eff-email - -d ${CERTBOT_DOMAIN} + networks: + - internal + - traefik-public + labels: + - traefik.enable=true + - traefik.http.routers.shumiland.rule=Host(`shumi.ai-impress.com`) + - traefik.http.routers.shumiland.entrypoints=websecure + - traefik.http.routers.shumiland.tls.certresolver=cloudflare + - traefik.http.services.shumiland.loadbalancer.server.port=3000 + - traefik.http.routers.shumiland.middlewares=security-headers@file pg_backup: image: alpine:3 @@ -65,6 +53,15 @@ services: depends_on: - postgres restart: unless-stopped + networks: + - internal + +networks: + internal: + name: shumiland-internal + traefik-public: + external: true volumes: postgres_data: + name: shumiland-postgres-data diff --git a/payload.config.ts b/payload.config.ts index fd72173..2792095 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -14,6 +14,9 @@ import { Tags } from './src/collections/Tags' import { Tariffs } from './src/collections/Tariffs' import { Leads } from './src/collections/Leads' import { Orders } from './src/collections/Orders' +import { Locations } from './src/collections/Locations' +import { Reviews } from './src/collections/Reviews' +import { BirthdayPackages } from './src/collections/BirthdayPackages' import { HomePage } from './src/globals/HomePage' import { CheckoutPage } from './src/globals/CheckoutPage' @@ -42,7 +45,7 @@ export default buildConfig({ editor: lexicalEditor(), sharp, - collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders], + collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders, Locations, Reviews, BirthdayPackages], globals: [HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings], @@ -62,6 +65,10 @@ export default buildConfig({ }, }, + i18n: { + fallbackLanguage: 'en', + }, + cors: [siteURL], typescript: { diff --git a/src/app/(frontend)/blog/page.tsx b/src/app/(frontend)/blog/page.tsx index bca0094..11e9b0c 100644 --- a/src/app/(frontend)/blog/page.tsx +++ b/src/app/(frontend)/blog/page.tsx @@ -39,43 +39,43 @@ export default async function BlogPage() { const posts = await getPosts() return ( -
+
-
+
{posts.length === 0 ? ( -
+

Статті незабаром з'являться тут.

) : ( -
+
{posts.map((post) => (

{post.title}

{post.excerpt && (

{post.excerpt}

)} {post.publishedAt && ( -

+

{new Date(post.publishedAt).toLocaleDateString('uk-UA', { day: 'numeric', month: 'long', diff --git a/src/app/(frontend)/dni-narodzhennia/page.tsx b/src/app/(frontend)/dni-narodzhennia/page.tsx index 053450a..311bd63 100644 --- a/src/app/(frontend)/dni-narodzhennia/page.tsx +++ b/src/app/(frontend)/dni-narodzhennia/page.tsx @@ -4,7 +4,8 @@ import { PageHero } from '@/components/ui/PageHero' export const metadata: Metadata = { title: 'Дні народження — Шуміленд', - description: 'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.', + description: + 'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.', } const PACKAGES = [ @@ -17,31 +18,46 @@ const PACKAGES = [ { name: 'Преміум', price: '8 900', - features: ['До 20 дітей', 'Аніматор 3 год', 'Вхідні квитки', 'Торт + солодкий стіл', 'Декор зали', 'Фотограф 1 год'], + features: [ + 'До 20 дітей', + 'Аніматор 3 год', + 'Вхідні квитки', + 'Торт + солодкий стіл', + 'Декор зали', + 'Фотограф 1 год', + ], highlight: true, }, { name: 'VIP', price: '15 000', - features: ['До 40 гостей', 'Аніматор 4 год', 'Вхідні квитки', 'Банкетний зал', 'Повне меню', 'Фотограф + відео', 'Персональний менеджер'], + features: [ + 'До 40 гостей', + 'Аніматор 4 год', + 'Вхідні квитки', + 'Банкетний зал', + 'Повне меню', + 'Фотограф + відео', + 'Персональний менеджер', + ], highlight: false, }, ] export default function BirthdayPage() { return ( -

+
-
-
+
+
{PACKAGES.map((pkg) => (
{pkg.highlight && ( Найпопулярніший @@ -57,13 +73,13 @@ export default function BirthdayPage() { )}

{pkg.name}

{pkg.price} @@ -73,7 +89,7 @@ export default function BirthdayPage() { {pkg.features.map((f) => (

  • {f} @@ -84,22 +100,22 @@ export default function BirthdayPage() { ))}
  • -
    +

    Замовити святкування

    Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин

    Залишити заявку diff --git a/src/app/(frontend)/grupovi-vidviduvannia/page.tsx b/src/app/(frontend)/grupovi-vidviduvannia/page.tsx index b29315d..b2d23d9 100644 --- a/src/app/(frontend)/grupovi-vidviduvannia/page.tsx +++ b/src/app/(frontend)/grupovi-vidviduvannia/page.tsx @@ -4,27 +4,31 @@ import { PageHero } from '@/components/ui/PageHero' export const metadata: Metadata = { title: 'Групові відвідування — Шуміленд', - description: 'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.', + description: + 'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.', } const GROUPS = [ { title: 'Шкільні екскурсії', - description: 'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.', + description: + 'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.', minPeople: '15 осіб', discount: '15%', icon: '🏫', }, { title: 'Дитячі садки', - description: 'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.', + description: + 'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.', minPeople: '10 осіб', discount: '20%', icon: '🎒', }, { title: 'Корпоративи', - description: 'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.', + description: + 'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.', minPeople: '20 осіб', discount: '10%', icon: '🏢', @@ -33,64 +37,64 @@ const GROUPS = [ export default function GroupVisitsPage() { return ( -
    +
    -
    -
    +
    +
    {GROUPS.map((g) => (
    {g.icon}

    {g.title}

    {g.description}

    -
    +
    -

    Від

    -

    {g.minPeople}

    +

    Від

    +

    {g.minPeople}

    -

    Знижка

    -

    {g.discount}

    +

    Знижка

    +

    {g.discount}

    ))}
    -
    +

    Подати заявку на групове відвідування

    Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.

    Написати нам diff --git a/src/app/(frontend)/kvytky/page.tsx b/src/app/(frontend)/kvytky/page.tsx index 790113f..98fc4d3 100644 --- a/src/app/(frontend)/kvytky/page.tsx +++ b/src/app/(frontend)/kvytky/page.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @next/next/no-img-element */ import type { Metadata } from 'next' import Link from 'next/link' import { PageHero } from '@/components/ui/PageHero' @@ -53,30 +52,30 @@ export default async function TicketsPage() { }, {}) return ( -
    +
    -
    +
    {hasWarning && ( -
    +
    Ціни можуть бути застарілими — сервіс ezy.com.ua тимчасово недоступний.
    )} {tariffs.length === 0 ? ( -
    +

    Квитки тимчасово недоступні. Спробуйте пізніше або зателефонуйте нам.

    Зателефонувати @@ -87,12 +86,12 @@ export default async function TicketsPage() { {Object.entries(grouped).map(([category, items]) => (

    {CATEGORY_LABELS[category] ?? category}

    -
    +
    {items.map((tariff) => ( ))} @@ -108,19 +107,17 @@ export default async function TicketsPage() { function TariffCard({ tariff }: { tariff: Tariff }) { return ( -
    - {tariff.icon && ( - {tariff.icon} - )} +
    + {tariff.icon && {tariff.icon}}

    {tariff.name}

    {tariff.price} ₴ @@ -136,7 +133,7 @@ function CheckoutButton({ tariffId }: { tariffId: string }) { return ( Придбати diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 5c47926..5f7d1a1 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -31,13 +31,14 @@ export const metadata: Metadata = { template: '%s | Shumiland', default: 'Шуміленд — світ, де казка оживає', }, - description: 'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.', + description: + 'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.', } export default function FrontendLayout({ children }: { children: React.ReactNode }) { return (

    {children}
    diff --git a/src/app/(frontend)/lokatsii/page.tsx b/src/app/(frontend)/lokatsii/page.tsx index 4a62f3e..0f3f1a4 100644 --- a/src/app/(frontend)/lokatsii/page.tsx +++ b/src/app/(frontend)/lokatsii/page.tsx @@ -1,7 +1,9 @@ -/* eslint-disable @next/next/no-img-element */ 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' +import type { LocationCMS, Media } from '@/types/globals' export const metadata: Metadata = { title: 'Локації — Шуміленд', @@ -10,71 +12,140 @@ export const metadata: Metadata = { export const revalidate = 3600 -const LOCATIONS = [ +const STATIC_LOCATIONS: LocationCMS[] = [ { - slug: 'dynopark', + id: 'dynopark', name: 'ДиноПарк', - description: 'Прогуляйтесь серед реалістичних динозаврів у повний зріст. Понад 20 видів доісторичних тварин у природному середовищі.', - tag: 'dyno', - color: '#223e0d', + slug: 'dynopark', + tagline: 'портал у світ динозаврів', + shortDesc: 'Прогуляйтесь серед реалістичних динозаврів у повний зріст. Понад 20 видів доісторичних тварин у природному середовищі.', + image: '/images/figma/loc-dinopark.jpg', }, { - slug: 'dyvolist', + id: 'dyvolis', name: 'Диво Ліс', - description: 'Чарівний ліс з інтерактивними атракціонами, мотузковими парками та пригодами для всіх вікових груп.', - tag: 'dyvolis', - color: '#2d5414', + slug: 'dyvolis', + tagline: 'зона казкових топіарних фігур', + shortDesc: 'Чарівний ліс з інтерактивними атракціонами, мотузковими парками та пригодами для всіх вікових груп.', + image: '/images/figma/loc-divo-lis.png', }, { - slug: 'labirynth', + id: 'maze', name: 'Дзеркальний Лабіринт', - description: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.', - tag: 'maze', - color: '#1a3009', + slug: 'maze', + tagline: 'справжній виклик кмітливості', + shortDesc: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.', + image: '/images/figma/gallery-1.png', + }, + { + id: 'tir', + name: 'Тир з призами', + slug: 'tir', + tagline: 'перемога, яку ви розділите разом', + shortDesc: 'Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.', + image: '/images/figma/gallery-3.png', + }, + { + id: 'playground', + name: 'Дитячий майданчик', + slug: 'playground', + tagline: 'територія забав і нових друзів', + shortDesc: 'Поки малеча підкорює гірки та знаходить перших друзів, ви можете нарешті зробити паузу.', + image: '/images/figma/gallery-8.png', }, ] -export default function LocationsPage() { - return ( -
    - +function getMediaUrl(img: Media | string | null | undefined): string | null { + if (!img) return null + if (typeof img === 'string') return img + return img.url ?? null +} -
    +const COLORS = ['#223e0d', '#2d5414', '#1a3009', '#2a5a12', '#1d4710'] + +async function getLocations(): Promise { + try { + const payload = await getPayload({ config: configPromise }) + const result = await payload.find({ + collection: 'locations', + sort: 'sort', + limit: 20, + overrideAccess: true, + }) + const docs = result.docs as unknown as LocationCMS[] + if (docs.length > 0) return docs + } catch { + // DB not available + } + return STATIC_LOCATIONS +} + +export default async function LocationsPage() { + const locations = await getLocations() + + return ( +
    + + +
    - {LOCATIONS.map((loc) => ( -
    + {locations.map((loc, i) => { + const imgUrl = getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.jpg' + const color = COLORS[i % COLORS.length] + const slug = loc.slug + + return (
    -

    + {/* eslint-disable-next-line @next/next/no-img-element */} + {loc.name} +

    + + {/* Text */} +
    - {loc.name} - -

    - {loc.description} -

    - - Купити квиток - + {loc.tagline && ( +

    + {loc.tagline} +

    + )} +

    + {loc.name} +

    +

    + {loc.shortDesc} +

    + + Купити квиток + +
    -
    - ))} + ) + })}
    diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index 6229f75..12c1882 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -1,4 +1,3 @@ -import { getGlobal } from '@/lib/payload' import { Hero } from '@/components/sections/Hero' import { Locations } from '@/components/sections/Locations' import { WhyParents } from '@/components/sections/WhyParents' @@ -7,11 +6,12 @@ import { VideoSection } from '@/components/sections/VideoSection' import { Gallery } from '@/components/sections/Gallery' import { Reviews } from '@/components/sections/Reviews' import { News } from '@/components/sections/News' -import type { HomePageGlobal } from '@/types/globals' +import { getHomeData } from '@/lib/getHomeData' +import type { HomePageHero } from '@/types/globals' export const revalidate = 60 -const STATIC_HERO: NonNullable = { +const STATIC_HERO: NonNullable = { title: 'Шуміленд — світ, де казка оживає', subtitle: 'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.', @@ -20,12 +20,7 @@ const STATIC_HERO: NonNullable = { } export default async function HomePage() { - let home: HomePageGlobal | null = null - try { - home = await getGlobal('home-page') - } catch { - // Fail gracefully — DB may not be available during static build - } + const { home, locations, reviews, birthdayPackages } = await getHomeData() const heroData = home?.hero const hero = heroData?.title ? heroData : STATIC_HERO @@ -33,12 +28,32 @@ export default async function HomePage() { return (
    - - - - - - + + + + + +
    ) diff --git a/src/app/api/admin/seed/route.ts b/src/app/api/admin/seed/route.ts new file mode 100644 index 0000000..cfd7edb --- /dev/null +++ b/src/app/api/admin/seed/route.ts @@ -0,0 +1,306 @@ +import { NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import path from 'path' +import fs from 'fs' + +async function uploadMedia(payload: Awaited>, filename: string, alt: string) { + const filePath = path.join(process.cwd(), 'public/images/figma', filename) + if (!fs.existsSync(filePath)) return null + try { + const buffer = fs.readFileSync(filePath) + const mimeType = filename.endsWith('.jpg') || filename.endsWith('.jpeg') ? 'image/jpeg' + : filename.endsWith('.png') ? 'image/png' + : filename.endsWith('.svg') ? 'image/svg+xml' + : 'image/png' + const doc = await payload.create({ + collection: 'media', + data: { alt } as never, + file: { data: buffer, mimetype: mimeType, name: filename, size: buffer.length }, + overrideAccess: true, + }) + return doc.id as string + } catch { + return null + } +} + +export async function POST() { + const payload = await getPayload({ config }) + const results: string[] = [] + + // Admin user + const { totalDocs } = await payload.find({ collection: 'users', limit: 1, overrideAccess: true }) + if (totalDocs === 0) { + await payload.create({ + collection: 'users', + data: { email: 'admin@shumiland.ua', password: 'changeMe123!', name: 'Admin', role: 'admin' } as never, + overrideAccess: true, + }) + results.push('Created admin user: admin@shumiland.ua / changeMe123!') + } else { + results.push('Admin user already exists') + } + + // === LOCATIONS === + const { totalDocs: locCount } = await payload.find({ collection: 'locations', limit: 1, overrideAccess: true }) + if (locCount === 0) { + const locationDefs = [ + { name: 'ДиноПарк', slug: 'dynopark', tagline: 'портал у світ динозаврів', shortDesc: 'Ви бачили їх у фільмах та мультиках, а тепер час зустріти в реальному житті та роздивитися їх зблизька! Найбільші динозаври України, які гарчать і рухаються, як справжні.', imageFile: 'loc-dinopark.jpg', imageAlt: 'ДиноПарк', sort: 1 }, + { name: 'Диво Ліс', slug: 'dyvolis', tagline: 'зона казкових топіарних фігур', shortDesc: 'Тут на лісових стежках оселилися єдинороги, дракони та добрі лісові звірята. Це ідеальне місце, щоб пофантазувати разом із дитиною.', imageFile: 'loc-divo-lis.png', imageAlt: 'Диво Ліс', sort: 2 }, + { name: 'Дзеркальний Лабіринт', slug: 'maze', tagline: 'справжній виклик кмітливості', shortDesc: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.', imageFile: 'gallery-1.png', imageAlt: 'Дзеркальний Лабіринт', sort: 3 }, + { name: 'Тир з призами', slug: 'tir', tagline: 'перемога, яку ви розділите разом', shortDesc: 'Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.', imageFile: 'gallery-3.png', imageAlt: 'Тир з призами', sort: 4 }, + { name: 'Дитячий майданчик', slug: 'playground', tagline: 'територія забав і нових друзів', shortDesc: 'Поки малеча підкорює гірки та знаходить перших друзів, ви можете нарешті зробити паузу.', imageFile: 'gallery-8.png', imageAlt: 'Дитячий майданчик', sort: 5 }, + ] + for (const loc of locationDefs) { + const imageId = await uploadMedia(payload, loc.imageFile, loc.imageAlt) + await payload.create({ + collection: 'locations', + data: { + name: loc.name, + slug: loc.slug, + tagline: loc.tagline, + shortDesc: loc.shortDesc, + image: imageId ?? undefined, + showInMenu: true, + showOnHome: true, + sort: loc.sort, + } as never, + overrideAccess: true, + }) + } + results.push('Seeded 5 locations') + } else { + results.push('Locations already exist') + } + + // === REVIEWS === + const { totalDocs: revCount } = await payload.find({ collection: 'reviews', limit: 1, overrideAccess: true }) + if (revCount === 0) { + const avatarId = await uploadMedia(payload, 'review-avatar-bg.jpg', 'Review avatar background') + const reviewDefs = [ + { name: 'Женя Олейник', initial: 'Ж', ago: '2 місяці тому', text: 'Гарне місце, цікаво для дітей і дорослих. Велика площа і різні локації. Мало відвідувачів у будні.', sort: 1 }, + { name: 'Анна Калініченко', initial: 'А', ago: '6 місяців тому', text: 'Чудовий динозавровий парк, фігури з трав\'яного покриття. Можна залізти на фігури, дітям в захваті! Динозаври рухаються і гарчать.', sort: 2 }, + { name: 'Volodymyr Prisajnuk', initial: 'V', ago: '10 місяців тому', text: 'Ми з сім\'єю відвідали відкритий парк, нам дуже сподобалось! Значно краще, ніж очікував. Динозаври дуже запам\'яталися - неймовірно!', sort: 3 }, + ] + for (const rev of reviewDefs) { + await payload.create({ + collection: 'reviews', + data: { + name: rev.name, + initial: rev.initial, + avatarBg: avatarId ?? undefined, + ago: rev.ago, + rating: 5, + text: rev.text, + source: 'google', + showOnHome: true, + sort: rev.sort, + } as never, + overrideAccess: true, + }) + } + results.push('Seeded 3 reviews') + } else { + results.push('Reviews already exist') + } + + // === BIRTHDAY PACKAGES === + const { totalDocs: pkgCount } = await payload.find({ collection: 'birthday-packages', limit: 1, overrideAccess: true }) + if (pkgCount === 0) { + const packages = [ + { + name: 'Стандарт', slug: 'standard', price: 4500, + features: [ + { text: 'Вхід для іменинника + 5 дітей' }, + { text: 'Анімаційна програма (1 год)' }, + { text: 'Декорації столу' }, + { text: 'Торт від кондитера' }, + ], + featured: false, sort: 1, + ctaHref: '/dni-narodzhennia#order-form', + }, + { + name: 'Преміум', slug: 'premium', price: 8900, + features: [ + { text: 'Вхід для іменинника + 10 дітей' }, + { text: 'Анімаційна програма (3 год)' }, + { text: 'Повна декорація локації' }, + { text: 'Фотозона та фотограф' }, + { text: 'Сюрприз-подарунок кожному' }, + ], + featured: true, badge: 'Найпопулярніший', sort: 2, + ctaHref: '/dni-narodzhennia#order-form', + }, + { + name: 'VIP', slug: 'vip', price: 15000, + features: [ + { text: 'Вхід для іменинника + 20 гостей' }, + { text: 'Індивідуальний сценарій' }, + { text: 'Квест по всьому парку' }, + { text: 'Відео-звіт свята' }, + { text: 'Персональний менеджер' }, + ], + featured: false, sort: 3, + ctaHref: '/dni-narodzhennia#order-form', + }, + ] + for (const pkg of packages) { + await payload.create({ + collection: 'birthday-packages', + data: pkg as never, + overrideAccess: true, + }) + } + results.push('Seeded 3 birthday packages') + } else { + results.push('Birthday packages already exist') + } + + // === HOME PAGE GLOBAL === + const wpGalleryFiles = [ + { file: 'why-parents-1.png', alt: 'Шуміленд — фото 1' }, + { file: 'why-parents-2.png', alt: 'Шуміленд — фото 2' }, + { file: 'why-parents-3.png', alt: 'Шуміленд — фото 3' }, + { file: 'why-parents-4.png', alt: 'Шуміленд — фото 4' }, + { file: 'gallery-1.png', alt: 'Шуміленд — фото 5' }, + { file: 'gallery-3.png', alt: 'Шуміленд — фото 6' }, + ] + const galleryFiles = [ + { file: 'gallery-1.png', alt: 'Шуміленд — галерея 1' }, + { file: 'why-parents-1.png', alt: 'Шуміленд — галерея 2' }, + { file: 'gallery-2.png', alt: 'Шуміленд — галерея 3' }, + { file: 'gallery-3.png', alt: 'Шуміленд — галерея 4' }, + { file: 'why-parents-2.png', alt: 'Шуміленд — галерея 5' }, + { file: 'gallery-4.png', alt: 'Шуміленд — галерея 6' }, + { file: 'gallery-6.png', alt: 'Шуміленд — галерея 7' }, + { file: 'gallery-7.png', alt: 'Шуміленд — галерея 8' }, + { file: 'gallery-8.png', alt: 'Шуміленд — галерея 9' }, + ] + const videoPosterId = await uploadMedia(payload, 'video-preview.png', 'Відео про Шуміленд') + + const wpGalleryIds = await Promise.all(wpGalleryFiles.map(({ file, alt }) => uploadMedia(payload, file, alt))) + const galleryIds = await Promise.all(galleryFiles.map(({ file, alt }) => uploadMedia(payload, file, alt))) + + await payload.updateGlobal({ + slug: 'home-page', + data: { + hero: { + title: 'ШУМІЛЕНД –\nСВІТ, ДЕ КАЗКА\nОЖИВАЄ', + subtitle: 'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.', + ctaLabel: 'Купити квиток', + ctaHref: '/kvytky', + }, + sectionTitles: { + locations: 'ЛАСКАВО ПРОСИМО ДО ШУМІЛЕНДУ', + whyParents: 'Чому батьки обирають Шуміленд', + birthday: 'День Народження в Шуміленді', + gallery: 'Фотогалерея', + reviews: 'Відгуки', + news: 'Новини', + }, + whyParents: { + items: [ + { title: 'Подорож кількома світами за один день', description: 'ДиноПарк, Диво Ліс, Дзеркальний лабіринт — кожна локація це окремий всесвіт пригод для дітей і батьків.' }, + { title: 'Свіже повітря та затишок лісу', description: "Ми оновлюємо тематику та декорації до кожного сезону, тому тут буде цікаво кожного візиту." }, + { title: 'Нова казка кожної пори року', description: 'Зима, весна, літо, осінь — кожен сезон у парку неповторний. Святкові декорації та тематичні заходи чекають на вас.' }, + { title: 'Безпека понад усе', description: 'Всі атракції та зони проходять регулярну перевірку. Охоронці, медичний персонал та чіткі правила безпеки.' }, + { title: 'Все необхідне — поруч і без пошуків', description: 'Паркування, вбиральні, зона для годування немовлят, укриття, фудкорт — все на місці.' }, + { title: 'Фудкорт — смачно для всієї родини', description: 'Хот-доги, піца, кава, лимонади та багато іншого. Є дитяче меню та здорові перекуси.' }, + ], + sideGallery: wpGalleryIds.filter(Boolean).map((id) => ({ image: id })), + }, + gallery: { + images: galleryIds.filter((id): id is string => !!id).map((id, i) => ({ image: id, alt: galleryFiles[i]?.alt ?? '' })), + }, + video: { + poster: videoPosterId ?? undefined, + src: null, + }, + birthdayIntro: { + text: 'Незабутнє свято для вашої дитини. Ми подбаємо про все: від декорацій до аніматорів!', + }, + news: { title: 'Новини', limit: 3 }, + } as never, + overrideAccess: true, + }) + results.push('Seeded home-page global') + + // === HEADER === + await payload.updateGlobal({ + slug: 'header', + data: { + ctaLabel: 'Купити квиток', + ctaHref: '/kvytky', + navLinks: [ + { label: 'Головна', href: '/', autoChildrenFrom: 'none' }, + { label: 'Локації', href: '/lokatsii', autoChildrenFrom: 'locations' }, + { label: 'Блог', href: '/blog', autoChildrenFrom: 'none' }, + { label: 'Дні народження', href: '/dni-narodzhennia', autoChildrenFrom: 'none' }, + { label: 'Групові відвідування', href: '/grupovi-vidviduvannia', autoChildrenFrom: 'none' }, + ], + } as never, + overrideAccess: true, + }) + results.push('Seeded header global') + + // === FOOTER === + await payload.updateGlobal({ + slug: 'footer', + data: { copyrightText: `© Шуміленд ${new Date().getFullYear()}` } as never, + overrideAccess: true, + }) + results.push('Seeded footer global') + + // === SITE SETTINGS === + await payload.updateGlobal({ + slug: 'site-settings', + data: {} as never, + overrideAccess: true, + }) + results.push('Seeded site-settings global') + + // === BLOG POSTS === + const { totalDocs: postCount } = await payload.find({ collection: 'blog-posts', limit: 1, overrideAccess: true }) + if (postCount === 0) { + const postDefs = [ + { title: 'Сезон динозаврів відкрито!', slug: 'sezon-dynozavriv-vidkryto', excerpt: 'Шуміленд вітає нових мешканців ДиноПарку — зустрічайте 12 нових динозаврів!', status: 'published', publishedAt: '2025-04-01T10:00:00.000Z', imgFile: 'news-bg1.jpg' }, + { title: 'Весняні канікули в Шуміленді', slug: 'vesniani-kanikuly', excerpt: 'Проведіть весняні канікули незабутньо! Спеціальні активності щодня з 28 березня по 6 квітня.', status: 'published', publishedAt: '2025-03-20T10:00:00.000Z', imgFile: 'news-bg2.png' }, + { title: 'Нова локація: Тир з призами', slug: 'nova-lokatsiya-tyr-z-pryzamy', excerpt: 'Відтепер у Шуміленді є Тир з призами — точний постріл приносить реальний виграш!', status: 'published', publishedAt: '2025-03-10T10:00:00.000Z', imgFile: 'news-bg3.jpg' }, + ] + for (const post of postDefs) { + const heroId = await uploadMedia(payload, post.imgFile, post.title) + await payload.create({ + collection: 'blog-posts', + data: { title: post.title, slug: post.slug, excerpt: post.excerpt, status: post.status, publishedAt: post.publishedAt, hero: heroId ? { image: heroId } : undefined } as never, + overrideAccess: true, + }) + } + results.push('Seeded 3 blog posts') + } else { + results.push('Blog posts already exist') + } + + // === TARIFFS === + const { totalDocs: tariffCount } = await payload.find({ collection: 'tariffs', limit: 1, overrideAccess: true }) + if (tariffCount === 0) { + const tariffs = [ + { ezy_id: 1001, last_synced_name: 'Дорослий — ДиноПарк', display_name: 'Дорослий', last_synced_price: 350, category_tag: 'dyno', sort: 1, visible: true }, + { ezy_id: 1002, last_synced_name: 'Дитячий — ДиноПарк', display_name: 'Дитячий (3–12 років)', last_synced_price: 250, category_tag: 'dyno', sort: 2, visible: true }, + { ezy_id: 1003, last_synced_name: 'До 3 років — ДиноПарк', display_name: 'До 3 років (безкоштовно)', last_synced_price: 0, category_tag: 'dyno', sort: 3, visible: true }, + { ezy_id: 2001, last_synced_name: 'Дорослий — Диво Ліс', display_name: 'Дорослий', last_synced_price: 300, category_tag: 'dyvolis', sort: 1, visible: true }, + { ezy_id: 2002, last_synced_name: 'Дитячий — Диво Ліс', display_name: 'Дитячий (3–12 років)', last_synced_price: 200, category_tag: 'dyvolis', sort: 2, visible: true }, + { ezy_id: 3001, last_synced_name: 'Комбо дорослий', display_name: 'Комбо дорослий', last_synced_price: 550, category_tag: 'combo', sort: 1, visible: true }, + { ezy_id: 3002, last_synced_name: 'Комбо дитячий', display_name: 'Комбо дитячий', last_synced_price: 400, category_tag: 'combo', sort: 2, visible: true }, + { ezy_id: 4001, last_synced_name: 'Сімейний (2+2)', display_name: 'Сімейний квиток', last_synced_price: 1200, category_tag: 'family', sort: 1, visible: true }, + ] + for (const t of tariffs) { + await payload.create({ collection: 'tariffs', data: t as never, overrideAccess: true }) + } + results.push('Seeded 8 tariffs') + } else { + results.push('Tariffs already exist') + } + + return NextResponse.json({ ok: true, results }) +} diff --git a/src/collections/BirthdayPackages.ts b/src/collections/BirthdayPackages.ts new file mode 100644 index 0000000..02f58e5 --- /dev/null +++ b/src/collections/BirthdayPackages.ts @@ -0,0 +1,28 @@ +import type { CollectionConfig } from 'payload' +import { isAdminOrEditor } from '@/access/isAdminOrEditor' + +export const BirthdayPackages: CollectionConfig = { + slug: 'birthday-packages', + access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor }, + admin: { + useAsTitle: 'name', + defaultColumns: ['name', 'price', 'featured', 'sort'], + }, + fields: [ + { name: 'name', type: 'text', required: true }, + { name: 'slug', type: 'text', required: true, unique: true }, + { name: 'price', type: 'number', required: true }, + { name: 'currency', type: 'text', defaultValue: '₴' }, + { name: 'priceLabel', type: 'text' }, + { + name: 'features', + type: 'array', + fields: [{ name: 'text', type: 'text', required: true }], + }, + { name: 'featured', type: 'checkbox', defaultValue: false }, + { name: 'badge', type: 'text' }, + { name: 'ctaLabel', type: 'text', defaultValue: 'Обрати пакет' }, + { name: 'ctaHref', type: 'text' }, + { name: 'sort', type: 'number', defaultValue: 0 }, + ], +} diff --git a/src/collections/Locations.ts b/src/collections/Locations.ts new file mode 100644 index 0000000..638f71f --- /dev/null +++ b/src/collections/Locations.ts @@ -0,0 +1,28 @@ +import type { CollectionConfig } from 'payload' +import { isAdminOrEditor } from '@/access/isAdminOrEditor' + +export const Locations: CollectionConfig = { + slug: 'locations', + access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor }, + admin: { + useAsTitle: 'name', + defaultColumns: ['name', 'slug', 'showInMenu', 'showOnHome', 'sort'], + }, + fields: [ + { name: 'name', type: 'text', required: true }, + { name: 'slug', type: 'text', required: true, unique: true }, + { name: 'tagline', type: 'text' }, + { name: 'shortDesc', type: 'textarea' }, + { name: 'description', type: 'richText' }, + { name: 'image', type: 'upload', relationTo: 'media' }, + { + name: 'gallery', + type: 'array', + fields: [{ name: 'image', type: 'upload', relationTo: 'media' }], + }, + { name: 'href', type: 'text' }, + { name: 'showInMenu', type: 'checkbox', defaultValue: true }, + { name: 'showOnHome', type: 'checkbox', defaultValue: true }, + { name: 'sort', type: 'number', defaultValue: 0 }, + ], +} diff --git a/src/collections/Reviews.ts b/src/collections/Reviews.ts new file mode 100644 index 0000000..1b2061b --- /dev/null +++ b/src/collections/Reviews.ts @@ -0,0 +1,32 @@ +import type { CollectionConfig } from 'payload' +import { isAdminOrEditor } from '@/access/isAdminOrEditor' + +export const Reviews: CollectionConfig = { + slug: 'reviews', + access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor }, + admin: { + useAsTitle: 'name', + defaultColumns: ['name', 'source', 'rating', 'showOnHome', 'sort'], + }, + fields: [ + { name: 'name', type: 'text', required: true }, + { name: 'initial', type: 'text', maxLength: 2 }, + { name: 'avatarBg', type: 'upload', relationTo: 'media' }, + { name: 'ago', type: 'text' }, + { name: 'rating', type: 'number', defaultValue: 5, min: 1, max: 5 }, + { name: 'text', type: 'textarea', required: true }, + { + name: 'source', + type: 'select', + options: [ + { label: 'Google', value: 'google' }, + { label: 'Facebook', value: 'facebook' }, + { label: 'Instagram', value: 'instagram' }, + { label: 'Manual', value: 'manual' }, + ], + defaultValue: 'google', + }, + { name: 'showOnHome', type: 'checkbox', defaultValue: true }, + { name: 'sort', type: 'number', defaultValue: 0 }, + ], +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index e78878f..24100e8 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,11 +1,22 @@ import { getGlobal } from '@/lib/payload' +import { getHeaderLocations } from '@/lib/getHomeData' import { safeCmsHref } from '@/lib/cn' import { HeaderClient, type NavLinkItem } from './HeaderClient' import type { HeaderGlobal } from '@/types/globals' const DEFAULT_NAV: NavLinkItem[] = [ { label: 'Головна', href: '/' }, - { label: 'Локації', href: '/lokatsii' }, + { + label: 'Локації', + href: '/lokatsii', + children: [ + { label: 'ДиноПарк', href: '/lokatsii#dynopark' }, + { label: 'Диво Ліс', href: '/lokatsii#dyvolis' }, + { label: 'Дзеркальний Лабіринт', href: '/lokatsii#maze' }, + { label: 'Тир з призами', href: '/lokatsii#tir' }, + { label: 'Дитячий майданчик', href: '/lokatsii#playground' }, + ], + }, { label: 'Блог', href: '/blog' }, { label: 'Дні народження', href: '/dni-narodzhennia' }, { label: 'Групові відвідування', href: '/grupovi-vidviduvannia' }, @@ -17,11 +28,37 @@ export async function Header() { let ctaLabel = 'Купити квиток' try { - const header = await getGlobal('header') + const [header, menuLocations] = await Promise.all([ + getGlobal('header'), + getHeaderLocations(), + ]) + + if (header?.ctaLabel) ctaLabel = header.ctaLabel + if (header?.ctaHref) ctaHref = safeCmsHref(header.ctaHref) ?? header.ctaHref + if (header?.navLinks && header.navLinks.length > 0) { - const parsed = header.navLinks + const parsed: NavLinkItem[] = header.navLinks .filter((l) => l.label && l.href) - .map((l) => ({ label: l.label!, href: safeCmsHref(l.href) ?? l.href! })) + .map((l) => { + const item: NavLinkItem = { + label: l.label!, + href: safeCmsHref(l.href) ?? l.href!, + } + + if (l.autoChildrenFrom === 'locations' && menuLocations.length > 0) { + item.children = menuLocations.map((loc) => ({ + label: loc.name, + href: loc.href ?? `/lokatsii#${loc.slug}`, + })) + } else if (l.children && l.children.length > 0) { + item.children = l.children + .filter((c) => c.label && c.href) + .map((c) => ({ label: c.label!, href: c.href! })) + } + + return item + }) + if (parsed.length > 0) navLinks = parsed } } catch { diff --git a/src/components/layout/HeaderClient.tsx b/src/components/layout/HeaderClient.tsx index 2fe6065..13d0eba 100644 --- a/src/components/layout/HeaderClient.tsx +++ b/src/components/layout/HeaderClient.tsx @@ -13,6 +13,7 @@ const LOGO_G3 = '/images/figma/logo-g3.svg' export interface NavLinkItem { label: string href: string + children?: { label: string; href: string }[] } interface HeaderClientProps { @@ -37,7 +38,7 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps) return (
      {navLinks.map((link) => ( -
    • +
    • {link.label} + + {link.children && ( +
      + {/* Highlight sheen — matches header glass top edge */} +
      + )}
    • ))}
    @@ -148,6 +182,22 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps) > {link.label} + {link.children && ( +
      + {link.children.map((child) => ( +
    • + setMenuOpen(false)} + className="text-white/70 font-medium text-[16px] hover:text-[#f28b4a] transition-colors" + style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }} + > + {child.label} + +
    • + ))} +
    + )} ))}
  • diff --git a/src/components/sections/BirthdayPricing.tsx b/src/components/sections/BirthdayPricing.tsx index 608e7d3..edf77fc 100644 --- a/src/components/sections/BirthdayPricing.tsx +++ b/src/components/sections/BirthdayPricing.tsx @@ -1,57 +1,69 @@ /* eslint-disable @next/next/no-img-element */ import Link from 'next/link' +import type { BirthdayPackageCMS } from '@/types/globals' const IMG_CHECK = '/images/figma/check-mark.png' -const PACKAGES = [ +const STATIC_PACKAGES: BirthdayPackageCMS[] = [ { + id: 'standard', name: 'Стандарт', - price: '4 500 ₴', + slug: 'standard', + price: 4500, featured: false, features: [ - 'Вхід для іменинника + 5 дітей', - 'Анімаційна програма (1 год)', - 'Декорації столу', - 'Торт від кондитера', + { text: 'Вхід для іменинника + 5 дітей' }, + { text: 'Анімаційна програма (1 год)' }, + { text: 'Декорації столу' }, + { text: 'Торт від кондитера' }, ], + ctaHref: '/dni-narodzhennia#order-form', + ctaLabel: 'Обрати пакет', }, { + id: 'premium', name: 'Преміум', - price: '8 900 ₴', + slug: 'premium', + price: 8900, featured: true, badge: 'Найпопулярніший', features: [ - 'Вхід для іменинника + 10 дітей', - 'Анімаційна програма (3 год)', - 'Повна декорація локації', - 'Фотозона та фотограф', - 'Сюрприз-подарунок кожному', + { text: 'Вхід для іменинника + 10 дітей' }, + { text: 'Анімаційна програма (3 год)' }, + { text: 'Повна декорація локації' }, + { text: 'Фотозона та фотограф' }, + { text: 'Сюрприз-подарунок кожному' }, ], + ctaHref: '/dni-narodzhennia#order-form', + ctaLabel: 'Обрати пакет', }, { + id: 'vip', name: 'VIP', - price: '15 000 ₴', + slug: 'vip', + price: 15000, featured: false, features: [ - 'Вхід для іменинника + 20 гостей', - 'Індивідуальний сценарій', - 'Квест по всьому парку', - 'Відео-звіт свята', - 'Персональний менеджер', + { text: 'Вхід для іменинника + 20 гостей' }, + { text: 'Індивідуальний сценарій' }, + { text: 'Квест по всьому парку' }, + { text: 'Відео-звіт свята' }, + { text: 'Персональний менеджер' }, ], + ctaHref: '/dni-narodzhennia#order-form', + ctaLabel: 'Обрати пакет', }, ] -/** - * 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() { +interface BirthdayPricingProps { + packages?: BirthdayPackageCMS[] + title?: string + intro?: string +} + +export function BirthdayPricing({ packages, title, intro }: BirthdayPricingProps) { + const activePackages = packages && packages.length > 0 ? packages : STATIC_PACKAGES + return (
    @@ -60,19 +72,20 @@ export function BirthdayPricing() { className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }} > - День Народження в Шуміленді + {title ?? 'День Народження в Шуміленді'}

    - Незабутнє свято для вашої дитини. Ми подбаємо про все: від декорацій до аніматорів! + {intro ?? 'Незабутнє свято для вашої дитини. Ми подбаємо про все: від декорацій до аніматорів!'}

    + {/* Figma 1:231 — cards same width 362px, featured lifts up by 60px via negative margin */}
    - {PACKAGES.map((pkg) => ( - + {activePackages.map((pkg) => ( + ))}
  • @@ -80,23 +93,20 @@ export function BirthdayPricing() { ) } -interface PricingCardProps { - name: string - price: string - featured: boolean - badge?: string - features: string[] +function formatPrice(price: number, currency: string | null | undefined): string { + return `${price.toLocaleString('uk-UA')} ${currency ?? '₴'}` } -function PricingCard({ name, price, featured, badge, features }: PricingCardProps) { +function PricingCard({ pkg }: { pkg: BirthdayPackageCMS }) { + const { name, price, currency = '₴', priceLabel, featured, badge, features, ctaLabel, ctaHref, slug } = pkg + const href = ctaHref ?? `/dni-narodzhennia#order-form${slug ? `?package=${slug}` : ''}` + return (
    - {/* Badge — only on featured. Sits *above* the card, overlapping by 14px. */} + {/* Desktop badge — overlaps card top by 14px */} {featured && badge && (
    )} - {/* Mobile badge above the card */} + {/* Mobile badge */} {featured && badge && (
    {name}

    -
    +

    - {price} + {formatPrice(price, currency)}

    + {priceLabel && ( +

    + {priceLabel} +

    + )}
    - {/* Features — natural flow, NOT flex-1. Gap 16, top margin 40. */} + {/* Features */}
      - {features.map((f) => ( -
    • + {(features ?? []).map((f, i) => ( +
    • - {f} + {f.text}
    • ))}
    - {/* CTA — sits 40px below the features list */} + {/* CTA */} - Обрати пакет + {ctaLabel ?? 'Обрати пакет'}
    diff --git a/src/components/sections/Gallery.tsx b/src/components/sections/Gallery.tsx index 752b4d5..208d9b6 100644 --- a/src/components/sections/Gallery.tsx +++ b/src/components/sections/Gallery.tsx @@ -1,33 +1,55 @@ import { GallerySlider } from './GallerySlider' import type { GalleryImage } from './GallerySlider' +import type { HomePageGalleryImage, Media } from '@/types/globals' -const IMAGES: GalleryImage[] = [ - { 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 }, +function getMediaUrl(img: Media | string | null | undefined): string | null { + if (!img) return null + if (typeof img === 'string') return img + return img.url ?? null +} + +const STATIC_IMAGES: GalleryImage[] = [ + { src: '/images/figma/gallery-1.png', alt: 'Шуміленд — фото 1', width: 320, height: 420, radius: 20 }, + { src: '/images/figma/why-parents-1.png', alt: 'Шуміленд — сімейні враження', width: 380, height: 420, radius: 20 }, + { src: '/images/figma/gallery-2.png', alt: 'Шуміленд — фото 2', width: 320, height: 420, radius: 20 }, + { src: '/images/figma/gallery-3.png', alt: 'Шуміленд — фото 3', width: 380, height: 420, radius: 20 }, + { src: '/images/figma/why-parents-2.png', alt: 'Шуміленд — прогулянка', width: 320, height: 420, radius: 20 }, + { src: '/images/figma/gallery-4.png', alt: 'Шуміленд — фото 4', width: 380, height: 420, radius: 20 }, + { src: '/images/figma/gallery-6.png', alt: 'Шуміленд — атракціони', width: 320, height: 420, radius: 20 }, + { src: '/images/figma/gallery-7.png', alt: 'Шуміленд — фото 5', width: 380, height: 420, radius: 20 }, + { src: '/images/figma/gallery-8.png', alt: 'Шуміленд — фото 6', width: 320, height: 420, radius: 20 }, ] -export function Gallery() { +interface GalleryProps { + images?: HomePageGalleryImage[] + title?: string +} + +export function Gallery({ images, title }: GalleryProps) { + const items: GalleryImage[] = + images && images.length > 0 + ? images.map((img, i) => ({ + src: getMediaUrl(img.image) ?? '/images/figma/gallery-1.png', + alt: img.alt ?? `Шуміленд — фото ${i + 1}`, + width: i % 2 === 0 ? 320 : 380, + height: 420, + radius: 20, + })) + : STATIC_IMAGES + return (
    -
    +

    - Фотогалерея + {title ?? 'Фотогалерея'}

    -
    - +
    +
    ) diff --git a/src/components/sections/GallerySlider.tsx b/src/components/sections/GallerySlider.tsx index ee4cf66..58d7b8a 100644 --- a/src/components/sections/GallerySlider.tsx +++ b/src/components/sections/GallerySlider.tsx @@ -1,7 +1,8 @@ 'use client' /* eslint-disable @next/next/no-img-element */ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' +import { ImageLightbox } from '@/components/ui/ImageLightbox' export interface GalleryImage { src: string @@ -13,65 +14,137 @@ export interface GalleryImage { interface GallerySliderProps { images: GalleryImage[] + /** Approximate seconds for one full loop. Default 60. */ speed?: number } -export function GallerySlider({ images, speed = 35 }: GallerySliderProps) { - const [paused, setPaused] = useState(false) +export function GallerySlider({ images, speed = 60 }: GallerySliderProps) { + const scrollRef = useRef(null) + const [scrollPaused, setScrollPaused] = useState(false) + const pauseTimer = useRef | null>(null) + const [lightboxIndex, setLightboxIndex] = useState(null) - // Duplicate for seamless infinite loop const doubled = [...images, ...images] - return ( -
    setPaused(true)} - onMouseLeave={() => setPaused(false)} - > - + useEffect(() => { + const el = scrollRef.current + if (!el) return + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return -
    - {doubled.map((img, i) => { - const w = img.width ?? 384 - const h = img.height ?? 280 - const r = img.radius ?? 20 - return ( -
    - {img.alt} -
    - ) - })} + // ~1px per tick — tune with speed prop + const pxPerTick = 1 + const intervalMs = Math.max(8, Math.round((speed * 1000) / (el.scrollWidth / 2))) + + const id = setInterval(() => { + if (scrollPaused) return + const half = el.scrollWidth / 2 + el.scrollLeft += pxPerTick + if (el.scrollLeft >= half) el.scrollLeft = 0 + }, intervalMs) + + return () => clearInterval(id) + }, [scrollPaused, speed]) + + function scrollByCard(dir: 1 | -1) { + const cardWidth = 380 + 26 + scrollRef.current?.scrollBy({ left: dir * cardWidth, behavior: 'smooth' }) + setScrollPaused(true) + if (pauseTimer.current) clearTimeout(pauseTimer.current) + pauseTimer.current = setTimeout(() => setScrollPaused(false), 3000) + } + + function openLightbox(idx: number) { + setLightboxIndex(idx % images.length) + setScrollPaused(true) + } + + function closeLightbox() { + setLightboxIndex(null) + setScrollPaused(false) + } + + function prevImage() { + setLightboxIndex((i) => (i === null ? 0 : (i - 1 + images.length) % images.length)) + } + + function nextImage() { + setLightboxIndex((i) => (i === null ? 0 : (i + 1) % images.length)) + } + + return ( + <> +
    + + +
    setScrollPaused(true)} + onMouseLeave={() => setScrollPaused(false)} + > +
    + {doubled.map((img, i) => { + const w = img.width ?? 384 + const h = img.height ?? 280 + const r = img.radius ?? 20 + return ( +
    openLightbox(i)} + role="button" + aria-label={`Відкрити фото: ${img.alt}`} + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && openLightbox(i)} + > + {img.alt} +
    + ) + })} +
    +
    + +
    -
    + + {lightboxIndex !== null && ( + + )} + ) } diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index fbdaf46..4717b35 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -3,6 +3,7 @@ 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 { @@ -23,13 +24,14 @@ export function Hero({ hero }: HeroProps) { const { title, subtitle, ctaLabel, ctaHref, backgroundImage, backgroundVideo } = hero const showCta = Boolean(ctaLabel && ctaHref && isSafeHref(ctaHref)) const bgMedia = isMedia(backgroundImage) ? backgroundImage : null + const useDefaultLayers = !bgMedia && !backgroundVideo return (
    - {/* Background layers */} + {/* ── Background ──────────────────────────────────────────────── */} {backgroundVideo ? (