Этап 1a: фундамент
- Next 16.4 и Payload 3.91 в одном приложении, PostgreSQL с UUID, TypeScript 6, ESLint 9, Prettier, Vitest - Payload: администраторы с ролями, multi-tenant по сайтам, локализация en/ru/de/fr/es, интерфейс админки ru/en, языки сайта (sites.locales) - Коллекции sites, domains, currencies, countries, locations, tags, tariffs, payment-systems (manual), media и начальные данные (pnpm seed) - Proxy: хост → домен, язык и валюта, внутренние маршруты по хосту; /admin и /api закрыты на доменах брендов - Токены и темы брендов, оболочки сайта и кабинета, переключатели языка, валюты и темы, заглушки страниц - /svc/health, юнит systemd veilix-dev.service - Документация: решения D-022…D-024, команды в CLAUDE.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3400f58a5a
commit
7e331c2c9d
140 files changed
+15885
-43
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import type { Access, FieldAccess } from 'payload'
|
||||
|
||||
export const ROLES = ['owner', 'admin', 'support', 'content'] as const
|
||||
|
||||
export type Role = (typeof ROLES)[number]
|
||||
|
||||
type AdminUser = { collection: 'admins'; id: string; roles?: Role[] | null }
|
||||
|
||||
export const isAdminUser = (user: unknown): user is AdminUser =>
|
||||
typeof user === 'object' && user !== null && 'collection' in user && user.collection === 'admins'
|
||||
|
||||
export const hasRole = (user: unknown, ...roles: Role[]): boolean =>
|
||||
isAdminUser(user) && (user.roles ?? []).some((role) => roles.includes(role))
|
||||
|
||||
/** Любой администратор с хотя бы одной ролью */
|
||||
export const anyRole: Access = ({ req }) =>
|
||||
isAdminUser(req.user) && (req.user.roles ?? []).length > 0
|
||||
|
||||
/** owner и admin: правка настроек и справочников */
|
||||
export const managers: Access = ({ req }) => hasRole(req.user, 'owner', 'admin')
|
||||
|
||||
export const owners: Access = ({ req }) => hasRole(req.user, 'owner')
|
||||
|
||||
/** owner, admin и content: правка контента */
|
||||
export const contentEditors: Access = ({ req }) => hasRole(req.user, 'owner', 'admin', 'content')
|
||||
|
||||
export const ownersField: FieldAccess = ({ req }) => hasRole(req.user, 'owner')
|
||||
|
||||
/** owner видит всех, остальные — только себя */
|
||||
export const ownerOrSelf: Access = ({ req }) => {
|
||||
if (hasRole(req.user, 'owner')) return true
|
||||
if (isAdminUser(req.user)) return { id: { equals: req.user.id } }
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
|
||||
import type {
|
||||
ArrayField,
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionBeforeDeleteHook,
|
||||
CollectionConfig,
|
||||
Field,
|
||||
PayloadRequest,
|
||||
} from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { ownerOrSelf, owners, ownersField, type Role } from '../access/roles'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
const roleOptions: { value: Role; label: { en: string; ru: string } }[] = [
|
||||
{ value: 'owner', label: label('Owner', 'Владелец') },
|
||||
{ value: 'admin', label: label('Admin', 'Администратор') },
|
||||
{ value: 'support', label: label('Support', 'Поддержка') },
|
||||
{ value: 'content', label: label('Content', 'Контент') },
|
||||
]
|
||||
|
||||
/** Первый администратор окружения (экран первого входа Payload) становится owner */
|
||||
const firstAdminIsOwner: CollectionBeforeChangeHook = async ({ data, operation, req }) => {
|
||||
if (operation !== 'create') return data
|
||||
const { totalDocs } = await req.payload.count({
|
||||
collection: 'admins',
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return totalDocs === 0 ? { ...data, roles: ['owner'] } : data
|
||||
}
|
||||
|
||||
const otherOwnersCount = async (req: PayloadRequest, id: number | string) => {
|
||||
const { totalDocs } = await req.payload.count({
|
||||
collection: 'admins',
|
||||
where: { and: [{ roles: { in: ['owner'] } }, { id: { not_equals: id } }] },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return totalDocs
|
||||
}
|
||||
|
||||
const lastOwnerError = (req: PayloadRequest) =>
|
||||
new APIError(
|
||||
message(req, {
|
||||
en: 'This is the last owner. Make someone else an owner first.',
|
||||
ru: 'Это последний владелец. Сначала назначьте владельцем кого-то ещё.',
|
||||
}),
|
||||
400,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
|
||||
/** Без владельца админкой некому управлять: последний owner не теряет роль */
|
||||
const keepLastOwner: CollectionBeforeChangeHook = async ({ data, operation, originalDoc, req }) => {
|
||||
if (operation !== 'update' || !originalDoc?.roles?.includes('owner')) return data
|
||||
const roles: Role[] | undefined = data.roles
|
||||
if (!roles || roles.includes('owner')) return data
|
||||
if ((await otherOwnersCount(req, originalDoc.id)) === 0) throw lastOwnerError(req)
|
||||
return data
|
||||
}
|
||||
|
||||
const keepLastOwnerOnDelete: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||
const doc = await req.payload.findByID({
|
||||
collection: 'admins',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (doc.roles?.includes('owner') && (await otherOwnersCount(req, id)) === 0) {
|
||||
throw lastOwnerError(req)
|
||||
}
|
||||
}
|
||||
|
||||
const pluginSites = tenantsArrayField({
|
||||
tenantsArrayFieldName: 'sites',
|
||||
tenantsArrayTenantFieldName: 'site',
|
||||
tenantsCollectionSlug: 'sites',
|
||||
arrayFieldAccess: { create: ownersField, update: ownersField },
|
||||
})
|
||||
|
||||
/**
|
||||
* Сайты, которые видит администратор (поле плагина multi-tenant). Нужно только поддержке
|
||||
* и контенту: owner и admin видят все сайты. На экране первого входа пользователя ещё нет,
|
||||
* а первый администратор и так становится owner, поэтому там поле скрыто.
|
||||
*/
|
||||
const sitesField: ArrayField = {
|
||||
...pluginSites,
|
||||
label: label('Sites', 'Сайты'),
|
||||
labels: { singular: label('Site', 'Сайт'), plural: label('Sites', 'Сайты') },
|
||||
fields: pluginSites.fields.map((field: Field) =>
|
||||
'name' in field && field.name === 'site' ? { ...field, label: label('Site', 'Сайт') } : field,
|
||||
) as ArrayField['fields'],
|
||||
admin: {
|
||||
condition: (data, _siblingData, { user }) =>
|
||||
Boolean(user) &&
|
||||
!(data.roles ?? []).some((role: Role) => role === 'owner' || role === 'admin'),
|
||||
description: label(
|
||||
'Sites available to support and content roles',
|
||||
'Сайты, доступные ролям «Поддержка» и «Контент»',
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Admins: CollectionConfig = {
|
||||
slug: 'admins',
|
||||
labels: {
|
||||
singular: label('Administrator', 'Администратор'),
|
||||
plural: label('Administrators', 'Администраторы'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.settings,
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'name', 'roles'],
|
||||
},
|
||||
auth: {
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 15 * 60 * 1000,
|
||||
// админка открывается только по HTTPS через nginx
|
||||
cookies: { secure: true, sameSite: 'Lax' },
|
||||
},
|
||||
access: {
|
||||
read: ownerOrSelf,
|
||||
create: owners,
|
||||
update: ownerOrSelf,
|
||||
delete: owners,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [firstAdminIsOwner, keepLastOwner],
|
||||
beforeDelete: [keepLastOwnerOnDelete],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: label('Name', 'Имя'),
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: roleOptions,
|
||||
label: label('Roles', 'Роли'),
|
||||
access: { create: ownersField, update: ownersField },
|
||||
admin: {
|
||||
description: label(
|
||||
'Owner and admin see all sites; support and content see only the sites below.',
|
||||
'Владелец и администратор видят все сайты, поддержка и контент — только сайты ниже.',
|
||||
),
|
||||
},
|
||||
},
|
||||
sitesField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CollectionConfig, FieldHook, TextFieldSingleValidation } from 'payload'
|
||||
|
||||
import { anyRole, managers } from '../access/roles'
|
||||
import { sortField } from '../fields/common'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
const upperCase: FieldHook = ({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value
|
||||
|
||||
const validateIso2: TextFieldSingleValidation = (value, { req }) =>
|
||||
typeof value === 'string' && /^[A-Z]{2}$/.test(value)
|
||||
? true
|
||||
: message(req, {
|
||||
en: 'Two capital letters ISO 3166-1, e.g. NL',
|
||||
ru: 'Две заглавные буквы ISO 3166-1, например NL',
|
||||
})
|
||||
|
||||
export const Countries: CollectionConfig = {
|
||||
slug: 'countries',
|
||||
labels: {
|
||||
singular: label('Country', 'Страна'),
|
||||
plural: label('Countries', 'Страны'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.catalog,
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'iso2', 'sort'],
|
||||
description: label('Shared by both brands', 'Общий справочник обоих брендов'),
|
||||
},
|
||||
defaultSort: 'sort',
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'iso2',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
label: label('Code (ISO 3166-1)', 'Код (ISO 3166-1)'),
|
||||
hooks: { beforeValidate: [upperCase] },
|
||||
validate: validateIso2,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
label: label('Name', 'Название'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'lat',
|
||||
type: 'number',
|
||||
min: -90,
|
||||
max: 90,
|
||||
label: label('Latitude', 'Широта'),
|
||||
admin: { description: label('Default point on the map', 'Точка на карте по умолчанию') },
|
||||
},
|
||||
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
|
||||
],
|
||||
},
|
||||
sortField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
CollectionBeforeDeleteHook,
|
||||
CollectionConfig,
|
||||
FieldHook,
|
||||
TextFieldSingleValidation,
|
||||
} from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { anyRole, managers } from '../access/roles'
|
||||
import { integerField, sortField } from '../fields/common'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
const upperCase: FieldHook = ({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value
|
||||
|
||||
const validateCode: TextFieldSingleValidation = (value, { req }) =>
|
||||
typeof value === 'string' && /^[A-Z]{3}$/.test(value)
|
||||
? true
|
||||
: message(req, {
|
||||
en: 'Three capital letters ISO 4217, e.g. USD',
|
||||
ru: 'Три заглавные буквы ISO 4217, например USD',
|
||||
})
|
||||
|
||||
/** Используемую валюту не удаляем: на её код ссылаются домены, тарифы и платёжные системы */
|
||||
const keepUsedCurrency: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||
const { code } = await req.payload.findByID({
|
||||
collection: 'currencies',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const domains = await req.payload.count({
|
||||
collection: 'domains',
|
||||
where: { or: [{ currencies: { in: [code] } }, { defaultCurrency: { equals: code } }] },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const paymentSystems = await req.payload.count({
|
||||
collection: 'payment-systems',
|
||||
where: { currencies: { in: [code] } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const tariffs = await req.payload.count({
|
||||
collection: 'tariffs',
|
||||
where: { 'prices.currency': { equals: code } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (domains.totalDocs + paymentSystems.totalDocs + tariffs.totalDocs > 0) {
|
||||
throw new APIError(
|
||||
message(req, {
|
||||
en: 'The currency is used by domains, tariffs or payment systems. Turn it off instead.',
|
||||
ru: 'Валюта используется в доменах, тарифах или платёжных системах. Вместо удаления выключите её.',
|
||||
}),
|
||||
400,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const Currencies: CollectionConfig = {
|
||||
slug: 'currencies',
|
||||
labels: {
|
||||
singular: label('Currency', 'Валюта'),
|
||||
plural: label('Currencies', 'Валюты'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.settings,
|
||||
useAsTitle: 'code',
|
||||
defaultColumns: ['code', 'symbol', 'name', 'active', 'sort'],
|
||||
},
|
||||
defaultSort: 'sort',
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
hooks: {
|
||||
beforeDelete: [keepUsedCurrency],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'code',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
label: label('Code (ISO 4217)', 'Код (ISO 4217)'),
|
||||
hooks: { beforeValidate: [upperCase] },
|
||||
validate: validateCode,
|
||||
// на код ссылаются денежные записи, после создания не меняется
|
||||
access: { update: () => false },
|
||||
},
|
||||
{
|
||||
name: 'symbol',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: label('Symbol', 'Символ'),
|
||||
},
|
||||
integerField('minorUnits', label('Minor units', 'Знаков после запятой'), {
|
||||
required: true,
|
||||
defaultValue: 2,
|
||||
min: 0,
|
||||
max: 4,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
label: label('Name', 'Название'),
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
label: label('Active', 'Включена'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
sortField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type {
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionConfig,
|
||||
FieldHook,
|
||||
TextFieldSingleValidation,
|
||||
Where,
|
||||
} from 'payload'
|
||||
import { ValidationError } from 'payload'
|
||||
import { text } from 'payload/shared'
|
||||
|
||||
import { anyRole, managers } from '../access/roles'
|
||||
import { currenciesField, currencyField } from '../fields/currency'
|
||||
import { localeOptions } from '../fields/locales'
|
||||
import { groups, label, message } from '../i18n'
|
||||
import { relationId } from '../utils'
|
||||
|
||||
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
|
||||
|
||||
const normalizeHostname: FieldHook = ({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : value
|
||||
|
||||
const validateHostname: TextFieldSingleValidation = (value, args) => {
|
||||
const base = text(value, args)
|
||||
if (base !== true) return base
|
||||
return typeof value === 'string' && HOSTNAME.test(value)
|
||||
? true
|
||||
: message(args.req, {
|
||||
en: 'Hostname without protocol and path, e.g. veilix.app',
|
||||
ru: 'Имя хоста без протокола и пути, например veilix.app',
|
||||
})
|
||||
}
|
||||
|
||||
type LocaleCurrencyRow = { locale?: string | null; currency?: string | null }
|
||||
|
||||
/** Проверки, которым нужны несколько полей сразу и сайт домена */
|
||||
const validateDomain: CollectionBeforeChangeHook = async ({
|
||||
collection,
|
||||
data,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
const doc = { ...originalDoc, ...data }
|
||||
const locales: string[] = doc.locales ?? []
|
||||
const currencies: string[] = doc.currencies ?? []
|
||||
const errors: { path: string; message: string }[] = []
|
||||
const notInLocales = message(req, {
|
||||
en: 'Not among the domain languages',
|
||||
ru: 'Не входит в языки домена',
|
||||
})
|
||||
const notInCurrencies = message(req, {
|
||||
en: 'Not among the domain currencies',
|
||||
ru: 'Не входит в валюты домена',
|
||||
})
|
||||
|
||||
const siteId = relationId(doc.site)
|
||||
if (siteId) {
|
||||
const site = await req.payload.findByID({
|
||||
collection: 'sites',
|
||||
id: siteId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const extra = locales.filter((locale) => !(site.locales ?? []).includes(locale as never))
|
||||
if (extra.length > 0) {
|
||||
errors.push({
|
||||
path: 'locales',
|
||||
message: message(req, {
|
||||
en: `The site has no content in these languages: ${extra.join(', ')}`,
|
||||
ru: `У сайта нет контента на этих языках: ${extra.join(', ')}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.defaultLocale && !locales.includes(doc.defaultLocale)) {
|
||||
errors.push({ path: 'defaultLocale', message: notInLocales })
|
||||
}
|
||||
if (doc.defaultCurrency && !currencies.includes(doc.defaultCurrency)) {
|
||||
errors.push({ path: 'defaultCurrency', message: notInCurrencies })
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const rows: LocaleCurrencyRow[] = doc.localeCurrency ?? []
|
||||
rows.forEach((row, index) => {
|
||||
if (row.locale && !locales.includes(row.locale)) {
|
||||
errors.push({ path: `localeCurrency.${index}.locale`, message: notInLocales })
|
||||
} else if (row.locale && seen.has(row.locale)) {
|
||||
errors.push({
|
||||
path: `localeCurrency.${index}.locale`,
|
||||
message: message(req, { en: 'Language is listed twice', ru: 'Язык указан дважды' }),
|
||||
})
|
||||
}
|
||||
if (row.locale) seen.add(row.locale)
|
||||
if (row.currency && !currencies.includes(row.currency)) {
|
||||
errors.push({ path: `localeCurrency.${index}.currency`, message: notInCurrencies })
|
||||
}
|
||||
})
|
||||
|
||||
if (doc.isPrimary && siteId) {
|
||||
const where: Where = {
|
||||
and: [
|
||||
{ site: { equals: siteId } },
|
||||
{ app: { equals: doc.app } },
|
||||
{ isPrimary: { equals: true } },
|
||||
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
|
||||
],
|
||||
}
|
||||
const { docs } = await req.payload.find({
|
||||
collection: 'domains',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (docs[0]) {
|
||||
errors.push({
|
||||
path: 'isPrimary',
|
||||
message: message(req, {
|
||||
en: `The site already has a primary domain of this type: ${docs[0].hostname}`,
|
||||
ru: `У сайта уже есть основной домен этого типа: ${docs[0].hostname}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
|
||||
return data
|
||||
}
|
||||
|
||||
export const Domains: CollectionConfig = {
|
||||
slug: 'domains',
|
||||
labels: {
|
||||
singular: label('Domain', 'Домен'),
|
||||
plural: label('Domains', 'Домены'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.settings,
|
||||
useAsTitle: 'hostname',
|
||||
defaultColumns: ['hostname', 'app', 'locales', 'currencies', 'active'],
|
||||
description: label(
|
||||
'A new mirror is a record here plus an nginx block and a certificate.',
|
||||
'Новое зеркало — запись здесь плюс блок nginx и сертификат.',
|
||||
),
|
||||
},
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [validateDomain],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'hostname',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
label: label('Hostname', 'Хост'),
|
||||
hooks: { beforeValidate: [normalizeHostname] },
|
||||
validate: validateHostname,
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'marketing', label: label('Website', 'Сайт') },
|
||||
{ value: 'cabinet', label: label('Account and checkout', 'Кабинет и оформление заказа') },
|
||||
{ value: 'miniapp', label: label('Telegram MiniApp', 'MiniApp в Telegram') },
|
||||
],
|
||||
label: label('Frontend', 'Фронтенд'),
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'locales',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
required: true,
|
||||
options: localeOptions,
|
||||
label: label('Languages', 'Языки'),
|
||||
},
|
||||
{
|
||||
name: 'defaultLocale',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'en',
|
||||
options: localeOptions,
|
||||
label: label('Default language', 'Язык по умолчанию'),
|
||||
admin: {
|
||||
description: label('Opens without a URL prefix', 'Открывается без префикса в адресе'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
currenciesField({
|
||||
name: 'currencies',
|
||||
label: label('Currencies', 'Валюты'),
|
||||
required: true,
|
||||
}),
|
||||
currencyField({
|
||||
name: 'defaultCurrency',
|
||||
label: label('Default currency', 'Валюта по умолчанию'),
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'localeCurrency',
|
||||
type: 'array',
|
||||
label: label('Currency by language', 'Валюта для языка'),
|
||||
labels: { singular: label('Rule', 'Правило'), plural: label('Rules', 'Правила') },
|
||||
admin: {
|
||||
description: label(
|
||||
'Default currency for a language, e.g. ru → RUB. Other languages get the default currency.',
|
||||
'Валюта по умолчанию для языка, например ru → RUB. Для остальных языков — валюта по умолчанию.',
|
||||
),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: localeOptions,
|
||||
label: label('Language', 'Язык'),
|
||||
},
|
||||
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'isPrimary',
|
||||
type: 'checkbox',
|
||||
label: label('Primary domain of this type', 'Основной домен этого типа'),
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: label('Used in links and the sitemap', 'Используется в ссылках и sitemap'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'indexable',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
label: label('Allow search indexing', 'Разрешить индексацию'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
label: label('Active', 'Включён'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import type { SiteCode } from '@/core/sites'
|
||||
|
||||
import { anyRole, managers } from '../access/roles'
|
||||
import { integerField, sortField } from '../fields/common'
|
||||
import { uniqueInSite } from '../hooks/uniqueInSite'
|
||||
import { groups, label, message } from '../i18n'
|
||||
import { relationId } from '../utils'
|
||||
|
||||
export const LOCATION_STATUSES = ['available', 'on_demand', 'soon'] as const
|
||||
|
||||
/** dedicated: «Серверы в наличии» и «Установим за 24 часа»; mass: «Включено» и «Скоро» */
|
||||
const STATUSES_BY_SITE: Record<SiteCode, readonly string[]> = {
|
||||
dedicated: ['available', 'on_demand'],
|
||||
mass: ['available', 'soon'],
|
||||
}
|
||||
|
||||
const validateStatus: CollectionBeforeChangeHook = async ({
|
||||
collection,
|
||||
data,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
const doc = { ...originalDoc, ...data }
|
||||
const siteId = relationId(doc.site)
|
||||
if (!siteId || !doc.status) return data
|
||||
|
||||
const site = await req.payload.findByID({
|
||||
collection: 'sites',
|
||||
id: siteId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (!STATUSES_BY_SITE[site.code].includes(doc.status)) {
|
||||
throw new ValidationError({
|
||||
collection: collection.slug,
|
||||
errors: [
|
||||
{
|
||||
path: 'status',
|
||||
message: message(req, {
|
||||
en: 'This status is not used by the brand of the site',
|
||||
ru: 'Этот статус не используется у бренда сайта',
|
||||
}),
|
||||
},
|
||||
],
|
||||
req,
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const Locations: CollectionConfig = {
|
||||
slug: 'locations',
|
||||
labels: {
|
||||
singular: label('Location', 'Локация'),
|
||||
plural: label('Locations', 'Локации'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.catalog,
|
||||
useAsTitle: 'city',
|
||||
defaultColumns: ['city', 'country', 'status', 'visible', 'sort'],
|
||||
},
|
||||
defaultSort: 'sort',
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
indexes: [{ fields: ['site', 'country'], unique: true }],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
validateStatus,
|
||||
uniqueInSite('country', {
|
||||
en: 'The site already has a location in this country',
|
||||
ru: 'У сайта уже есть локация в этой стране',
|
||||
}),
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'country',
|
||||
type: 'relationship',
|
||||
relationTo: 'countries',
|
||||
required: true,
|
||||
label: label('Country', 'Страна'),
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
label: label('City', 'Город'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'lat',
|
||||
type: 'number',
|
||||
min: -90,
|
||||
max: 90,
|
||||
label: label('Latitude', 'Широта'),
|
||||
admin: { description: label('City point on the map', 'Точка города на карте') },
|
||||
},
|
||||
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'available', label: label('Available now', 'Доступна сейчас') },
|
||||
{
|
||||
value: 'on_demand',
|
||||
label: label('Ready within 24 hours (Veilix)', 'Установим за 24 часа (Вейликс)'),
|
||||
},
|
||||
{ value: 'soon', label: label('Coming soon (VeilixVPN)', 'Скоро (ВейликсВПН)') },
|
||||
],
|
||||
label: label('Status', 'Статус'),
|
||||
},
|
||||
integerField('freeServers', label('Free servers', 'Свободных серверов'), {
|
||||
min: 0,
|
||||
admin: {
|
||||
description: label(
|
||||
'Optional counter. Whether to show it on the site is decided in stage 1b.',
|
||||
'Необязательный счётчик. Показывать ли его на сайте — решаем на этапе 1b.',
|
||||
),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'visible',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
label: label('Shown on the site', 'Показывать на сайте'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
sortField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from 'node:path'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { contentEditors } from '../access/roles'
|
||||
import { groups, label } from '../i18n'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
labels: {
|
||||
singular: label('File', 'Файл'),
|
||||
plural: label('Media', 'Медиатека'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.content,
|
||||
},
|
||||
access: {
|
||||
// файлы открыты всем: их показывают сайты брендов
|
||||
read: () => true,
|
||||
create: contentEditors,
|
||||
update: contentEditors,
|
||||
delete: contentEditors,
|
||||
},
|
||||
upload: {
|
||||
// папка media в корне проекта; в prod это ссылка на shared/media
|
||||
staticDir: path.resolve(process.cwd(), 'media'),
|
||||
mimeTypes: ['image/*'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
label: label('Alt text', 'Описание изображения'),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig, FieldHook } from 'payload'
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import {
|
||||
isPaymentAdapterCode,
|
||||
PAYMENT_ADAPTER_CODES,
|
||||
paymentAdapters,
|
||||
} from '@/core/payments/adapters'
|
||||
|
||||
import { anyRole, managers } from '../access/roles'
|
||||
import { amountField, sortField } from '../fields/common'
|
||||
import { currenciesField, currencyField } from '../fields/currency'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
type LimitRow = { currency?: string | null; min?: number | null; max?: number | null }
|
||||
|
||||
const validatePaymentSystem: CollectionBeforeChangeHook = ({
|
||||
collection,
|
||||
data,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
const doc = { ...originalDoc, ...data }
|
||||
const currencies: string[] = doc.currencies ?? []
|
||||
const errors: { path: string; message: string }[] = []
|
||||
|
||||
// ручным платежом пользуются только администраторы
|
||||
if (doc.adapter === 'manual' && doc.visibleToCustomers) {
|
||||
errors.push({
|
||||
path: 'visibleToCustomers',
|
||||
message: message(req, {
|
||||
en: 'Manual payments are for administrators only',
|
||||
ru: 'Ручным платежом пользуются только администраторы',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const rows: LimitRow[] = doc.limits ?? []
|
||||
rows.forEach((row, index) => {
|
||||
if (row.currency && !currencies.includes(row.currency)) {
|
||||
errors.push({
|
||||
path: `limits.${index}.currency`,
|
||||
message: message(req, {
|
||||
en: 'Not among the payment system currencies',
|
||||
ru: 'Не входит в валюты платёжной системы',
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (typeof row.min === 'number' && typeof row.max === 'number' && row.min > row.max) {
|
||||
errors.push({
|
||||
path: `limits.${index}.max`,
|
||||
message: message(req, {
|
||||
en: 'Maximum is less than minimum',
|
||||
ru: 'Максимум меньше минимума',
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
|
||||
return data
|
||||
}
|
||||
|
||||
/** Возможности не хранятся: их объявляет адаптер в коде */
|
||||
const readCapabilities: FieldHook = ({ siblingData }) => {
|
||||
const adapter: unknown = siblingData?.adapter
|
||||
if (!isPaymentAdapterCode(adapter)) return null
|
||||
const capabilities: readonly string[] = paymentAdapters[adapter].capabilities
|
||||
return capabilities.length > 0 ? capabilities.join(', ') : '—'
|
||||
}
|
||||
|
||||
export const PaymentSystems: CollectionConfig = {
|
||||
slug: 'payment-systems',
|
||||
labels: {
|
||||
singular: label('Payment system', 'Платёжная система'),
|
||||
plural: label('Payment systems', 'Платёжные системы'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.settings,
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'adapter', 'currencies', 'mode', 'active'],
|
||||
description: label(
|
||||
'Available in its currencies on every site where they are enabled. Provider keys are in .env only.',
|
||||
'Доступна в своих валютах на всех сайтах, где они включены. Ключи провайдеров — только в .env.',
|
||||
),
|
||||
},
|
||||
defaultSort: 'sort',
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [validatePaymentSystem],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: label('Name in admin', 'Название в админке'),
|
||||
},
|
||||
{
|
||||
name: 'adapter',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: PAYMENT_ADAPTER_CODES.map((code) => ({ value: code, label: code })),
|
||||
label: label('Adapter', 'Адаптер'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
label: label('Title for customers', 'Название для пользователя'),
|
||||
admin: { description: label('e.g. “Bank card”', 'например, «Банковская карта»') },
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
label: label('Hint for customers', 'Подсказка для пользователя'),
|
||||
},
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: label('Icon', 'Иконка'),
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
currenciesField({
|
||||
name: 'currencies',
|
||||
label: label('Currencies', 'Валюты'),
|
||||
required: true,
|
||||
}),
|
||||
{
|
||||
name: 'purposes',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'order', label: label('Order payment', 'Оплата заказа') },
|
||||
{ value: 'topup', label: label('Balance top-up', 'Пополнение баланса') },
|
||||
],
|
||||
label: label('Purposes', 'Назначения'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'limits',
|
||||
type: 'array',
|
||||
label: label('Limits', 'Лимиты'),
|
||||
labels: { singular: label('Limit', 'Лимит'), plural: label('Limits', 'Лимиты') },
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
|
||||
amountField('min', label('Minimum', 'Минимум')),
|
||||
amountField('max', label('Maximum', 'Максимум')),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'capabilities',
|
||||
type: 'text',
|
||||
virtual: true,
|
||||
label: label('Capabilities (from the adapter)', 'Возможности (из адаптера)'),
|
||||
admin: { readOnly: true },
|
||||
hooks: { afterRead: [readCapabilities] },
|
||||
},
|
||||
{
|
||||
name: 'visibleToCustomers',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
label: label('Visible to customers', 'Видна пользователям'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'test',
|
||||
options: [
|
||||
{ value: 'test', label: label('Test', 'Тестовый') },
|
||||
{ value: 'live', label: label('Live', 'Боевой') },
|
||||
],
|
||||
label: label('Mode', 'Режим'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
label: label('Active', 'Включена'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
sortField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
CollectionConfig,
|
||||
SelectFieldManyValidation,
|
||||
TextFieldSingleValidation,
|
||||
} from 'payload'
|
||||
import { select } from 'payload/shared'
|
||||
|
||||
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/locales'
|
||||
|
||||
import { anyRole, managers, owners } from '../access/roles'
|
||||
import { localeOptions } from '../fields/locales'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
/**
|
||||
* Языки сайта — языки его контента. Домен показывает любую их часть.
|
||||
* Английский обязателен: на него откатывается непереведённый контент.
|
||||
*/
|
||||
const validateLocales: SelectFieldManyValidation = async (value, args) => {
|
||||
const base = select(value, args)
|
||||
if (base !== true) return base
|
||||
|
||||
const locales = value ?? []
|
||||
if (!locales.includes(DEFAULT_LOCALE)) {
|
||||
return message(args.req, {
|
||||
en: 'English is required: untranslated content falls back to it',
|
||||
ru: 'Английский обязателен: на него откатывается непереведённый контент',
|
||||
})
|
||||
}
|
||||
|
||||
if (args.id) {
|
||||
const { docs } = await args.req.payload.find({
|
||||
collection: 'domains',
|
||||
where: { site: { equals: args.id } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req: args.req,
|
||||
})
|
||||
const used = new Set(docs.flatMap((domain) => domain.locales ?? []))
|
||||
const missing = [...used].filter((locale) => !locales.includes(locale))
|
||||
if (missing.length > 0) {
|
||||
return message(args.req, {
|
||||
en: `These languages are enabled on the site's domains, remove them there first: ${missing.join(', ')}`,
|
||||
ru: `Эти языки включены на доменах сайта, сначала уберите их там: ${missing.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const validateOrderPrefix: TextFieldSingleValidation = (value, { req }) =>
|
||||
typeof value === 'string' && /^[A-Z]{2,4}$/.test(value)
|
||||
? true
|
||||
: message(req, {
|
||||
en: '2–4 capital Latin letters, e.g. VX',
|
||||
ru: '2–4 заглавные латинские буквы, например VX',
|
||||
})
|
||||
|
||||
export const Sites: CollectionConfig = {
|
||||
slug: 'sites',
|
||||
labels: {
|
||||
singular: label('Site', 'Сайт'),
|
||||
plural: label('Sites', 'Сайты'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.settings,
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'code', 'orderPrefix', 'locales'],
|
||||
},
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
// вместе с сайтом плагин удаляет все его документы
|
||||
delete: owners,
|
||||
},
|
||||
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.',
|
||||
'Каждый домен сайта показывает часть этих языков.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CollectionConfig, TextFieldSingleValidation } from 'payload'
|
||||
|
||||
import { anyRole, managers } from '../access/roles'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
const validateCode: TextFieldSingleValidation = (value, { req }) =>
|
||||
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
|
||||
? true
|
||||
: message(req, {
|
||||
en: 'Lowercase Latin letters, digits, - and _',
|
||||
ru: 'Строчные латинские буквы, цифры, - и _',
|
||||
})
|
||||
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: 'tags',
|
||||
labels: {
|
||||
singular: label('Tag', 'Тег'),
|
||||
plural: label('Tags', 'Теги'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.catalog,
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'code'],
|
||||
description: label(
|
||||
'For filtering tariffs and, later, automatic key provisioning',
|
||||
'Для фильтрации тарифов и будущей автоматической выдачи ключей',
|
||||
),
|
||||
},
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true, label: label('Name', 'Название') },
|
||||
{
|
||||
name: 'code',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
label: label('Code', 'Код'),
|
||||
validate: validateCode,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ArrayFieldValidation, CollectionConfig, TextFieldSingleValidation } 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 { uniqueInSite } from '../hooks/uniqueInSite'
|
||||
import { groups, label, message } from '../i18n'
|
||||
|
||||
const validateCode: TextFieldSingleValidation = (value, { req }) =>
|
||||
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
|
||||
? true
|
||||
: message(req, {
|
||||
en: 'Lowercase Latin letters, digits, - and _',
|
||||
ru: 'Строчные латинские буквы, цифры, - и _',
|
||||
})
|
||||
|
||||
/** Строка на валюту: нет строки — тариф в этой валюте не продаётся */
|
||||
const validatePrices: ArrayFieldValidation = (value, args) => {
|
||||
const base = array(value, args)
|
||||
if (base !== true) return base
|
||||
const codes = ((value ?? []) as { currency?: string }[])
|
||||
.map((row) => row.currency)
|
||||
.filter((code): code is string => Boolean(code))
|
||||
const duplicates = codes.filter((code, index) => codes.indexOf(code) !== index)
|
||||
return duplicates.length === 0
|
||||
? true
|
||||
: message(args.req, {
|
||||
en: `One row per currency: ${duplicates.join(', ')}`,
|
||||
ru: `По одной строке на валюту: ${duplicates.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
export const Tariffs: CollectionConfig = {
|
||||
slug: 'tariffs',
|
||||
labels: {
|
||||
singular: label('Tariff', 'Тариф'),
|
||||
plural: label('Tariffs', 'Тарифы'),
|
||||
},
|
||||
admin: {
|
||||
group: groups.catalog,
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'code', 'type', 'active', 'sort'],
|
||||
},
|
||||
defaultSort: 'sort',
|
||||
access: {
|
||||
read: anyRole,
|
||||
create: managers,
|
||||
update: managers,
|
||||
delete: managers,
|
||||
},
|
||||
indexes: [{ fields: ['site', 'code'], unique: true }],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
uniqueInSite('code', {
|
||||
en: 'The site already has a tariff with this code',
|
||||
ru: 'У сайта уже есть тариф с таким кодом',
|
||||
}),
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
label: label('Name', 'Название'),
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: label('Code', 'Код'),
|
||||
validate: validateCode,
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'dedicated', label: label('Dedicated IP', 'Индивидуальный IP') },
|
||||
{ value: 'mass', label: label('Shared VPN', 'Общий VPN') },
|
||||
],
|
||||
label: label('Key type', 'Тип ключа'),
|
||||
admin: {
|
||||
description: label(
|
||||
'dedicated: the country is chosen at purchase; mass: all tariff countries',
|
||||
'dedicated — страна выбирается при покупке, mass — все страны тарифа',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
label: label('Description', 'Описание'),
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'array',
|
||||
label: label('What is included', 'Что входит'),
|
||||
labels: { singular: label('Item', 'Пункт'), plural: label('Items', 'Пункты') },
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
label: label('Text', 'Текст'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'period',
|
||||
type: 'group',
|
||||
label: label('Period', 'Период'),
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'unit',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'month',
|
||||
options: [
|
||||
{ value: 'month', label: label('Month', 'Месяц') },
|
||||
{ value: 'year', label: label('Year', 'Год') },
|
||||
],
|
||||
label: label('Unit', 'Единица'),
|
||||
},
|
||||
integerField('count', label('Count', 'Количество'), {
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
integerField('devicesLimit', label('Devices per key', 'Устройств на ключ'), {
|
||||
required: true,
|
||||
defaultValue: 5,
|
||||
min: 1,
|
||||
}),
|
||||
{
|
||||
name: 'prices',
|
||||
type: 'array',
|
||||
label: label('Prices', 'Цены'),
|
||||
labels: { singular: label('Price', 'Цена'), plural: label('Prices', 'Цены') },
|
||||
validate: validatePrices,
|
||||
admin: {
|
||||
description: label(
|
||||
'One row per currency. No row means the tariff is not sold in that currency.',
|
||||
'Строка на валюту. Нет строки — тариф в этой валюте не продаётся.',
|
||||
),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
|
||||
amountField('firstPeriodAmount', label('First period', 'Первый период')),
|
||||
amountField('renewalAmount', label('Renewal', 'Продление')),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'countries',
|
||||
type: 'relationship',
|
||||
relationTo: 'countries',
|
||||
hasMany: true,
|
||||
label: label('Countries', 'Страны'),
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'relationship',
|
||||
relationTo: 'tags',
|
||||
hasMany: true,
|
||||
label: label('Tags', 'Теги'),
|
||||
},
|
||||
{
|
||||
name: 'badge',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
label: label('Badge', 'Плашка'),
|
||||
admin: { description: label('e.g. “best value”', 'например, «выгоднее»') },
|
||||
},
|
||||
{
|
||||
name: 'isDefault',
|
||||
type: 'checkbox',
|
||||
label: label(
|
||||
'Selected by default on the pricing page',
|
||||
'Выбран по умолчанию на странице цен',
|
||||
),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
label: label('On sale', 'В продаже'),
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
sortField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { SelectInput, useConfig, useField } from '@payloadcms/ui'
|
||||
import type { OptionObject, StaticDescription, TextFieldClientComponent } from 'payload'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type CurrencyDoc = { code: string; symbol: string }
|
||||
|
||||
/** Поле с кодом валюты: выпадающий список из справочника «Валюты» */
|
||||
export const CurrencyCodeField: TextFieldClientComponent = ({
|
||||
field,
|
||||
path: pathFromProps,
|
||||
readOnly,
|
||||
}) => {
|
||||
const { admin, hasMany = false, label, localized, name, required } = field
|
||||
const {
|
||||
config: {
|
||||
routes: { api },
|
||||
serverURL,
|
||||
},
|
||||
} = useConfig()
|
||||
const { disabled, path, setValue, showError, value } = useField<string | string[]>({
|
||||
potentiallyStalePath: pathFromProps,
|
||||
})
|
||||
const [options, setOptions] = useState<OptionObject[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetch(`${serverURL}${api}/currencies?depth=0&limit=100&sort=sort`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data: { docs?: CurrencyDoc[] }) =>
|
||||
setOptions(
|
||||
(data.docs ?? []).map((doc) => ({ label: `${doc.code} ${doc.symbol}`, value: doc.code })),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
return () => controller.abort()
|
||||
}, [api, serverURL])
|
||||
|
||||
const onChange = useCallback(
|
||||
(selected: unknown) => {
|
||||
if (hasMany) {
|
||||
setValue(
|
||||
Array.isArray(selected) ? selected.map((option: OptionObject) => option.value) : [],
|
||||
)
|
||||
} else {
|
||||
setValue(selected && !Array.isArray(selected) ? (selected as OptionObject).value : null)
|
||||
}
|
||||
},
|
||||
[hasMany, setValue],
|
||||
)
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
description={admin?.description as StaticDescription | undefined}
|
||||
hasMany={hasMany}
|
||||
label={label}
|
||||
localized={localized}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
path={path}
|
||||
readOnly={readOnly || disabled}
|
||||
required={required}
|
||||
showError={showError}
|
||||
value={value ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NumberField, NumberFieldSingleValidation } from 'payload'
|
||||
import { number } from 'payload/shared'
|
||||
|
||||
import { label, message } from '../i18n'
|
||||
|
||||
export const sortField: NumberField = {
|
||||
name: 'sort',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
label: label('Sort order', 'Порядок'),
|
||||
admin: { position: 'sidebar' },
|
||||
}
|
||||
|
||||
const integer: NumberFieldSingleValidation = (value, args) => {
|
||||
const base = number(value, args)
|
||||
if (base !== true) return base
|
||||
if (typeof value === 'number' && !Number.isInteger(value)) {
|
||||
return message(args.req, { en: 'Must be a whole number', ru: 'Нужно целое число' })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Сумма денег: целое число в минимальных единицах валюты */
|
||||
export const amountField = (name: string, fieldLabel: { en: string; ru: string }): NumberField => ({
|
||||
name,
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0,
|
||||
label: fieldLabel,
|
||||
validate: integer,
|
||||
admin: {
|
||||
description: label(
|
||||
'In minor units: 249900 = 2,499.00',
|
||||
'В минимальных единицах: 249900 = 2 499,00',
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const integerField = (
|
||||
name: string,
|
||||
fieldLabel: { en: string; ru: string },
|
||||
options: Pick<NumberField, 'defaultValue' | 'min' | 'max' | 'required' | 'admin'> = {},
|
||||
): NumberField => ({
|
||||
name,
|
||||
type: 'number',
|
||||
label: fieldLabel,
|
||||
validate: integer,
|
||||
...options,
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
FieldHook,
|
||||
TextField,
|
||||
TextFieldManyValidation,
|
||||
TextFieldSingleValidation,
|
||||
} from 'payload'
|
||||
import { text } from 'payload/shared'
|
||||
|
||||
import { message } from '../i18n'
|
||||
|
||||
type Options = {
|
||||
name: string
|
||||
label: TextField['label']
|
||||
required?: boolean
|
||||
description?: { en: string; ru: string }
|
||||
}
|
||||
|
||||
const normalize: FieldHook = ({ value }) => {
|
||||
if (Array.isArray(value)) return value.map((code) => String(code).trim().toUpperCase())
|
||||
return typeof value === 'string' ? value.trim().toUpperCase() : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Коды из списка есть в справочнике «Валюты» и не повторяются.
|
||||
* Валюту храним кодом ISO 4217, а не ссылкой: так её хранят и денежные записи.
|
||||
*/
|
||||
const checkCodes = async (
|
||||
codes: string[],
|
||||
req: Parameters<TextFieldSingleValidation>[1]['req'],
|
||||
): Promise<string | true> => {
|
||||
if (codes.length === 0) return true
|
||||
|
||||
const duplicates = codes.filter((code, index) => codes.indexOf(code) !== index)
|
||||
if (duplicates.length > 0) {
|
||||
return message(req, {
|
||||
en: `Currency is listed twice: ${duplicates.join(', ')}`,
|
||||
ru: `Валюта указана дважды: ${duplicates.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
const { docs } = await req.payload.find({
|
||||
collection: 'currencies',
|
||||
where: { code: { in: codes } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const known = new Set(docs.map((doc) => doc.code))
|
||||
const unknown = codes.filter((code) => !known.has(code))
|
||||
if (unknown.length > 0) {
|
||||
return message(req, {
|
||||
en: `Not in the currencies list: ${unknown.join(', ')}`,
|
||||
ru: `Нет в справочнике валют: ${unknown.join(', ')}`,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const validateOne: TextFieldSingleValidation = async (value, args) => {
|
||||
const base = text(value, args)
|
||||
if (base !== true) return base
|
||||
return checkCodes(value ? [value] : [], args.req)
|
||||
}
|
||||
|
||||
const validateMany: TextFieldManyValidation = async (value, args) => {
|
||||
const codes = value ?? []
|
||||
if (args.required && codes.length === 0) return args.req.t('validation:required')
|
||||
return checkCodes(codes, args.req)
|
||||
}
|
||||
|
||||
const component = '/payload/components/CurrencyCodeField#CurrencyCodeField'
|
||||
|
||||
/** Код одной валюты: в админке — выпадающий список из справочника */
|
||||
export const currencyField = ({ name, label, required, description }: Options): TextField => ({
|
||||
name,
|
||||
type: 'text',
|
||||
label,
|
||||
required,
|
||||
hooks: { beforeValidate: [normalize] },
|
||||
validate: validateOne,
|
||||
admin: { components: { Field: component }, description },
|
||||
})
|
||||
|
||||
/** Несколько валют */
|
||||
export const currenciesField = ({ name, label, required, description }: Options): TextField => ({
|
||||
name,
|
||||
type: 'text',
|
||||
hasMany: true,
|
||||
label,
|
||||
required,
|
||||
hooks: { beforeValidate: [normalize] },
|
||||
validate: validateMany,
|
||||
admin: { components: { Field: component }, description },
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { OptionObject } from 'payload'
|
||||
|
||||
import { LOCALE_NAMES, LOCALES } from '@/i18n/locales'
|
||||
|
||||
export const localeOptions: OptionObject[] = LOCALES.map((code) => ({
|
||||
value: code,
|
||||
label: LOCALE_NAMES[code],
|
||||
}))
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { CollectionBeforeChangeHook, CollectionSlug, Where } from 'payload'
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import { message } from '../i18n'
|
||||
import { relationId } from '../utils'
|
||||
|
||||
/**
|
||||
* Значение поля уникально в пределах сайта. Гарантию даёт составной индекс в базе,
|
||||
* а хук нужен для понятного сообщения вместо ошибки базы.
|
||||
*/
|
||||
export const uniqueInSite =
|
||||
(field: string, text: { en: string; ru: string }): CollectionBeforeChangeHook =>
|
||||
async ({ collection, data, originalDoc, req }) => {
|
||||
const doc = { ...originalDoc, ...data }
|
||||
const siteId = relationId(doc.site)
|
||||
const value = relationId(doc[field]) ?? doc[field]
|
||||
if (!siteId || value === undefined || value === null) return data
|
||||
|
||||
const where: Where = {
|
||||
and: [
|
||||
{ site: { equals: siteId } },
|
||||
{ [field]: { equals: value } },
|
||||
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
|
||||
],
|
||||
}
|
||||
const { totalDocs } = await req.payload.count({
|
||||
collection: collection.slug as CollectionSlug,
|
||||
where,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (totalDocs > 0) {
|
||||
throw new ValidationError({
|
||||
collection: collection.slug,
|
||||
errors: [{ path: field, message: message(req, text) }],
|
||||
req,
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
type Text = { en: string; ru: string }
|
||||
|
||||
/** Подпись в админке на двух её языках интерфейса */
|
||||
export const label = (en: string, ru: string): Text => ({ en, ru })
|
||||
|
||||
/** Сообщение (например, об ошибке проверки) на языке интерфейса админки */
|
||||
export const message = (req: Partial<PayloadRequest> | undefined, text: Text): string =>
|
||||
req?.i18n?.language === 'ru' ? text.ru : text.en
|
||||
|
||||
export const groups = {
|
||||
settings: label('Settings', 'Настройки'),
|
||||
catalog: label('Catalog', 'Каталог'),
|
||||
content: label('Content', 'Контент'),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import { DEFAULT_LOCALE, LOCALE_NAMES, LOCALES } from '@/i18n/locales'
|
||||
|
||||
type Localization = Exclude<Config['localization'], false | undefined>
|
||||
|
||||
/** В админке видны только языки сайта, выбранного в переключателе. Без выбранного сайта — все. */
|
||||
const filterAvailableLocales: NonNullable<Localization['filterAvailableLocales']> = async ({
|
||||
locales,
|
||||
req,
|
||||
}) => {
|
||||
const siteId = getTenantFromCookie(req.headers, 'text')
|
||||
if (!siteId) return locales
|
||||
try {
|
||||
const site = await req.payload.findByID({
|
||||
collection: 'sites',
|
||||
id: siteId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const enabled = new Set<string>(site.locales ?? [])
|
||||
return locales.filter((locale) => enabled.has(locale.code))
|
||||
} catch {
|
||||
return locales
|
||||
}
|
||||
}
|
||||
|
||||
export const localization: Localization = {
|
||||
locales: LOCALES.map((code) => ({ code, label: LOCALE_NAMES[code] })),
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
fallback: true,
|
||||
filterAvailableLocales,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** id связанного документа: в зависимости от depth Payload отдаёт id или сам документ */
|
||||
export const relationId = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') return value
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'id' in value &&
|
||||
typeof value.id === 'string'
|
||||
) {
|
||||
return value.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in new issue
Block a user