feat: Phase 2 complete — content model, blocks, collections, globals

- 11 Page Builder blocks: HeroBlock, TextBlock, FeaturesBlock,
  LocationCardBlock, PricingBlock, GalleryBlock, FormBlock (with label
  relation for lead tracking), CTABlock, CountdownBlock, BlogPreviewBlock,
  MapBlock
- Collections: Labels, Pages, LandingPages, Blog, Events, Leads (UTM +
  googleClientId + label relation), Orders, Tickets
- Globals: SiteSettings (GTM/GA4/Binotel/Umami), TicketsConfig, Navigation
- Added "type": "module" to package.json for Payload CLI ESM compatibility
- payload-types.ts generated (1874 lines)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-02 17:52:54 +01:00
parent 04b112d476
commit 61e73033fe
25 changed files with 2927 additions and 4 deletions

View file

@ -10,7 +10,9 @@
"Bash(pnpm approve-builds:*)",
"Bash(pnpm info:*)",
"Bash(node -e \"const v=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const versions=JSON.parse\\(v\\); const compatible=versions.filter\\(v=>{ const [,maj,min,patch]=v.match\\(/^\\(\\\\d+\\)\\\\.\\(\\\\d+\\)\\\\.\\(\\\\d+\\)/\\) || []; if\\(!maj\\) return false; const n=+maj*10000 + +min*100 + +patch; return \\(n>=150209 && n<150300\\) || \\(n>=150309 && n<150400\\) || \\(n>=150411 && n<150500\\); }\\); console.log\\(compatible.slice\\(-5\\).join\\('\\\\n'\\)\\);\")",
"Bash(pnpm next:*)"
"Bash(pnpm next:*)",
"Bash(pnpm payload:*)",
"Bash(NODE_OPTIONS='--import tsx/esm' pnpm payload generate:types)"
]
}
}

View file

@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "Shumiland — сімейний тематичний парк",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",

View file

@ -0,0 +1,95 @@
import type { Block } from 'payload'
export const BlogPreviewBlock: Block = {
slug: 'blogPreviewBlock',
interfaceName: 'BlogPreviewBlock',
labels: {
singular: 'Блок анонсів',
plural: 'Блоки анонсів',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок секції',
admin: {
placeholder: 'Новини та події',
},
},
{
name: 'subtitle',
type: 'textarea',
label: 'Підзаголовок',
admin: {
rows: 2,
},
},
{
name: 'source',
type: 'select',
label: 'Джерело контенту',
defaultValue: 'blog',
options: [
{ label: 'Блог (новини)', value: 'blog' },
{ label: 'Події', value: 'events' },
{ label: 'Блог + події', value: 'both' },
],
},
{
name: 'count',
type: 'number',
label: 'Кількість постів',
defaultValue: 3,
min: 1,
max: 9,
admin: {
description: 'Скільки останніх постів показувати',
},
},
{
name: 'layout',
type: 'select',
label: 'Розмітка',
defaultValue: 'cards',
options: [
{ label: 'Картки (3 в ряд)', value: 'cards' },
{ label: 'Список', value: 'list' },
{ label: 'Великий + малі', value: 'featured' },
],
},
{
name: 'showMoreLink',
type: 'checkbox',
label: 'Показати посилання "Всі публікації"',
defaultValue: true,
},
{
name: 'moreLinkLabel',
type: 'text',
label: 'Текст посилання "Більше"',
defaultValue: 'Всі публікації',
admin: {
condition: (_, siblingData) => siblingData?.showMoreLink,
},
},
{
name: 'moreLinkUrl',
type: 'text',
label: 'URL посилання "Більше"',
admin: {
placeholder: '/blog',
condition: (_, siblingData) => siblingData?.showMoreLink,
},
},
{
name: 'backgroundColor',
type: 'select',
label: 'Фон секції',
defaultValue: 'white',
options: [
{ label: 'Білий', value: 'white' },
{ label: 'Світло-сірий', value: 'gray' },
],
},
],
}

135
src/blocks/CTABlock.ts Normal file
View file

@ -0,0 +1,135 @@
import type { Block } from 'payload'
export const CTABlock: Block = {
slug: 'ctaBlock',
interfaceName: 'CTABlock',
labels: {
singular: 'CTA банер',
plural: 'CTA банери',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок',
required: true,
admin: {
placeholder: 'Готові до пригод?',
},
},
{
name: 'description',
type: 'textarea',
label: 'Опис',
admin: {
rows: 2,
placeholder: 'Придбайте квитки онлайн зі знижкою',
},
},
{
name: 'backgroundType',
type: 'select',
label: 'Тип фону',
defaultValue: 'gradient',
options: [
{ label: 'Градієнт (brand)', value: 'gradient' },
{ label: 'Зображення', value: 'image' },
{ label: 'Однотонний', value: 'solid' },
],
},
{
name: 'backgroundImage',
type: 'upload',
relationTo: 'media',
label: 'Фонове зображення',
admin: {
condition: (_, siblingData) => siblingData?.backgroundType === 'image',
},
},
{
name: 'backgroundColor',
type: 'select',
label: 'Колір фону',
defaultValue: 'green',
options: [
{ label: 'Зелений (brand)', value: 'green' },
{ label: 'Помаранчевий (brand)', value: 'orange' },
{ label: 'Синій (brand)', value: 'blue' },
{ label: 'Фіолетовий (brand)', value: 'purple' },
],
admin: {
condition: (_, siblingData) => siblingData?.backgroundType === 'solid',
},
},
{
name: 'overlayOpacity',
type: 'number',
label: 'Затемнення фону (%)',
defaultValue: 50,
min: 0,
max: 90,
admin: {
condition: (_, siblingData) => siblingData?.backgroundType === 'image',
},
},
{
name: 'buttons',
type: 'array',
label: 'Кнопки',
minRows: 1,
maxRows: 3,
fields: [
{
name: 'label',
type: 'text',
label: 'Текст кнопки',
required: true,
admin: {
placeholder: 'Купити квиток',
},
},
{
name: 'url',
type: 'text',
label: 'Посилання',
required: true,
admin: {
placeholder: '/payments',
},
},
{
name: 'variant',
type: 'select',
label: 'Стиль',
defaultValue: 'orange',
options: [
{ label: 'Помаранчевий', value: 'orange' },
{ label: 'Зелений', value: 'green' },
{ label: 'Синій', value: 'blue' },
{ label: 'Фіолетовий', value: 'purple' },
{ label: 'Білий обводка', value: 'outline-white' },
],
},
{
name: 'gtmEvent',
type: 'text',
label: 'GTM подія',
admin: {
placeholder: 'cta_click',
description: 'Назва події для Google Analytics',
},
},
],
},
{
name: 'align',
type: 'select',
label: 'Вирівнювання',
defaultValue: 'center',
options: [
{ label: 'По центру', value: 'center' },
{ label: 'Ліворуч', value: 'left' },
],
},
],
}

View file

