Этап 1b: сайт

- Страницы обоих брендов из блоков Payload (D-029): главная, цены,
  локации, поддержка, 4 юридические, 404 и 500. Коллекции pages
  (черновики и версии) и faq с местами показа, 12 типов блоков; поля
  sites — почта и Telegram поддержки, часы работы, SEO по умолчанию.
- Карта и глобус рисуются на сервере (d3-geo), контуры во внешнем
  SVG через <use>; выбор локации синхронизирован между картой и сеткой.
- Тексты сайта и юридические черновики на 5 языках: de, fr, es —
  перевод с глоссарием; seed создаёт страницы и FAQ, только если их
  нет, SEED_CONTENT=reset пересоздаёт.
- SEO: title и описания, canonical, hreflang с x-default,
  sitemap.xml и robots.txt на каждый домен, картинки для соцсетей.
- Статическая сборка и пересборка через revalidatePath из хуков
  Payload (D-030); /svc/internal/revalidate для проверки.
- 404 — статичная страница со статусом 404 через proxy; на сайте
  только опубликованные страницы.
- Производительность: Client Hints темы Payload только в админке,
  предзагрузка 2 файлов шрифтов вместо 6; Lighthouse mobile 97–99.
- Контраст --ink-3 по WCAG AA, радиокнопки вместо div role=button.
- Документация: итоги 1b, глоссарий de/fr/es, трактовки юридических
  черновиков подтверждены, переносимые вопросы — в этапах 3e–3h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreypr207andClaude Opus 5.5 committed 2026-10-22 04:53:15 +02:00
