perf: compress images to WebP (-97%) and fix slider/performance issues
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

- Convert all 40+ public/images/figma assets from raw PNG/JPG to WebP at
  max 1920px, 82% quality: 397 MB → 13 MB total (96.7% reduction)
- Update all component image references to .webp
- Add 1-year Cache-Control headers for /images/* and /_next/static/*
- Fix GallerySlider initial scroll offset (first card no longer clipped by mask)
- Fix arrow2.svg missing explicit width/height (Lighthouse unsized-images)
- Hero, LocationsSlider: aspect-ratio height + seamless infinite loop (prior session)
- Add drizzle-kit to dependencies for production schema push (prior session)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-10 22:49:46 +01:00
parent a5a9410c07
commit 96dc23b74b
70 changed files with 218 additions and 72 deletions

View file

@ -1,9 +1,23 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
const ONE_YEAR = 'public, max-age=31536000, immutable'
const nextConfig: NextConfig = {
output: 'standalone',
reactStrictMode: true,
async headers() {
return [
{
source: '/images/:path*',
headers: [{ key: 'Cache-Control', value: ONE_YEAR }],
},
{
source: '/_next/static/:path*',
headers: [{ key: 'Cache-Control', value: ONE_YEAR }],
},
]
},
webpack: (config) => {
config.watchOptions = {
...config.watchOptions,

View file

@ -40,6 +40,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cyrillic-to-translit-js": "^3.2.1",
"drizzle-kit": "0.31.7",
"graphql": "^16.9.0",
"next": "^16.2.6",
"payload": "^3.33.0",

View file

@ -45,7 +45,20 @@ export default buildConfig({
editor: lexicalEditor(),
sharp,
collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders, Locations, Reviews, BirthdayPackages],
collections: [
Users,
Media,
Pages,
BlogPosts,
Categories,
Tags,
Tariffs,
Leads,
Orders,
Locations,
Reviews,
BirthdayPackages,
],
globals: [HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings],

3
pnpm-lock.yaml generated
View file

@ -29,6 +29,9 @@ importers:
cyrillic-to-translit-js:
specifier: ^3.2.1
version: 3.2.1
drizzle-kit:
specifier: 0.31.7
version: 0.31.7
graphql:
specifier: ^16.9.0
version: 16.14.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View file

@ -0,0 +1,74 @@
import sharp from 'sharp'
import { readdir, stat } from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const imgDir = path.resolve(__dirname, '../public/images/figma')
const SKIP = /\.(svg|webp|gif)$/i
const SIZE_LIMITS = {
// hero / footer / video-preview — full bleed, keep wide
'hero-bg1.png': { width: 1920 },
'hero-bg2.png': { width: 1920 },
'hero-bg-family.png': { width: 1920 },
'footer-bg.png': { width: 1920 },
'video-preview.png': { width: 1920 },
// location cards — displayed at ~560px, 2× retina = 1120
'loc-dinopark.jpg': { width: 1200 },
'loc-divo-lis.png': { width: 1200 },
// gallery tiles — displayed at ~380px, 2× = 760
'gallery-1.png': { width: 800 },
'gallery-2.png': { width: 800 },
'gallery-3.png': { width: 800 },
'gallery-4.png': { width: 800 },
'gallery-6.png': { width: 800 },
'gallery-7.png': { width: 800 },
'gallery-8.png': { width: 800 },
// why-parents side tiles
'why-parents-1.png': { width: 800 },
'why-parents-2.png': { width: 800 },
'why-parents-3.png': { width: 800 },
'why-parents-4.png': { width: 800 },
// news / blog thumbnails
'news-bg1.jpg': { width: 800 },
'news-bg2.png': { width: 800 },
'news-bg3.jpg': { width: 800 },
// misc
'review-avatar-bg.jpg': { width: 160 },
'check-mark.png': { width: 48 },
}
const files = await readdir(imgDir)
let totalBefore = 0
let totalAfter = 0
for (const file of files) {
if (SKIP.test(file)) continue
if (file.endsWith('.webp')) continue
const src = path.join(imgDir, file)
const dst = path.join(imgDir, file.replace(/\.(png|jpg|jpeg)$/i, '.webp'))
const srcStat = await stat(src)
totalBefore += srcStat.size
const limit = SIZE_LIMITS[file] ?? { width: 1920 }
try {
const info = await sharp(src)
.resize({ width: limit.width, withoutEnlargement: true })
.webp({ quality: 82, effort: 4 })
.toFile(dst)
totalAfter += info.size
const pct = ((1 - info.size / srcStat.size) * 100).toFixed(1)
console.log(`${file}${file.replace(/\.(png|jpg|jpeg)$/i, '.webp')} ${(srcStat.size / 1e6).toFixed(1)}MB → ${(info.size / 1e6).toFixed(1)}MB (${pct}% smaller)`)
} catch (err) {
console.error(`${file}: ${err.message}`)
}
}
console.log(`\nTotal: ${(totalBefore / 1e6).toFixed(1)}MB → ${(totalAfter / 1e6).toFixed(1)}MB (${((1 - totalAfter / totalBefore) * 100).toFixed(1)}% reduction)`)

View file

@ -19,7 +19,7 @@ const STATIC_LOCATIONS: LocationCMS[] = [
slug: 'dynopark',
tagline: 'портал у світ динозаврів',
shortDesc: 'Прогуляйтесь серед реалістичних динозаврів у повний зріст. Понад 20 видів доісторичних тварин у природному середовищі.',
image: '/images/figma/loc-dinopark.jpg',
image: '/images/figma/loc-dinopark.webp',
},
{
id: 'dyvolis',
@ -27,7 +27,7 @@ const STATIC_LOCATIONS: LocationCMS[] = [
slug: 'dyvolis',
tagline: 'зона казкових топіарних фігур',
shortDesc: 'Чарівний ліс з інтерактивними атракціонами, мотузковими парками та пригодами для всіх вікових груп.',
image: '/images/figma/loc-divo-lis.png',
image: '/images/figma/loc-divo-lis.webp',
},
{
id: 'maze',
@ -35,7 +35,7 @@ const STATIC_LOCATIONS: LocationCMS[] = [
slug: 'maze',
tagline: 'справжній виклик кмітливості',
shortDesc: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.',
image: '/images/figma/gallery-1.png',
image: '/images/figma/gallery-1.webp',
},
{
id: 'tir',
@ -43,7 +43,7 @@ const STATIC_LOCATIONS: LocationCMS[] = [
slug: 'tir',
tagline: 'перемога, яку ви розділите разом',
shortDesc: 'Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.',
image: '/images/figma/gallery-3.png',
image: '/images/figma/gallery-3.webp',
},
{
id: 'playground',
@ -51,7 +51,7 @@ const STATIC_LOCATIONS: LocationCMS[] = [
slug: 'playground',
tagline: 'територія забав і нових друзів',
shortDesc: 'Поки малеча підкорює гірки та знаходить перших друзів, ви можете нарешті зробити паузу.',
image: '/images/figma/gallery-8.png',
image: '/images/figma/gallery-8.webp',
},
]
@ -90,7 +90,7 @@ export default async function LocationsPage() {
<div className="mx-auto max-w-[1204px] px-8 py-16">
<div className="flex flex-col gap-10">
{locations.map((loc, i) => {
const imgUrl = getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.jpg'
const imgUrl = getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.webp'
const color = COLORS[i % COLORS.length]
const slug = loc.slug

View file

@ -38,7 +38,12 @@ export const Leads: CollectionConfig = {
},
{ name: 'message', type: 'textarea', label: 'Повідомлення' },
{ name: 'groupSize', type: 'number', label: 'Кількість учасників' },
{ name: 'preferredDate', type: 'date', label: 'Бажана дата', admin: { date: { pickerAppearance: 'dayOnly' } } },
{
name: 'preferredDate',
type: 'date',
label: 'Бажана дата',
admin: { date: { pickerAppearance: 'dayOnly' } },
},
{ name: 'packageSlug', type: 'text', label: 'Пакет (slug)' },
{ name: 'notes', type: 'textarea' },
{

View file

@ -46,10 +46,9 @@ export function BirthdayBookingForm({ defaultPackage }: BirthdayBookingFormProps
packageSlug: packageSlug || undefined,
groupSize: guestCount ? Number(guestCount) : undefined,
preferredDate: preferredDate || undefined,
message: [
childAge ? `Вік іменинника: ${childAge}` : '',
wishes,
].filter(Boolean).join('\n') || undefined,
message:
[childAge ? `Вік іменинника: ${childAge}` : '', wishes].filter(Boolean).join('\n') ||
undefined,
...utm,
}),
})
@ -61,7 +60,7 @@ export function BirthdayBookingForm({ defaultPackage }: BirthdayBookingFormProps
}
setSuccess(true)
} catch {
setError('Помилка мережі. Перевірте з\'єднання та спробуйте ще раз.')
setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
}
})
}
@ -73,7 +72,7 @@ export function BirthdayBookingForm({ defaultPackage }: BirthdayBookingFormProps
<h3 className="text-[24px] font-bold text-white" style={{ fontFamily: FONT }}>
Заявку отримано!
</h3>
<p className="text-white/70 text-[16px]" style={{ fontFamily: FONT }}>
<p className="text-[16px] text-white/70" style={{ fontFamily: FONT }}>
Менеджер зв&apos;яжеться з вами протягом 30 хвилин для уточнення деталей свята.
</p>
</div>
@ -220,7 +219,11 @@ function Field({
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={htmlFor} className="text-[14px] font-medium text-white/80" style={{ fontFamily: FONT }}>
<label
htmlFor={htmlFor}
className="text-[14px] font-medium text-white/80"
style={{ fontFamily: FONT }}
>
{label}
</label>
{children}

View file

@ -41,7 +41,9 @@ export function GroupRequestForm() {
formSource: 'group-request',
groupSize: groupSize ? Number(groupSize) : undefined,
preferredDate: preferredDate || undefined,
message: [groupType ? `Тип групи: ${groupType}` : '', message].filter(Boolean).join('\n') || undefined,
message:
[groupType ? `Тип групи: ${groupType}` : '', message].filter(Boolean).join('\n') ||
undefined,
...utm,
}),
})
@ -53,7 +55,7 @@ export function GroupRequestForm() {
}
setSuccess(true)
} catch {
setError('Помилка мережі. Перевірте з\'єднання та спробуйте ще раз.')
setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
}
})
}
@ -65,7 +67,7 @@ export function GroupRequestForm() {
<h3 className="text-[24px] font-bold text-white" style={{ fontFamily: FONT }}>
Заявку отримано!
</h3>
<p className="text-white/70 text-[16px]" style={{ fontFamily: FONT }}>
<p className="text-[16px] text-white/70" style={{ fontFamily: FONT }}>
Менеджер зателефонує вам протягом 30 хвилин для уточнення деталей.
</p>
</div>
@ -198,7 +200,11 @@ function Field({
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={htmlFor} className="text-[14px] font-medium text-white/80" style={{ fontFamily: FONT }}>
<label
htmlFor={htmlFor}
className="text-[14px] font-medium text-white/80"
style={{ fontFamily: FONT }}
>
{label}
</label>
{children}

View file

@ -3,7 +3,7 @@ import Link from 'next/link'
import { getGlobal } from '@/lib/payload'
import type { FooterGlobal } from '@/types/globals'
const IMG_BG = '/images/figma/footer-bg.png'
const IMG_BG = '/images/figma/footer-bg.webp'
const LOGO_G1 = '/images/figma/logo-g1-lg.svg'
const LOGO_G2 = '/images/figma/logo-g2-lg.svg'
const LOGO_G3 = '/images/figma/logo-g3-lg.svg'
@ -19,10 +19,14 @@ const STATIC_NAV = [
]
const SOCIAL_ICONS: Record<string, string> = {
instagram: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z',
facebook: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z',
youtube: 'M23.495 6.205a3.007 3.007 0 0 0-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 0 0 .527 6.205a31.247 31.247 0 0 0-.522 5.805 31.247 31.247 0 0 0 .522 5.783 3.007 3.007 0 0 0 2.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 0 0 2.088-2.088 31.247 31.247 0 0 0 .5-5.783 31.247 31.247 0 0 0-.5-5.805zM9.609 15.601V8.408l6.264 3.602z',
tiktok: 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
instagram:
'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z',
facebook:
'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z',
youtube:
'M23.495 6.205a3.007 3.007 0 0 0-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 0 0 .527 6.205a31.247 31.247 0 0 0-.522 5.805 31.247 31.247 0 0 0 .522 5.783 3.007 3.007 0 0 0 2.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 0 0 2.088-2.088 31.247 31.247 0 0 0 .5-5.783 31.247 31.247 0 0 0-.5-5.805zM9.609 15.601V8.408l6.264 3.602z',
tiktok:
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
}
export async function Footer() {
@ -52,13 +56,22 @@ export async function Footer() {
{/* Logo */}
<Link href="/" aria-label="Шуміленд — на головну" className="shrink-0">
<div className="relative" style={{ width: '120px', height: '104px' }}>
<div className="absolute" style={{ top: '91.32%', right: '21.81%', bottom: '0.97%', left: '22.26%' }}>
<div
className="absolute"
style={{ top: '91.32%', right: '21.81%', bottom: '0.97%', left: '22.26%' }}
>
<img src={LOGO_G1} alt="" aria-hidden="true" className="block h-full w-full" />
</div>
<div className="absolute" style={{ top: '71.76%', right: '2.82%', bottom: '7.3%', left: '1.41%' }}>
<div
className="absolute"
style={{ top: '71.76%', right: '2.82%', bottom: '7.3%', left: '1.41%' }}
>
<img src={LOGO_G2} alt="" aria-hidden="true" className="block h-full w-full" />
</div>
<div className="absolute" style={{ top: '1.61%', right: '2.82%', bottom: '38.73%', left: '21.27%' }}>
<div
className="absolute"
style={{ top: '1.61%', right: '2.82%', bottom: '38.73%', left: '21.27%' }}
>
<img src={LOGO_G3} alt="" aria-hidden="true" className="block h-full w-full" />
</div>
</div>
@ -78,7 +91,7 @@ export async function Footer() {
{link.label}
</Link>
</li>
) : null,
) : null
)}
</ul>
</nav>
@ -132,7 +145,9 @@ export async function Footer() {
<path d={icon} />
</svg>
) : (
<span className="text-[12px] font-bold text-white">{s.platform?.[0]?.toUpperCase()}</span>
<span className="text-[12px] font-bold text-white">
{s.platform?.[0]?.toUpperCase()}
</span>
)}
</a>
)