@ -0,0 +1,97 @@
import type { Block } from 'payload'
export const CountdownBlock: Block = {
slug: 'countdownBlock',
interfaceName: 'CountdownBlock',
labels: {
singular: 'Зворотний відлік',
plural: 'Зворотні відліки',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок',
admin: {
placeholder: 'До відкриття залишилось',
},
},
{
name: 'targetDate',
type: 'date',
label: 'Дата та час події',
required: true,
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
description: 'Коли відлік досягне нуля — покаже expiredMessage',
},
},
{
name: 'expiredMessage',
type: 'text',
label: 'Повідомлення після закінчення відліку',
defaultValue: 'Подія вже відбулась!',
},
{
name: 'showDays',
type: 'checkbox',
label: 'Показати дні',
defaultValue: true,
},
{
name: 'showHours',
type: 'checkbox',
label: 'Показати години',
defaultValue: true,
},
{
name: 'showMinutes',
type: 'checkbox',
label: 'Показати хвилини',
defaultValue: true,
},
{
name: 'showSeconds',
type: 'checkbox',
label: 'Показати секунди',
defaultValue: true,
},
{
name: 'backgroundColor',
type: 'select',
label: 'Фон секції',
defaultValue: 'green',
options: [
{ label: 'Зелений (brand)', value: 'green' },
{ label: 'Синій (brand)', value: 'blue' },
{ label: 'Фіолетовий (brand)', value: 'purple' },
{ label: 'Білий', value: 'white' },
],
},
{
name: 'cta',
type: 'group',
label: 'Кнопка (необов\'язково)',
fields: [
{
name: 'label',
type: 'text',
label: 'Текст кнопки',
admin: {
placeholder: 'Дізнатись більше',
},
},
{
name: 'url',
type: 'text',
label: 'Посилання',
admin: {
placeholder: '/events',
},
},
],
},
],
}

View file

@ -0,0 +1,98 @@
import type { Block } from 'payload'
export const FeaturesBlock: Block = {
slug: 'featuresBlock',
interfaceName: 'FeaturesBlock',
labels: {
singular: 'Блок переваг',
plural: 'Блоки переваг',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок секції',
admin: {
placeholder: 'Чому Шуміленд?',
},
},
{
name: 'subtitle',
type: 'textarea',
label: 'Підзаголовок секції',
admin: {
rows: 2,
},
},
{
name: 'columns',
type: 'select',
label: 'Кількість колонок',
defaultValue: '3',
options: [
{ label: '2 колонки', value: '2' },
{ label: '3 колонки', value: '3' },
{ label: '4 колонки', value: '4' },
],
},
{
name: 'items',
type: 'array',
label: 'Переваги',
minRows: 1,
maxRows: 12,
admin: {
description: 'Додайте переваги або особливості',
},
fields: [
{
name: 'icon',
type: 'text',
label: 'Іконка (Lucide React назва)',
admin: {
placeholder: 'Trees, Shield, UtensilsCrossed, Bus',
description: 'Назва іконки з lucide.dev',
},
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Або зображення',
admin: {
description: 'Якщо вказано — замінює іконку',
},
},
{
name: 'title',
type: 'text',
label: 'Назва',
required: true,
admin: {
placeholder: 'Безпека та комфорт',
},
},
{
name: 'description',
type: 'textarea',
label: 'Опис',
admin: {
rows: 2,
placeholder: 'Розкажіть детальніше про цю перевагу',
},
},
],
},
{
name: 'backgroundColor',
type: 'select',
label: 'Фон секції',
defaultValue: 'white',
options: [
{ label: 'Білий', value: 'white' },
{ label: 'Світло-сірий', value: 'gray' },
{ label: 'Зелений (brand)', value: 'green' },
],
},
],
}

104
src/blocks/FormBlock.ts Normal file
View file

@ -0,0 +1,104 @@
import type { Block } from 'payload'
export const FormBlock: Block = {
slug: 'formBlock',
interfaceName: 'FormBlock',
labels: {
singular: 'Форма',
plural: 'Форми',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок форми',
admin: {
placeholder: 'Замовити день народження',
},
},
{
name: 'subtitle',
type: 'textarea',
label: 'Підзаголовок',
admin: {
rows: 2,
},
},
{
name: 'formType',
type: 'select',
label: 'Тип форми',
required: true,
defaultValue: 'generic',
options: [
{ label: 'День народження', value: 'birthday' },
{ label: 'Групове відвідування', value: 'group' },
{ label: 'Зворотний дзвінок', value: 'callback' },
{ label: 'Загальна форма', value: 'generic' },
],
admin: {
description: 'Визначає набір полів форми та тег для ліда',
},
},
{
name: 'label',
type: 'relationship',
relationTo: 'labels',
label: 'Мітка (джерело ліда)',
admin: {
description:
'Мітка буде збережена разом з лідом для трекінгу джерела. Наприклад: "Instagram Літо 2025", "Google Акція", "Landing Динопарк".',
},
},
{
name: 'gtmEvent',
type: 'text',
label: 'GTM подія (при відправці)',
admin: {
placeholder: 'lead_submit_birthday',
description: 'Назва події для Google Tag Manager / GA4 після успішної відправки',
},
},
{
name: 'successMessage',
type: 'textarea',
label: 'Повідомлення після відправки',
defaultValue: 'Дякуємо! Ми зв\'яжемось з вами найближчим часом.',
admin: {
rows: 2,
},
},
{
name: 'backgroundColor',
type: 'select',
label: 'Фон секції',
defaultValue: 'white',
options: [
{ label: 'Білий', value: 'white' },
{ label: 'Світло-сірий', value: 'gray' },
{ label: 'Зелений (brand)', value: 'green' },
],
},
{
name: 'layout',
type: 'select',
label: 'Розмітка',
defaultValue: 'centered',
options: [
{ label: 'По центру (вузька)', value: 'centered' },
{ label: 'Повна ширина', value: 'full' },
{ label: 'З бічним зображенням', value: 'split' },
],
},
{
name: 'sideImage',
type: 'upload',
relationTo: 'media',
label: 'Бічне зображення',
admin: {
condition: (_, siblingData) => siblingData?.layout === 'split',
description: 'Відображається поруч з формою при виборі "З бічним зображенням"',
},
},
],
}

View file

@ -0,0 +1,81 @@
import type { Block } from 'payload'
export const GalleryBlock: Block = {
slug: 'galleryBlock',
interfaceName: 'GalleryBlock',
labels: {
singular: 'Галерея',
plural: 'Галереї',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок',
admin: {
placeholder: 'Фотогалерея',
},
},
{
name: 'subtitle',
type: 'textarea',
label: 'Підзаголовок',
admin: {
rows: 2,
},
},
{
name: 'layout',
type: 'select',
label: 'Тип відображення',
defaultValue: 'grid',
options: [
{ label: 'Сітка (рівні квадрати)', value: 'grid' },
{ label: 'Мозаїка (masonry)', value: 'masonry' },
{ label: 'Карусель (слайдер)', value: 'carousel' },
],
},
{
name: 'columns',
type: 'select',
label: 'Кількість колонок (для сітки/мозаїки)',
defaultValue: '3',
options: [
{ label: '2 колонки', value: '2' },
{ label: '3 колонки', value: '3' },
{ label: '4 колонки', value: '4' },
],
admin: {
condition: (_, siblingData) =>
siblingData?.layout === 'grid' || siblingData?.layout === 'masonry',
},
},
{
name: 'images',
type: 'array',
label: 'Зображення',
minRows: 1,
maxRows: 30,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Зображення',
required: true,
},
{
name: 'caption',
type: 'text',
label: 'Підпис (необов\'язково)',
},
],
},
{
name: 'enableLightbox',
type: 'checkbox',
label: 'Відкривати у повному розмірі при кліку',
defaultValue: true,
},
],
}

161
src/blocks/HeroBlock.ts Normal file
View file