1 parent 515acfb69d
commit 20491d1ba4
134 files changed
+11232 -212

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import type { ArrayField, Field, SelectField, TextareaField, TextField } from 'payload'
import { label } from '../i18n'
type Label = { en: string; ru: string }
/** Подстановки в текстах блоков: значения берутся из тарифов и локаций сайта */
export const PLACEHOLDERS_HINT = label(
'Placeholders: {priceFirst}, {priceRenewal}, {priceMonth}, {priceYear}, {pricePerMonth}, {savings}, {countries}, {devices}.',
'Подстановки: {priceFirst}, {priceRenewal}, {priceMonth}, {priceYear}, {pricePerMonth}, {savings}, {countries}, {devices}.',
)
export const text = (
name: string,
fieldLabel: Label,
options: Partial<Pick<TextField, 'required' | 'admin'>> = {},
): TextField => ({ name, type: 'text', localized: true, label: fieldLabel, ...options })
export const textarea = (
name: string,
fieldLabel: Label,
options: Partial<Pick<TextareaField, 'required' | 'admin'>> = {},
): TextareaField => ({ name, type: 'textarea', localized: true, label: fieldLabel, ...options })
/** Список строк: пункты, чипы, абзацы */
export const lines = (
name: string,
fieldLabel: Label,
options: Partial<Pick<ArrayField, 'minRows' | 'maxRows' | 'admin'>> = {},
extra: Field[] = [],
): ArrayField => ({
name,
type: 'array',
label: fieldLabel,
labels: { singular: label('Line', 'Строка'), plural: label('Lines', 'Строки') },
...options,
fields: [text('text', label('Text', 'Текст'), { required: true }), ...extra],
})
/** Ссылка кнопки: «#якорь», «/путь» (язык добавится сам) или полный адрес */
export const button = (name: string, fieldLabel: Label): Field => ({
name,
type: 'group',
label: fieldLabel,
fields: [
{
type: 'row',
fields: [
text('label', label('Label', 'Подпись')),
{
name: 'href',
type: 'text',
label: label('Link', 'Ссылка'),
admin: {
description: label('#anchor, /path or https://…', '#якорь, /путь или https://…'),
},
},
],
},
],
})
export const select = (
name: string,
fieldLabel: Label,
options: { value: string; label: Label }[],
extra: Partial<Pick<SelectField, 'required' | 'defaultValue' | 'admin'>> = {},
): SelectField => ({ name, type: 'select', label: fieldLabel, options, ...extra })
+418
View File
@@ -0,0 +1,418 @@
import type { Block } from 'payload'
import { FAQ_PLACEMENTS, type FaqPlacement } from '@/core/pages'
import { label } from '../i18n'
import { button, lines, PLACEHOLDERS_HINT, select, text, textarea } from './fields'
const withHint = { admin: { description: PLACEHOLDERS_HINT } }
/** Первый экран главной: плашки, H1, кнопки, карточка с глобусом */
export const HeroBlock: Block = {
slug: 'hero',
interfaceName: 'HeroBlock',
labels: { singular: label('Hero', 'Первый экран'), plural: label('Heroes', 'Первые экраны') },
fields: [
{
name: 'badges',
type: 'array',
maxRows: 3,
label: label('Badges', 'Плашки'),
fields: [
{
type: 'row',
fields: [
text('text', label('Text', 'Текст'), { required: true }),
select(
'style',
label('Style', 'Вид'),
[
{ value: 'outline', label: label('Outline with dot', 'Рамка с точкой') },
{ value: 'soft', label: label('Accent fill', 'Заливка акцентом') },
],
{ required: true, defaultValue: 'outline' },
),
],
},
],
},
text('title', label('Title (H1)', 'Заголовок (H1)'), { required: true }),
textarea('text', label('Text', 'Текст'), withHint),
button('primary', label('Main button', 'Основная кнопка')),
button('secondary', label('Second button', 'Вторая кнопка')),
{
name: 'plate',
type: 'group',
label: label('Card under the globe', 'Плашка под глобусом'),
fields: [
text('title', label('Title', 'Заголовок'), withHint),
lines('items', label('Items', 'Пункты'), { maxRows: 4, ...withHint }),
],
},
],
}
const ICONS = [
{ value: 'devices', label: label('Devices', 'Устройства') },
{ value: 'ru', label: label('RU address', 'RU-адрес') },
{ value: 'protocols', label: label('Protocols', 'Протоколы') },
]
/** «Что это даёт на практике»: большая карточка, три малые и схема маршрутов */
export const AdvantagesBlock: Block = {
slug: 'advantages',
interfaceName: 'AdvantagesBlock',
labels: {
singular: label('Advantages', 'Преимущества'),
plural: label('Advantages', 'Преимущества'),
},
fields: [
text('title', label('Title', 'Заголовок')),
{
name: 'main',
type: 'group',
label: label('Large card', 'Большая карточка'),
fields: [
text('title', label('Title', 'Заголовок'), withHint),
textarea('text', label('Text', 'Текст'), withHint),
{
name: 'items',
type: 'array',
maxRows: 4,
label: label('Points', 'Пункты'),
fields: [
text('title', label('Title', 'Заголовок'), { required: true, ...withHint }),
textarea('text', label('Text', 'Текст'), withHint),
],
},
],
},
{
name: 'cards',
type: 'array',
maxRows: 3,
label: label('Small cards', 'Малые карточки'),
fields: [
select('icon', label('Icon', 'Значок'), ICONS, { required: true, defaultValue: 'devices' }),
text('title', label('Title', 'Заголовок'), { required: true, ...withHint }),
textarea('text', label('Text', 'Текст'), withHint),
lines('chips', label('Chips', 'Чипы'), { maxRows: 4 }),
],
},
{
name: 'routes',
type: 'group',
label: label('Routes card', 'Карточка маршрутов'),
fields: [
text('title', label('Title', 'Заголовок')),
textarea('text', label('Text', 'Текст')),
{
name: 'schemes',
type: 'array',
maxRows: 2,
label: label('Schemes', 'Схемы'),
fields: [
text('title', label('Title', 'Заголовок'), { required: true }),
{
name: 'rows',
type: 'array',
maxRows: 3,
label: label('Routes', 'Маршруты'),
fields: [
{
type: 'row',
fields: [
text('label', label('Where', 'Куда'), { required: true }),
select(
'mode',
label('Line', 'Линия'),
[
{ value: 'direct', label: label('Direct', 'Напрямую') },
{ value: 'tunnel', label: label('Tunnel', 'Туннель') },
],
{ required: true, defaultValue: 'direct' },
),
text('text', label('How', 'Как'), { required: true }),
],
},
],
},
textarea('note', label('Note', 'Сноска')),
],
},
],
},
],
}
/** Блок цен на главной: у dedicated — выбор локации, у mass — планы «Месяц» и «Год» */
export const PricingBlock: Block = {
slug: 'pricing',
interfaceName: 'PricingBlock',
labels: { singular: label('Pricing', 'Цены'), plural: label('Pricing', 'Цены') },
fields: [
text('title', label('Title (VeilixVPN)', 'Заголовок (ВейликсВПН)'), withHint),
textarea('text', label('Text (VeilixVPN)', 'Текст (ВейликсВПН)'), withHint),
lines('points', label('Points', 'Пункты'), { maxRows: 6, ...withHint }, [
lines('sub', label('Sub-points', 'Подпункты'), { maxRows: 4 }),
]),
text('listTitle', label('List title', 'Заголовок списка'), {
admin: {
description: label(
'Veilix: above the locations; VeilixVPN: above the countries',
'Вейликс — над локациями, ВейликсВПН — над странами',
),
},
}),
text('buyLabel', label('Buy button', 'Кнопка покупки'), { required: true }),
textarea('note', label('Note under the button', 'Сноска под кнопкой'), withHint),
{
name: 'lead',
type: 'group',
label: label('Custom terms request (Veilix)', 'Заявка на особые условия (Вейликс)'),
fields: [
text('buttonLabel', label('Button', 'Кнопка')),
text('title', label('Form title', 'Заголовок формы')),
],
},
],
}
/** Ссылка на другой бренд: «Нужен адрес, закреплённый только за вами» */
export const CrossLinkBlock: Block = {
slug: 'crossLink',
interfaceName: 'CrossLinkBlock',
labels: {
singular: label('Other brand', 'Другой бренд'),
plural: label('Other brand', 'Другой бренд'),
},
fields: [
text('title', label('Title', 'Заголовок'), { required: true }),
textarea('text', label('Text', 'Текст')),
text('buttonLabel', label('Button', 'Кнопка'), { required: true }),
select(
'target',
label('Page of the other brand', 'Страница другого бренда'),
[
{ value: 'home', label: label('Home', 'Главная') },
{ value: 'pricing', label: label('Pricing', 'Цены') },
],
{ required: true, defaultValue: 'home' },
),
],
}
const PLACEMENT_LABELS: Record<FaqPlacement, { en: string; ru: string }> = {
home: label('Home', 'Главная'),
pricing: label('Pricing', 'Цены'),
support_connection: label('Support: connection', 'Поддержка: подключение'),
support_billing: label('Support: billing', 'Поддержка: оплата'),
support_locations: label('Support: locations', 'Поддержка: локации'),
cabinet: label('Cabinet', 'Кабинет'),
miniapp: label('MiniApp', 'MiniApp'),
}
export const FAQ_PLACEMENT_OPTIONS = FAQ_PLACEMENTS.map((value) => ({
value,
label: PLACEMENT_LABELS[value],
}))
/** Вопросы и ответы: одно место показа — список, несколько — вкладки */
export const FaqBlock: Block = {
slug: 'faq',
interfaceName: 'FaqBlock',
labels: { singular: label('FAQ', 'Вопросы'), plural: label('FAQ', 'Вопросы') },
fields: [
text('title', label('Title', 'Заголовок'), { required: true }),
{
name: 'groups',
type: 'array',
minRows: 1,
maxRows: 4,
label: label('Sections', 'Разделы'),
admin: {
description: label(
'Questions of each placement; several sections are shown as tabs',
'Вопросы из места показа; несколько разделов показываются вкладками',
),
},
fields: [
{
type: 'row',
fields: [
select('placement', label('Placement', 'Место показа'), FAQ_PLACEMENT_OPTIONS, {
required: true,
}),
text('label', label('Tab title', 'Название вкладки')),
],
},
],
},
],
}
/** Верх страницы цен: крупная цена и «Что входит» */
export const PricingHeroBlock: Block = {
slug: 'pricingHero',
interfaceName: 'PricingHeroBlock',
labels: {
singular: label('Price card', 'Карточка цены'),
plural: label('Price cards', 'Карточки цены'),
},
fields: [
text('subline', label('Line under the price', 'Строка под ценой'), withHint),
textarea('note', label('Note', 'Пояснение'), withHint),
text('includedTitle', label('List title', 'Заголовок списка')),
lines('included', label('What is included', 'Что входит'), { maxRows: 6, ...withHint }),
text('buttonLabel', label('Button (VeilixVPN)', 'Кнопка (ВейликсВПН)')),
],
}
/** Сетка локаций: у dedicated с ценами и «Купить», у mass — со статусами */
export const LocationGridBlock: Block = {
slug: 'locationGrid',
interfaceName: 'LocationGridBlock',
labels: {
singular: label('Locations grid', 'Сетка локаций'),
plural: label('Locations grids', 'Сетки локаций'),
},
fields: [
text('title', label('Title', 'Заголовок'), { required: true }),
lines('paragraphs', label('Paragraphs', 'Абзацы'), { maxRows: 3, ...withHint }),
],
}
/** Две карточки внизу страниц цен и локаций */
export const CardsBlock: Block = {
slug: 'cards',
interfaceName: 'CardsBlock',
labels: { singular: label('Cards', 'Карточки'), plural: label('Cards', 'Карточки') },
fields: [
{
name: 'cards',
type: 'array',
minRows: 1,
maxRows: 2,
label: label('Cards', 'Карточки'),
fields: [
select(
'kind',
label('Kind', 'Вид'),
[
{ value: 'info', label: label('Text', 'Текст') },
{ value: 'lead', label: label('Custom terms request', 'Заявка на особые условия') },
{
value: 'crossLink',
label: label('Link to the other brand', 'Ссылка на другой бренд'),
},
],
{ required: true, defaultValue: 'info' },
),
text('title', label('Title', 'Заголовок'), { required: true }),
textarea('text', label('Text', 'Текст'), withHint),
text('buttonLabel', label('Button', 'Кнопка')),
],
},
],
}
/** Верх страницы локаций: H1, карта, легенда */
export const MapHeroBlock: Block = {
slug: 'mapHero',
interfaceName: 'MapHeroBlock',
labels: { singular: label('Map', 'Карта'), plural: label('Maps', 'Карты') },
fields: [
text('title', label('Title (H1)', 'Заголовок (H1)'), { required: true }),
textarea('text', label('Text', 'Текст'), withHint),
text('hint', label('Hint under the map', 'Подсказка под картой')),
text('scrollHint', label('Hint on narrow screens', 'Подсказка на узком экране')),
text('ruLegend', label('RU address in the legend', 'RU-адрес в легенде')),
],
}
/** Верх страницы поддержки: H1, Telegram и почта из настроек сайта, часы работы */
export const ContactsBlock: Block = {
slug: 'contacts',
interfaceName: 'ContactsBlock',
labels: { singular: label('Contacts', 'Контакты'), plural: label('Contacts', 'Контакты') },
fields: [
text('title', label('Title (H1)', 'Заголовок (H1)'), { required: true }),
{
type: 'row',
fields: [
textarea('telegramText', label('Telegram card', 'Карточка Telegram')),
textarea('emailText', label('Email card', 'Карточка почты')),
],
},
],
}
/** Форма «Написать нам» */
export const ContactFormBlock: Block = {
slug: 'contactForm',
interfaceName: 'ContactFormBlock',
labels: {
singular: label('Contact form', 'Форма связи'),
plural: label('Contact forms', 'Формы связи'),
},
fields: [
text('title', label('Title', 'Заголовок'), { required: true }),
textarea('text', label('Text', 'Текст')),
{
name: 'topics',
type: 'array',
maxRows: 6,
label: label('Topics', 'Темы'),
fields: [
{
type: 'row',
fields: [
select(
'value',
label('Topic', 'Тема'),
[
{ value: 'connection', label: label('Connection', 'Подключение') },
{ value: 'billing', label: label('Billing', 'Оплата') },
{ value: 'locations', label: label('Locations', 'Локации') },
{ value: 'other', label: label('Other', 'Другое') },
],
{ required: true },
),
text('label', label('Label', 'Подпись'), { required: true }),
],
},
],
},
],
}
/** Текст: юридические страницы */
export const RichTextBlock: Block = {
slug: 'richText',
interfaceName: 'RichTextBlock',
labels: { singular: label('Text', 'Текст'), plural: label('Texts', 'Тексты') },
fields: [
{
name: 'content',
type: 'richText',
localized: true,
required: true,
label: label('Text', 'Текст'),
},
],
}
export const PAGE_BLOCKS: Block[] = [
HeroBlock,
AdvantagesBlock,
PricingBlock,
CrossLinkBlock,
FaqBlock,
PricingHeroBlock,
LocationGridBlock,
CardsBlock,
MapHeroBlock,
ContactsBlock,
ContactFormBlock,
RichTextBlock,
]
+2
View File
@@ -3,6 +3,7 @@ import type { CollectionConfig, FieldHook, TextFieldSingleValidation } from 'pay
import { anyRole, managers } from '../access/roles'
import { sortField } from '../fields/common'
import { groups, label, message } from '../i18n'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
const upperCase: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value
@@ -28,6 +29,7 @@ export const Countries: CollectionConfig = {
description: label('Shared by both brands', 'Общий справочник обоих брендов'),
},
defaultSort: 'sort',
hooks: revalidateSiteHooks,
access: {
read: anyRole,
create: managers,
+2
View File
@@ -9,6 +9,7 @@ import { APIError } from 'payload'
import { anyRole, managers } from '../access/roles'
import { integerField, sortField } from '../fields/common'
import { groups, label, message } from '../i18n'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
const upperCase: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value
@@ -81,6 +82,7 @@ export const Currencies: CollectionConfig = {
},
hooks: {
beforeDelete: [keepUsedCurrency],
...revalidateSiteHooks,
},
fields: [
{
+2
View File
@@ -13,6 +13,7 @@ import { currenciesField, currencyField } from '../fields/currency'
import { localeOptions } from '../fields/locales'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
@@ -152,6 +153,7 @@ export const Domains: CollectionConfig = {
},
hooks: {
beforeChange: [validateDomain],
...revalidateSiteHooks,
},
fields: [
{
+62
View File
@@ -0,0 +1,62 @@
import type { CollectionConfig } from 'payload'
import { anyRole, contentEditors } from '../access/roles'
import { FAQ_PLACEMENT_OPTIONS } from '../blocks'
import { sortField } from '../fields/common'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
import { groups, label } from '../i18n'
/** Вопросы и ответы. Один вопрос показывается в нескольких местах */
export const Faq: CollectionConfig = {
slug: 'faq',
labels: {
singular: label('Question', 'Вопрос'),
plural: label('FAQ', 'Вопросы и ответы'),
},
admin: {
group: groups.content,
useAsTitle: 'question',
defaultColumns: ['question', 'placements', 'active', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: contentEditors,
update: contentEditors,
delete: contentEditors,
},
hooks: revalidateSiteHooks,
fields: [
{
name: 'question',
type: 'text',
required: true,
localized: true,
label: label('Question', 'Вопрос'),
},
{
name: 'answer',
type: 'richText',
required: true,
localized: true,
label: label('Answer', 'Ответ'),
},
{
name: 'placements',
type: 'select',
hasMany: true,
required: true,
options: FAQ_PLACEMENT_OPTIONS,
label: label('Where to show', 'Где показывать'),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Shown', 'Показывать'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+2
View File
@@ -8,6 +8,7 @@ import { integerField, sortField } from '../fields/common'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
export const LOCATION_STATUSES = ['available', 'on_demand', 'soon'] as const
@@ -79,6 +80,7 @@ export const Locations: CollectionConfig = {
ru: 'У сайта уже есть локация в этой стране',
}),
],
...revalidateSiteHooks,
},
fields: [
{
+118
View File
@@ -0,0 +1,118 @@
import type { CollectionConfig } from 'payload'
import { PAGE_SLUGS } from '@/core/pages'
import { anyRole, contentEditors } from '../access/roles'
import { PAGE_BLOCKS } from '../blocks'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label } from '../i18n'
const SLUG_LABELS: Record<(typeof PAGE_SLUGS)[number], { en: string; ru: string }> = {
home: label('Home', 'Главная'),
pricing: label('Pricing', 'Цены'),
locations: label('Locations', 'Локации'),
support: label('Support', 'Поддержка'),
terms: label('Terms of use', 'Условия использования'),
privacy: label('Privacy policy', 'Политика конфиденциальности'),
refund: label('Refund policy', 'Возврат средств'),
cookies: label('Cookie policy', 'Политика cookies'),
}
/** Страницы сайта из блоков (D-029). Адреса страниц заданы в коде, поэтому slug — из списка */
export const Pages: CollectionConfig = {
slug: 'pages',
labels: {
singular: label('Page', 'Страница'),
plural: label('Pages', 'Страницы'),
},
admin: {
group: groups.content,
useAsTitle: 'title',
defaultColumns: ['title', 'slug', '_status', 'updatedAt'],
},
access: {
read: anyRole,
create: contentEditors,
update: contentEditors,
delete: contentEditors,
},
versions: { drafts: true, maxPerDoc: 30 },
indexes: [{ fields: ['site', 'slug'], unique: true }],
hooks: {
beforeChange: [
uniqueInSite('slug', {
en: 'The site already has this page',
ru: 'У сайта уже есть эта страница',
}),
],
...revalidateSiteHooks,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: label('Title', 'Название'),
admin: {
description: label(
'Page name in the title bar and in search results',
'Название страницы во вкладке браузера и в поиске',
),
},
},
{
name: 'slug',
type: 'select',
required: true,
options: PAGE_SLUGS.map((value) => ({ value, label: SLUG_LABELS[value] })),
label: label('Page', 'Страница'),
admin: { position: 'sidebar' },
},
{
name: 'layout',
type: 'blocks',
blocks: PAGE_BLOCKS,
label: label('Sections', 'Секции'),
labels: { singular: label('Section', 'Секция'), plural: label('Sections', 'Секции') },
},
{
name: 'seo',
type: 'group',
label: label('Search and social networks', 'Поиск и соцсети'),
fields: [
{
name: 'title',
type: 'text',
localized: true,
label: label('Title', 'Заголовок'),
admin: {
description: label(
'Empty — the page name is used',
'Пусто — используется название страницы',
),
},
},
{
name: 'description',
type: 'textarea',
localized: true,
label: label('Description', 'Описание'),
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: label('Social image', 'Картинка для соцсетей'),
admin: {
description: label(
'Empty — generated automatically',
'Пусто — создаётся автоматически',
),
},
},
],
},
],
}
+132 -41
View File
@@ -9,6 +9,7 @@ import { DEFAULT_LOCALE, LOCALES } from '@/i18n/locales'
import { anyRole, managers, owners } from '../access/roles'
import { localeOptions } from '../fields/locales'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
import { groups, label, message } from '../i18n'
/**
@@ -48,6 +49,15 @@ const validateLocales: SelectFieldManyValidation = async (value, args) => {
return true
}
/** Имя для ссылки t.me/…: латиница, цифры и подчёркивание, без @ */
const validateTelegram: TextFieldSingleValidation = (value, { req }) =>
!value || (typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(value))
? true
: message(req, {
en: 'Telegram username without @, e.g. veilix_support',
ru: 'Имя в Telegram без @, например veilix_support',
})
const validateOrderPrefix: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{2,4}$/.test(value)
? true
@@ -74,50 +84,131 @@ export const Sites: CollectionConfig = {
// вместе с сайтом плагин удаляет все его документы
delete: owners,
},
hooks: revalidateSiteHooks,
fields: [
{
name: 'code',
type: 'select',
required: true,
unique: true,
options: [
{ value: 'dedicated', label: label('Veilix (dedicated)', 'Вейликс (dedicated)') },
{ value: 'mass', label: label('VeilixVPN (mass)', 'ВейликсВПН (mass)') },
type: 'tabs',
tabs: [
{
label: label('Main', 'Основное'),
fields: [
{
name: 'code',
type: 'select',
required: true,
unique: true,
options: [
{ value: 'dedicated', label: label('Veilix (dedicated)', 'Вейликс (dedicated)') },
{ value: 'mass', label: label('VeilixVPN (mass)', 'ВейликсВПН (mass)') },
],
label: label('Brand', 'Бренд'),
// по коду выбираются тема и страницы бренда, после создания не меняется
access: { update: () => false },
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'orderPrefix',
type: 'text',
required: true,
unique: true,
label: label('Order number prefix', 'Префикс номеров'),
validate: validateOrderPrefix,
},
{
name: 'locales',
type: 'select',
hasMany: true,
required: true,
options: localeOptions,
defaultValue: [...LOCALES],
label: label('Content languages', 'Языки контента'),
validate: validateLocales,
admin: {
description: label(
'Each domain of the site shows some of these languages.',
'Каждый домен сайта показывает часть этих языков.',
),
},
},
],
},
{
label: label('Support', 'Поддержка'),
fields: [
{
name: 'supportEmail',
type: 'email',
label: label('Support email', 'Почта поддержки'),
},
{
name: 'supportBot',
type: 'text',
label: label('Support Telegram', 'Telegram поддержки'),
validate: validateTelegram,
admin: {
description: label(
'Username for the t.me link, without @. Empty — the card is hidden.',
'Имя для ссылки t.me, без @. Пусто — карточка не показывается.',
),
},
},
{
name: 'supportHours',
type: 'text',
localized: true,
label: label('Support hours', 'Часы работы поддержки'),
},
],
},
{
label: label('SEO', 'SEO'),
fields: [
{
name: 'seo',
type: 'group',
label: false,
fields: [
{
name: 'titleTemplate',
type: 'text',
localized: true,
label: label('Title template', 'Шаблон заголовка'),
admin: {
description: label(
'%s is replaced with the page title, e.g. “%s · Veilix”',
'%s заменяется названием страницы, например «%s · Вейликс»',
),
},
},
{
name: 'description',
type: 'textarea',
localized: true,
label: label('Default description', 'Описание по умолчанию'),
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: label('Social image', 'Картинка для соцсетей'),
admin: {
description: label(
'Empty — generated automatically in the brand colors',
'Пусто — создаётся автоматически в цветах бренда',
),
},
},
],
},
],
},
],
label: label('Brand', 'Бренд'),
// по коду выбираются тема и страницы бренда, после создания не меняется
access: { update: () => false },
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'orderPrefix',
type: 'text',
required: true,
unique: true,
label: label('Order number prefix', 'Префикс номеров'),
validate: validateOrderPrefix,
},
{
name: 'locales',
type: 'select',
hasMany: true,
required: true,
options: localeOptions,
defaultValue: [...LOCALES],
label: label('Content languages', 'Языки контента'),
validate: validateLocales,
admin: {
description: label(
'Each domain of the site shows some of these languages.',
'Каждый домен сайта показывает часть этих языков.',
),
},
},
],
}
+72 -1
View File
@@ -1,11 +1,80 @@
import type { ArrayFieldValidation, CollectionConfig, TextFieldSingleValidation } from 'payload'
import type {
ArrayFieldValidation,
CollectionBeforeChangeHook,
CollectionConfig,
TextFieldSingleValidation,
} from 'payload'
import { ValidationError } from 'payload'
import { array } from 'payload/shared'
import { anyRole, managers } from '../access/roles'
import { amountField, integerField, sortField } from '../fields/common'
import { currencyField } from '../fields/currency'
import { revalidateSiteHooks } from '../hooks/revalidateSite'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
/**
* У dedicated цена локации — цена тарифа, в странах которого она есть. Страна в двух
* активных тарифах сайта дала бы две цены, поэтому такое сохранение отклоняется.
*/
const oneDedicatedTariffPerCountry: CollectionBeforeChangeHook = async ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const siteId = relationId(doc.site)
const countries = ((doc.countries ?? []) as unknown[])
.map(relationId)
.filter((id): id is string => id !== null)
if (doc.type !== 'dedicated' || doc.active === false || !siteId || countries.length === 0) {
return data
}
const { docs } = await req.payload.find({
collection: 'tariffs',
where: {
and: [
{ site: { equals: siteId } },
{ type: { equals: 'dedicated' } },
{ active: { equals: true } },
{ countries: { in: countries } },
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
],
},
depth: 1,
pagination: false,
overrideAccess: true,
req,
})
const taken = new Set(
docs.flatMap((tariff) =>
(tariff.countries ?? []).flatMap((country) =>
typeof country === 'object' && countries.includes(country.id) ? [country.iso2] : [],
),
),
)
if (taken.size > 0) {
const list = [...taken].sort().join(', ')
throw new ValidationError({
collection: collection.slug,
errors: [
{
path: 'countries',
message: message(req, {
en: `Already in another active tariff of the site: ${list}`,
ru: `Уже входят в другой активный тариф сайта: ${list}`,
}),
},
],
req,
})
}
return data
}
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
@@ -56,7 +125,9 @@ export const Tariffs: CollectionConfig = {
en: 'The site already has a tariff with this code',
ru: 'У сайта уже есть тариф с таким кодом',
}),
oneDedicatedTariffPerCountry,
],
...revalidateSiteHooks,
},
fields: [
{
+44
View File
@@ -0,0 +1,44 @@
import { revalidatePath } from 'next/cache'
import type { PayloadRequest } from 'payload'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { revalidateAfterChange, revalidateAfterDelete, SKIP_REVALIDATE } from './revalidateSite'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
const req = { payload: { logger: { debug: vi.fn() } } } as unknown as PayloadRequest
// хукам нужны только doc, previousDoc, req и context
const change = (doc: object, previousDoc: object = {}, context: object = {}) =>
revalidateAfterChange({ doc, previousDoc, req, context } as never)
describe('пересборка маркетинговых страниц', () => {
beforeEach(() => vi.mocked(revalidatePath).mockReset())
it('правка без черновиков пересобирает layout всех маркетинговых страниц', () => {
change({ id: '1' })
expect(revalidatePath).toHaveBeenCalledWith('/(front)/m/[host]/[locale]/[currency]', 'layout')
})
it('черновик страницы не пересобирает, публикация и снятие с публикации — да', () => {
change({ _status: 'draft' }, { _status: 'draft' })
expect(revalidatePath).not.toHaveBeenCalled()
change({ _status: 'published' }, { _status: 'draft' })
change({ _status: 'draft' }, { _status: 'published' })
expect(revalidatePath).toHaveBeenCalledTimes(2)
})
it('seed и пакетные правки пропускают пересборку', () => {
change({ id: '1' }, {}, { [SKIP_REVALIDATE]: true })
revalidateAfterDelete({ doc: { id: '1' }, req, context: { [SKIP_REVALIDATE]: true } } as never)
expect(revalidatePath).not.toHaveBeenCalled()
})
it('вне запроса Next ошибка пересборки не мешает сохранению', () => {
vi.mocked(revalidatePath).mockImplementationOnce(() => {
throw new Error('static generation store missing')
})
expect(() => change({ id: '1' })).not.toThrow()
expect(req.payload.logger.debug).toHaveBeenCalled()
})
})
+46
View File
@@ -0,0 +1,46 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, PayloadRequest } from 'payload'
import { revalidateMarketingPages } from '@/core/content/revalidate'
/** Флаг в `context` операции: seed и пакетные правки пересборку не запускают */
export const SKIP_REVALIDATE = 'skipRevalidate'
const revalidate = (req: PayloadRequest, context: Record<string, unknown>) => {
if (context[SKIP_REVALIDATE]) return
try {
revalidateMarketingPages()
} catch (error) {
// вне запроса Next (скрипты Payload) пересобирать нечего: кэш страниц живёт в процессе Next
req.payload.logger.debug({ msg: 'revalidatePath пропущен', err: error })
}
}
/** У коллекций с черновиками пересобираем только на публикацию и снятие с публикации */
const affectsPublished = (doc: unknown, previousDoc: unknown): boolean => {
const status = (value: unknown) =>
typeof value === 'object' && value !== null && '_status' in value ? value._status : undefined
const current = status(doc)
if (current === undefined) return true
return current === 'published' || status(previousDoc) === 'published'
}
export const revalidateAfterChange: CollectionAfterChangeHook = ({
doc,
previousDoc,
req,
context,
}) => {
if (affectsPublished(doc, previousDoc)) revalidate(req, context)
return doc
}
export const revalidateAfterDelete: CollectionAfterDeleteHook = ({ doc, req, context }) => {
revalidate(req, context)
return doc
}
/** Хуки для коллекций, чьи данные показывают маркетинговые страницы */
export const revalidateSiteHooks = {
afterChange: [revalidateAfterChange],
afterDelete: [revalidateAfterDelete],
}