From 61e73033fe520e36ea496612319f77e877c2a29e Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 2 Apr 2026 17:52:54 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20complete=20=E2=80=94=20cont?= =?UTF-8?q?ent=20model,=20blocks,=20collections,=20globals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 4 +- package.json | 1 + src/blocks/BlogPreviewBlock.ts | 95 ++++++++++++ src/blocks/CTABlock.ts | 135 +++++++++++++++++ src/blocks/CountdownBlock.ts | 97 ++++++++++++ src/blocks/FeaturesBlock.ts | 98 ++++++++++++ src/blocks/FormBlock.ts | 104 +++++++++++++ src/blocks/GalleryBlock.ts | 81 ++++++++++ src/blocks/HeroBlock.ts | 161 ++++++++++++++++++++ src/blocks/LocationCardBlock.ts | 100 +++++++++++++ src/blocks/MapBlock.ts | 120 +++++++++++++++ src/blocks/PricingBlock.ts | 115 ++++++++++++++ src/blocks/TextBlock.ts | 56 +++++++ src/collections/Blog.ts | 161 ++++++++++++++++++++ src/collections/Events.ts | 141 ++++++++++++++++++ src/collections/Labels.ts | 73 +++++++++ src/collections/LandingPages.ts | 181 +++++++++++++++++++++++ src/collections/Leads.ts | 189 +++++++++++++++++++++++ src/collections/Orders.ts | 164 ++++++++++++++++++++ src/collections/Pages.ts | 143 ++++++++++++++++++ src/collections/Tickets.ts | 93 ++++++++++++ src/globals/Navigation.ts | 185 +++++++++++++++++++++++ src/globals/SiteSettings.ts | 255 ++++++++++++++++++++++++++++++++ src/globals/TicketsConfig.ts | 137 +++++++++++++++++ src/payload.config.ts | 42 +++++- 25 files changed, 2927 insertions(+), 4 deletions(-) create mode 100644 src/blocks/BlogPreviewBlock.ts create mode 100644 src/blocks/CTABlock.ts create mode 100644 src/blocks/CountdownBlock.ts create mode 100644 src/blocks/FeaturesBlock.ts create mode 100644 src/blocks/FormBlock.ts create mode 100644 src/blocks/GalleryBlock.ts create mode 100644 src/blocks/HeroBlock.ts create mode 100644 src/blocks/LocationCardBlock.ts create mode 100644 src/blocks/MapBlock.ts create mode 100644 src/blocks/PricingBlock.ts create mode 100644 src/blocks/TextBlock.ts create mode 100644 src/collections/Blog.ts create mode 100644 src/collections/Events.ts create mode 100644 src/collections/Labels.ts create mode 100644 src/collections/LandingPages.ts create mode 100644 src/collections/Leads.ts create mode 100644 src/collections/Orders.ts create mode 100644 src/collections/Pages.ts create mode 100644 src/collections/Tickets.ts create mode 100644 src/globals/Navigation.ts create mode 100644 src/globals/SiteSettings.ts create mode 100644 src/globals/TicketsConfig.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bd5d4f3..2d5fd9a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/package.json b/package.json index 6c09a1b..5db59bc 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "Shumiland — сімейний тематичний парк", "private": true, + "type": "module", "scripts": { "dev": "next dev --turbopack", "build": "next build", diff --git a/src/blocks/BlogPreviewBlock.ts b/src/blocks/BlogPreviewBlock.ts new file mode 100644 index 0000000..386b5ae --- /dev/null +++ b/src/blocks/BlogPreviewBlock.ts @@ -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' }, + ], + }, + ], +} diff --git a/src/blocks/CTABlock.ts b/src/blocks/CTABlock.ts new file mode 100644 index 0000000..094b1b1 --- /dev/null +++ b/src/blocks/CTABlock.ts @@ -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' }, + ], + }, + ], +} diff --git a/src/blocks/CountdownBlock.ts b/src/blocks/CountdownBlock.ts new file mode 100644 index 0000000..55c1a52 --- /dev/null +++ b/src/blocks/CountdownBlock.ts @@ -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', + }, + }, + ], + }, + ], +} diff --git a/src/blocks/FeaturesBlock.ts b/src/blocks/FeaturesBlock.ts new file mode 100644 index 0000000..d49cd34 --- /dev/null +++ b/src/blocks/FeaturesBlock.ts @@ -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' }, + ], + }, + ], +} diff --git a/src/blocks/FormBlock.ts b/src/blocks/FormBlock.ts new file mode 100644 index 0000000..2936113 --- /dev/null +++ b/src/blocks/FormBlock.ts @@ -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: 'Відображається поруч з формою при виборі "З бічним зображенням"', + }, + }, + ], +} diff --git a/src/blocks/GalleryBlock.ts b/src/blocks/GalleryBlock.ts new file mode 100644 index 0000000..17c4638 --- /dev/null +++ b/src/blocks/GalleryBlock.ts @@ -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, + }, + ], +} diff --git a/src/blocks/HeroBlock.ts b/src/blocks/HeroBlock.ts new file mode 100644 index 0000000..4d0667f --- /dev/null +++ b/src/blocks/HeroBlock.ts @@ -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' }, + ], + }, + ], +} diff --git a/src/blocks/LocationCardBlock.ts b/src/blocks/LocationCardBlock.ts new file mode 100644 index 0000000..2a933e3 --- /dev/null +++ b/src/blocks/LocationCardBlock.ts @@ -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, + }, + ], +} diff --git a/src/blocks/MapBlock.ts b/src/blocks/MapBlock.ts new file mode 100644 index 0000000..48337b9 --- /dev/null +++ b/src/blocks/MapBlock.ts @@ -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, + }, + }, + ], +} diff --git a/src/blocks/PricingBlock.ts b/src/blocks/PricingBlock.ts new file mode 100644 index 0000000..7980427 --- /dev/null +++ b/src/blocks/PricingBlock.ts @@ -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: '*Знижка діє лише на квитки повної вартості на окремі зони', + }, + }, + ], +} diff --git a/src/blocks/TextBlock.ts b/src/blocks/TextBlock.ts new file mode 100644 index 0000000..5f01fc7 --- /dev/null +++ b/src/blocks/TextBlock.ts @@ -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' }, + ], + }, + ], +} diff --git a/src/collections/Blog.ts b/src/collections/Blog.ts new file mode 100644 index 0000000..cad5cd8 --- /dev/null +++ b/src/collections/Blog.ts @@ -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: 'Якщо порожнє — використовується обкладинка', + }, + }, + ], + }, + ], +} diff --git a/src/collections/Events.ts b/src/collections/Events.ts new file mode 100644 index 0000000..ca30068 --- /dev/null +++ b/src/collections/Events.ts @@ -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', + }, + ], + }, + ], +} diff --git a/src/collections/Labels.ts b/src/collections/Labels.ts new file mode 100644 index 0000000..15bef44 --- /dev/null +++ b/src/collections/Labels.ts @@ -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 diff --git a/src/collections/LandingPages.ts b/src/collections/LandingPages.ts new file mode 100644 index 0000000..5ab2657 --- /dev/null +++ b/src/collections/LandingPages.ts @@ -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, + }, + ], + }, + ], +} diff --git a/src/collections/Leads.ts b/src/collections/Leads.ts new file mode 100644 index 0000000..2fc6a57 --- /dev/null +++ b/src/collections/Leads.ts @@ -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: 'Видимо тільки в адмінці', + }, + }, + ], +} diff --git a/src/collections/Orders.ts b/src/collections/Orders.ts new file mode 100644 index 0000000..859f51c --- /dev/null +++ b/src/collections/Orders.ts @@ -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', + }, + ], + }, + ], +} diff --git a/src/collections/Pages.ts b/src/collections/Pages.ts new file mode 100644 index 0000000..ee03ce5 --- /dev/null +++ b/src/collections/Pages.ts @@ -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: 'Рекомендовано: 50–60 символів', + }, + }, + { + name: 'metaDescription', + type: 'textarea', + label: 'Meta Description', + admin: { + rows: 3, + placeholder: 'Відвідайте Шуміленд — парк розваг для всієї родини.', + description: 'Рекомендовано: 120–160 символів', + }, + }, + { + name: 'ogImage', + type: 'upload', + relationTo: 'media', + label: 'OG Image (для соцмереж)', + admin: { + description: 'Рекомендований розмір: 1200×630 px', + }, + }, + { + name: 'noIndex', + type: 'checkbox', + label: 'Заборонити індексацію (noindex)', + defaultValue: false, + }, + ], + }, + ], +} diff --git a/src/collections/Tickets.ts b/src/collections/Tickets.ts new file mode 100644 index 0000000..33ce25c --- /dev/null +++ b/src/collections/Tickets.ts @@ -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: 'Співробітник, який відсканував квиток', + }, + }, + ], +} diff --git a/src/globals/Navigation.ts b/src/globals/Navigation.ts new file mode 100644 index 0000000..10f8816 --- /dev/null +++ b/src/globals/Navigation.ts @@ -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', + }, + }, + ], + }, + ], +} diff --git a/src/globals/SiteSettings.ts b/src/globals/SiteSettings.ts new file mode 100644 index 0000000..4da006b --- /dev/null +++ b/src/globals/SiteSettings.ts @@ -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:00–20: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, + }, + }, + ], + }, + ], + }, + ], +} diff --git a/src/globals/TicketsConfig.ts b/src/globals/TicketsConfig.ts new file mode 100644 index 0000000..833f972 --- /dev/null +++ b/src/globals/TicketsConfig.ts @@ -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: 'Дитина (3–13 р)', 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: '*Знижка діє лише на квитки повної вартості на окремі зони', + }, + }, + ], +} diff --git a/src/payload.config.ts b/src/payload.config.ts index e3540e5..4083476 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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: {