@ -0,0 +1,161 @@
import type { Block } from 'payload'
export const HeroBlock: Block = {
slug: 'heroBlock',
interfaceName: 'HeroBlock',
labels: {
singular: 'Hero секція',
plural: 'Hero секції',
},
fields: [
{
name: 'headline',
type: 'text',
label: 'Заголовок',
required: true,
admin: {
placeholder: 'Ласкаво просимо до Шуміленду!',
},
},
{
name: 'subheadline',
type: 'textarea',
label: 'Підзаголовок',
admin: {
rows: 2,
placeholder: 'Парк розваг для всієї родини',
},
},
{
name: 'backgroundType',
type: 'select',
label: 'Тип фону',
required: true,
defaultValue: 'gradient',
options: [
{ label: 'Відео', value: 'video' },
{ label: 'Зображення', value: 'image' },
{ label: 'Градієнт', value: 'gradient' },
],
},
{
name: 'backgroundVideo',
type: 'upload',
relationTo: 'media',
label: 'Фонове відео',
admin: {
condition: (_, siblingData) => siblingData?.backgroundType === 'video',
description: 'MP4, рекомендовано до 10MB',
},
},
{
name: 'backgroundImage',
type: 'upload',
relationTo: 'media',
label: 'Фонове зображення',
admin: {
condition: (_, siblingData) => siblingData?.backgroundType === 'image',
},
},
{
name: 'backgroundGradient',
type: 'select',
label: 'Варіант градієнту',
defaultValue: 'green',
options: [
{ label: 'Зелений (brand)', value: 'green' },
{ label: 'Синій', value: 'blue' },
{ label: 'Фіолетовий', value: 'purple' },
],
admin: {
condition: (_, siblingData) => siblingData?.backgroundType === 'gradient',
},
},
{
name: 'overlayOpacity',
type: 'number',
label: 'Затемнення фону (%)',
defaultValue: 40,
min: 0,
max: 90,
admin: {
description: 'Від 0 (без затемнення) до 90 (майже чорний)',
condition: (_, siblingData) => siblingData?.backgroundType !== 'gradient',
},
},
{
name: 'cta',
type: 'group',
label: 'Основна кнопка CTA',
fields: [
{
name: 'label',
type: 'text',
label: 'Текст кнопки',
defaultValue: 'Купити квиток',
},
{
name: 'url',
type: 'text',
label: 'Посилання',
defaultValue: '/payments',
},
{
name: 'variant',
type: 'select',
label: 'Стиль',
defaultValue: 'orange',
options: [
{ label: 'Помаранчевий', value: 'orange' },
{ label: 'Зелений', value: 'green' },
{ label: 'Синій', value: 'blue' },
{ label: 'Фіолетовий', value: 'purple' },
],
},
{
name: 'gtmEvent',
type: 'text',
label: 'GTM подія',
defaultValue: 'hero_cta_click',
admin: {
description: 'Назва події для Google Analytics',
},
},
],
},
{
name: 'secondaryCta',
type: 'group',
label: 'Додаткова кнопка (необов\'язково)',
fields: [
{
name: 'label',
type: 'text',
label: 'Текст кнопки',
},
{
name: 'url',
type: 'text',
label: 'Посилання',
},
],
},
{
name: 'showScrollIndicator',
type: 'checkbox',
label: 'Показати індикатор прокрутки',
defaultValue: true,
},
{
name: 'minHeight',
type: 'select',
label: 'Мінімальна висота',
defaultValue: 'screen',
options: [
{ label: 'Повний екран (100vh)', value: 'screen' },
{ label: '80% екрану', value: '80vh' },
{ label: '60% екрану', value: '60vh' },
],
},
],
}

View file

@ -0,0 +1,100 @@
import type { Block } from 'payload'
export const LocationCardBlock: Block = {
slug: 'locationCardBlock',
interfaceName: 'LocationCardBlock',
labels: {
singular: 'Картки локацій',
plural: 'Картки локацій',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок секції',
admin: {
placeholder: 'Наші локації',
},
},
{
name: 'subtitle',
type: 'textarea',
label: 'Підзаголовок',
admin: {
rows: 2,
},
},
{
name: 'locations',
type: 'array',
label: 'Локації',
minRows: 1,
maxRows: 6,
fields: [
{
name: 'name',
type: 'text',
label: 'Назва локації',
required: true,
admin: {
placeholder: 'Динопарк',
},
},
{
name: 'slug',
type: 'text',
label: 'URL (slug)',
required: true,
admin: {
placeholder: 'dinopark',
description: 'URL-адреса сторінки, наприклад dinopark',
},
},
{
name: 'description',
type: 'textarea',
label: 'Короткий опис',
required: true,
admin: {
rows: 2,
},
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Зображення',
required: true,
},
{
name: 'badge',
type: 'text',
label: 'Бейдж (необов\'язково)',
admin: {
placeholder: 'Новинка! або 23 динозаври',
},
},
{
name: 'price',
type: 'text',
label: 'Ціна (відображення)',
admin: {
placeholder: 'від 160 грн',
},
},
{
name: 'ctaLabel',
type: 'text',
label: 'Текст кнопки',
defaultValue: 'Дізнатись більше',
},
],
},
{
name: 'showBuyButton',
type: 'checkbox',
label: 'Показати кнопку "Купити квиток"',
defaultValue: true,
},
],
}

120
src/blocks/MapBlock.ts Normal file
View file

@ -0,0 +1,120 @@
import type { Block } from 'payload'
export const MapBlock: Block = {
slug: 'mapBlock',
interfaceName: 'MapBlock',
labels: {
singular: 'Блок з картою',
plural: 'Блоки з картою',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок',
admin: {
placeholder: 'Як нас знайти',
},
},
{
name: 'address',
type: 'textarea',
label: 'Адреса',
admin: {
rows: 2,
placeholder: 'с. Малютянка, Бучанський район, Київська область',
},
},
{
name: 'embedUrl',
type: 'text',
label: 'URL карти (Google Maps embed)',
admin: {
placeholder: 'https://www.google.com/maps/embed?pb=...',
description: 'Google Maps → Поділитися → Вставити карту → скопіювати src з iframe',
},
},
{
name: 'mapHeight',
type: 'select',
label: 'Висота карти',
defaultValue: 'medium',
options: [
{ label: 'Низька (300px)', value: 'small' },
{ label: 'Середня (450px)', value: 'medium' },
{ label: 'Висока (600px)', value: 'large' },
],
},
{
name: 'transportInfo',
type: 'array',
label: 'Як дістатись',
maxRows: 5,
fields: [
{
name: 'icon',
type: 'select',
label: 'Транспорт',
options: [
{ label: 'Автомобіль', value: 'car' },
{ label: 'Автобус', value: 'bus' },
{ label: 'Потяг', value: 'train' },
{ label: 'Таксі', value: 'taxi' },
{ label: 'Велосипед', value: 'bike' },
],
},
{
name: 'description',
type: 'textarea',
label: 'Опис маршруту',
required: true,
admin: {
rows: 2,
placeholder: 'З Києва маршруткою №xxx до зупинки "Шуміленд"',
},
},
],
},
{
name: 'workingHours',
type: 'array',
label: 'Графік роботи',
maxRows: 7,
fields: [
{
name: 'days',
type: 'text',
label: 'Дні',
required: true,
admin: {
placeholder: 'Понеділок П\'ятниця',
},
},
{
name: 'hours',
type: 'text',
label: 'Години',
required: true,
admin: {
placeholder: '10:00 20:00',
},
},
],
},
{
name: 'showDirectionsButton',
type: 'checkbox',
label: 'Показати кнопку "Прокласти маршрут"',
defaultValue: true,
},
{
name: 'directionsUrl',
type: 'text',
label: 'Посилання на Google Maps (для маршруту)',
admin: {
placeholder: 'https://goo.gl/maps/...',
condition: (_, siblingData) => siblingData?.showDirectionsButton,
},
},
],
}

115
src/blocks/PricingBlock.ts Normal file
View file

