feat(blog): add 3 real articles + blog image placeholders to seed
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

- Add seed data for 3 Shumiland articles (Сезон пригод, Капсула часу, Травень)
- Create public/images/blog/ with placeholder hero images
- Full Lexical body content for each post
- Add makeLexical() helper for paragraph formatting in seed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-11 14:08:37 +01:00
parent 574b125626
commit 9562db84e3
22 changed files with 273 additions and 150 deletions

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import './.next/types/routes.d.ts'
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -27,7 +27,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const page = await getPage(slug)
if (!page) return { title: 'Не знайдено — Шуміленд' }
const p = page as unknown as { title: string; meta?: { metaTitle?: string; metaDescription?: string } }
const p = page as unknown as {
title: string
meta?: { metaTitle?: string; metaDescription?: string }
}
return {
title: p.meta?.metaTitle ?? `${p.title} — Шуміленд`,
description: p.meta?.metaDescription ?? '',
@ -48,8 +51,8 @@ export default async function PageRoute({ params }: Props) {
{p.layout && p.layout.length > 0 ? (
<RenderBlocks blocks={p.layout as Parameters<typeof RenderBlocks>[0]['blocks']} />
) : (
<div className="max-w-[1204px] mx-auto px-8 py-16">
<p className="text-[#272727]/60 text-[18px]">Сторінка у процесі створення.</p>
<div className="mx-auto max-w-[1204px] px-8 py-16">
<p className="text-[18px] text-[#272727]/60">Сторінка у процесі створення.</p>
</div>
)}
</div>

View file

@ -53,18 +53,18 @@ export default async function BlogPostPage({ params }: Props) {
if (!post) notFound()
return (
<div className="bg-[#f1fbeb] min-h-screen">
<div className="min-h-screen bg-[#f1fbeb]">
{/* Header band */}
<div className="bg-[#396817] py-16 px-8">
<div className="max-w-[800px] mx-auto">
<div className="bg-[#396817] px-8 py-16">
<div className="mx-auto max-w-[800px]">
<h1
className="text-white font-bold text-[28px] md:text-[40px] lg:text-[48px] leading-tight"
className="text-[28px] leading-tight font-bold text-white md:text-[40px] lg:text-[48px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{post.title}
</h1>
{post.publishedAt && (
<p className="text-white/60 text-[14px] mt-3">
<p className="mt-3 text-[14px] text-white/60">
{new Date(post.publishedAt).toLocaleDateString('uk-UA', {
day: 'numeric',
month: 'long',
@ -77,19 +77,19 @@ export default async function BlogPostPage({ params }: Props) {
{/* Cover image */}
{post.hero?.url && (
<div className="max-w-[800px] mx-auto px-8 -mt-8">
<div className="rounded-[20px] overflow-hidden shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
<div className="mx-auto -mt-8 max-w-[800px] px-8">
<div className="overflow-hidden rounded-[20px] shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
<img
src={post.hero.url}
alt={post.hero.alt ?? post.title}
className="w-full max-h-[450px] object-cover"
className="max-h-[450px] w-full object-cover"
/>
</div>
</div>
)}
{/* Body */}
<div className="max-w-[800px] mx-auto px-8 py-16">
<div className="mx-auto max-w-[800px] px-8 py-16">
{post.body ? (
<div
className="prose prose-lg max-w-none text-[#272727]"
@ -99,7 +99,7 @@ export default async function BlogPostPage({ params }: Props) {
</div>
) : (
<p
className="text-[#272727]/60 text-[18px]"
className="text-[18px] text-[#272727]/60"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Вміст статті незабаром з&apos;явиться тут.

View file

@ -8,23 +8,23 @@ export default function FrontendError({
reset: () => void
}) {
return (
<div className="bg-[#f1fbeb] min-h-screen flex items-center justify-center px-8">
<div className="text-center max-w-[500px]">
<div className="flex min-h-screen items-center justify-center bg-[#f1fbeb] px-8">
<div className="max-w-[500px] text-center">
<h1
className="text-[#272727] font-bold text-[32px] mb-4"
className="mb-4 text-[32px] font-bold text-[#272727]"
style={{ fontFamily: 'Montserrat, sans-serif' }}
>
Щось пішло не так
</h1>
<p
className="text-[#272727]/60 text-[16px] mb-8"
className="mb-8 text-[16px] text-[#272727]/60"
style={{ fontFamily: 'Montserrat, sans-serif' }}
>
{error.message || 'Виникла помилка при завантаженні сторінки.'}
</p>
<button
onClick={reset}
className="bg-[#f28b4a] text-white font-bold px-8 py-3 rounded-[64px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
className="rounded-[64px] bg-[#f28b4a] px-8 py-3 font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'Montserrat, sans-serif' }}
>
Спробувати знову

View file

@ -37,17 +37,17 @@ function CheckoutForm() {
window.location.href = data.url
} catch {
setError('Помилка мережі. Перевірте з\'єднання та спробуйте ще раз.')
setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
}
})
}
return (
<div className="bg-[#f1fbeb] min-h-screen">
<div className="bg-[#396817] py-12 px-8">
<div className="max-w-[600px] mx-auto">
<div className="min-h-screen bg-[#f1fbeb]">
<div className="bg-[#396817] px-8 py-12">
<div className="mx-auto max-w-[600px]">
<h1
className="text-white font-bold text-[clamp(28px,4vw,48px)] uppercase"
className="text-[clamp(28px,4vw,48px)] font-bold text-white uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Оформлення
@ -55,12 +55,12 @@ function CheckoutForm() {
</div>
</div>
<div className="max-w-[600px] mx-auto px-8 py-16">
<div className="mx-auto max-w-[600px] px-8 py-16">
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label
htmlFor="email"
className="font-bold text-[16px] text-[#272727]"
className="text-[16px] font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Email для підтвердження
@ -72,7 +72,7 @@ function CheckoutForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="w-full border-2 border-[#e8d5c0] rounded-[12px] px-4 py-3 text-[16px] text-[#272727] bg-white focus:outline-none focus:border-[#f28b4a]"
className="w-full rounded-[12px] border-2 border-[#e8d5c0] bg-white px-4 py-3 text-[16px] text-[#272727] focus:border-[#f28b4a] focus:outline-none"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
/>
</div>
@ -80,7 +80,7 @@ function CheckoutForm() {
<div className="flex flex-col gap-2">
<label
htmlFor="count"
className="font-bold text-[16px] text-[#272727]"
className="text-[16px] font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Кількість квитків
@ -93,13 +93,13 @@ function CheckoutForm() {
required
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="w-full border-2 border-[#e8d5c0] rounded-[12px] px-4 py-3 text-[16px] text-[#272727] bg-white focus:outline-none focus:border-[#f28b4a]"
className="w-full rounded-[12px] border-2 border-[#e8d5c0] bg-white px-4 py-3 text-[16px] text-[#272727] focus:border-[#f28b4a] focus:outline-none"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-[14px]">
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[14px] text-red-700">
{error}
</div>
)}
@ -107,14 +107,14 @@ function CheckoutForm() {
<button
type="submit"
disabled={isPending || !tariffId}
className="bg-[#f28b4a] text-white font-bold text-[18px] py-4 rounded-[64px] disabled:opacity-50 hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
className="rounded-[64px] bg-[#f28b4a] py-4 text-[18px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a] disabled:opacity-50"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{isPending ? 'Зачекайте...' : 'Перейти до оплати'}
</button>
<p
className="text-[#272727]/60 text-[13px] text-center"
className="text-center text-[13px] text-[#272727]/60"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Оплата відбувається через Monobank. Ваші дані захищені.

View file

@ -28,10 +28,7 @@ export default async function HomePage() {
return (
<div className="bg-[#f1fbeb]">
<Hero hero={hero} />
<Locations
data={locations}
title={home?.sectionTitles?.locations ?? undefined}
/>
<Locations data={locations} title={home?.sectionTitles?.locations ?? undefined} />
<WhyParents
items={home?.whyParents?.items ?? undefined}
sideGallery={home?.whyParents?.sideGallery ?? undefined}
@ -42,18 +39,12 @@ export default async function HomePage() {
title={home?.sectionTitles?.birthday ?? undefined}
intro={home?.birthdayIntro?.text ?? undefined}
/>
<VideoSection
poster={home?.video?.poster ?? undefined}
src={home?.video?.src ?? undefined}
/>
<VideoSection poster={home?.video?.poster ?? undefined} src={home?.video?.src ?? undefined} />
<Gallery
images={home?.gallery?.images ?? undefined}
title={home?.sectionTitles?.gallery ?? undefined}
/>
<Reviews
data={reviews}
title={home?.sectionTitles?.reviews ?? undefined}
/>
<Reviews data={reviews} title={home?.sectionTitles?.reviews ?? undefined} />
<News />
</div>
)

View file

@ -464,6 +464,26 @@ export async function POST(req: NextRequest) {
results.push('Seeded site-settings global')
// === BLOG POSTS ===
function makeLexical(paragraphs: string[]) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: paragraphs.map((text) => ({
type: 'paragraph',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [{ type: 'text', detail: 0, format: 0, mode: 'normal', style: '', text, version: 1 }],
})),
},
}
}
const { totalDocs: postCount } = await payload.find({
collection: 'blog-posts',
limit: 1,
@ -472,33 +492,67 @@ export async function POST(req: NextRequest) {
if (postCount === 0) {
const postDefs = [
{
title: 'Сезон динозаврів відкрито!',
slug: 'sezon-dynozavriv-vidkryto',
excerpt: 'Шуміленд вітає нових мешканців ДиноПарку — зустрічайте 12 нових динозаврів!',
title: 'Сезон пригод відкрито: Shumiland на ВДНГ запрошує у світ, де оживають динозаври та казки!',
slug: 'sezon-pryhod-vidkryto',
excerpt: 'Сезон стартував із події національного масштабу — фіксації рекорду України: найбільші динозаври країни. 19 квітня у Shumiland представники Книги рекордів України офіційно зафіксували рекорд.',
status: 'published',
publishedAt: '2025-04-01T10:00:00.000Z',
imgFile: 'news-bg1.jpg',
publishedAt: '2026-04-19T10:00:00.000Z',
imgFile: 'sezon-pryhod.webp',
imgPath: 'public/images/blog/sezon-pryhod.webp',
body: makeLexical([
'Сезон стартував із події національного масштабу — фіксації рекорду України: найбільші динозаври країни. 19 квітня у Shumiland представники Книги рекордів України офіційно зафіксували рекорд. Тепер Shumiland — це не просто парк розваг, а простір, де можна на власні очі побачити рекордсменів і водночас зануритися у світ пригод і казки.',
'Більше ніж парк: простір, де фантазія стає реальністю',
'Після яскравого старту в стилі «Аліси в Дивокраї», парк працює у повноцінному режимі, пропонуючи гостям унікальний формат відпочинку просто неба. На території ВДНГ розгорнувся масштабний всесвіт пригод, що поєднує освіту, розваги та естетику.',
'Динопарк — рекорд, який вражає: Локація в парку, де серед густої зелені «ожили» реалістичні динозаври натуральної величини, серед яких — найбільші динозаври України, офіційно зафіксовані як рекорд. Завдяки сучасним технологіям гіганти рухаються та гарчать, створюючи ефект повної присутності.',
'Диво Ліс та Будиночок Лісовика: Казкова локація з унікальними топіарними фігурами та арт-об\'єктами. Тут розташована резиденція Лісовика, де діти можуть зануритися у світ легенд та інтерактивних історій.',
'Дзеркальний лабіринт: Простір світла та ілюзій, що відкриває нові ракурси реальності та створює десятки ефектних фотолокацій.',
'Атракціони: У сезоні 2026 парк значно розширив зону розваг — на гостей чекає багато нових атракціонів для дітей різного віку.',
'Shumiland — обов\'язкова точка для візиту на мапі Києва. Велика територія для активного відпочинку, продумана комфортна інфраструктура та оновлені фудкорти для всієї родини.',
'«Ми створили місце, де кожен візит перетворюється на родинне свято. Shumiland — це простір, де діти стають дослідниками, а дорослі дозволяють собі мріяти», — зазначає команда парку.',
]),
},
{
title: 'Весняні канікули в Шуміленді',
slug: 'vesniani-kanikuly',
excerpt:
'Проведіть весняні канікули незабутньо! Спеціальні активності щодня з 28 березня по 6 квітня.',
title: 'У Шуміленді заклали капсулу часу',
slug: 'kapsula-chasu',
excerpt: 'У Шуміленді відбулася особлива подія — ми заклали капсулу часу як символ початку великого шляху, сповненого дитячого сміху, щирих емоцій, сімейних моментів і незабутніх вражень.',
status: 'published',
publishedAt: '2025-03-20T10:00:00.000Z',
imgFile: 'news-bg2.png',
publishedAt: '2026-04-20T10:00:00.000Z',
imgFile: 'kapsula-chasu.webp',
imgPath: 'public/images/blog/kapsula-chasu.webp',
body: makeLexical([
'У Шуміленді відбулася особлива подія — ми заклали капсулу часу як символ початку великого шляху, сповненого дитячого сміху, щирих емоцій, сімейних моментів і незабутніх вражень.',
'Ця капсула стала знаком нашої віри у майбутнє, у розвиток простору, де діти можуть радіти, мріяти та створювати найтепліші спогади разом із родиною.',
'Відкриття капсули заплановане на 19 квітня 2031 року. Саме тоді ми зможемо повернутися у цей день і побачити, яким яскравим, теплим та наповненим щасливими моментами став шлях Шуміленду.',
'Попереду — багато щасливих історій, свят і дитячих усмішок. І ми раді створювати їх разом з вами.',
]),
},
{
title: 'Нова локація: Тир з призами',
slug: 'nova-lokatsiya-tyr-z-pryzamy',
excerpt: 'Відтепер у Шуміленді є Тир з призами — точний постріл приносить реальний виграш!',
title: 'Травень у Шуміленді — місяць пригод для всієї родини',
slug: 'traven-u-shumilendt',
excerpt: 'Травень у Шуміленді обіцяє бути яскравим, веселим і сповненим незабутніх емоцій. Щовихідних на гостей чекають тематичні програми, квести, улюблені герої та атмосфера справжнього сімейного відпочинку.',
status: 'published',
publishedAt: '2025-03-10T10:00:00.000Z',
imgFile: 'news-bg3.jpg',
publishedAt: '2026-05-01T10:00:00.000Z',
imgFile: 'traven-shymiland.webp',
imgPath: 'public/images/blog/traven-shymiland.webp',
body: makeLexical([
'Травень у Шуміленді обіцяє бути яскравим, веселим і сповненим незабутніх емоцій. Щовихідних на гостей чекають тематичні програми, квести, улюблені герої, чарівні пригоди та атмосфера справжнього сімейного відпочинку.',
'Плануйте свої вихідні разом із Шумілендом та створюйте теплі спогади всією родиною.',
'Календар подій:',
'09.05 — Розважальна програма «Леді Баг»: Захопливий світ пригод разом із відважною супергероїнею.',
'10.05 — День Матері: Майстер-клас «Подарунок для мами» та тематичний квест «Русалонька».',
'16.05 — Розважальна програма «Футбольний переполох»: Світ драйву та командного духу для маленьких любителів спорту.',
'17.05 — Квест «Орел і Решка»: Весела подорож навколо світу у форматі популярної тревел-пригоди.',
'23.05 — Розважальна програма «Стіч»: Яскрава гавайська пригода разом із веселим та бешкетним героєм.',
'24.05 — Квест «Аніме»: Пройдіть усі випробування та дізнайтеся, де загубився священний меч «Хігуроші».',
'30.05 — Випуск «Школа магії»: Діти поринуть у світ чаклунства, таємниць та неймовірних пригод.',
'31.05 — Тематичний день Wednesday: Загадковий і стильний світ темної естетики, натхненний атмосферою Wednesday та Академією Невермор.',
'Щодня у Шуміленді на гостей чекають: вражаючий динопарк з динозаврами що рухаються і гарчать, магічний Диво Ліс, атракціони для всієї родини, смачний фудкорт та затишна атмосфера.',
'Київ, пр-т Академіка Глушкова, 1 ВДНГ. Щодня: 11:0020:00.',
]),
},
]
for (const post of postDefs) {
const heroId = await uploadMedia(payload, post.imgFile, post.title)
const heroId = await findOrUploadMedia(payload, post.imgFile, post.imgPath, post.title)
await payload.create({
collection: 'blog-posts',
data: {
@ -507,7 +561,8 @@ export async function POST(req: NextRequest) {
excerpt: post.excerpt,
status: post.status,
publishedAt: post.publishedAt,
hero: heroId ? { image: heroId } : undefined,
hero: heroId ?? undefined,
body: post.body,
} as never,
overrideAccess: true,
})

View file

@ -12,31 +12,34 @@ export function CTABlockComponent({ title, subtitle, ctaLabel, ctaHref, variant
if (!ctaHref) return null
const isDark = variant === 'dark'
const bg = isDark ? 'bg-[#272727]' : 'bg-[#396817]'
const btnCls = variant === 'secondary'
? 'border-2 border-white text-white hover:bg-white hover:text-[#396817]'
: 'bg-[#f28b4a] text-white hover:shadow-[0_0_20px_0_#f28b4a]'
const btnCls =
variant === 'secondary'
? 'border-2 border-white text-white hover:bg-white hover:text-[#396817]'
: 'bg-[#f28b4a] text-white hover:shadow-[0_0_20px_0_#f28b4a]'
return (
<section className={`${bg} py-[40px] md:py-[80px]`}>
<div className="max-w-[1204px] mx-auto px-8 text-center">
<div className="mx-auto max-w-[1204px] px-8 text-center">
{title && (
<h2
className="font-bold text-[24px] md:text-[40px] text-white uppercase mb-4"
className="mb-4 text-[24px] font-bold text-white uppercase md:text-[40px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-white/80 text-[16px] md:text-[20px] mb-8 max-w-[600px] mx-auto"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<p
className="mx-auto mb-8 max-w-[600px] text-[16px] text-white/80 md:text-[20px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}
</p>
)}
{ctaLabel && (
<Link
href={ctaHref}
className={`inline-flex items-center justify-center font-bold text-[20px] px-[30px] py-[10px] rounded-[64px] transition-all ${btnCls}`}
className={`inline-flex items-center justify-center rounded-[64px] px-[30px] py-[10px] text-[20px] font-bold transition-all ${btnCls}`}
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{ctaLabel}

View file

@ -9,32 +9,45 @@ interface HeroBlockProps {
backgroundImage?: { url?: string | null } | string | null
}
export function HeroBlockComponent({ title, subtitle, ctaLabel, ctaHref, backgroundImage }: HeroBlockProps) {
export function HeroBlockComponent({
title,
subtitle,
ctaLabel,
ctaHref,
backgroundImage,
}: HeroBlockProps) {
const bgUrl = typeof backgroundImage === 'object' ? backgroundImage?.url : null
return (
<section className="relative overflow-hidden bg-[#396817] min-h-[400px] flex items-center">
<section className="relative flex min-h-[400px] items-center overflow-hidden bg-[#396817]">
{bgUrl && (
<img src={bgUrl} alt="" aria-hidden="true" className="absolute inset-0 w-full h-full object-cover pointer-events-none" />
<img
src={bgUrl}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
)}
<div className="relative z-10 w-full max-w-[1204px] mx-auto px-8 py-[80px]">
<div className="relative z-10 mx-auto w-full max-w-[1204px] px-8 py-[80px]">
<div className="flex flex-col gap-[38px]">
<h1
className="text-white font-bold uppercase leading-[1.2] text-[32px] md:text-[48px] lg:text-[80px]"
className="text-[32px] leading-[1.2] font-bold text-white uppercase md:text-[48px] lg:text-[80px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h1>
{subtitle && (
<p className="text-white font-medium text-[16px] lg:text-[20px] leading-[1.5] max-w-[629px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<p
className="max-w-[629px] text-[16px] leading-[1.5] font-medium text-white lg:text-[20px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}
</p>
)}
{ctaLabel && ctaHref && (
<Link
href={ctaHref}
className="self-start bg-[#f28b4a] text-white font-bold text-[20px] px-[30px] py-[10px] rounded-[64px] transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
className="self-start rounded-[64px] bg-[#f28b4a] px-[30px] py-[10px] text-[20px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{ctaLabel}

View file

@ -34,30 +34,35 @@ export function LeadFormBlockComponent({
await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, phone: showPhone ? phone : undefined, email: showEmail ? email : undefined, source: formSource ?? 'block-form' }),
body: JSON.stringify({
name,
phone: showPhone ? phone : undefined,
email: showEmail ? email : undefined,
source: formSource ?? 'block-form',
}),
})
} catch { /* noop */ }
} catch {
/* noop */
}
setSubmitted(true)
setLoading(false)
}
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[600px] mx-auto px-8">
<div className="mx-auto max-w-[600px] px-8">
{title && (
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-4"
className="mb-4 text-[24px] font-bold text-[#272727] uppercase md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-[#272727] text-[16px] mb-8">{subtitle}</p>
)}
{subtitle && <p className="mb-8 text-[16px] text-[#272727]">{subtitle}</p>}
{submitted ? (
<div className="bg-[#d6f2c0] rounded-[20px] p-8 text-center">
<p className="text-[#396817] font-bold text-[18px]">
<div className="rounded-[20px] bg-[#d6f2c0] p-8 text-center">
<p className="text-[18px] font-bold text-[#396817]">
{successMessage ?? "Дякуємо! Ми зв'яжемося з вами найближчим часом."}
</p>
</div>
@ -69,7 +74,7 @@ export function LeadFormBlockComponent({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше ім'я"
className="w-full px-5 py-3 rounded-[10px] border border-[#272727]/20 text-[#272727] text-[16px] outline-none focus:ring-2 focus:ring-[#f28b4a]"
className="w-full rounded-[10px] border border-[#272727]/20 px-5 py-3 text-[16px] text-[#272727] outline-none focus:ring-2 focus:ring-[#f28b4a]"
/>
{showPhone && (
<input
@ -78,7 +83,7 @@ export function LeadFormBlockComponent({
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="Телефон"
className="w-full px-5 py-3 rounded-[10px] border border-[#272727]/20 text-[#272727] text-[16px] outline-none focus:ring-2 focus:ring-[#f28b4a]"
className="w-full rounded-[10px] border border-[#272727]/20 px-5 py-3 text-[16px] text-[#272727] outline-none focus:ring-2 focus:ring-[#f28b4a]"
/>
)}
{showEmail && (
@ -87,13 +92,13 @@ export function LeadFormBlockComponent({
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
className="w-full px-5 py-3 rounded-[10px] border border-[#272727]/20 text-[#272727] text-[16px] outline-none focus:ring-2 focus:ring-[#f28b4a]"
className="w-full rounded-[10px] border border-[#272727]/20 px-5 py-3 text-[16px] text-[#272727] outline-none focus:ring-2 focus:ring-[#f28b4a]"
/>
)}
<button
type="submit"
disabled={loading}
className="bg-[#f28b4a] text-white font-bold text-[20px] px-[30px] py-[10px] rounded-[64px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow disabled:opacity-50"
className="rounded-[64px] bg-[#f28b4a] px-[30px] py-[10px] text-[20px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a] disabled:opacity-50"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loading ? '...' : (ctaLabel ?? 'Відправити')}

View file

@ -19,10 +19,10 @@ export function LocationsTeaserBlockComponent({ title, locations }: LocationsTea
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8">
<div className="mx-auto max-w-[1204px] px-8">
{title && (
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-[40px] md:mb-[60px]"
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
@ -32,18 +32,31 @@ export function LocationsTeaserBlockComponent({ title, locations }: LocationsTea
{locations.map((loc, i) => {
const imgUrl = typeof loc.image === 'object' ? loc.image?.url : null
return (
<article key={i} className="flex-none w-full md:w-[min(694px,90vw)] rounded-[20px] overflow-hidden">
<div className="relative flex items-center h-[491px]">
<article
key={i}
className="w-full flex-none overflow-hidden rounded-[20px] md:w-[min(694px,90vw)]"
>
<div className="relative flex h-[491px] items-center">
{imgUrl && (
<img src={imgUrl} alt={loc.name ?? ''} className="absolute inset-0 w-full h-full object-cover" />
<img
src={imgUrl}
alt={loc.name ?? ''}
className="absolute inset-0 h-full w-full object-cover"
/>
)}
<div className="absolute right-0 top-0 h-full w-[327px] bg-[rgba(57,104,23,0.8)] flex flex-col justify-center gap-7 px-[30px]">
<div className="absolute top-0 right-0 flex h-full w-[327px] flex-col justify-center gap-7 bg-[rgba(57,104,23,0.8)] px-[30px]">
<div className="flex flex-col gap-3 text-white">
<h3 className="font-bold text-[24px]" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<h3
className="text-[24px] font-bold"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h3>
{loc.description && (
<p className="text-[16px]" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<p
className="text-[16px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}
</p>
)}
@ -51,7 +64,7 @@ export function LocationsTeaserBlockComponent({ title, locations }: LocationsTea
{loc.href && (
<Link
href={loc.href}
className="self-start inline-flex items-center gap-2.5 bg-gradient-to-r from-[#f28b4a] via-[#fdcf54] to-[#f28d4b] text-[#272727] font-bold text-[16px] uppercase px-[30px] py-[10px] rounded-[20px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
className="inline-flex items-center gap-2.5 self-start rounded-[20px] bg-gradient-to-r from-[#f28b4a] via-[#fdcf54] to-[#f28d4b] px-[30px] py-[10px] text-[16px] font-bold text-[#272727] uppercase transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.ctaLabel ?? 'Детальніше'}

View file

@ -38,10 +38,10 @@ export async function NewsBlockComponent({ title, limit }: NewsBlockProps) {
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8">
<div className="mx-auto max-w-[1204px] px-8">
{title && (
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-[40px] md:mb-[60px]"
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
@ -50,24 +50,35 @@ export async function NewsBlockComponent({ title, limit }: NewsBlockProps) {
{posts.length === 0 ? (
<p className="text-[#272727]/60">Публікацій ще немає.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => {
const heroUrl = typeof post.hero === 'object' ? post.hero?.url : null
return (
<Link key={post.id} href={`/blog/${post.slug}`} className="flex flex-col gap-4 group">
<div className="h-[221px] rounded-[20px] overflow-hidden bg-[#396817]/10">
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col gap-4"
>
<div className="h-[221px] overflow-hidden rounded-[20px] bg-[#396817]/10">
{heroUrl && (
<img src={heroUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
<img
src={heroUrl}
alt={post.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
)}
</div>
<h3
className="font-bold text-[20px] text-[#272727] line-clamp-2"
className="line-clamp-2 text-[20px] font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{post.title}
</h3>
{post.excerpt && (
<p className="text-[#272727] text-[16px] line-clamp-3" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<p
className="line-clamp-3 text-[16px] text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{post.excerpt}
</p>
)}

View file

@ -8,7 +8,11 @@ interface NewsletterFormBlockProps {
ctaLabel?: string | null
}
export function NewsletterFormBlockComponent({ title, subtitle, ctaLabel }: NewsletterFormBlockProps) {
export function NewsletterFormBlockComponent({
title,
subtitle,
ctaLabel,
}: NewsletterFormBlockProps) {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
@ -18,36 +22,42 @@ export function NewsletterFormBlockComponent({ title, subtitle, ctaLabel }: News
}
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px] bg-[#396817]">
<div className="max-w-[1204px] mx-auto px-8 text-center">
<section className="bg-[#396817] py-[20px] md:py-[60px] lg:py-[40px]">
<div className="mx-auto max-w-[1204px] px-8 text-center">
{title && (
<h2
className="font-bold text-[24px] md:text-[32px] text-white uppercase mb-4"
className="mb-4 text-[24px] font-bold text-white uppercase md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-white/80 text-[16px] mb-8" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<p
className="mb-8 text-[16px] text-white/80"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{subtitle}
</p>
)}
{submitted ? (
<p className="text-[#fdcf54] font-bold text-[18px]">Дякуємо за підписку!</p>
<p className="text-[18px] font-bold text-[#fdcf54]">Дякуємо за підписку!</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-4 max-w-[500px] mx-auto">
<form
onSubmit={handleSubmit}
className="mx-auto flex max-w-[500px] flex-col gap-4 sm:flex-row"
>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Ваш email"
className="flex-1 px-5 py-3 rounded-[64px] text-[#272727] text-[16px] outline-none focus:ring-2 focus:ring-[#f28b4a]"
className="flex-1 rounded-[64px] px-5 py-3 text-[16px] text-[#272727] outline-none focus:ring-2 focus:ring-[#f28b4a]"
/>
<button
type="submit"
className="bg-[#f28b4a] text-white font-bold text-[16px] px-[30px] py-[10px] rounded-[64px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow whitespace-nowrap"
className="rounded-[64px] bg-[#f28b4a] px-[30px] py-[10px] text-[16px] font-bold whitespace-nowrap text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{ctaLabel ?? 'Підписатися'}

View file

@ -36,45 +36,60 @@ async function fetchTariffs(showOnlyVisible: boolean): Promise<Tariff[]> {
}
}
export async function PricingBlockComponent({ title, subtitle, showOnlyVisible, ctaLabel }: PricingBlockProps) {
export async function PricingBlockComponent({
title,
subtitle,
showOnlyVisible,
ctaLabel,
}: PricingBlockProps) {
const tariffs = await fetchTariffs(showOnlyVisible ?? true)
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8">
<div className="mx-auto max-w-[1204px] px-8">
{title && (
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-4"
className="mb-4 text-[24px] font-bold text-[#272727] uppercase md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-[#272727] text-[16px] mb-10">{subtitle}</p>
)}
{subtitle && <p className="mb-10 text-[16px] text-[#272727]">{subtitle}</p>}
{tariffs.length === 0 ? (
<p className="text-[#272727]/60">Тарифи незабаром з&apos;являться.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{tariffs.map((t) => (
<div key={t.id} className="bg-[#396817] rounded-[20px] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] flex flex-col gap-4">
<div
key={t.id}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div>
<p className="text-[#f28b4a] font-bold text-[14px] uppercase mb-2" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<p
className="mb-2 text-[14px] font-bold text-[#f28b4a] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{t.category_tag}
</p>
<h3 className="text-white font-bold text-[20px]" style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}>
<h3
className="text-[20px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{t.display_name ?? t.last_synced_name}
</h3>
</div>
{t.last_synced_price != null && (
<p className="text-white font-black text-[48px] leading-none" style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}>
<p
className="text-[48px] leading-none font-black text-white"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{t.last_synced_price} <span className="text-[24px] font-bold">грн</span>
</p>
)}
<Link
href="/kvytky"
className="mt-auto text-center bg-[#f28b4a] text-white font-bold text-[16px] px-[30px] py-[10px] rounded-[56px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
className="mt-auto rounded-[56px] bg-[#f28b4a] px-[30px] py-[10px] text-center text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{ctaLabel ?? 'Купити квиток'}

View file

@ -22,12 +22,15 @@ export function VideoBlockComponent({ videoUrl, caption, autoplay }: VideoBlockP
return (
<section className="py-[20px] md:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8">
<div className="relative w-full rounded-[20px] overflow-hidden" style={{ paddingTop: '56.25%' }}>
<div className="mx-auto max-w-[1204px] px-8">
<div
className="relative w-full overflow-hidden rounded-[20px]"
style={{ paddingTop: '56.25%' }}
>
{playing ? (
<iframe
src={toEmbedUrl(videoUrl, true)}
className="absolute inset-0 w-full h-full"
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen"
allowFullScreen
title={caption ?? 'Video'}
@ -35,10 +38,10 @@ export function VideoBlockComponent({ videoUrl, caption, autoplay }: VideoBlockP
) : (
<button
onClick={() => setPlaying(true)}
className="absolute inset-0 w-full h-full bg-[#396817] flex items-center justify-center group"
className="group absolute inset-0 flex h-full w-full items-center justify-center bg-[#396817]"
aria-label="Відтворити відео"
>
<span className="w-20 h-20 rounded-full bg-[#f28b4a] flex items-center justify-center group-hover:shadow-[0_0_40px_0_#f28b4a] transition-shadow">
<span className="flex h-20 w-20 items-center justify-center rounded-full bg-[#f28b4a] transition-shadow group-hover:shadow-[0_0_40px_0_#f28b4a]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M10 6L26 16L10 26V6Z" fill="white" />
</svg>
@ -46,9 +49,7 @@ export function VideoBlockComponent({ videoUrl, caption, autoplay }: VideoBlockP
</button>
)}
</div>
{caption && (
<p className="text-center text-[14px] text-[#272727]/60 mt-3">{caption}</p>
)}
{caption && <p className="mt-3 text-center text-[14px] text-[#272727]/60">{caption}</p>}
</div>
</section>
)

View file

@ -65,7 +65,7 @@ export function BirthdayPricing({ packages, title, intro }: BirthdayPricingProps
const activePackages = packages && packages.length > 0 ? packages : STATIC_PACKAGES
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px] bg-[#fefaf6]">
<section className="bg-[#fefaf6] py-[20px] md:py-[60px] lg:py-[40px]">
<div className="mx-auto max-w-[1204px] px-8">
<div className="mb-[40px] flex flex-col gap-5 md:mb-[60px]">
<h2

View file

@ -60,7 +60,9 @@ export function GallerySlider({ images, speed = 50 }: GallerySliderProps) {
scrollRef.current?.scrollBy({ left: dir * cardWidth, behavior: 'smooth' })
pausedRef.current = true
if (pauseTimer.current) clearTimeout(pauseTimer.current)
pauseTimer.current = setTimeout(() => { pausedRef.current = false }, 3000)
pauseTimer.current = setTimeout(() => {
pausedRef.current = false
}, 3000)
}
function openLightbox(idx: number) {
@ -108,8 +110,12 @@ export function GallerySlider({ images, speed = 50 }: GallerySliderProps) {
WebkitMaskImage:
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
}}
onMouseEnter={() => { pausedRef.current = true }}
onMouseLeave={() => { pausedRef.current = false }}
onMouseEnter={() => {
pausedRef.current = true
}}
onMouseLeave={() => {
pausedRef.current = false
}}
>
<div
ref={scrollRef}

View file

@ -32,7 +32,11 @@ export function BtnPrimary({ children, className = '', ...rest }: BtnPrimaryProp
}
return (
<button className={cls} style={BTN_STYLE} {...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}>
<button
className={cls}
style={BTN_STYLE}
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
>
{children}
</button>
)

View file

@ -19,14 +19,7 @@ export function PageHero({ title, subtitle, bgSrc, children }: PageHeroProps) {
const src = bgSrc ?? DEFAULT_BG
return (
<div className="relative -mt-[60px] overflow-hidden px-8 pt-[calc(60px+48px)] pb-16 lg:-mt-[120px] lg:pt-[calc(120px+64px)]">
<Image
src={src}
alt=""
fill
priority
sizes="100vw"
className="object-cover"
/>
<Image src={src} alt="" fill priority sizes="100vw" className="object-cover" />
<div className="absolute inset-0 bg-[rgba(57,104,23,0.55)]" />
<div className="relative z-10 mx-auto max-w-[1140px] text-white">
<h1