Этап 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:
1 parent
515acfb69d
commit
20491d1ba4
134 files changed
+11232
-212
No files matched your search
@@ -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 })
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
'Пусто — создаётся автоматически',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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.',
|
||||
'Каждый домен сайта показывает часть этих языков.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
}
|
||||
Reference in new issue
Block a user