@ -0,0 +1,115 @@
import type { Block } from 'payload'
export const PricingBlock: Block = {
slug: 'pricingBlock',
interfaceName: 'PricingBlock',
labels: {
singular: 'Блок цін',
plural: 'Блоки цін',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок',
admin: {
placeholder: 'Ціни та квитки',
},
},
{
name: 'subtitle',
type: 'textarea',
label: 'Підзаголовок',
admin: {
rows: 2,
},
},
{
name: 'showTicketSelector',
type: 'checkbox',
label: 'Показати інтерактивний вибір квитків',
defaultValue: true,
admin: {
description: 'Якщо увімкнено — відображається калькулятор з категоріями з TicketsConfig. Якщо вимкнено — відображається таблиця нижче.',
},
},
{
name: 'location',
type: 'select',
label: 'Фільтр по локації',
admin: {
condition: (_, siblingData) => siblingData?.showTicketSelector,
description: 'Показати квитки тільки для цієї локації',
},
options: [
{ label: 'Всі квитки', value: 'all' },
{ label: 'Динопарк', value: 'dinopark' },
{ label: 'ДивоЛіс', value: 'dyvo-lis' },
{ label: 'Лабіринт', value: 'labiryn' },
{ label: 'Комбо', value: 'combo' },
],
defaultValue: 'all',
},
{
name: 'customTable',
type: 'array',
label: 'Таблиця цін (ручне заповнення)',
admin: {
condition: (_, siblingData) => !siblingData?.showTicketSelector,
description: 'Заповніть вручну якщо не використовується автоматичний вибір квитків',
},
fields: [
{
name: 'category',
type: 'text',
label: 'Категорія / назва',
required: true,
admin: {
placeholder: 'Динопарк',
},
},
{
name: 'description',
type: 'text',
label: 'Опис',
admin: {
placeholder: 'Включає вхід на територію з 23 динозаврами',
},
},
{
name: 'price',
type: 'text',
label: 'Ціна',
required: true,
admin: {
placeholder: '300 грн',
},
},
{
name: 'highlight',
type: 'checkbox',
label: 'Виділити рядок',
defaultValue: false,
},
],
},
{
name: 'showFreeCategories',
type: 'checkbox',
label: 'Показати умови безкоштовного входу',
defaultValue: true,
admin: {
description: 'УБД, діти до 3р, діти з інвалідністю тощо',
},
},
{
name: 'note',
type: 'textarea',
label: 'Примітка',
admin: {
rows: 3,
placeholder: '*Знижка діє лише на квитки повної вартості на окремі зони',
},
},
],
}

56
src/blocks/TextBlock.ts Normal file
View file

@ -0,0 +1,56 @@
import type { Block } from 'payload'
export const TextBlock: Block = {
slug: 'textBlock',
interfaceName: 'TextBlock',
labels: {
singular: 'Текстовий блок',
plural: 'Текстові блоки',
},
fields: [
{
name: 'content',
type: 'richText',
label: 'Контент',
required: true,
admin: {
description: 'Підтримується форматування: заголовки, списки, посилання, жирний текст',
},
},
{
name: 'align',
type: 'select',
label: 'Вирівнювання',
defaultValue: 'left',
options: [
{ label: 'Ліворуч', value: 'left' },
{ label: 'По центру', value: 'center' },
{ label: 'Праворуч', value: 'right' },
],
},
{
name: 'maxWidth',
type: 'select',
label: 'Максимальна ширина',
defaultValue: 'full',
options: [
{ label: 'Повна ширина', value: 'full' },
{ label: 'Широка (900px)', value: 'wide' },
{ label: 'Середня (720px)', value: 'medium' },
{ label: 'Вузька (600px)', value: 'narrow' },
],
},
{
name: 'backgroundColor',
type: 'select',
label: 'Фон секції',
defaultValue: 'white',
options: [
{ label: 'Білий', value: 'white' },
{ label: 'Світло-сірий', value: 'gray' },
{ label: 'Зелений (brand)', value: 'green' },
{ label: 'Синій (brand)', value: 'blue' },
],
},
],
}

161
src/collections/Blog.ts Normal file
View file

@ -0,0 +1,161 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor, isAdminOrPublished } from '../access'
export const Blog: CollectionConfig = {
slug: 'blog',
labels: {
singular: 'Пост блогу',
plural: 'Пости блогу',
},
admin: {
group: 'Контент',
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'publishedAt', '_status'],
preview: (doc) => {
if (doc?.slug) {
return `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${doc.slug}`
}
return null
},
},
access: {
read: isAdminOrPublished,
create: isEditor,
update: isEditor,
delete: isAdmin,
},
versions: {
drafts: {
autosave: {
interval: 375,
},
},
},
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок',
required: true,
admin: {
placeholder: 'Як ми готуємось до літнього сезону',
},
},
{
name: 'slug',
type: 'text',
label: 'URL (slug)',
required: true,
unique: true,
index: true,
admin: {
placeholder: 'litniy-sezon-2026',
description: 'Автоматично генерується з заголовку. Доступний за: /blog/{slug}',
},
},
{
name: 'excerpt',
type: 'textarea',
label: 'Короткий опис (анонс)',
admin: {
rows: 3,
placeholder: 'Розповідаємо про нові атракціони та заходи цього сезону',
description: 'Відображається в картці на списку блогу та в META description',
},
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
label: 'Обкладинка',
admin: {
description: 'Рекомендований розмір: 1200×630 px',
},
},
{
name: 'category',
type: 'select',
label: 'Категорія',
options: [
{ label: 'Новини', value: 'news' },
{ label: 'Заходи', value: 'events' },
{ label: 'Корисне', value: 'tips' },
{ label: 'За лаштунками', value: 'behind-scenes' },
],
admin: {
placeholder: 'Оберіть категорію',
},
},
{
name: 'tags',
type: 'array',
label: 'Теги',
maxRows: 10,
fields: [
{
name: 'tag',
type: 'text',
label: 'Тег',
required: true,
admin: {
placeholder: 'динопарк',
},
},
],
},
{
name: 'publishedAt',
type: 'date',
label: 'Дата публікації',
admin: {
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'd MMM yyyy',
},
description: 'Відображається на сторінці. Якщо не вказано — використовується дата створення.',
},
},
{
name: 'body',
type: 'richText',
label: 'Контент',
required: true,
admin: {
description: 'Повний текст статті',
},
},
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta Title',
admin: {
description: 'Якщо порожнє — використовується заголовок поста',
},
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta Description',
admin: {
rows: 3,
description: 'Якщо порожнє — використовується анонс поста',
},
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: 'OG Image',
admin: {
description: 'Якщо порожнє — використовується обкладинка',
},
},
],
},
],
}

141
src/collections/Events.ts Normal file
View file