View file

@ -2,7 +2,7 @@
import Link from 'next/link'
import type { BirthdayPackageCMS } from '@/types/globals'
const IMG_CHECK = '/images/figma/check-mark.png'
const IMG_CHECK = '/images/figma/check-mark.webp'
const STATIC_PACKAGES: BirthdayPackageCMS[] = [
{

View file

@ -9,15 +9,15 @@ function getMediaUrl(img: Media | string | null | undefined): string | 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 },
{ src: '/images/figma/gallery-1.webp', alt: 'Шуміленд — фото 1', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-1.webp', alt: 'Шуміленд — сімейні враження', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-2.webp', alt: 'Шуміленд — фото 2', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-3.webp', alt: 'Шуміленд — фото 3', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-2.webp', alt: 'Шуміленд — прогулянка', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-4.webp', alt: 'Шуміленд — фото 4', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-6.webp', alt: 'Шуміленд — атракціони', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-7.webp', alt: 'Шуміленд — фото 5', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-8.webp', alt: 'Шуміленд — фото 6', width: 320, height: 420, radius: 20 },
]
interface GalleryProps {
@ -29,7 +29,7 @@ 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',
src: getMediaUrl(img.image) ?? '/images/figma/gallery-1.webp',
alt: img.alt ?? `Шуміленд — фото ${i + 1}`,
width: i % 2 === 0 ? 320 : 380,
height: 420,

View file

@ -31,7 +31,8 @@ export function GallerySlider({ images, speed = 60 }: GallerySliderProps) {
if (!el) return
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
// ~1px per tick — tune with speed prop
el.scrollLeft = 1
const pxPerTick = 1
const intervalMs = Math.max(8, Math.round((speed * 1000) / (el.scrollWidth / 2)))

View file

@ -2,9 +2,9 @@
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'
const IMG_BG2 = '/images/figma/hero-bg2.webp'
const IMG_BG1 = '/images/figma/hero-bg1.webp'
const IMG_FAMILY = '/images/figma/hero-bg-family.webp'
interface HeroProps {
hero?: HomePageHero | null
@ -28,8 +28,7 @@ export function Hero({ hero }: HeroProps) {
return (
<section
className="relative mx-[10px] -mt-[60px] overflow-hidden rounded-b-[20px] bg-black lg:-mt-[120px]"
style={{ minHeight: 'min(1080px, 100vh)' }}
className="relative mx-[10px] -mt-[60px] overflow-hidden rounded-b-[20px] bg-black h-[clamp(480px,calc(56.25vw+60px),calc(100vh+60px))] lg:-mt-[120px] lg:h-[clamp(600px,calc(56.25vw+120px),calc(100vh+120px))]"
>
{/* ── Background ──────────────────────────────────────────────── */}
{backgroundVideo ? (
@ -91,7 +90,7 @@ export function Hero({ hero }: HeroProps) {
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[20] h-full w-full object-cover"
style={{ objectPosition: 'right center' }}
style={{ objectPosition: 'center center' }}
/>
)}

View file

@ -15,7 +15,7 @@ const STATIC_LOCATIONS: LocationData[] = [
tagline: 'портал у світ динозаврів',
description:
'Ви бачили їх у фільмах та мультиках, а тепер час зустріти в реальному житті та роздивитися їх зблизька! Найбільші динозаври України, які гарчать і рухаються, як справжні.',
image: '/images/figma/loc-dinopark.jpg',
image: '/images/figma/loc-dinopark.webp',
href: '/lokatsii#dynopark',
},
{
@ -24,7 +24,7 @@ const STATIC_LOCATIONS: LocationData[] = [
tagline: 'зона казкових топіарних фігур',
description:
'Тут на лісових стежках оселилися єдинороги, дракони та добрі лісові звірята. Це ідеальне місце, щоб пофантазувати разом із дитиною.',
image: '/images/figma/loc-divo-lis.png',
image: '/images/figma/loc-divo-lis.webp',
href: '/lokatsii#dyvolis',
},
{
@ -33,7 +33,7 @@ const STATIC_LOCATIONS: LocationData[] = [
tagline: 'справжній виклик кмітливості',
description:
'Чи зможете ви разом знайти вихід? Це справжній пригодницький виклик для всієї родини! Тут діти вчаться бути уважними та впевненими у собі.',
image: '/images/figma/gallery-1.png',
image: '/images/figma/gallery-1.webp',
href: '/lokatsii#maze',
},
{
@ -42,7 +42,7 @@ const STATIC_LOCATIONS: LocationData[] = [
tagline: 'перемога, яку ви розділите разом',
description:
'Для дітей це не просто гра, а можливість проявити себе та "заробити" подарунок. Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.',
image: '/images/figma/gallery-3.png',
image: '/images/figma/gallery-3.webp',
href: '/lokatsii#tir',
},
{
@ -51,7 +51,7 @@ const STATIC_LOCATIONS: LocationData[] = [
tagline: 'територія забав і нових друзів',
description:
'Поки малеча підкорює гірки, випробовує безпечні лазанки та знаходить перших друзів, ви можете нарешті зробити паузу та просто спостерігати.',
image: '/images/figma/gallery-8.png',
image: '/images/figma/gallery-8.webp',
href: '/lokatsii#playground',
},
]
@ -69,7 +69,7 @@ export function Locations({ data, title }: LocationsProps) {
slug: loc.slug,
tagline: loc.tagline ?? '',
description: loc.shortDesc ?? '',
image: getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.jpg',
image: getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.webp',
href: loc.href ?? `/lokatsii#${loc.slug}`,
}))
: STATIC_LOCATIONS

View file

@ -1,8 +1,7 @@
'use client'
import { useRef, useState } from 'react'
import { useRef, useState, useEffect } from 'react'
import { BtnGradient } from '@/components/ui/BtnGradient'
import { useAutoScroll } from '@/hooks/useAutoScroll'
export interface LocationData {
name: string
@ -21,7 +20,20 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
const trackRef = useRef<HTMLDivElement>(null)
const pauseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [autoPaused, setAutoPaused] = useState(false)
useAutoScroll(trackRef, { speed: 1.5, intervalMs: 16, disabled: autoPaused })
const doubled = [...locations, ...locations]
useEffect(() => {
const el = trackRef.current
if (!el) return
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
const id = setInterval(() => {
if (autoPaused) return
const half = el.scrollWidth / 2
el.scrollLeft += 1
if (el.scrollLeft >= half) el.scrollLeft = 0
}, 16)
return () => clearInterval(id)
}, [autoPaused])
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 580, behavior: 'smooth' })
@ -62,9 +74,9 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
className="flex gap-5 overflow-x-auto scroll-smooth pb-4"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{locations.map((loc) => (
{doubled.map((loc, idx) => (
<article
key={loc.name}
key={`${loc.slug}-${idx}`}
className="group w-full flex-none overflow-hidden rounded-[20px] md:w-[min(560px,90vw)] lg:w-[560px]"
>
<div className="relative h-[420px]">

View file

@ -4,19 +4,19 @@ import { BtnDetails } from '@/components/ui/BtnDetails'
const FALLBACK_NEWS = [
{
image: '/images/figma/news-bg1.jpg',
image: '/images/figma/news-bg1.webp',
title: 'Відкриття нового сезону у Шуміленді',
body: 'На території є чисті вбиральні, де можна також помити руку. Поруч розташовані велика паркувальна зона та укриття. Зручний громадський транспорт: метро, маршрутні таксі, що з\'єднують правий та лівий береги.',
href: '/blog',
},
{
image: '/images/figma/news-bg2.png',
image: '/images/figma/news-bg2.webp',
title: 'Нові атракціони у ДиноПарку',
body: 'На території є чисті вбиральні, де можна також помити руку. Поруч розташовані велика паркувальна зона та укриття. Зручний громадський транспорт: метро, маршрутні таксі, що з\'єднують правий та лівий береги.',
href: '/blog',
},
{
image: '/images/figma/news-bg3.jpg',
image: '/images/figma/news-bg3.webp',
title: 'Програма на вихідні в Шуміленді',
body: 'На території є чисті вбиральні, де можна також помити руку. Поруч розташовані велика паркувальна зона та укриття. Зручний громадський транспорт: метро, маршрутні таксі, що з\'єднують правий та лівий береги.',
href: '/blog',

View file

@ -6,7 +6,7 @@ import { useAutoScroll } from '@/hooks/useAutoScroll'
import type { ReviewCMS, Media } from '@/types/globals'
const IMG_RATE = '/images/figma/rate-stars.svg'
const IMG_AVATAR_DEFAULT = '/images/figma/review-avatar-bg.jpg'
const IMG_AVATAR_DEFAULT = '/images/figma/review-avatar-bg.webp'
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null

View file

@ -4,7 +4,7 @@
import { useState } from 'react'
import type { Media } from '@/types/globals'
const IMG_POSTER_DEFAULT = '/images/figma/video-preview.png'
const IMG_POSTER_DEFAULT = '/images/figma/video-preview.webp'
const IMG_PLAY = '/images/figma/btn-video-play.svg'
function getMediaUrl(img: Media | string | null | undefined): string | null {

View file

@ -11,12 +11,12 @@ function getMediaUrl(img: Media | string | null | undefined): string | null {
}
const STATIC_GALLERY = [
'/images/figma/why-parents-1.png',
'/images/figma/why-parents-2.png',
'/images/figma/why-parents-3.png',
'/images/figma/why-parents-4.png',
'/images/figma/gallery-1.png',
'/images/figma/gallery-3.png',
'/images/figma/why-parents-1.webp',
'/images/figma/why-parents-2.webp',
'/images/figma/why-parents-3.webp',
'/images/figma/why-parents-4.webp',
'/images/figma/gallery-1.webp',
'/images/figma/gallery-3.webp',
]
const STATIC_ITEMS: HomePageWhyParentsItem[] = [
@ -62,7 +62,7 @@ export function WhyParents({ items, sideGallery, title }: WhyParentsProps) {
const galleryUrls: string[] =
sideGallery && sideGallery.length > 0
? sideGallery.map((g) => getMediaUrl(g.image) ?? '/images/figma/gallery-1.png')
? sideGallery.map((g) => getMediaUrl(g.image) ?? '/images/figma/gallery-1.webp')
: STATIC_GALLERY
const doubled = [...galleryUrls, ...galleryUrls]

View file

@ -26,7 +26,7 @@ export function BtnDetails({ children, className = '', variant = 'dark', ...rest
const content = (
<>
{children}
<img src={arrow} alt="" aria-hidden="true" className="w-10 h-auto flex-none" />
<img src={arrow} alt="" aria-hidden="true" width={40} height={40} className="w-10 h-auto flex-none" />
</>
)

View file

@ -4,7 +4,7 @@ export function validateEnv(): void {
const missing = REQUIRED_VARS.filter((key) => !process.env[key])
if (missing.length > 0) {
throw new Error(
`Missing required environment variables:\n${missing.map((k) => ` - ${k}`).join('\n')}\n\nCheck your .env file.`,
`Missing required environment variables:\n${missing.map((k) => ` - ${k}`).join('\n')}\n\nCheck your .env file.`
)
}
}