@ -0,0 +1,141 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor, isAdminOrPublished } from '../access'
export const Events: CollectionConfig = {
slug: 'events',
labels: {
singular: 'Подія',
plural: 'Події',
},
admin: {
group: 'Контент',
useAsTitle: 'title',
defaultColumns: ['title', 'eventDate', 'isFeatured', '_status'],
preview: (doc) => {
if (doc?.slug) {
return `${process.env.NEXT_PUBLIC_SITE_URL}/events/${doc.slug}`
}
return null
},
},
access: {
read: isAdminOrPublished,
create: isEditor,
update: isEditor,
delete: isAdmin,
},
versions: {
drafts: {
autosave: {
interval: 375,
},
},
},
fields: [
{
name: 'title',
type: 'text',
label: 'Назва події',
required: true,
admin: {
placeholder: 'Новорічне шоу з Шуміком',
},
},
{
name: 'slug',
type: 'text',
label: 'URL (slug)',
required: true,
unique: true,
index: true,
admin: {
placeholder: 'novrichne-sho-z-shumikom',
description: 'Доступний за: /events/{slug}',
},
},
{
name: 'excerpt',
type: 'textarea',
label: 'Короткий опис',
admin: {
rows: 3,
placeholder: 'Незабутнє новорічне шоу для дітей та їхніх батьків',
},
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
label: 'Обкладинка',
},
{
name: 'eventDate',
type: 'date',
label: 'Дата початку події',
required: true,
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'eventEndDate',
type: 'date',
label: 'Дата закінчення події',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
description: 'Заповніть якщо подія триватиме кілька днів',
},
},
{
name: 'isFeatured',
type: 'checkbox',
label: 'Виділена подія (показувати на головній)',
defaultValue: false,
},
{
name: 'relatedLanding',
type: 'relationship',
relationTo: 'landing-pages',
label: 'Пов\'язаний лендінг',
admin: {
description: 'Якщо для події є окремий лендінг — прив\'яжіть його тут',
},
},
{
name: 'body',
type: 'richText',
label: 'Опис події',
admin: {
description: 'Детальний опис програми, учасників, умов',
},
},
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta Title',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta Description',
admin: { rows: 3 },
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: 'OG Image',
},
],
},
],
}

73
src/collections/Labels.ts Normal file
View file

@ -0,0 +1,73 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor } from '@/access'
const Labels: CollectionConfig = {
slug: 'labels',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'color', 'isActive', 'createdAt'],
group: 'Маркетинг',
description: 'Мітки для відстеження джерел лідів (Instagram, Google Ads, подія тощо)',
},
labels: {
singular: 'Мітка',
plural: 'Мітки',
},
access: {
read: isEditor,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: 'name',
type: 'text',
label: 'Назва мітки',
required: true,
unique: true,
admin: {
placeholder: 'Наприклад: Instagram Акція Літо 2026',
description: 'Унікальна назва для ідентифікації джерела ліда',
},
},
{
name: 'color',
type: 'select',
label: 'Колір',
defaultValue: 'blue',
options: [
{ label: '🟢 Зелений', value: 'green' },
{ label: '🔵 Синій', value: 'blue' },
{ label: '🟠 Помаранчевий', value: 'orange' },
{ label: '🟣 Фіолетовий', value: 'purple' },
{ label: '🟡 Жовтий', value: 'yellow' },
{ label: '🔴 Червоний', value: 'red' },
{ label: '⚫ Чорний', value: 'black' },
],
admin: {
description: 'Колір для відображення в таблиці лідів',
},
},
{
name: 'description',
type: 'textarea',
label: 'Опис',
admin: {
placeholder: 'Для якої кампанії або акції ця мітка?',
rows: 3,
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Активна',
defaultValue: true,
admin: {
description: 'Неактивні мітки не відображаються при виборі форми',
},
},
],
}
export default Labels

View file

@ -0,0 +1,181 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor, isAdminOrPublished } from '../access'
import { HeroBlock } from '../blocks/HeroBlock'
import { TextBlock } from '../blocks/TextBlock'
import { FeaturesBlock } from '../blocks/FeaturesBlock'
import { LocationCardBlock } from '../blocks/LocationCardBlock'
import { PricingBlock } from '../blocks/PricingBlock'
import { GalleryBlock } from '../blocks/GalleryBlock'
import { FormBlock } from '../blocks/FormBlock'
import { CTABlock } from '../blocks/CTABlock'
import { CountdownBlock } from '../blocks/CountdownBlock'
import { BlogPreviewBlock } from '../blocks/BlogPreviewBlock'
import { MapBlock } from '../blocks/MapBlock'
export const LandingPages: CollectionConfig = {
slug: 'landing-pages',
labels: {
singular: 'Лендінг',
plural: 'Лендінги',
},
admin: {
group: 'Контент',
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'eventDate', '_status', 'updatedAt'],
preview: (doc) => {
if (doc?.slug) {
return `${process.env.NEXT_PUBLIC_SITE_URL}/landing/${doc.slug}`
}
return null
},
},
access: {
read: isAdminOrPublished,
create: isEditor,
update: isEditor,
delete: isAdmin,
},
versions: {
drafts: {
autosave: {
interval: 375,
},
},
},
fields: [
{
name: 'title',
type: 'text',
label: 'Назва лендінгу',
required: true,
admin: {
placeholder: 'Новорічне свято 2026',
},
},
{
name: 'slug',
type: 'text',
label: 'URL (slug)',
required: true,
unique: true,
index: true,
admin: {
placeholder: 'novyy-rik-2026',
description: 'Доступний за адресою: /landing/{slug}',
},
},
{
name: 'eventDate',
type: 'date',
label: 'Дата події',
admin: {
description: 'Відображається на сторінці та використовується для відліку',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'stickyCta',
type: 'group',
label: 'Sticky CTA (плаваюча кнопка знизу)',
fields: [
{
name: 'enabled',
type: 'checkbox',
label: 'Увімкнути sticky CTA',
defaultValue: true,
},
{
name: 'label',
type: 'text',
label: 'Текст кнопки',
defaultValue: 'Купити квиток',
admin: {
condition: (_, siblingData) => siblingData?.enabled,
},
},
{
name: 'url',
type: 'text',
label: 'Посилання',
defaultValue: '/payments',
admin: {
condition: (_, siblingData) => siblingData?.enabled,
},
},
{
name: 'gtmEvent',
type: 'text',
label: 'GTM подія',
defaultValue: 'landing_sticky_cta_click',
admin: {
condition: (_, siblingData) => siblingData?.enabled,
},
},
],
},
{
name: 'conversionGoal',
type: 'select',
label: 'Ціль конверсії',
defaultValue: 'ticket_purchase',
options: [
{ label: 'Купівля квитка', value: 'ticket_purchase' },
{ label: 'Заявка (лід)', value: 'lead_form' },
{ label: 'Дзвінок', value: 'phone_call' },
],
admin: {
description: 'Використовується для аналітики та GTM налаштувань',
},
},
{
name: 'blocks',
type: 'blocks',
label: 'Блоки лендінгу',
blocks: [
HeroBlock,
TextBlock,
FeaturesBlock,
LocationCardBlock,
PricingBlock,
GalleryBlock,
FormBlock,
CTABlock,
CountdownBlock,
BlogPreviewBlock,
MapBlock,
],
},
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta Title',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta Description',
admin: { rows: 3 },
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: 'OG Image',
},
{
name: 'noIndex',
type: 'checkbox',
label: 'Заборонити індексацію (noindex)',
defaultValue: false,
},
],
},
],
}

189
src/collections/Leads.ts Normal file
View file

@ -0,0 +1,189 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor } from '../access'
export const Leads: CollectionConfig = {
slug: 'leads',
labels: {
singular: 'Лід',
plural: 'Ліди',
},
admin: {
group: 'CRM',
useAsTitle: 'name',
defaultColumns: ['name', 'phone', 'label', 'tag', 'status', 'createdAt'],
listSearchableFields: ['name', 'phone', 'email'],
},
access: {
read: isEditor,
create: () => true, // public API can create leads
update: isEditor,
delete: isAdmin,
},
fields: [
{
name: 'name',
type: 'text',
label: 'Ім\'я',
required: true,
},
{
name: 'phone',
type: 'text',
label: 'Телефон',
admin: {
placeholder: '+380501234567',
},
},
{
name: 'email',
type: 'email',
label: 'Email',
},
{
name: 'label',
type: 'relationship',
relationTo: 'labels',
label: 'Мітка (джерело)',
admin: {
description: 'Звідки прийшов лід (мітка форми)',
},
},
{
name: 'tag',
type: 'select',
label: 'Тип заявки',
options: [
{ label: 'День народження', value: 'birthday' },
{ label: 'Групове відвідування', value: 'group' },
{ label: 'Зворотний дзвінок', value: 'callback' },
{ label: 'Загальна', value: 'generic' },
],
},
{
name: 'status',
type: 'select',
label: 'Статус',
defaultValue: 'new',
options: [
{ label: 'Новий', value: 'new' },
{ label: 'В обробці', value: 'contacted' },
{ label: 'Конвертований', value: 'converted' },
{ label: 'Архів', value: 'archived' },
],
},
{
name: 'message',
type: 'textarea',
label: 'Повідомлення',
admin: {
rows: 3,
},
},
{
name: 'guestCount',
type: 'number',
label: 'Кількість гостей',
admin: {
condition: (_, siblingData) =>
siblingData?.tag === 'birthday' || siblingData?.tag === 'group',
},
},
{
name: 'eventDate',
type: 'date',
label: 'Бажана дата',
admin: {
condition: (_, siblingData) =>
siblingData?.tag === 'birthday' || siblingData?.tag === 'group',
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'organization',
type: 'text',
label: 'Організація',
admin: {
condition: (_, siblingData) => siblingData?.tag === 'group',
placeholder: 'Школа №5, ТОВ "Компанія"',
},
},
{
type: 'collapsible',
label: 'UTM & Analytics',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'utmSource',
type: 'text',
label: 'UTM Source',
},
{
name: 'utmMedium',
type: 'text',
label: 'UTM Medium',
},
{
name: 'utmCampaign',
type: 'text',
label: 'UTM Campaign',
},
{
name: 'utmContent',
type: 'text',
label: 'UTM Content',
},
{
name: 'utmTerm',
type: 'text',
label: 'UTM Term',
},
{
name: 'googleClientId',
type: 'text',
label: 'Google Client ID',
admin: {
description: 'Витягується з cookie _ga для зв\'язку з GA4',
},
},
],
},
{
type: 'collapsible',
label: 'Інтеграції',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'twentyId',
type: 'text',
label: 'ID в Twenty CRM',
admin: {
readOnly: true,
},
},
{
name: 'binotelCallId',
type: 'text',
label: 'ID дзвінка в Binotel',
admin: {
readOnly: true,
},
},
],
},
{
name: 'notes',
type: 'textarea',
label: 'Внутрішні нотатки',
admin: {
rows: 3,
description: 'Видимо тільки в адмінці',
},
},
],
}

164
src/collections/Orders.ts Normal file
View file

@ -0,0 +1,164 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor } from '../access'
export const Orders: CollectionConfig = {
slug: 'orders',
labels: {
singular: 'Замовлення',
plural: 'Замовлення',
},
admin: {
group: 'CRM',
useAsTitle: 'orderNumber',
defaultColumns: ['orderNumber', 'customerName', 'totalAmount', 'status', 'createdAt'],
listSearchableFields: ['orderNumber', 'customerName', 'customerEmail', 'customerPhone'],
},
access: {
read: isEditor,
create: () => true, // API route creates orders
update: isEditor,
delete: isAdmin,
},
fields: [
{
name: 'orderNumber',
type: 'text',
label: 'Номер замовлення',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Генерується автоматично',
},
},
{
name: 'status',
type: 'select',
label: 'Статус',
required: true,
defaultValue: 'pending',
options: [
{ label: 'Очікує оплати', value: 'pending' },
{ label: 'Оплачено', value: 'paid' },
{ label: 'Скасовано', value: 'cancelled' },
{ label: 'Повернення', value: 'refunded' },
],
},
{
name: 'customerName',
type: 'text',
label: 'Ім\'я покупця',
required: true,
},
{
name: 'customerEmail',
type: 'email',
label: 'Email покупця',
required: true,
},
{
name: 'customerPhone',
type: 'text',
label: 'Телефон покупця',
},
{
name: 'items',
type: 'array',
label: 'Позиції замовлення',
fields: [
{
name: 'categoryName',
type: 'text',
label: 'Назва категорії',
required: true,
},
{
name: 'quantity',
type: 'number',
label: 'Кількість',
required: true,
min: 1,
},
{
name: 'price',
type: 'number',
label: 'Ціна за одиницю (грн)',
required: true,
},
{
name: 'ezyTariffId',
type: 'text',
label: 'ID тарифу ezy.com.ua',
},
],
},
{
name: 'totalAmount',
type: 'number',
label: 'Сума замовлення (грн)',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'tickets',
type: 'relationship',
relationTo: 'tickets',
hasMany: true,
label: 'Квитки',
admin: {
readOnly: true,
},
},
{
name: 'ezyPaymentUrl',
type: 'text',
label: 'URL оплати (Monobank)',
admin: {
readOnly: true,
description: 'Повертається від ezy.com.ua після створення платежу',
},
},
{
type: 'collapsible',
label: 'UTM & Analytics',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'utmSource',
type: 'text',
label: 'UTM Source',
},
{
name: 'utmMedium',
type: 'text',
label: 'UTM Medium',
},
{
name: 'utmCampaign',
type: 'text',
label: 'UTM Campaign',
},
{
name: 'utmContent',
type: 'text',
label: 'UTM Content',
},
{
name: 'utmTerm',
type: 'text',
label: 'UTM Term',
},
{
name: 'googleClientId',
type: 'text',
label: 'Google Client ID',
},
],
},
],
}

143
src/collections/Pages.ts Normal file
View file

@ -0,0 +1,143 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor, isAdminOrPublished } from '../access'
import { HeroBlock } from '../blocks/HeroBlock'
import { TextBlock } from '../blocks/TextBlock'
import { FeaturesBlock } from '../blocks/FeaturesBlock'
import { LocationCardBlock } from '../blocks/LocationCardBlock'
import { PricingBlock } from '../blocks/PricingBlock'
import { GalleryBlock } from '../blocks/GalleryBlock'
import { FormBlock } from '../blocks/FormBlock'
import { CTABlock } from '../blocks/CTABlock'
import { CountdownBlock } from '../blocks/CountdownBlock'
import { BlogPreviewBlock } from '../blocks/BlogPreviewBlock'
import { MapBlock } from '../blocks/MapBlock'
export const Pages: CollectionConfig = {
slug: 'pages',
labels: {
singular: 'Сторінка',
plural: 'Сторінки',
},
admin: {
group: 'Контент',
useAsTitle: 'title',
defaultColumns: ['title', 'slug', '_status', 'updatedAt'],
preview: (doc) => {
if (doc?.slug) {
return `${process.env.NEXT_PUBLIC_SITE_URL}/${doc.slug === 'home' ? '' : doc.slug}`
}
return null
},
},
access: {
read: isAdminOrPublished,
create: isEditor,
update: isEditor,
delete: isAdmin,
},
versions: {
drafts: {
autosave: {
interval: 375,
},
},
},
fields: [
{
name: 'title',
type: 'text',
label: 'Назва сторінки',
required: true,
admin: {
placeholder: 'Головна',
},
},
{
name: 'slug',
type: 'text',
label: 'URL (slug)',
required: true,
unique: true,
index: true,
admin: {
placeholder: 'home',
description:
'URL-адреса сторінки. Для головної — "home". Не змінюйте без потреби — це зламає посилання.',
},
},
{
name: 'isTemplate',
type: 'checkbox',
label: 'Шаблонна сторінка',
defaultValue: false,
admin: {
description: 'Приховати зі списку звичайних сторінок (для внутрішнього використання)',
},
},
{
name: 'blocks',
type: 'blocks',
label: 'Блоки сторінки',
blocks: [
HeroBlock,
TextBlock,
FeaturesBlock,
LocationCardBlock,
PricingBlock,
GalleryBlock,
FormBlock,
CTABlock,
CountdownBlock,
BlogPreviewBlock,
MapBlock,
],
admin: {
description: 'Конструктор сторінки — додавайте та впорядковуйте блоки',
},
},
{
name: 'seo',
type: 'group',
label: 'SEO',
admin: {
description: 'Мета-теги для пошукових систем та соцмереж',
},
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta Title',
admin: {
placeholder: 'Шуміленд — Сімейний тематичний парк',
description: 'Рекомендовано: 5060 символів',
},
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta Description',
admin: {
rows: 3,
placeholder: 'Відвідайте Шуміленд — парк розваг для всієї родини.',
description: 'Рекомендовано: 120160 символів',
},
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: 'OG Image (для соцмереж)',
admin: {
description: 'Рекомендований розмір: 1200×630 px',
},
},
{
name: 'noIndex',
type: 'checkbox',
label: 'Заборонити індексацію (noindex)',
defaultValue: false,
},
],
},
],
}

View file

@ -0,0 +1,93 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isEditor } from '../access'
export const Tickets: CollectionConfig = {
slug: 'tickets',
labels: {
singular: 'Квиток',
plural: 'Квитки',
},
admin: {
group: 'CRM',
useAsTitle: 'ticketCode',
defaultColumns: ['ticketCode', 'categoryName', 'order', 'isUsed', 'createdAt'],
listSearchableFields: ['ticketCode'],
},
access: {
read: isEditor,
create: () => true, // webhook creates tickets
update: isEditor,
delete: isAdmin,
},
fields: [
{
name: 'ticketCode',
type: 'text',
label: 'Код квитка',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Формат: SL-YYYYMMDD-XXXXXX (генерується автоматично)',
},
},
{
name: 'order',
type: 'relationship',
relationTo: 'orders',
label: 'Замовлення',
required: true,
},
{
name: 'categoryName',
type: 'text',
label: 'Категорія квитка',
required: true,
admin: {
placeholder: 'Динопарк — дорослий',
},
},
{
name: 'price',
type: 'number',
label: 'Ціна (грн)',
},
{
name: 'qrCodeUrl',
type: 'text',
label: 'URL QR-коду',
admin: {
readOnly: true,
description: 'Генерується автоматично після оплати',
},
},
{
name: 'isUsed',
type: 'checkbox',
label: 'Використаний',
defaultValue: false,
},
{
name: 'usedAt',
type: 'date',
label: 'Час використання',
admin: {
readOnly: true,
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'usedByEmployee',
type: 'relationship',
relationTo: 'users',
label: 'Хто перевірив',
admin: {
readOnly: true,
description: 'Співробітник, який відсканував квиток',
},
},
],
}

185
src/globals/Navigation.ts Normal file
View file

@ -0,0 +1,185 @@
import type { GlobalConfig } from 'payload'
import { isAdmin, isEditor } from '../access'
export const Navigation: GlobalConfig = {
slug: 'navigation',
label: 'Навігація',
admin: {
group: 'Налаштування',
description: 'Меню сайту: header та footer',
},
access: {
read: () => true,
update: isEditor,
},
fields: [
{
name: 'headerMenu',
type: 'array',
label: 'Головне меню (header)',
maxRows: 10,
admin: {
description: 'Пункти меню у шапці сайту',
},
fields: [
{
name: 'label',
type: 'text',
label: 'Назва пункту',
required: true,
admin: {
placeholder: 'Локації',
},
},
{
name: 'url',
type: 'text',
label: 'Посилання',
admin: {
placeholder: '/locations',
},
},
{
name: 'hasDropdown',
type: 'checkbox',
label: 'Має підменю',
defaultValue: false,
},
{
name: 'dropdown',
type: 'array',
label: 'Підменю',
maxRows: 8,
admin: {
condition: (_, siblingData) => siblingData?.hasDropdown,
},
fields: [
{
name: 'label',
type: 'text',
label: 'Назва',
required: true,
admin: {
placeholder: 'Динопарк',
},
},
{
name: 'url',
type: 'text',
label: 'Посилання',
required: true,
admin: {
placeholder: '/dinopark',
},
},
{
name: 'description',
type: 'text',
label: 'Короткий опис (необов\'язково)',
admin: {
placeholder: '23 динозаври у натуральну величину',
},
},
],
},
],
},
{
name: 'headerCta',
type: 'group',
label: 'Кнопка в шапці',
fields: [
{
name: 'label',
type: 'text',
label: 'Текст кнопки',
defaultValue: 'Купити квиток',
},
{
name: 'url',
type: 'text',
label: 'Посилання',
defaultValue: '/payments',
},
],
},
{
name: 'footerColumns',
type: 'array',
label: 'Колонки футера',
maxRows: 5,
fields: [
{
name: 'title',
type: 'text',
label: 'Заголовок колонки',
required: true,
admin: {
placeholder: 'Локації',
},
},
{
name: 'links',
type: 'array',
label: 'Посилання',
maxRows: 10,
fields: [
{
name: 'label',
type: 'text',
label: 'Назва',
required: true,
admin: {
placeholder: 'Динопарк',
},
},
{
name: 'url',
type: 'text',
label: 'URL',
required: true,
admin: {
placeholder: '/dinopark',
},
},
{
name: 'isExternal',
type: 'checkbox',
label: 'Відкривати в новій вкладці',
defaultValue: false,
},
],
},
],
},
{
name: 'footerBottomLinks',
type: 'array',
label: 'Посилання в нижній частині футера',
maxRows: 5,
admin: {
description: 'Наприклад: Політика конфіденційності, Публічна оферта',
},
fields: [
{
name: 'label',
type: 'text',
label: 'Назва',
required: true,
admin: {
placeholder: 'Політика конфіденційності',
},
},
{
name: 'url',
type: 'text',
label: 'URL',
required: true,
admin: {
placeholder: '/privacy',
},
},
],
},
],
}

255
src/globals/SiteSettings.ts Normal file
View file

@ -0,0 +1,255 @@
import type { GlobalConfig } from 'payload'
import { isAdmin } from '../access'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
label: 'Налаштування сайту',
admin: {
group: 'Налаштування',
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Загальне',
fields: [
{
name: 'siteName',
type: 'text',
label: 'Назва сайту',
defaultValue: 'Шуміленд',
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
label: 'Логотип',
},
{
name: 'favicon',
type: 'upload',
relationTo: 'media',
label: 'Favicon',
},
{
name: 'phone',
type: 'text',
label: 'Телефон',
admin: {
placeholder: '+38 (050) 123 45 67',
},
},
{
name: 'email',
type: 'email',
label: 'Email',
admin: {
placeholder: 'info@shumiland.com.ua',
},
},
{
name: 'address',
type: 'textarea',
label: 'Адреса',
admin: {
rows: 2,
placeholder: 'с. Малютянка, Бучанський район, Київська область',
},
},
{
name: 'workingHours',
type: 'array',
label: 'Графік роботи',
maxRows: 7,
fields: [
{
name: 'days',
type: 'text',
label: 'Дні',
required: true,
admin: { placeholder: 'Пн–Пт' },
},
{
name: 'hours',
type: 'text',
label: 'Години',
required: true,
admin: { placeholder: '10:0020:00' },
},
],
},
],
},
{
label: 'Соцмережі',
fields: [
{
name: 'socialLinks',
type: 'array',
label: 'Посилання на соцмережі',
maxRows: 8,
fields: [
{
name: 'platform',
type: 'select',
label: 'Платформа',
required: true,
options: [
{ label: 'Instagram', value: 'instagram' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'TikTok', value: 'tiktok' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Viber', value: 'viber' },
],
},
{
name: 'url',
type: 'text',
label: 'URL',
required: true,
admin: {
placeholder: 'https://instagram.com/shumiland',
},
},
{
name: 'label',
type: 'text',
label: 'Підпис (необов\'язково)',
admin: {
placeholder: '@shumiland',
},
},
],
},
],
},
{
label: 'Аналітика',
fields: [
{
name: 'gtmId',
type: 'text',
label: 'Google Tag Manager ID',
admin: {
placeholder: 'GTM-KJTSVLBC',
description: 'Формат: GTM-XXXXXXX',
},
},
{
name: 'ga4MeasurementId',
type: 'text',
label: 'GA4 Measurement ID',
admin: {
placeholder: 'G-XXXXXXXXXX',
description: 'Формат: G-XXXXXXXXXX',
},
},
{
name: 'umamiWebsiteId',
type: 'text',
label: 'Umami Website ID',
admin: {
placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
},
},
{
name: 'umamiScriptUrl',
type: 'text',
label: 'Umami Script URL',
admin: {
placeholder: 'https://analytics.shumiland.com.ua/script.js',
},
},
],
},
{
label: 'Binotel',
fields: [
{
name: 'binotelWidgetHash',
type: 'text',
label: 'Binotel Widget Hash',
admin: {
placeholder: 'fgfz5owkoc9rxip2brp2',
description: 'Хеш для підключення віджету зворотного дзвінка',
},
},
{
name: 'binotelEnabled',
type: 'checkbox',
label: 'Увімкнути Binotel віджет',
defaultValue: true,
},
],
},
{
label: 'SEO',
fields: [
{
name: 'defaultMetaTitle',
type: 'text',
label: 'Meta Title (за замовчуванням)',
admin: {
placeholder: 'Шуміленд — Сімейний тематичний парк',
},
},
{
name: 'defaultMetaDescription',
type: 'textarea',
label: 'Meta Description (за замовчуванням)',
admin: {
rows: 3,
},
},
{
name: 'defaultOgImage',
type: 'upload',
relationTo: 'media',
label: 'OG Image (за замовчуванням)',
},
{
name: 'robotsTxt',
type: 'textarea',
label: 'robots.txt',
admin: {
rows: 6,
placeholder: 'User-agent: *\nAllow: /\nSitemap: https://shumiland.com.ua/sitemap.xml',
},
},
],
},
{
label: 'Системне',
fields: [
{
name: 'maintenanceMode',
type: 'checkbox',
label: 'Режим обслуговування',
defaultValue: false,
admin: {
description:
'Якщо увімкнено — всі відвідувачі (крім адмінів) бачать сторінку "Зараз ведуться роботи"',
},
},
{
name: 'maintenanceMessage',
type: 'textarea',
label: 'Повідомлення на сторінці обслуговування',
defaultValue: 'Ведуться технічні роботи. Повернемось дуже скоро!',
admin: {
rows: 3,
condition: (data) => data?.maintenanceMode,
},
},
],
},
],
},
],
}

View file

@ -0,0 +1,137 @@
import type { GlobalConfig } from 'payload'
import { isAdmin, isEditor } from '../access'
export const TicketsConfig: GlobalConfig = {
slug: 'tickets-config',
label: 'Налаштування квитків',
admin: {
group: 'Налаштування',
description: 'Категорії квитків, ціни та умови продажу',
},
access: {
read: () => true,
update: isEditor,
},
fields: [
{
name: 'categories',
type: 'array',
label: 'Категорії квитків',
admin: {
description:
'Налаштуйте категорії квитків. Вони відображаються на сторінці /payments та в PricingBlock.',
},
fields: [
{
name: 'categoryId',
type: 'text',
label: 'ID тарифу (ezy.com.ua)',
required: true,
admin: {
placeholder: '3120',
description: 'Числовий ID тарифу з ezy.com.ua',
},
},
{
name: 'name',
type: 'text',
label: 'Назва категорії',
required: true,
admin: {
placeholder: 'Динопарк — дорослий',
},
},
{
name: 'description',
type: 'text',
label: 'Короткий опис',
admin: {
placeholder: 'Від 14 років',
},
},
{
name: 'price',
type: 'number',
label: 'Ціна (грн)',
required: true,
min: 0,
},
{
name: 'location',
type: 'select',
label: 'Локація',
required: true,
options: [
{ label: 'Динопарк', value: 'dinopark' },
{ label: 'ДивоЛіс', value: 'dyvo-lis' },
{ label: 'Лабіринт', value: 'labiryn' },
{ label: 'Комбо', value: 'combo' },
],
},
{
name: 'ageGroup',
type: 'select',
label: 'Вікова група',
options: [
{ label: 'Дорослий (14+)', value: 'adult' },
{ label: 'Дитина (313 р)', value: 'child' },
{ label: 'Пенсіонер', value: 'senior' },
{ label: 'Без вікового обмеження', value: 'any' },
],
},
{
name: 'isFree',
type: 'checkbox',
label: 'Безкоштовно',
defaultValue: false,
},
{
name: 'freeCondition',
type: 'text',
label: 'Умова безкоштовного входу',
admin: {
placeholder: 'Діти до 3 років',
condition: (_, siblingData) => siblingData?.isFree,
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Активна категорія',
defaultValue: true,
admin: {
description: 'Неактивні категорії не відображаються на сайті',
},
},
{
name: 'order',
type: 'number',
label: 'Порядок відображення',
defaultValue: 0,
admin: {
description: 'Менше число — вище в списку',
},
},
],
},
{
name: 'freeCategoriesNote',
type: 'textarea',
label: 'Примітка про безкоштовний вхід',
admin: {
rows: 4,
placeholder:
'Безкоштовний вхід: діти до 3 років, діти з інвалідністю (при наявності довідки), учасники бойових дій (УБД) при пред\'явленні посвідчення.',
},
},
{
name: 'generalNote',
type: 'textarea',
label: 'Загальна примітка (внизу списку цін)',
admin: {
rows: 3,
placeholder: '*Знижка діє лише на квитки повної вартості на окремі зони',
},
},
],
}

View file

@ -4,8 +4,32 @@ import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import Users from '@/collections/Users'
import Media from '@/collections/Media'
import Users from './collections/Users'
import Media from './collections/Media'
import Labels from './collections/Labels'
import { Pages } from './collections/Pages'
import { LandingPages } from './collections/LandingPages'
import { Blog } from './collections/Blog'
import { Events } from './collections/Events'
import { Leads } from './collections/Leads'
import { Orders } from './collections/Orders'
import { Tickets } from './collections/Tickets'
import { SiteSettings } from './globals/SiteSettings'
import { TicketsConfig } from './globals/TicketsConfig'
import { Navigation } from './globals/Navigation'
import { HeroBlock } from './blocks/HeroBlock'
import { TextBlock } from './blocks/TextBlock'
import { FeaturesBlock } from './blocks/FeaturesBlock'
import { LocationCardBlock } from './blocks/LocationCardBlock'
import { PricingBlock } from './blocks/PricingBlock'
import { GalleryBlock } from './blocks/GalleryBlock'
import { FormBlock } from './blocks/FormBlock'
import { CTABlock } from './blocks/CTABlock'
import { CountdownBlock } from './blocks/CountdownBlock'
import { BlogPreviewBlock } from './blocks/BlogPreviewBlock'
import { MapBlock } from './blocks/MapBlock'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -23,8 +47,20 @@ export default buildConfig({
collections: [
Users,
Media,
Labels,
Pages,
LandingPages,
Blog,
Events,
Leads,
Orders,
Tickets,
],
globals: [
SiteSettings,
TicketsConfig,
Navigation,
],
globals: [],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || 'shumiland-dev-secret-change-in-prod',
typescript: {