Этап 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:
andreypr207andClaude Opus 5.5 committed 2026-10-21 05:08:05 +02:00
1 parent 3400f58a5a
commit 7e331c2c9d
140 files changed
+15885 -43

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Образец .env. Сам .env лежит вне git с правами 600, у dev и prod свои значения.
DATABASE_URL=postgresql://vx_dev@/veilix_dev?host=/var/run/postgresql
# случайная строка: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
PAYLOAD_SECRET=
# хост админки: только на нём работают /admin и REST Payload
ADMIN_HOST=dev.plc.veilix.cc
# адрес приложения изнутри сервера и токен для служебных запросов /svc/internal/*
INTERNAL_URL=http://127.0.0.1:3100
INTERNAL_TOKEN=
# только для dev: хосты, с которых браузер обращается к dev-серверу Next
DEV_ALLOWED_ORIGINS=dev.veilix.app,dev.my.veilix.app,dev.tg.veilix.app,dev.veilixvpn.com,dev.my.veilixvpn.com,dev.tg.veilixvpn.com,dev.plc.veilix.cc
# не отправлять анонимную телеметрию Next.js
NEXT_TELEMETRY_DISABLED=1
+23
View File
@@ -0,0 +1,23 @@
# зависимости
/node_modules
# next.js
/.next/
/out/
next-env.d.ts
*.tsbuildinfo
# секреты и локальные настройки
.env
.env*.local
# загруженные файлы
/media
# тесты
/coverage
# прочее
.DS_Store
*.pem
npm-debug.log*
+10
View File
@@ -0,0 +1,10 @@
.next/
design/
docs/
media/
CLAUDE.md
README.md
pnpm-lock.yaml
src/payload-types.ts
src/migrations/
src/app/(payload)/admin/importMap.js
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/styles/globals.css"
}
+18 -1
View File
@@ -35,4 +35,21 @@
## 5. Команды ## 5. Команды
Заполняется на этапе 1a. Из `/home/vx_dev/veilix_dev` под vx_dev. Dev-сервер работает под systemd, вручную `pnpm dev` не запускаем: порт 3100 занят.
| Команда | Что делает |
|---|---|
| `systemctl --user restart veilix-dev` | перезапустить dev-сервер (`pnpm dev`, 127.0.0.1:3100); юнит — `ops/systemd/veilix-dev.service` |
| `journalctl --user -u veilix-dev -f` | логи dev-сервера |
| `pnpm lint` | ESLint |
| `pnpm typecheck` | типы маршрутов Next и `tsc --noEmit` |
| `pnpm test` | модульные тесты (Vitest, файлы `*.test.ts`) |
| `pnpm build` | production-сборка; dev-серверу не мешает, он пишет в `.next/dev` |
| `pnpm format` | Prettier (`pnpm format:check` — только проверка) |
| `pnpm seed` | начальные данные dev; повторный запуск обновляет записи и возвращает значения seed. `SEED_ENV=prod` — данные prod |
| `pnpm generate:types` | типы Payload в `src/payload-types.ts` после изменения коллекций |
| `pnpm generate:importmap` | карта импортов админки после добавления своих компонентов |
| `pnpm db:reset` | очистить `veilix_dev` (только её), если push схемы задаёт вопросы (переименование, удаление): без терминала их некому подтвердить. Затем перезапуск dev-сервера и `pnpm seed` |
| `pnpm payload migrate:create <имя>` | миграция для prod; до первой выкладки (1h) миграции не создаём |
Проверка без nginx: `curl -A 'Mozilla/5.0' -H 'Host: dev.veilix.app' http://127.0.0.1:3100/…`. Без браузерного User-Agent curl считается ботом и не перенаправляется; заголовок `X-Geo-Country` так можно подставить, через nginx — нет.
+2 -2
View File
@@ -24,7 +24,7 @@
| Этап | Содержание | Статус | | Этап | Содержание | Статус |
|---|---|---| |---|---|---|
| [0](stages/stage-0-docs.md) | Документация | готово | | [0](stages/stage-0-docs.md) | Документация | готово |
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | не начат | | [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | готово |
| [1b](stages/stage-1b-marketing.md) | Маркетинговые страницы обоих брендов, контент, формы с ALTCHA, SEO, юр. тексты | не начат | | [1b](stages/stage-1b-marketing.md) | Маркетинговые страницы обоих брендов, контент, формы с ALTCHA, SEO, юр. тексты | не начат |
| [1c](stages/stage-1c-auth.md) | Вход по коду, почта, сессии | не начат | | [1c](stages/stage-1c-auth.md) | Вход по коду, почта, сессии | не начат |
| [1d](stages/stage-1d-billing.md) | Биллинг без эквайринга, оформление заказа, продления | не начат | | [1d](stages/stage-1d-billing.md) | Биллинг без эквайринга, оформление заказа, продления | не начат |
@@ -64,7 +64,7 @@
| № | Вопрос | Предложение | Нужно к этапу | | № | Вопрос | Предложение | Нужно к этапу |
|---|---|---|---| |---|---|---|---|
| Q-02 | Цены mass в USD, суммы пополнения и реферального бонуса в USD для обоих брендов | — нужны цифры | 1b | | Q-02 | Цены mass в USD, суммы пополнения и реферального бонуса в USD для обоих брендов | Нужны цифры. Пока в seed временные $6.99 и $69.90 (около $1 за 100 ₽, как у dedicated в макете) | 1b |
| Q-08 | Email поддержки брендов | `help@veilix.app` и `help@veilixvpn.com` (в макете были домены .cc) | 1b | | Q-08 | Email поддержки брендов | `help@veilix.app` и `help@veilixvpn.com` (в макете были домены .cc) | 1b |
| Q-09 | Часовой пояс часов поддержки (10–20 в рабочие дни) | МСК | 1b | | Q-09 | Часовой пояс часов поддержки (10–20 в рабочие дни) | МСК | 1b |
| Q-10 | Имена Telegram-ботов: 2 основных, 2 поддержки, тестовые | — | 3 | | Q-10 | Имена Telegram-ботов: 2 основных, 2 поддержки, тестовые | — | 3 |
+30 -18
View File
@@ -11,19 +11,22 @@
| Payload CMS | 3.91.x | Payload 4 в бете; переход после стабильного релиза ([D-002](decisions/D-002-payload-version.md)) | | Payload CMS | 3.91.x | Payload 4 в бете; переход после стабильного релиза ([D-002](decisions/D-002-payload-version.md)) |
| PostgreSQL | 18 (на хосте) | адаптер `@payloadcms/db-postgres`, `idType: 'uuid'` | | PostgreSQL | 18 (на хосте) | адаптер `@payloadcms/db-postgres`, `idType: 'uuid'` |
| Tailwind CSS | 4.x | токены из макета как CSS-переменные | | Tailwind CSS | 4.x | токены из макета как CSS-переменные |
| TypeScript | 6.0 | не 7.0: typescript-eslint из eslint-config-next поддерживает только TS < 6.1 |
| ESLint | 9.39 | не 10: eslint-plugin-react, import и jsx-a11y не заявляют поддержку ESLint 10 |
| Vitest | 5.x | модульные тесты |
| next-intl | 4.x | переводы интерфейса, префиксы языков в URL | | next-intl | 4.x | переводы интерфейса, префиксы языков в URL |
| Nodemailer | через `@payloadcms/email-nodemailer` | транспорты SMTP, sendmail, «только журнал» | | Nodemailer | через `@payloadcms/email-nodemailer` | транспорты SMTP, sendmail, «только журнал» |
| ALTCHA | 3.x | капча без третьих сторон | | ALTCHA | 3.x | капча без третьих сторон |
| grammY | 1.x | Telegram-боты, этап 3 | | grammY | 1.x | Telegram-боты, этап 3 |
| Stripe SDK | актуальный | этап 4 | | Stripe SDK | актуальный | этап 4 |
Точные версии фиксируются в `package.json` на этапе 1a. Если к старту этапа 1a выйдет стабильный Payload 4, начинаем сразу на нём. Точные версии закреплены в `package.json`: на этапе 1a — Next 16.4.2, Payload 3.91.0, React 19.3.1, next-intl 4.14.9, Tailwind 4.3.3, pnpm 12.14. На старте 1a Payload 4 был в бете (4.0.0-beta.3), поэтому начали на 3.91 ([D-002](decisions/D-002-payload-version.md)). Телеметрия Next.js и Payload выключена.
## 2. Одно приложение на все домены ## 2. Одно приложение на все домены
1. Одно Next.js-приложение, Payload установлен внутрь как библиотека: админка, API и фронтенды — один процесс ([D-001](decisions/D-001-single-app.md)). 1. Одно Next.js-приложение, Payload установлен внутрь как библиотека: админка, API и фронтенды — один процесс ([D-001](decisions/D-001-single-app.md)).
2. Proxy Next.js (`src/proxy.ts`, бывший middleware) по заголовку Host находит запись в коллекции «Домены» и узнаёт сайт (`dedicated` или `mass`) и тип фронтенда: сайт, кабинет или MiniApp. Список доменов кэшируется в памяти и сбрасывается при изменении записи. 2. Proxy Next.js (`src/proxy.ts`, бывший middleware) по заголовку Host находит домен и узнаёт сайт (`dedicated` или `mass`) и тип фронтенда: сайт, кабинет или MiniApp. Список активных доменов proxy берёт у `/svc/internal/domains` (по токену из `.env`) и держит в памяти 15 секунд: правка домена в админке применяется в пределах 15 секунд ([D-022](decisions/D-022-proxy-routing.md)).
3. Proxy переписывает запрос во внутренний маршрут вида `/{тип}/{сайт}/{язык}/…`, для маркетинговых страниц ещё и `/{валюта}/`. Снаружи эти пути недоступны: прямой запрос к ним даёт 404. 3. Proxy переписывает запрос во внутренний маршрут по хосту: `/m/{хост}/{язык}/{валюта}/…` — сайт, `/c/{хост}/{язык}/…` — кабинет, `/t/{хост}/…` — MiniApp. Снаружи эти пути недоступны: прямой запрос к ним получает ещё один префикс и попадает в 404. Решения proxy — чистая функция `src/core/routing/decide.ts` с модульными тестами.
4. Хост админки (`plc.veilix.cc`, `dev.plc.veilix.cc`) задаётся в `.env`. Только на нём работают `/admin` и REST API Payload. На доменах брендов эти пути закрыты. 4. Хост админки (`plc.veilix.cc`, `dev.plc.veilix.cc`) задаётся в `.env`. Только на нём работают `/admin` и REST API Payload. На доменах брендов эти пути закрыты.
5. Сайтам, кабинетам и MiniApp хост админки не нужен: 5. Сайтам, кабинетам и MiniApp хост админки не нужен:
a. данные Payload они читают в том же процессе через Local API — это вызов функции, а не HTTP-запрос; a. данные Payload они читают в том же процессе через Local API — это вызов функции, а не HTTP-запрос;
@@ -32,29 +35,38 @@
d. файлы медиатеки отдаются через домены сайтов. d. файлы медиатеки отдаются через домены сайтов.
6. Ядро общее (биллинг, ключи, вход, рефералы, почта), UI-кит общий, а страницы у каждого бренда свои: данные в дизайнах представлены по-разному. 6. Ядро общее (биллинг, ключи, вход, рефералы, почта), UI-кит общий, а страницы у каждого бренда свои: данные в дизайнах представлены по-разному.
## 3. Структура кода (план) ## 3. Структура кода
``` ```
src/ src/
proxy.ts — домен → сайт, тип фронтенда, язык, валюта proxy.ts — хост → домен, язык, валюта, внутренний маршрут
payload.config.ts payload.config.ts
payload-types.ts — типы Payload (pnpm generate:types)
app/ app/
(payload)/ — админка и REST API Payload (стандартные файлы) (payload)/ — админка и REST API Payload (стандартные файлы)
(front)/m/[site]/[locale]/[currency]/… — маркетинговые сайты (front)/m/[host]/[locale]/[currency]/(site)/… — маркетинговые сайты
(front)/c/[site]/[locale]/… — кабинеты и оформление заказа (front)/c/[host]/[locale]/(shell)/… — кабинет с навигацией
(front)/t/[site]/… — MiniApp (этап 3) (front)/c/[host]/[locale]/(plain)/… — вход и оформление заказа
svc/… — свои эндпоинты: ALTCHA, вебхуки, health (front)/t/[host]/… — MiniApp (этап 3)
(front)/context.ts — домен, язык и валюта страницы, данные оболочек
svc/… — свои эндпоинты: health, internal/domains; дальше ALTCHA, вебхуки
payload/ payload/
collections/ globals/ hooks/ access/ fields/ collections/ access/ fields/ hooks/ components/
core/ — бизнес-логика без привязки к UI core/ — бизнес-логика без привязки к UI
billing/ keys/ auth/ referral/ email/ payments/ currency/ geo/ sites.ts domains/ routing/ currencies/ payments/ — этап 1a
billing/ keys/ auth/ referral/ email/ geo/ — следующие этапы
brands/ brands/
dedicated/ mass/ — страницы и компоненты каждого бренда dedicated/ mass/ — различия брендов; страницы и компоненты — с этапа 1b
ui/ — общие компоненты: кнопки, поля, шиты, переключатели, флаги, карта ui/ — общие компоненты: оболочки сайта и кабинета, переключатели, тема, логотип, иконки
i18n/ — конфиг next-intl и messages/{en,ru,de,fr,es}.json i18n/ — языки, конфиг next-intl и messages/{en,ru,de,fr,es}.json
styles/ — Tailwind, токены брендов, темы styles/ — Tailwind, токены брендов
seed/ — начальные данные (pnpm seed)
ops/systemd/ — юниты systemd
scripts/ — служебные скрипты (очистка dev-базы)
``` ```
Корневые layout сайта и кабинета дают только документ: `<html>` с `lang`, `data-site` и скриптом темы. Шапка, подвал и навигация — во вложенных группах `(site)`, `(shell)` и `(plain)`.
Бизнес-логика живёт в `core/` и вызывается одинаково из хуков Payload, server actions, вебхуков, фоновых задач и (на этапе 3) бота. Бизнес-логика живёт в `core/` и вызывается одинаково из хуков Payload, server actions, вебхуков, фоновых задач и (на этапе 3) бота.
## 4. Окружения ## 4. Окружения
@@ -66,7 +78,7 @@ src/
| Запуск | `pnpm dev` (горячая перезагрузка) | `pnpm start` из `current` после `pnpm build` | | Запуск | `pnpm dev` (горячая перезагрузка) | `pnpm start` из `current` после `pnpm build` |
| Порт | 3100 | 3000 | | Порт | 3100 | 3000 |
| База | `veilix_dev` | `veilix_prod` | | База | `veilix_dev` | `veilix_prod` |
| Схема базы | синхронизируется автоматически (push) | миграции Payload, применяются при выкладке | | Схема базы | синхронизируется автоматически (push); если push задаёт вопросы — `pnpm db:reset` и `pnpm seed` | миграции Payload, применяются при выкладке |
| Домены | `dev.*`, `dev.plc.veilix.cc` | основные, `plc.veilix.cc` | | Домены | `dev.*`, `dev.plc.veilix.cc` | основные, `plc.veilix.cc` |
| Секреты | `/home/vx_dev/veilix_dev/.env` | `/home/vx_prod/veilix/shared/.env` | | Секреты | `/home/vx_dev/veilix_dev/.env` | `/home/vx_prod/veilix/shared/.env` |
| Файлы (media) | `/home/vx_dev/veilix_dev/media` | `/home/vx_prod/veilix/shared/media` | | Файлы (media) | `/home/vx_dev/veilix_dev/media` | `/home/vx_prod/veilix/shared/media` |
@@ -77,7 +89,7 @@ src/
## 5. Выкладка в prod ## 5. Выкладка в prod
1. В dev: изменения схемы оформляются миграцией (`payload migrate:create`), коммит, push в Gitea, тег релиза. 1. В dev: изменения схемы оформляются миграцией (`payload migrate:create`), коммит, push в Gitea, тег релиза. До первой выкладки (этап 1h) миграции не накапливаем: перед ней создаём одну начальную по итоговой схеме. Механизм проверен на этапе 1a: миграция, применённая к пустой схеме, создаёт те же таблицы, что push в dev.
2. Выкладка — команда `deploy <тег>` пользователя vx_prod, появится на этапе 1h. Запускает её пользователь; по желанию — sudo-правило, разрешающее vx_dev запускать только эту команду (Q-18). Шаги: 2. Выкладка — команда `deploy <тег>` пользователя vx_prod, появится на этапе 1h. Запускает её пользователь; по желанию — sudo-правило, разрешающее vx_dev запускать только эту команду (Q-18). Шаги:
a. взять тег из Gitea (у vx_prod ключ только на чтение) в новую папку `releases/<дата-тег>`; a. взять тег из Gitea (у vx_prod ключ только на чтение) в новую папку `releases/<дата-тег>`;
b. `pnpm install --frozen-lockfile` и `pnpm build`; ссылки на `shared/.env` и `shared/media`; b. `pnpm install --frozen-lockfile` и `pnpm build`; ссылки на `shared/.env` и `shared/media`;
@@ -89,7 +101,7 @@ src/
## 6. Рендеринг и кэширование ## 6. Рендеринг и кэширование
1. Маркетинговые страницы собираются статически во всех вариантах «язык × валюта» и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты. 1. Маркетинговые страницы собираются статически в вариантах «домен × язык × валюта» при первом запросе (ISR) и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
2. Изменение в админке (цена, статус локации, FAQ) вызывает хук Payload, и Next.js пересобирает затронутые страницы за секунды (on-demand revalidation по тегам). Запасной вариант — плановое обновление раз в 5 минут. 2. Изменение в админке (цена, статус локации, FAQ) вызывает хук Payload, и Next.js пересобирает затронутые страницы за секунды (on-demand revalidation по тегам). Запасной вариант — плановое обновление раз в 5 минут.
3. При оформлении заказа наличие и цена проверяются заново на сервере. Устаревшая страница не может продать то, чего нет. 3. При оформлении заказа наличие и цена проверяются заново на сервере. Устаревшая страница не может продать то, чего нет.
4. Кабинет рендерится на сервере (SSR) для каждого запроса, без кэша. 4. Кабинет рендерится на сервере (SSR) для каждого запроса, без кэша.
+18 -9
View File
@@ -8,6 +8,8 @@
4. Деньги — целое число в минимальных единицах (`amount`: 249900 = 2 499,00 ₽) плюс `currency` — код ISO 4217 (`RUB`, `USD`). Код проверяется по справочнику «Валюты». 4. Деньги — целое число в минимальных единицах (`amount`: 249900 = 2 499,00 ₽) плюс `currency` — код ISO 4217 (`RUB`, `USD`). Код проверяется по справочнику «Валюты».
5. Даты — `timestamptz`, в UTC. 5. Даты — `timestamptz`, в UTC.
6. Секреты в полях (конфиги WG, ссылки подписок, SMTP-пароль, id сохранённых карт) шифруются приложением (AES-256-GCM, ключ в `.env`). 6. Секреты в полях (конфиги WG, ссылки подписок, SMTP-пароль, id сохранённых карт) шифруются приложением (AES-256-GCM, ключ в `.env`).
7. Валюта везде хранится кодом ISO 4217 — и в деньгах, и в настройках (валюты домена и платёжной системы, строки цен). Код проверяется по справочнику, в админке это выпадающий список из «Валют».
8. Переводы полей Payload хранятся в таблицах `<коллекция>_l10n` (настройка адаптера `localesSuffix`). Имя по умолчанию `<коллекция>_locales` совпало бы с таблицей поля `locales` у сайтов и доменов.
## 2. Схема связей ## 2. Схема связей
@@ -48,6 +50,7 @@ erDiagram
| code | select `dedicated` \| `mass`, уникальное | бренд: по нему выбираются тема и страницы | | code | select `dedicated` \| `mass`, уникальное | бренд: по нему выбираются тема и страницы |
| name | text (L) | «Вейликс» / «Veilix» | | name | text (L) | «Вейликс» / «Veilix» |
| orderPrefix | text | префикс номеров: `VX`, `VV` | | orderPrefix | text | префикс номеров: `VX`, `VV` |
| locales | multi-select из языков конфига | языки контента сайта; en обязателен; домены показывают часть этих языков ([D-023](decisions/D-023-site-locales.md)) |
| supportEmail | email | почта поддержки (Q-08) | | supportEmail | email | почта поддержки (Q-08) |
| emailFrom | group: name, address | отправитель писем, напр. «Вейликс» <no-reply@veilix.app> | | emailFrom | group: name, address | отправитель писем, напр. «Вейликс» <no-reply@veilix.app> |
| supportBot, mainBot | text | имена ботов (этап 3) | | supportBot, mainBot | text | имена ботов (этап 3) |
@@ -60,22 +63,26 @@ erDiagram
| expiringBadgeDays | number, по умолчанию 7 | когда показывать «Истекает» | | expiringBadgeDays | number, по умолчанию 7 | когда показывать «Истекает» |
| seo | group: titleTemplate (L), description (L), ogImage | значения по умолчанию | | seo | group: titleTemplate (L), description (L), ogImage | значения по умолчанию |
На этапе 1a сделаны `code` (после создания не меняется), `name`, `orderPrefix` и `locales`. Остальные поля добавляют этапы, которые ими пользуются: SEO и контакты — 1b, отправитель писем — 1c, сроки и пресеты — 1d и 1f, рефералы — 1g, боты — 3.
### 3.2 `domains` — Домены (сайт) ### 3.2 `domains` — Домены (сайт)
| Поле | Тип | Описание | | Поле | Тип | Описание |
|---|---|---| |---|---|---|
| hostname | text, уникальное | `veilix.app`, `my.veilix.app`, … | | hostname | text, уникальное | `veilix.app`, `my.veilix.app`, … |
| app | select `marketing` \| `cabinet` \| `miniapp` | тип фронтенда | | app | select `marketing` \| `cabinet` \| `miniapp` | тип фронтенда |
| locales | multi-select из языков конфига | включённые языки | | locales | multi-select из языков конфига | включённые языки — часть языков сайта |
| defaultLocale | select | обычно `en` | | defaultLocale | select | обычно `en`; открывается без префикса в адресе |
| currencies | multi-select валют | включённые валюты | | currencies | multi-select валют | включённые валюты |
| defaultCurrency | select | валюта по умолчанию | | defaultCurrency | select | валюта по умолчанию |
| localeCurrency | array: locale, currency | валюта по умолчанию для языка: ru → RUB | | localeCurrency | array: locale, currency | валюта по умолчанию для языка: ru → RUB |
| isPrimary | checkbox | основной домен этого типа; используется в ссылках и sitemap | | isPrimary | checkbox | основной домен этого типа (у сайта один на каждый тип); используется в ссылках и sitemap |
| indexable | checkbox | разрешена ли индексация (для кабинета и MiniApp — нет) | | indexable | checkbox | разрешена ли индексация (для кабинета и MiniApp — нет) |
| umamiWebsiteId | text | этап 1h | | umamiWebsiteId | text | этап 1h |
| active | checkbox | | | active | checkbox | |
Язык и валюта по умолчанию должны входить в списки домена, язык в правилах «язык → валюта» не повторяется — это проверяется при сохранении.
### 3.3 `currencies` — Валюты ### 3.3 `currencies` — Валюты
| Поле | Тип | Описание | | Поле | Тип | Описание |
@@ -99,10 +106,10 @@ erDiagram
| icon | upload | | | icon | upload | |
| currencies | multi-select валют | в каких валютах доступна | | currencies | multi-select валют | в каких валютах доступна |
| purposes | multi-select `order` \| `topup` | оплата заказа, пополнение | | purposes | multi-select `order` \| `topup` | оплата заказа, пополнение |
| visibleToCustomers | checkbox | у `manual` выключено: ей пользуются только администраторы | | visibleToCustomers | checkbox | у `manual` всегда выключено (проверяется при сохранении): ей пользуются только администраторы |
| mode | select `test` \| `live` | | | mode | select `test` \| `live` | |
| limits | array: currency, min, max | | | limits | array: currency, min, max | |
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod` | | capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod`; объявлены в `src/core/payments/adapters.ts`, в базе не хранятся |
| active, sort | | | | active, sort | | |
Ключи доступа к провайдерам — только в `.env` (`PAYMENT_<КОД>_…`), не в базе. Ключи доступа к провайдерам — только в `.env` (`PAYMENT_<КОД>_…`), не в базе.
@@ -123,7 +130,7 @@ erDiagram
| country | relationship → countries | уникальна в пределах сайта | | country | relationship → countries | уникальна в пределах сайта |
| city | text (L) | | | city | text (L) | |
| lat, lng | number | точка города на карте | | lat, lng | number | точка города на карте |
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро» | | status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро». Чужой для бренда статус не сохранится |
| freeServers | number, необязательное | счётчик (решение о показе — этап 1b) | | freeServers | number, необязательное | счётчик (решение о показе — этап 1b) |
| visible, sort | | | | visible, sort | | |
@@ -153,13 +160,13 @@ erDiagram
### 4.1 `admins` — Администраторы ### 4.1 `admins` — Администраторы
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`. Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`. Первый администратор окружения (экран первого входа) становится owner. Последнего owner нельзя лишить роли и удалить. Роли и сайты меняет только owner, остальные правят только своё имя и пароль.
| Поле | Тип | Описание | | Поле | Тип | Описание |
|---|---|---| |---|---|---|
| name | text | | | name | text | |
| roles | multi-select `owner` \| `admin` \| `support` \| `content` | см. раздел 8 | | roles | multi-select `owner` \| `admin` \| `support` \| `content` | см. раздел 8 |
| sites | доступ к тенантам | owner и admin видят все сайты | | sites | доступ к тенантам | owner и admin видят все сайты, поэтому поле показывается только ролям support и content; на экране первого входа скрыто |
### 4.2 `customers` — Пользователи (сайт) ### 4.2 `customers` — Пользователи (сайт)
@@ -362,7 +369,9 @@ QR-коды строятся на лету из `payload`, не хранятся
| Коллекция | owner, admin | support | content | пользователь (через приложение) | | Коллекция | owner, admin | support | content | пользователь (через приложение) |
|---|---|---|---|---| |---|---|---|---|---|
| Настройки и справочники | всё | чтение | чтение | — | | Настройки и справочники | всё (удаление сайта — только owner) | чтение | чтение | — |
| admins | owner — всё, admin — только себя | только себя | только себя | — |
| media | всё | чтение | всё | файлы открыты всем: их показывают сайты |
| pages, faq, email-templates, локализация | всё | чтение | всё | — | | pages, faq, email-templates, локализация | всё | чтение | всё | — |
| customers | всё | чтение, правка заметок, блокировка | — | только свой профиль | | customers | всё | чтение, правка заметок, блокировка | — | только свой профиль |
| keys, connections | всё | всё | — | только свои, чтение | | keys, connections | всё | всё | — | только свои, чтение |
+1 -1
View File
@@ -87,7 +87,7 @@
1. Стандартная авторизация Payload: email и пароль, cookie только на хосте админки. 1. Стандартная авторизация Payload: email и пароль, cookie только на хосте админки.
2. Админка открывается только на `plc.veilix.cc` и `dev.plc.veilix.cc`; nginx пускает туда только с разрешённых IP. 2. Админка открывается только на `plc.veilix.cc` и `dev.plc.veilix.cc`; nginx пускает туда только с разрешённых IP.
3. 5 неудачных попыток — блокировка на 15 минут. 3. 5 неудачных попыток — блокировка на 15 минут.
4. Первый администратор каждого окружения создаётся на экране первого входа Payload. 4. Первый администратор каждого окружения создаётся на экране первого входа Payload и автоматически получает роль owner.
## 8. Cookies ## 8. Cookies
+3 -3
View File
@@ -20,7 +20,7 @@
## 3. URL ## 3. URL
1. Английский без префикса, остальные — с префиксом: `veilix.app/pricing`, `veilix.app/ru/pricing`, `veilix.app/de/pricing`. 1. Английский без префикса, остальные — с префиксом: `veilix.app/pricing`, `veilix.app/ru/pricing`, `veilix.app/de/pricing`. Точнее, без префикса открывается язык домена по умолчанию (`domains.defaultLocale`, сейчас везде en), а адрес с его префиксом (`/en/pricing`) перенаправляется на адрес без префикса.
2. Так же в кабинете: `my.veilix.app/keys`, `my.veilix.app/ru/keys`. 2. Так же в кабинете: `my.veilix.app/keys`, `my.veilix.app/ru/keys`.
3. MiniApp: адрес в BotFather один, поэтому язык берётся из профиля пользователя или из Telegram; префикс в URL необязателен. 3. MiniApp: адрес в BotFather один, поэтому язык берётся из профиля пользователя или из Telegram; префикс в URL необязателен.
4. Пути одинаковые для всех языков (английские): `/pricing`, `/locations`, `/support`, `/legal/terms`, `/keys`, `/billing`, `/referral`, `/order`, `/login`. 4. Пути одинаковые для всех языков (английские): `/pricing`, `/locations`, `/support`, `/legal/terms`, `/keys`, `/billing`, `/referral`, `/order`, `/login`.
@@ -29,9 +29,9 @@
## 4. Определение языка ## 4. Определение языка
1. Порядок: язык из URL → выбранный ранее (cookie `NEXT_LOCALE`, у вошедшего — язык профиля) → язык браузера (`Accept-Language`) среди включённых → страна из заголовка nginx `X-Geo-Country` (MaxMind) → `en`. 1. Порядок: язык из URL → выбранный ранее (cookie `NEXT_LOCALE`, у вошедшего — язык профиля) → язык браузера (`Accept-Language`) среди включённых → страна из заголовка nginx `X-Geo-Country` (MaxMind) → `en`.
2. Перенаправление на версию с префиксом — только при первом заходе на адрес без префикса, без cookie, и только если выбран не английский. Поисковых ботов не перенаправляем никогда: так рекомендует Google. 2. Перенаправление на версию с префиксом — с адреса без префикса и только если выбран не язык домена по умолчанию ([D-024](decisions/D-024-locale-redirect.md)). Есть cookie `NEXT_LOCALE` — ведём на язык из cookie; cookie нет — определяем по языку браузера и стране. Перенаправляются только GET и HEAD. Поисковых ботов не перенаправляем никогда: так рекомендует Google.
3. Переключатель языка ставит cookie `NEXT_LOCALE`. У вошедшего пользователя язык сохраняется ещё и в профиле: на нём уходят письма и сообщения бота. 3. Переключатель языка ставит cookie `NEXT_LOCALE`. У вошедшего пользователя язык сохраняется ещё и в профиле: на нём уходят письма и сообщения бота.
4. Соответствие стран языкам по умолчанию: RU, BY, KZ, KG → ru; DE, AT → de; FR → fr; ES и страны Латинской Америки → es; остальные → en. 4. Соответствие стран языкам по умолчанию: RU, BY, KZ, KG → ru; DE, AT → de; FR → fr; ES и испаноязычные страны Латинской Америки → es (Бразилия — en, пока нет pt-br); остальные → en. Таблица — `src/core/routing/locale.ts`.
## 5. SEO ## 5. SEO
+9 -1
View File
@@ -32,6 +32,7 @@
| `/order` | оформление заказа: шаги «Почта» и «Оплата», сводка | | `/order` | оформление заказа: шаги «Почта» и «Оплата», сводка |
| `/billing` | баланс, пополнение, карта, ближайшие списания, история | | `/billing` | баланс, пополнение, карта, ближайшие списания, история |
| `/referral` | реферальная программа | | `/referral` | реферальная программа |
| `/more` | вкладка «Ещё» на узком экране: счёт, рефералы, поддержка, тема, язык |
| `/r/[код]` | реферальная ссылка: ставит cookie и ведёт на сайт бренда | | `/r/[код]` | реферальная ссылка: ставит cookie и ведёт на сайт бренда |
Навигация по макету: на широком экране — боковая панель, на узком — плавающая панель вкладок «Главная, Ключи, Купить, Ещё». Навигация по макету: на широком экране — боковая панель, на узком — плавающая панель вкладок «Главная, Ключи, Купить, Ещё».
@@ -39,7 +40,7 @@
## 4. Темы и токены ## 4. Темы и токены
1. Tailwind v4: токены макета — CSS-переменные, подключённые в `@theme`. Бренд задаёт атрибут `data-site="dedicated|mass"` на `<html>`, тему — `data-theme="light|dark"`. Режим «Как в системе» следует `prefers-color-scheme`. 1. Tailwind v4: токены макета — CSS-переменные, подключённые в `@theme`. Бренд задаёт атрибут `data-site="dedicated|mass"` на `<html>`, тему — `data-theme="light|dark"`. Режим «Как в системе» следует `prefers-color-scheme`.
2. Переключатель темы — 3 режима, как в макете. Выбор хранится в localStorage, тема применяется скриптом в `<head>` до отрисовки. 2. Переключатель темы — 3 режима, как в макете. Выбор хранится в localStorage (ключ `vx_theme`), тема применяется скриптом в `<head>` до отрисовки. Новый посетитель видит «Как в системе». localStorage у каждого хоста свой: тема, выбранная на сайте, не переходит в кабинет.
3. Токены в OKLCH, как в макете. Ниже — для ориентира; точные значения — в `<style>` прототипов, при расхождении верен прототип. 3. Токены в OKLCH, как в макете. Ниже — для ориентира; точные значения — в `<style>` прототипов, при расхождении верен прототип.
| Токен | Вейликс светлая | Вейликс тёмная | ВейликсВПН светлая | ВейликсВПН тёмная | | Токен | Вейликс светлая | Вейликс тёмная | ВейликсВПН светлая | ВейликсВПН тёмная |
@@ -60,6 +61,8 @@
Общие для брендов: `--warn` (62% .14 62 / 78% .13 62), `--tg` (62% .15 250 / 70% .13 250), `--veil` (белый 55% / 7%), `--shadow`, цвета карты `--land`, `--land-line`, `--ocean`. Общие для брендов: `--warn` (62% .14 62 / 78% .13 62), `--tg` (62% .15 250 / 70% .13 250), `--veil` (белый 55% / 7%), `--shadow`, цвета карты `--land`, `--land-line`, `--ocean`.
В коде токены — `src/styles/tokens.css`, утилиты Tailwind называются так же (`bg-surface`, `text-ink-2`, `bg-sel`); стандартная палитра Tailwind отключена. У Вейликса `--sel` равен `--accent-soft`. Цвета карты Вейликса в `Main` светлее, чем в `Locations`; на этапе 1a взяты из `Locations`, окончательно решаем на этапе 1b.
## 5. Шрифты, иконки, логотип ## 5. Шрифты, иконки, логотип
1. Commissioner 300–600 — весь текст; Unbounded 600 — только надпись логотипа. Подключение через `next/font`: файлы скачиваются при сборке и отдаются с нашего домена, без запросов к Google Fonts. Наборы символов: latin, latin-ext, cyrillic. 1. Commissioner 300–600 — весь текст; Unbounded 600 — только надпись логотипа. Подключение через `next/font`: файлы скачиваются при сборке и отдаются с нашего домена, без запросов к Google Fonts. Наборы символов: latin, latin-ext, cyrillic.
@@ -85,6 +88,11 @@
9. Плашки способов оплаты в подвале — по активным платёжным системам сайта. 9. Плашки способов оплаты в подвале — по активным платёжным системам сайта.
10. Добавляем то, чего нет в макете: переключатель валюты, `<title>` и мета-описания, H1 на каждой странице, поддержку `prefers-reduced-motion` для анимаций. 10. Добавляем то, чего нет в макете: переключатель валюты, `<title>` и мета-описания, H1 на каждой странице, поддержку `prefers-reduced-motion` для анимаций.
11. Показывать ли на сайте количество свободных серверов (в MiniApp макет его показывает) — решаем на этапе 1b. По умолчанию — только статус. 11. Показывать ли на сайте количество свободных серверов (в MiniApp макет его показывает) — решаем на этапе 1b. По умолчанию — только статус.
12. Переключатели при 5 языках и с валютой (в макете только RU/EN, валюты нет). В шапке — выпадающие списки языка и валюты. В подвале, мобильном меню и кабинете — сегменты с кодами языков (EN RU DE FR ES) и символами валют (₽ $). В оболочке кабинета валюты нет: она появится в настройках (1f).
13. В подвале четыре документа: добавлена «Политика cookies». Плашек способов оплаты нет, пока нет видимых пользователю платёжных систем (пункт 9).
14. Кабинет mass: подписи навигации те же, что у dedicated («Список ключей», «Купить ключ», «Ключи»). Подписи макета MassApp («Подключения», «Выбрать страну», «Подписка») не используем (пункт 4).
15. Ширина надписи логотипа — по содержимому: в макете она подогнана под русское название.
16. 404. При `notFound()` Next 16 отдаёт пустую оболочку (`<html id="__next_error__">`), а страница 404 в оформлении бренда отрисовывается в браузере; статус ответа — 404. Серверную отрисовку 404 и 500 решаем на этапе 1b вместе с их оформлением: вариант — `global-not-found.js`.
## 8. Состояния, которых нет в макетах ## 8. Состояния, которых нет в макетах
+5 -4
View File
@@ -28,7 +28,7 @@ vx — личный пользователь администратора, к п
## 3. Процессы ## 3. Процессы
1. Каждый сервис — под своим пользователем ОС, как systemd user-юнит. Root один раз включает `loginctl enable-linger` для vx_dev, vx_prod и umami, чтобы юниты работали без входа в систему. 1. Каждый сервис — под своим пользователем ОС, как systemd user-юнит. Root один раз включает `loginctl enable-linger` для vx_dev, vx_prod и umami, чтобы юниты работали без входа в систему.
2. `veilix-dev.service` (vx_dev): `pnpm dev` на порту 3100 из `/home/vx_dev/veilix_dev`, перезапуск при сбое. 2. `veilix-dev.service` (vx_dev): `pnpm dev` на 127.0.0.1:3100 из `/home/vx_dev/veilix_dev`, перезапуск при сбое. Файл юнита — `ops/systemd/veilix-dev.service` в репозитории, подключён через `systemctl --user link`.
3. `veilix.service` (vx_prod): `pnpm start` на порту 3000 из `/home/vx_prod/veilix/current`, `NODE_ENV=production`, перезапуск всегда. 3. `veilix.service` (vx_prod): `pnpm start` на порту 3000 из `/home/vx_prod/veilix/current`, `NODE_ENV=production`, перезапуск всегда.
4. `umami.service` (umami): Umami на порту 3300. 4. `umami.service` (umami): Umami на порту 3300.
5. Фоновые задачи Payload выполняются внутри процессов приложения ([02-architecture.md](02-architecture.md), раздел 7). 5. Фоновые задачи Payload выполняются внутри процессов приложения ([02-architecture.md](02-architecture.md), раздел 7).
@@ -89,12 +89,12 @@ vx — личный пользователь администратора, к п
| № | Этап | Задача | Статус на 21.10.2026 | | № | Этап | Задача | Статус на 21.10.2026 |
|---|---|---|---| |---|---|---|---|
| R-00 | 0 | Переезд разработки: перенести `/home/vx/veilix_dev` в `/home/vx_dev/veilix_dev` (владелец vx_dev); SSH-ключ vx_dev для VSCode; Claude Code под vx_dev | не сделано | | R-00 | 0 | Переезд разработки: перенести `/home/vx/veilix_dev` в `/home/vx_dev/veilix_dev` (владелец vx_dev); SSH-ключ vx_dev для VSCode; Claude Code под vx_dev | сделано |
| R-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано | | R-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано: remote в репозитории нет |
| R-02 | 1a | PostgreSQL: роли `vx_dev`, `vx_prod`, `umami` и их базы (раздел 5) | сделано и проверено: vx_dev входит в `veilix_dev`, в `veilix_prod` — отказ | | R-02 | 1a | PostgreSQL: роли `vx_dev`, `vx_prod`, `umami` и их базы (раздел 5) | сделано и проверено: vx_dev входит в `veilix_dev`, в `veilix_prod` — отказ |
| R-03 | 1a | pnpm: `corepack enable pnpm` | сделано | | R-03 | 1a | pnpm: `corepack enable pnpm` | сделано |
| R-04 | 1a | Пользователи vx_dev, vx_prod, umami; `loginctl enable-linger` для них | сделано | | R-04 | 1a | Пользователи vx_dev, vx_prod, umami; `loginctl enable-linger` для них | сделано |
| R-05 | 1a | nginx и сертификаты для всех хостов (раздел 4) | работает; осталось исправить опечатку в `conf/ip_list_admin.conf` и добавить `X-Robots-Tag` для `dev.tg.*` | | R-05 | 1a | nginx и сертификаты для всех хостов (раздел 4) | сделано: опечатка исправлена, `X-Robots-Tag` на всех dev-хостах |
| R-06 | 1a | MaxMind: `geoipupdate` (ключ в `/etc/GeoIP.conf`, база в `/var/lib/GeoIP`, таймер `geoipupdate.timer`); отключить старый модуль GeoIP v1; блок `geoip2` (раздел 4, пункт 4) | сделано | | R-06 | 1a | MaxMind: `geoipupdate` (ключ в `/etc/GeoIP.conf`, база в `/var/lib/GeoIP`, таймер `geoipupdate.timer`); отключить старый модуль GeoIP v1; блок `geoip2` (раздел 4, пункт 4) | сделано |
| R-07 | 1c | Postfix, OpenDKIM, DNS-записи SPF, DKIM, DMARC — или данные SMTP-провайдера | | | R-07 | 1c | Postfix, OpenDKIM, DNS-записи SPF, DKIM, DMARC — или данные SMTP-провайдера | |
| R-08 | 1h | PostgreSQL для prod — если не сделано вместе с R-02 | сделано в R-02 | | R-08 | 1h | PostgreSQL для prod — если не сделано вместе с R-02 | сделано в R-02 |
@@ -106,3 +106,4 @@ vx — личный пользователь администратора, к п
| R-14 | 4 | Аккаунты платёжных систем (сначала тестовые), адреса вебхуков | | | R-14 | 4 | Аккаунты платёжных систем (сначала тестовые), адреса вебхуков | |
| R-15 | 1h | По желанию (Q-18): sudo-правило, разрешающее vx_dev запускать от имени vx_prod только команду выкладки | | | R-15 | 1h | По желанию (Q-18): sudo-правило, разрешающее vx_dev запускать от имени vx_prod только команду выкладки | |
| R-16 | 1h | По желанию: правило nftables — к 127.0.0.1:3000 подключаются только `www-data` (nginx) и vx_prod ([10-security.md](10-security.md), раздел 7) | | | R-16 | 1h | По желанию: правило nftables — к 127.0.0.1:3000 подключаются только `www-data` (nginx) и vx_prod ([10-security.md](10-security.md), раздел 7) | |
| R-17 | 1a | nginx dev: отдельный `location /_next/webpack-hmr` с `proxy_read_timeout 1h` в server-блоках dev-хостов и `dev.plc.veilix.cc`. Иначе WebSocket горячей перезагрузки закрывается каждые 30 секунд (`proxy_read_timeout 30` в `nginx.conf`) | |
+2
View File
@@ -17,6 +17,8 @@
2. Роли owner, admin, support, content ([03-data-model.md](03-data-model.md), раздел 10). Поле `debugCode` журнала писем видят только owner и admin. 2. Роли owner, admin, support, content ([03-data-model.md](03-data-model.md), раздел 10). Поле `debugCode` журнала писем видят только owner и admin.
3. Блокировка после 5 неудачных попыток входа. 3. Блокировка после 5 неудачных попыток входа.
4. Денежные операции, выдача и блокировка ключей, объединение аккаунтов пишутся в журнал действий. Журнал нельзя изменить. 4. Денежные операции, выдача и блокировка ключей, объединение аккаунтов пишутся в журнал действий. Журнал нельзя изменить.
5. GraphQL Payload выключен. Телеметрия Next.js и Payload выключена.
6. Служебный эндпоинт `/svc/internal/domains` отвечает только с токеном `INTERNAL_TOKEN` из `.env`, иначе 404. Cookie администратора — только по HTTPS.
## 4. Пользователи ## 4. Пользователи
+26
View File
@@ -0,0 +1,26 @@
# D-022. Proxy: внутренние маршруты по хосту, домены из внутреннего эндпоинта
Статус: принято, 21.10.2026. Уточняет [D-005](D-005-multitenancy.md), пункт 3.
## Контекст
1. Языки, валюты, основной домен и индексация заданы у домена, а не у сайта. Страница, собранная на весь сайт, не знает, на каком зеркале её открыли: у зеркала со своим набором языков переключатель, `hreflang` и `canonical` были бы чужими.
2. Proxy в Next 16 работает отдельно от кода страниц, и документация Next запрещает полагаться на общие с ним модули и глобальные переменные. Payload внутри proxy создал бы второй экземпляр поверх общего `global._payload`.
## Решение
1. Во внутреннем маршруте — хост: `/m/{хост}/{язык}/{валюта}/…` — сайт, `/c/{хост}/{язык}/…` — кабинет, `/t/{хост}/…` — MiniApp. Сайт и настройки страница берёт из записи домена.
2. Proxy получает список активных доменов у `/svc/internal/domains` (заголовок `x-vx-internal-token` с `INTERNAL_TOKEN` из `.env`) и держит его в памяти 15 секунд. Устаревший список отдаётся сразу и обновляется в фоне. Правка домена в админке применяется в пределах 15 секунд.
3. Proxy свой, а не middleware next-intl: тот не умеет брать домены из базы, класть хост и валюту в путь и учитывать `X-Geo-Country`. next-intl используется для сообщений, форматирования и ссылок. Ботов определяет библиотека `isbot`.
4. `skipProxyUrlNormalize: true` в `next.config.ts`. Без него адрес запроса в proxy строится от `localhost`, а роутер Next слушает `127.0.0.1`, и любое переписывание считается внешним.
## Рассмотренные варианты
1. Код сайта во внутреннем маршруте, как было в 02 — ломается, как только у зеркала свой набор языков или валют.
2. Local API Payload прямо в proxy — второй экземпляр Payload и пул соединений в отдельной сборке, конфликт по `global._payload`.
3. Сброс кэша proxy из хука Payload — хук срабатывает до фиксации транзакции, и proxy мог бы перечитать старые данные; при кэше в 15 секунд выигрыш не нужен.
## Последствия
1. Статических вариантов страниц столько, сколько сочетаний «домен × язык × валюта»; сейчас у сайта по одному домену каждого типа.
2. Если эндпоинт недоступен, proxy работает по последнему полученному списку, а без него отвечает 503.
+21
View File
@@ -0,0 +1,21 @@
# D-023. Языки сайта
Статус: принято, 21.10.2026. Уточняет [D-006](D-006-localization.md), пункты 4 и 5.
## Контекст
В модели данных языки были только у доменов. Но языки контента — свойство сайта: домен может показывать хоть один язык, а контент сайта нужен на всех его языках.
## Решение
1. У сайта поле `locales` — языки контента. По умолчанию все языки конфига. Английский обязателен: на него откатывается непереведённый контент.
2. Языки домена — часть языков сайта. Язык нельзя убрать у сайта, пока он включён на каком-то домене сайта.
3. `filterAvailableLocales` показывает в админке языки сайта, выбранного в переключателе; без выбранного сайта — все языки.
## Рассмотренные варианты
Языки сайта как объединение языков его доменов — без отдельного поля, но тогда контент на языке существует, только пока язык включён на домене.
## Последствия
Когда появятся pt-br и ja, их можно включить одному бренду: редакторы второго не увидят пустых языков.
+18
View File
@@ -0,0 +1,18 @@
# D-024. Перенаправление на выбранный язык
Статус: принято, 21.10.2026. Уточняет [D-007](D-007-language-detection.md), пункт 2.
## Контекст
По D-007 перенаправление шло только при первом заходе без cookie. Тогда выбравший русский при следующем заходе на `veilix.app/` видел бы английский.
## Решение
1. Без префикса открывается язык домена по умолчанию (`domains.defaultLocale`, сейчас везде en). Адрес с его префиксом (`/en/…`) перенаправляется на адрес без префикса.
2. Адрес без префикса и cookie `NEXT_LOCALE` с языком, включённым на домене, — перенаправление на этот язык, если он не язык по умолчанию.
3. Cookie нет — язык браузера среди включённых на домене, затем страна из `X-Geo-Country`, затем язык по умолчанию; перенаправление, если вышел не язык по умолчанию.
4. Cookie ставит только переключатель языка. Поисковых ботов (`isbot`) не перенаправляем никогда; перенаправляются только запросы GET и HEAD.
## Последствия
Так же по умолчанию работает next-intl. Выбор английского в переключателе тоже запоминается и отключает перенаправление по языку браузера.
+6 -3
View File
@@ -8,9 +8,9 @@
| [D-002](D-002-payload-version.md) | Payload 3, переход на 4 после стабильного релиза | принято | | [D-002](D-002-payload-version.md) | Payload 3, переход на 4 после стабильного релиза | принято |
| [D-003](D-003-hosting.md) | Без Docker: всё на хосте, процессы под systemd | принято | | [D-003](D-003-hosting.md) | Без Docker: всё на хосте, процессы под systemd | принято |
| [D-004](D-004-environments.md) | Dev и prod на одном сервере под разными пользователями | принято | | [D-004](D-004-environments.md) | Dev и prod на одном сервере под разными пользователями | принято |
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято | | [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято, уточнено в D-022 |
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято | | [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято, уточнено в D-023 |
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято | | [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято, уточнено в D-024 |
| [D-008](D-008-uuid.md) | UUID и случайные публичные коды | принято | | [D-008](D-008-uuid.md) | UUID и случайные публичные коды | принято |
| [D-009](D-009-billing-model.md) | Заказы, платежи, движения по счёту; всё через баланс | принято | | [D-009](D-009-billing-model.md) | Заказы, платежи, движения по счёту; всё через баланс | принято |
| [D-010](D-010-currencies.md) | Валюты и платёжные системы — сущности; платёжные системы привязаны к валютам | принято | | [D-010](D-010-currencies.md) | Валюты и платёжные системы — сущности; платёжные системы привязаны к валютам | принято |
@@ -25,3 +25,6 @@
| [D-019](D-019-external-services.md) | Chatwoot и Twenty CRM на отдельном VPS | принято | | [D-019](D-019-external-services.md) | Chatwoot и Twenty CRM на отдельном VPS | принято |
| [D-020](D-020-key-models.md) | Модели ключей dedicated и mass | принято | | [D-020](D-020-key-models.md) | Модели ключей dedicated и mass | принято |
| [D-021](D-021-admin-host.md) | Админка на plc.veilix.cc с доступом по IP | принято | | [D-021](D-021-admin-host.md) | Админка на plc.veilix.cc с доступом по IP | принято |
| [D-022](D-022-proxy-routing.md) | Proxy: внутренние маршруты по хосту, домены из внутреннего эндпоинта | принято |
| [D-023](D-023-site-locales.md) | Языки сайта — поле `sites.locales`, домены показывают их часть | принято |
| [D-024](D-024-locale-redirect.md) | Перенаправление на выбранный язык | принято |
+21 -1
View File
@@ -1,6 +1,6 @@
# Этап 1a. Фундамент # Этап 1a. Фундамент
Статус: не начат. Статус: готово 21.10.2026. Остался R-17 (root, nginx), см. раздел 6.
## 1. Цель ## 1. Цель
@@ -37,3 +37,23 @@ Root: R-00…R-06 ([09-infrastructure.md](../09-infrastructure.md), раздел
5. В URL админки — UUID. 5. В URL админки — UUID.
6. `dev.my.*` показывает оболочку кабинета; `/admin` на доменах брендов — 404. 6. `dev.my.*` показывает оболочку кабинета; `/admin` на доменах брендов — 404.
7. `pnpm build`, линтер и проверка типов проходят. 7. `pnpm build`, линтер и проверка типов проходят.
## 6. Итоги
Проверено curl через nginx и напрямую на 127.0.0.1:3100: пункты 2, 3, 5, 6 и 7, а также HTTPS, `lang`, `data-site`, скрипт темы в `<head>` и токены обоих брендов в CSS. Внешний вид сайтов и кабинета и админку (пункты 1 и 4) проверил пользователь в браузере, первый администратор создан.
Решения и отклонения от плана:
1. Стек: TypeScript 6.0 и ESLint 9.39 вместо последних 7 и 10 — их пока не поддерживают плагины ([02-architecture.md](../02-architecture.md), раздел 1).
2. Внутренние маршруты — по хосту, а не по коду сайта; домены proxy берёт у `/svc/internal/domains` с кэшем на 15 секунд ([D-022](../decisions/D-022-proxy-routing.md)).
3. Языки сайта — поле `sites.locales` ([D-023](../decisions/D-023-site-locales.md)); перенаправление на язык из cookie ([D-024](../decisions/D-024-locale-redirect.md)).
4. В `sites` сделаны только `code`, `name`, `orderPrefix` и `locales`; остальные поля добавят этапы, которые ими пользуются ([03-data-model.md](../03-data-model.md), раздел 3.1).
5. Таблицы переводов Payload — с суффиксом `_l10n`; для вопросов push в dev — `pnpm db:reset`.
6. Миграции: механизм проверен на пустой схеме; начальную миграцию создаём перед первой выкладкой (1h).
7. Mass в USD — временные цены $6.99 и $69.90 до решения Q-02.
8. Адаптация макетов: переключатели для 5 языков и валюты, «Политика cookies» в подвале, подписи кабинета mass как у dedicated ([07-frontend.md](../07-frontend.md), раздел 7).
Осталось:
1. R-17 (root): `location /_next/webpack-hmr` с большим `proxy_read_timeout` в nginx dev — иначе горячая перезагрузка переподключается каждые 30 секунд.
2. На этап 1b: серверная отрисовка 404 и 500 ([07-frontend.md](../07-frontend.md), раздел 7.16), цвета карты, пересборка страниц по событию из Payload.
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import nextTs from 'eslint-config-next/typescript'
import prettier from 'eslint-config-prettier/flat'
export default defineConfig([
...nextVitals,
...nextTs,
prettier,
{
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
},
},
globalIgnores([
'.next/**',
'design/**',
'media/**',
'next-env.d.ts',
'src/payload-types.ts',
'src/migrations/**',
'src/app/(payload)/admin/importMap.js',
]),
])
+31
View File
@@ -0,0 +1,31 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
const nextConfig: NextConfig = {
// dev открывается через nginx на доменах dev.*, без этого Next не отдаёт им ресурсы dev-сервера
allowedDevOrigins: (process.env.DEV_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean),
poweredByHeader: false,
// proxy получает адрес запроса таким, каким его видит роутер Next. Иначе адрес строится
// от localhost, а роутер слушает 127.0.0.1, и любое переписывание считается внешним
skipProxyUrlNormalize: true,
turbopack: {
root: dirname,
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
}
export default withPayload(withNextIntl(nextConfig), { devBundleServerPackages: false })
+18
View File
@@ -0,0 +1,18 @@
# dev-окружение Veilix: systemd user-юнит пользователя vx_dev.
# Подключение: systemctl --user link ~/veilix_dev/ops/systemd/veilix-dev.service
# systemctl --user enable --now veilix-dev
# Логи: journalctl --user -u veilix-dev -f
[Unit]
Description=Veilix dev (Next.js + Payload, 127.0.0.1:3100)
[Service]
Type=simple
WorkingDirectory=/home/vx_dev/veilix_dev
ExecStart=/usr/bin/pnpm dev
Environment=NEXT_TELEMETRY_DISABLED=1
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
+56
View File
@@ -0,0 +1,56 @@
{
"name": "veilix",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@12.14.0",
"engines": {
"node": ">=24.15"
},
"scripts": {
"dev": "NODE_OPTIONS=--no-deprecation next dev -H 127.0.0.1 -p 3100",
"build": "NODE_OPTIONS='--no-deprecation --max-old-space-size=4096' next build",
"start": "NODE_OPTIONS=--no-deprecation next start -H 127.0.0.1 -p ${PORT:-3000}",
"lint": "eslint .",
"typecheck": "next typegen && tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"payload": "NODE_OPTIONS=--no-deprecation payload",
"generate:types": "NODE_OPTIONS=--no-deprecation payload generate:types",
"generate:importmap": "NODE_OPTIONS=--no-deprecation payload generate:importmap",
"seed": "NODE_OPTIONS=--no-deprecation payload run src/seed/index.ts",
"db:reset": "set -a && . ./.env && set +a && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -q -f scripts/reset-dev-db.sql"
},
"dependencies": {
"@payloadcms/db-postgres": "3.91.0",
"@payloadcms/next": "3.91.0",
"@payloadcms/plugin-multi-tenant": "3.91.0",
"@payloadcms/richtext-lexical": "3.91.0",
"@payloadcms/translations": "3.91.0",
"@payloadcms/ui": "3.91.0",
"graphql": "16.14.2",
"isbot": "5.2.2",
"next": "16.4.2",
"next-intl": "4.14.9",
"payload": "3.91.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"sharp": "0.35.5"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "24.19.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"eslint": "9.39.5",
"eslint-config-next": "16.4.2",
"eslint-config-prettier": "10.1.8",
"postcss": "8.5.31",
"prettier": "3.9.10",
"prettier-plugin-tailwindcss": "0.8.1",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"vitest": "5.0.5"
}
}
+9178
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': false
'@swc/core': false
esbuild: true
unrs-resolver: true
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" xmlns:c2pa="http://c2pa.org/manifest">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6137a6"></stop><stop offset="1" stop-color="#9c66b6"></stop>
</linearGradient></defs>
<rect x="1" y="1" width="24" height="24" rx="8" fill="url(#g)"></rect>
<rect x="7" y="7" width="24" height="24" rx="8" fill="#d9c4ea"></rect>
<rect x="7.7" y="7.7" width="22.6" height="22.6" rx="7.3" fill="none" stroke="#6137a6" stroke-opacity="0.55" stroke-width="1.4"></rect>
</svg>

After

Width:  |  Height:  |  Size: 571 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" xmlns:c2pa="http://c2pa.org/manifest">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00705e"></stop><stop offset="1" stop-color="#419883"></stop>
</linearGradient></defs>
<rect x="1" y="1" width="24" height="24" rx="8" fill="url(#g)"></rect>
<rect x="7" y="7" width="24" height="24" rx="8" fill="#a9dfd2"></rect>
<rect x="7.7" y="7.7" width="22.6" height="22.6" rx="7.3" fill="none" stroke="#00705e" stroke-opacity="0.55" stroke-width="1.4"></rect>
</svg>

After

Width:  |  Height:  |  Size: 571 B

+18
View File
@@ -0,0 +1,18 @@
-- Очистка dev-базы: удаляет все таблицы и перечисления схемы public.
-- Нужна, когда push схемы в dev требует ответа на вопросы (переименования, удаления):
-- без терминала его некому дать. Потом — перезапуск dev-сервера и `pnpm seed`.
SET client_min_messages = warning;
DO $$
DECLARE r record;
BEGIN
IF current_database() <> 'veilix_dev' THEN
RAISE EXCEPTION 'reset-dev-db: only for veilix_dev, connected to %', current_database();
END IF;
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
END LOOP;
FOR r IN SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typtype = 'e' LOOP
EXECUTE format('DROP TYPE IF EXISTS public.%I CASCADE', r.typname);
END LOOP;
END $$;
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { cabinetShell } from '@/app/(front)/context'
import { Plain } from '@/ui/cabinet/Plain'
type Props = { children: ReactNode; params: Promise<{ host: string; locale: string }> }
/** Вход и оформление заказа: без навигации кабинета */
export default async function PlainLayout({ children, params }: Props) {
const { host, locale } = await params
return <Plain shell={await cabinetShell(host, locale)}>{children}</Plain>
}
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.login', 'card')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.order', 'card')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,6 @@
import { notFound } from 'next/navigation'
/** Любой другой путь кабинета — 404 с навигацией кабинета */
export default function UnknownPage() {
notFound()
}
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.billing')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.buy')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.key')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.keys')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { cabinetShell } from '@/app/(front)/context'
import { Shell } from '@/ui/cabinet/Shell'
type Props = { children: ReactNode; params: Promise<{ host: string; locale: string }> }
/** Разделы кабинета с навигацией */
export default async function ShellLayout({ children, params }: Props) {
const { host, locale } = await params
return <Shell shell={await cabinetShell(host, locale)}>{children}</Shell>
}
@@ -0,0 +1,70 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { cabinetShell } from '@/app/(front)/context'
import { localizedPath } from '@/core/routing/paths'
import { LanguageSegments } from '@/ui/switchers/LanguageSwitch'
import { ThemeSwitch } from '@/ui/theme/ThemeSwitch'
type Props = { params: Promise<{ host: string; locale: string }> }
const rowClass =
'flex min-h-14 items-center justify-between gap-3 rounded-2xl border border-border bg-surface px-[18px] py-3.5 text-base text-ink hover:text-ink'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { host, locale } = await params
const shell = await cabinetShell(host, locale)
const t = await getTranslations({ locale: shell.locale })
return { title: t('cabinet.more') }
}
/** Вкладка «Ещё» на узком экране: остальные разделы и настройки (App.dc.html) */
export default async function MorePage({ params }: Props) {
const { host, locale } = await params
const shell = await cabinetShell(host, locale)
setRequestLocale(shell.locale)
const t = await getTranslations()
const href = (path: string) => localizedPath(path, shell.locale, shell.defaultLocale)
const chevron = (
<span aria-hidden className="text-ink-3">
›
</span>
)
return (
<div className="grid gap-4">
<h1 className="text-[26px] font-semibold tracking-[-0.03em]">{t('cabinet.more')}</h1>
<Link href={href('/billing')} className={rowClass}>
<span>{t('cabinet.billing')}</span>
{chevron}
</Link>
<Link href={href('/referral')} className={rowClass}>
<span>{t('cabinet.referral')}</span>
{chevron}
</Link>
{shell.supportHref && (
<a href={shell.supportHref} className={rowClass}>
<span>{t('cabinet.support')}</span>
{chevron}
</a>
)}
<div className="grid gap-3 rounded-2xl border border-border bg-surface px-[18px] py-3.5">
<div className="flex items-center justify-between gap-3">
<div className="text-[15px]">{t('switch.theme')}</div>
<ThemeSwitch variant="more" />
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<div className="text-[15px]">{t('switch.language')}</div>
<LanguageSegments
variant="more"
locale={shell.locale}
locales={shell.locales}
defaultLocale={shell.defaultLocale}
/>
</div>
</div>
{/* «Выйти · почта» — с входом по коду, этап 1c */}
</div>
)
}
@@ -0,0 +1,5 @@
import { NotFound } from '@/ui/NotFound'
export default function CabinetNotFound() {
return <NotFound size="cabinet" />
}
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.home')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.referral')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,65 @@
import '@/styles/globals.css'
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'
import type { ReactNode } from 'react'
import { cabinetContext } from '@/app/(front)/context'
import { brands } from '@/brands'
import { pick } from '@/i18n/pick'
import { fontVariables } from '@/ui/fonts'
import { THEME_SCRIPT } from '@/ui/theme/script'
import { ThemeWatcher } from '@/ui/theme/ThemeWatcher'
type Params = Promise<{ host: string; locale: string }>
// кабинет рендерится на сервере для каждого запроса, без кэша (D-017)
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
const t = await getTranslations({ locale: context.locale })
const name = t(`brand.${context.domain.site}.name`)
return {
title: { default: name, template: `%s · ${name}` },
icons: { icon: brands[context.domain.site].favicon },
// кабинет не индексируется
robots: { index: false, follow: false },
}
}
/** Корневой layout кабинета: бренд — data-site, тема — data-theme */
export default async function CabinetLayout({
children,
params,
}: {
children: ReactNode
params: Params
}) {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
setRequestLocale(context.locale)
const messages = await getMessages()
return (
<html
lang={context.locale}
data-site={context.domain.site}
className={fontVariables}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
{/* расширения браузера (ColorZilla, Grammarly) дописывают атрибуты в <body> до гидратации */}
<body suppressHydrationWarning>
<NextIntlClientProvider messages={pick(messages, ['nav', 'switch'])}>
<ThemeWatcher />
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { notFound } from 'next/navigation'
import { cache } from 'react'
import { listCurrencies } from '@/core/currencies/server'
import { getDomain, getPrimaryDomain } from '@/core/domains/server'
import type { DomainConfig } from '@/core/domains/types'
import { localizedPath } from '@/core/routing/paths'
import { isLocale, type Locale } from '@/i18n/locales'
import type { CabinetShell } from '@/ui/cabinet/types'
import type { MarketingShell } from '@/ui/marketing/types'
/** Адрес страницы на другом домене бренда; язык — текущий, если он там включён */
export const urlOn = (domain: DomainConfig | null, path: string, locale: Locale): string | null => {
if (!domain) return null
const target = domain.locales.includes(locale) ? locale : domain.defaultLocale
return `https://${domain.hostname}${localizedPath(path, target, domain.defaultLocale)}`
}
/** Маркетинговая страница: /m/{хост}/{язык}/{валюта}/… Неизвестное сочетание — 404 */
export const marketingContext = cache(async (host: string, locale: string, currency: string) => {
const domain = await getDomain(host)
const code = currency.toUpperCase()
if (
!domain ||
domain.app !== 'marketing' ||
!isLocale(locale) ||
!domain.locales.includes(locale) ||
!domain.currencies.includes(code)
) {
notFound()
}
return { domain, locale, currency: code }
})
export const marketingShell = cache(
async (host: string, rawLocale: string, rawCurrency: string): Promise<MarketingShell> => {
const { domain, locale, currency } = await marketingContext(host, rawLocale, rawCurrency)
const [cabinet, currencies] = await Promise.all([
getPrimaryDomain(domain.site, 'cabinet'),
listCurrencies(domain.currencies, locale),
])
return {
site: domain.site,
locale,
locales: domain.locales,
defaultLocale: domain.defaultLocale,
currency,
currencies,
loginHref: urlOn(cabinet, '/login', locale),
}
},
)
/** Страница кабинета: /c/{хост}/{язык}/… */
export const cabinetContext = cache(async (host: string, locale: string) => {
const domain = await getDomain(host)
if (
!domain ||
domain.app !== 'cabinet' ||
!isLocale(locale) ||
!domain.locales.includes(locale)
) {
notFound()
}
return { domain, locale }
})
export const cabinetShell = cache(
async (host: string, rawLocale: string): Promise<CabinetShell> => {
const { domain, locale } = await cabinetContext(host, rawLocale)
const site = await getPrimaryDomain(domain.site, 'marketing')
return {
site: domain.site,
locale,
locales: domain.locales,
defaultLocale: domain.defaultLocale,
siteHref: urlOn(site, '/', locale),
supportHref: urlOn(site, '/support', locale),
termsHref: urlOn(site, '/legal/terms', locale),
privacyHref: urlOn(site, '/legal/privacy', locale),
}
},
)
@@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'
// неизвестные адреса не кэшируем: иначе случайные пути от ботов копились бы в кэше страниц
export const dynamic = 'force-dynamic'
/** Любой другой путь сайта — 404 в оформлении бренда */
export default function UnknownPage() {
notFound()
}
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { marketingShell } from '@/app/(front)/context'
import { Footer } from '@/ui/marketing/Footer'
import { Header } from '@/ui/marketing/Header'
type Props = {
children: ReactNode
params: Promise<{ host: string; locale: string; currency: string }>
}
/** Шапка и подвал маркетингового сайта */
export default async function SiteLayout({ children, params }: Props) {
const { host, locale, currency } = await params
const shell = await marketingShell(host, locale, currency)
return (
<>
<Header shell={shell} />
<main id="top">{children}</main>
<Footer shell={shell} />
</>
)
}
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.cookies')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.privacy')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.refund')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.terms')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('nav.locations')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,5 @@
import { NotFound } from '@/ui/NotFound'
export default function SiteNotFound() {
return <NotFound size="site" />
}
@@ -0,0 +1,22 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { marketingContext } from '@/app/(front)/context'
import { Stub } from '@/ui/Stub'
type Props = { params: Promise<{ host: string; locale: string; currency: string }> }
/** Главная бренда — заглушка до этапа 1b */
export default async function HomePage({ params }: Props) {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
setRequestLocale(context.locale)
const t = await getTranslations()
const site = context.domain.site
return (
<Stub
size="site"
title={t(`brand.${site}.name`)}
text={`${t(`brand.${site}.tagline`)} ${t('stub.text')}`}
/>
)
}
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('nav.pricing')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('nav.support')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,70 @@
import '@/styles/globals.css'
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'
import type { ReactNode } from 'react'
import { marketingContext } from '@/app/(front)/context'
import { brands } from '@/brands'
import { pick } from '@/i18n/pick'
import { fontVariables } from '@/ui/fonts'
import { THEME_SCRIPT } from '@/ui/theme/script'
import { ThemeWatcher } from '@/ui/theme/ThemeWatcher'
type Params = Promise<{ host: string; locale: string; currency: string }>
// варианты «язык × валюта» собираются при первом запросе и кэшируются;
// запасное обновление — раз в 5 минут, пересборка по событию из Payload — этап 1b (D-017)
export const generateStaticParams = () => []
export const revalidate = 300
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
const t = await getTranslations({ locale: context.locale })
const name = t(`brand.${context.domain.site}.name`)
return {
title: { default: name, template: `%s · ${name}` },
icons: { icon: brands[context.domain.site].favicon },
robots: context.domain.indexable ? undefined : { index: false, follow: false },
}
}
/**
* Корневой layout маркетингового сайта: документ, бренд (data-site) и тема (data-theme).
* Шапка и подвал — в (site)/layout.tsx: внутри вложенного сегмента 404 рендерится
* на сервере вместе с ними, а у корневого layout с динамическими сегментами — нет.
*/
export default async function MarketingRootLayout({
children,
params,
}: {
children: ReactNode
params: Params
}) {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
setRequestLocale(context.locale)
const messages = await getMessages()
return (
<html
lang={context.locale}
data-site={context.domain.site}
className={fontVariables}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
{/* расширения браузера (ColorZilla, Grammarly) дописывают атрибуты в <body> до гидратации */}
<body suppressHydrationWarning>
<NextIntlClientProvider messages={pick(messages, ['nav', 'switch'])}>
<ThemeWatcher />
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from 'next'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Stub } from '@/ui/Stub'
import { cabinetContext, marketingContext } from './context'
type SiteKey =
| 'nav.pricing'
| 'nav.locations'
| 'nav.support'
| 'footer.terms'
| 'footer.privacy'
| 'footer.refund'
| 'footer.cookies'
type CabinetKey =
| 'cabinet.home'
| 'cabinet.keys'
| 'cabinet.key'
| 'cabinet.buy'
| 'cabinet.billing'
| 'cabinet.referral'
| 'cabinet.login'
| 'cabinet.order'
type SiteParams = { params: Promise<{ host: string; locale: string; currency: string }> }
type CabinetParams = { params: Promise<{ host: string; locale: string }> }
/** Заглушка страницы сайта до этапа 1b: заголовок и короткая подпись */
export const siteStub = (key: SiteKey) => ({
async generateMetadata({ params }: SiteParams): Promise<Metadata> {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
const t = await getTranslations({ locale: context.locale })
return { title: t(key) }
},
async Page({ params }: SiteParams) {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
setRequestLocale(context.locale)
const t = await getTranslations()
return <Stub size="site" title={t(key)} text={t('stub.text')} />
},
})
/** Заглушка страницы кабинета до этапов 1c–1g; card — для страниц без навигации */
export const cabinetStub = (key: CabinetKey, size: 'cabinet' | 'card' = 'cabinet') => ({
async generateMetadata({ params }: CabinetParams): Promise<Metadata> {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
const t = await getTranslations({ locale: context.locale })
return { title: t(key) }
},
async Page({ params }: CabinetParams) {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
setRequestLocale(context.locale)
const t = await getTranslations()
return <Stub size={size} title={t(key)} text={t('stub.text')} />
},
})
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page
+18
View File
@@ -0,0 +1,18 @@
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { CurrencyCodeField as CurrencyCodeField_5f6e45c2d44d3ceaa88e6ac4e2162ad8 } from '../../../payload/components/CurrencyCodeField'
import { AssignTenantFieldTrigger as AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
"/payload/components/CurrencyCodeField#CurrencyCodeField": CurrencyCodeField_5f6e45c2d44d3ceaa88e6ac4e2162ad8,
"@payloadcms/plugin-multi-tenant/client#AssignTenantFieldTrigger": AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
+24
View File
@@ -0,0 +1,24 @@
import config from '@payload-config'
import { getPayload } from 'payload'
export const dynamic = 'force-dynamic'
const TIMEOUT_MS = 3000
/** Проверка живости для выкладки и мониторинга: приложение отвечает и база доступна */
export async function GET() {
const headers = { 'Cache-Control': 'no-store' }
try {
const check = (async () => {
const payload = await getPayload({ config })
await payload.count({ collection: 'sites', overrideAccess: true })
})()
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS),
)
await Promise.race([check, timeout])
return Response.json({ status: 'ok', db: 'ok' }, { headers })
} catch {
return Response.json({ status: 'error', db: 'unavailable' }, { status: 503, headers })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { timingSafeEqual } from 'node:crypto'
import { listActiveDomains } from '@/core/domains/server'
export const dynamic = 'force-dynamic'
const isAuthorized = (request: Request) => {
const expected = process.env.INTERNAL_TOKEN
const given = request.headers.get('x-vx-internal-token')
if (!expected || !given) return false
const a = Buffer.from(given)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
/** Список активных доменов для proxy: тот не ходит в Payload сам, а берёт его здесь */
export async function GET(request: Request) {
if (!isAuthorized(request)) return new Response('Not Found', { status: 404 })
return Response.json(await listActiveDomains(), { headers: { 'Cache-Control': 'no-store' } })
}
+6
View File
@@ -0,0 +1,6 @@
import type { Brand } from '../types'
/** Вейликс: личный VPN с индивидуальным IP. Тексты — brand.dedicated в сообщениях next-intl */
export const dedicated: Brand = {
favicon: '/static/brands/dedicated/favicon.svg',
}
+8
View File
@@ -0,0 +1,8 @@
import type { SiteCode } from '@/core/sites'
import { dedicated } from './dedicated/brand'
import { mass } from './mass/brand'
import type { Brand } from './types'
/** Различия брендов; цвета — токены в src/styles/tokens.css по data-site */
export const brands: Record<SiteCode, Brand> = { dedicated, mass }
+6
View File
@@ -0,0 +1,6 @@
import type { Brand } from '../types'
/** ВейликсВПН: классический VPN. Тексты — brand.mass в сообщениях next-intl */
export const mass: Brand = {
favicon: '/static/brands/mass/favicon.svg',
}
+3
View File
@@ -0,0 +1,3 @@
export type Brand = {
favicon: string
}
+24
View File
@@ -0,0 +1,24 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import type { Locale } from '@/i18n/locales'
import type { CurrencyOption } from './types'
/** Включённые валюты из списка кодов, в порядке справочника, с названиями на языке страницы */
export const listCurrencies = async (
codes: string[],
locale: Locale,
): Promise<CurrencyOption[]> => {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'currencies',
where: { and: [{ code: { in: codes } }, { active: { equals: true } }] },
locale,
sort: 'sort',
depth: 0,
pagination: false,
overrideAccess: true,
})
return docs.map(({ code, symbol, name }) => ({ code, symbol, name }))
}
+6
View File
@@ -0,0 +1,6 @@
/** Валюта для переключателя: код ISO 4217, символ и название на языке страницы */
export type CurrencyOption = {
code: string
symbol: string
name: string
}
+54
View File
@@ -0,0 +1,54 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { cache } from 'react'
import {
type DomainApp,
type DomainConfig,
DOMAINS_TTL_MS,
type SiteCode,
toDomainConfig,
} from './types'
/** Активные домены из Payload */
export const listActiveDomains = async (): Promise<DomainConfig[]> => {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'domains',
where: { active: { equals: true } },
depth: 1,
pagination: false,
overrideAccess: true,
})
return docs.map(toDomainConfig).filter((domain) => domain !== null)
}
let cached: { at: number; domains: Promise<DomainConfig[]> } | null = null
const activeDomains = (): Promise<DomainConfig[]> => {
if (!cached || Date.now() - cached.at > DOMAINS_TTL_MS) {
const domains = listActiveDomains()
cached = { at: Date.now(), domains }
domains.catch(() => {
cached = null
})
}
return cached.domains
}
/** Домен по хосту для страниц; null — домена нет или он выключен */
export const getDomain = cache(async (hostname: string): Promise<DomainConfig | null> => {
const domains = await activeDomains()
return domains.find((domain) => domain.hostname === hostname) ?? null
})
/** Основной домен сайта нужного типа: на него ведут ссылки между сайтом и кабинетом */
export const getPrimaryDomain = cache(
async (site: SiteCode, app: DomainApp): Promise<DomainConfig | null> => {
const domains = await activeDomains()
return (
domains.find((domain) => domain.site === site && domain.app === app && domain.isPrimary) ??
null
)
},
)
+53
View File
@@ -0,0 +1,53 @@
import { type DomainApp, isSiteCode, type SiteCode } from '@/core/sites'
import { isLocale, type Locale } from '@/i18n/locales'
import type { Domain } from '@/payload-types'
export type { DomainApp, SiteCode }
/** Сколько живёт список доменов в памяти: в proxy и в процессе страниц */
export const DOMAINS_TTL_MS = 15_000
/** Всё, что proxy и страницы знают о домене. Берётся из коллекции «Домены» */
export type DomainConfig = {
hostname: string
site: SiteCode
app: DomainApp
locales: Locale[]
defaultLocale: Locale
currencies: string[]
defaultCurrency: string
/** валюта по умолчанию для языка, например ru → RUB */
localeCurrency: Partial<Record<Locale, string>>
isPrimary: boolean
indexable: boolean
}
const siteCode = (site: unknown): SiteCode | null => {
if (typeof site !== 'object' || site === null || !('code' in site)) return null
return isSiteCode(site.code) ? site.code : null
}
/** Документ домена (с depth: 1, чтобы пришёл код сайта) → конфигурация; битая запись → null */
export const toDomainConfig = (doc: Domain): DomainConfig | null => {
const site = siteCode(doc.site)
const locales = (doc.locales ?? []).filter(isLocale)
if (!site || !isLocale(doc.defaultLocale) || !locales.includes(doc.defaultLocale)) return null
const localeCurrency: Partial<Record<Locale, string>> = {}
for (const row of doc.localeCurrency ?? []) {
if (isLocale(row.locale) && row.currency) localeCurrency[row.locale] = row.currency
}
return {
hostname: doc.hostname,
site,
app: doc.app,
locales,
defaultLocale: doc.defaultLocale,
currencies: doc.currencies ?? [],
defaultCurrency: doc.defaultCurrency,
localeCurrency,
isPrimary: Boolean(doc.isPrimary),
indexable: Boolean(doc.indexable),
}
}
+22
View File
@@ -0,0 +1,22 @@
/** Что умеет провайдер, кроме приёма платежа */
export type PaymentCapability = 'recurring' | 'refunds' | 'saveMethod'
export type PaymentAdapter = {
capabilities: readonly PaymentCapability[]
}
/**
* Реализации платёжных систем. Запись в коллекции «Платёжные системы» выбирает адаптер по коду.
* Этап 4 добавит stripe, процессинги RUB и криптовалюту.
*/
export const paymentAdapters = {
// ручной платёж администратора: проводится как настоящий, провайдера нет
manual: { capabilities: [] },
} as const satisfies Record<string, PaymentAdapter>
export type PaymentAdapterCode = keyof typeof paymentAdapters
export const PAYMENT_ADAPTER_CODES = Object.keys(paymentAdapters) as PaymentAdapterCode[]
export const isPaymentAdapterCode = (value: unknown): value is PaymentAdapterCode =>
typeof value === 'string' && value in paymentAdapters
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest'
import type { DomainConfig } from '@/core/domains/types'
import { decide, pickCurrency, type RouteInput } from './decide'
const marketing: DomainConfig = {
hostname: 'dev.veilix.app',
site: 'dedicated',
app: 'marketing',
locales: ['en', 'ru', 'de', 'fr', 'es'],
defaultLocale: 'en',
currencies: ['RUB', 'USD'],
defaultCurrency: 'USD',
localeCurrency: { ru: 'RUB' },
isPrimary: true,
indexable: false,
}
const cabinet: DomainConfig = { ...marketing, hostname: 'dev.my.veilix.app', app: 'cabinet' }
const input = (overrides: Partial<RouteInput>): RouteInput => ({
method: 'GET',
pathname: '/',
search: '',
isAdminHost: false,
domain: marketing,
cookieLocale: undefined,
cookieCurrency: undefined,
acceptLanguage: null,
country: null,
isBot: false,
...overrides,
})
describe('хост админки', () => {
it('корень ведёт в админку, /admin и /api пропускаются, остальное — 404', () => {
const admin = { isAdminHost: true, domain: null }
expect(decide(input({ ...admin, pathname: '/' }))).toEqual({
type: 'redirect',
location: '/admin',
})
expect(decide(input({ ...admin, pathname: '/admin/collections/sites' }))).toEqual({
type: 'next',
})
expect(decide(input({ ...admin, pathname: '/api/sites' }))).toEqual({ type: 'next' })
expect(decide(input({ ...admin, pathname: '/pricing' }))).toEqual({ type: 'not-found' })
})
})
describe('домены брендов', () => {
it('/svc работает на любом хосте, даже неизвестном', () => {
expect(decide(input({ domain: null, pathname: '/svc/health' }))).toEqual({ type: 'next' })
})
it('неизвестный хост — 404', () => {
expect(decide(input({ domain: null, pathname: '/pricing' }))).toEqual({ type: 'not-found' })
})
it('/admin и /api закрыты', () => {
expect(decide(input({ pathname: '/admin' }))).toEqual({ type: 'not-found' })
expect(decide(input({ pathname: '/api/admins' }))).toEqual({ type: 'not-found' })
expect(decide(input({ pathname: '/administrator' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd/administrator',
})
})
it('медиатека — только чтение через файловый маршрут Payload', () => {
expect(decide(input({ pathname: '/media/logo.png' }))).toEqual({
type: 'rewrite',
pathname: '/api/media/file/logo.png',
})
expect(decide(input({ method: 'POST', pathname: '/media/logo.png' }))).toEqual({
type: 'not-found',
})
expect(decide(input({ pathname: '/media' }))).toEqual({ type: 'not-found' })
})
it('прямой запрос к внутреннему маршруту получает ещё один префикс и попадает в 404', () => {
expect(decide(input({ pathname: '/m/dev.veilix.app/ru/rub/pricing' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd/m/dev.veilix.app/ru/rub/pricing',
})
})
})
describe('язык', () => {
it('английский без префикса', () => {
expect(decide(input({ pathname: '/pricing' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd/pricing',
})
})
it('префикс языка и валюта для языка', () => {
expect(decide(input({ pathname: '/ru/pricing' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/ru/rub/pricing',
})
expect(decide(input({ pathname: '/de' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/de/usd',
})
})
it('префикс языка по умолчанию убирается', () => {
expect(decide(input({ pathname: '/en/pricing', search: '?a=1' }))).toEqual({
type: 'redirect',
location: '/pricing?a=1',
})
})
it('язык, выключенный на домене, — 404', () => {
const onlyEnRu = { ...marketing, locales: ['en' as const, 'ru' as const] }
expect(decide(input({ domain: onlyEnRu, pathname: '/de/pricing' }))).toEqual({
type: 'not-found',
})
})
it('первый заход: по языку браузера, затем по стране', () => {
expect(decide(input({ pathname: '/pricing', acceptLanguage: 'fr-FR,fr;q=0.9' }))).toEqual({
type: 'redirect',
location: '/fr/pricing',
})
expect(decide(input({ pathname: '/', country: 'KZ' }))).toEqual({
type: 'redirect',
location: '/ru',
})
expect(decide(input({ pathname: '/', acceptLanguage: 'en-GB', country: 'KZ' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
})
it('выбранный ранее язык важнее браузера; выбранный английский не перенаправляется', () => {
expect(decide(input({ cookieLocale: 'es', acceptLanguage: 'ru' }))).toEqual({
type: 'redirect',
location: '/es',
})
expect(decide(input({ cookieLocale: 'en', acceptLanguage: 'ru' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
})
it('cookie с языком, которого нет на домене, не учитывается', () => {
expect(decide(input({ cookieLocale: 'ja', acceptLanguage: 'de' }))).toEqual({
type: 'redirect',
location: '/de',
})
})
it('поисковых ботов и не-GET запросы не перенаправляем', () => {
expect(decide(input({ isBot: true, acceptLanguage: 'ru', country: 'RU' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
expect(decide(input({ method: 'POST', cookieLocale: 'ru' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
})
})
describe('валюта', () => {
it('выбор из cookie, если валюта есть на домене', () => {
expect(pickCurrency(marketing, 'ru', 'usd', false)).toBe('USD')
expect(pickCurrency(marketing, 'en', 'RUB', false)).toBe('RUB')
expect(pickCurrency(marketing, 'en', 'EUR', false)).toBe('USD')
})
it('боты видят валюту по умолчанию', () => {
expect(pickCurrency(marketing, 'en', 'RUB', true)).toBe('USD')
expect(pickCurrency(marketing, 'ru', 'USD', true)).toBe('RUB')
})
})
describe('кабинет', () => {
it('без валюты в маршруте', () => {
expect(decide(input({ domain: cabinet, pathname: '/ru/keys' }))).toEqual({
type: 'rewrite',
pathname: '/c/dev.my.veilix.app/ru/keys',
})
})
})
+121
View File
@@ -0,0 +1,121 @@
import type { DomainConfig } from '@/core/domains/types'
import { isLocale, type Locale } from '@/i18n/locales'
import { detectLocale } from './locale'
import { localizedPath } from './paths'
export type RouteInput = {
method: string
pathname: string
search: string
isAdminHost: boolean
/** домен по Host; null — неизвестный или выключенный */
domain: DomainConfig | null
cookieLocale: string | undefined
cookieCurrency: string | undefined
acceptLanguage: string | null
/** страна от nginx (MaxMind) */
country: string | null
isBot: boolean
}
export type RouteDecision =
| { type: 'next' }
| { type: 'rewrite'; pathname: string }
| { type: 'redirect'; location: string }
| { type: 'not-found' }
const startsWith = (pathname: string, segment: string) =>
pathname === `/${segment}` || pathname.startsWith(`/${segment}/`)
const isNavigation = (method: string) => method === 'GET' || method === 'HEAD'
/** Валюта страницы: выбор из cookie, если она есть на домене; иначе валюта для языка или по умолчанию */
export const pickCurrency = (
domain: DomainConfig,
locale: Locale,
cookie: string | undefined,
isBot: boolean,
): string => {
const chosen = cookie?.toUpperCase()
if (!isBot && chosen && domain.currencies.includes(chosen)) return chosen
const forLocale = domain.localeCurrency[locale]
return forLocale && domain.currencies.includes(forLocale) ? forLocale : domain.defaultCurrency
}
/**
* Что делать с запросом: пропустить, переписать во внутренний маршрут, перенаправить или 404.
* Правила — 02-architecture, раздел 2, и 06-i18n, разделы 3, 4 и 6.
*/
export const decide = (input: RouteInput): RouteDecision => {
const { domain, pathname, search } = input
// свои эндпоинты работают на любом хосте
if (startsWith(pathname, 'svc')) return { type: 'next' }
if (input.isAdminHost) {
if (pathname === '/') return { type: 'redirect', location: '/admin' }
if (startsWith(pathname, 'admin') || startsWith(pathname, 'api')) return { type: 'next' }
return { type: 'not-found' }
}
if (!domain) return { type: 'not-found' }
// админка и REST Payload — только на хосте админки
if (startsWith(pathname, 'admin') || startsWith(pathname, 'api')) return { type: 'not-found' }
// файлы медиатеки: файловый маршрут Payload, только чтение
if (startsWith(pathname, 'media')) {
const file = pathname.slice('/media/'.length)
return isNavigation(input.method) && file
? { type: 'rewrite', pathname: `/api/media/file/${file}` }
: { type: 'not-found' }
}
// MiniApp — этап 3: язык берётся из профиля, префикс необязателен
if (domain.app === 'miniapp') {
return { type: 'rewrite', pathname: `/t/${domain.hostname}${pathname}` }
}
const segments = pathname.split('/').filter(Boolean)
const prefix = isLocale(segments[0]) ? segments[0] : null
const rest = `/${(prefix ? segments.slice(1) : segments).join('/')}`
let locale: Locale = domain.defaultLocale
if (prefix) {
if (!domain.locales.includes(prefix)) return { type: 'not-found' }
// у языка по умолчанию префикса нет: /en/pricing → /pricing
if (prefix === domain.defaultLocale) return { type: 'redirect', location: rest + search }
locale = prefix
} else if (isNavigation(input.method) && !input.isBot) {
// без префикса: выбранный ранее язык, а при первом заходе — язык браузера или страны
const chosen =
isLocale(input.cookieLocale) && domain.locales.includes(input.cookieLocale)
? input.cookieLocale
: null
const target =
chosen ??
detectLocale({
acceptLanguage: input.acceptLanguage,
country: input.country,
enabled: domain.locales,
fallback: domain.defaultLocale,
})
if (target !== domain.defaultLocale) {
return {
type: 'redirect',
location: localizedPath(rest, target, domain.defaultLocale) + search,
}
}
}
const tail = rest === '/' ? '' : rest
if (domain.app === 'cabinet') {
return { type: 'rewrite', pathname: `/c/${domain.hostname}/${locale}${tail}` }
}
const currency = pickCurrency(domain, locale, input.cookieCurrency, input.isBot)
return {
type: 'rewrite',
pathname: `/m/${domain.hostname}/${locale}/${currency.toLowerCase()}${tail}`,
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import type { Locale } from '@/i18n/locales'
import { detectLocale, localeForCountry, matchAcceptLanguage, parseAcceptLanguage } from './locale'
const all: Locale[] = ['en', 'ru', 'de', 'fr', 'es']
describe('parseAcceptLanguage', () => {
it('сортирует по весу, при равном весе сохраняет порядок', () => {
expect(parseAcceptLanguage('de;q=0.5, ru-RU, en;q=0.9, fr')).toEqual([
'ru-ru',
'fr',
'en',
'de',
])
})
it('отбрасывает *, нулевой и битый вес', () => {
expect(parseAcceptLanguage('*, es;q=0, de;q=abc, fr;q=0.1')).toEqual(['fr'])
})
it('пустой заголовок — пустой список', () => {
expect(parseAcceptLanguage(null)).toEqual([])
expect(parseAcceptLanguage('')).toEqual([])
})
})
describe('matchAcceptLanguage', () => {
it('берёт основной подтег', () => {
expect(matchAcceptLanguage('es-419,es;q=0.9', all)).toBe('es')
})
it('пропускает языки, которых нет на домене', () => {
expect(matchAcceptLanguage('zh-CN, de;q=0.8', ['en', 'ru'])).toBeNull()
expect(matchAcceptLanguage('pt-BR, ru;q=0.7', all)).toBe('ru')
})
})
describe('localeForCountry', () => {
it('сопоставляет страны по 06-i18n', () => {
expect(localeForCountry('KZ', all)).toBe('ru')
expect(localeForCountry('at', all)).toBe('de')
expect(localeForCountry('MX', all)).toBe('es')
expect(localeForCountry('BR', all)).toBeNull()
expect(localeForCountry('NL', all)).toBeNull()
})
it('учитывает языки домена', () => {
expect(localeForCountry('DE', ['en', 'ru'])).toBeNull()
})
})
describe('detectLocale', () => {
it('язык браузера важнее страны', () => {
expect(
detectLocale({ acceptLanguage: 'en-US', country: 'RU', enabled: all, fallback: 'en' }),
).toBe('en')
})
it('без подходящего языка браузера — страна, затем язык по умолчанию', () => {
expect(
detectLocale({ acceptLanguage: 'zh', country: 'RU', enabled: all, fallback: 'en' }),
).toBe('ru')
expect(
detectLocale({ acceptLanguage: null, country: null, enabled: all, fallback: 'en' }),
).toBe('en')
})
})
+84
View File
@@ -0,0 +1,84 @@
import { isLocale, type Locale } from '@/i18n/locales'
/** Страна → язык по умолчанию (06-i18n, раздел 4.4). Остальные страны — язык домена по умолчанию */
const COUNTRY_LOCALE: Record<string, Locale> = {
RU: 'ru',
BY: 'ru',
KZ: 'ru',
KG: 'ru',
DE: 'de',
AT: 'de',
FR: 'fr',
ES: 'es',
// испаноязычная Латинская Америка
AR: 'es',
BO: 'es',
CL: 'es',
CO: 'es',
CR: 'es',
CU: 'es',
DO: 'es',
EC: 'es',
GT: 'es',
HN: 'es',
MX: 'es',
NI: 'es',
PA: 'es',
PE: 'es',
PR: 'es',
PY: 'es',
SV: 'es',
UY: 'es',
VE: 'es',
}
/** Языковые теги из Accept-Language в порядке убывания веса */
export const parseAcceptLanguage = (header: string | null | undefined): string[] => {
if (!header) return []
return header
.split(',')
.map((part, index) => {
const [tag = '', ...params] = part.trim().split(';')
const q = params.map((param) => param.trim()).find((param) => param.startsWith('q='))
const weight = q === undefined ? 1 : Number(q.slice(2))
return { tag: tag.trim().toLowerCase(), weight: Number.isFinite(weight) ? weight : 0, index }
})
.filter((item) => item.tag !== '' && item.tag !== '*' && item.weight > 0)
.sort((a, b) => b.weight - a.weight || a.index - b.index)
.map((item) => item.tag)
}
/** Первый язык браузера среди включённых: точное совпадение (pt-br), затем основной подтег */
export const matchAcceptLanguage = (
header: string | null | undefined,
enabled: readonly Locale[],
): Locale | null => {
for (const tag of parseAcceptLanguage(header)) {
if (isLocale(tag) && enabled.includes(tag)) return tag
const primary = tag.split('-')[0]
if (isLocale(primary) && enabled.includes(primary)) return primary
}
return null
}
export const localeForCountry = (
country: string | null | undefined,
enabled: readonly Locale[],
): Locale | null => {
const locale = country ? COUNTRY_LOCALE[country.toUpperCase()] : undefined
return locale && enabled.includes(locale) ? locale : null
}
/** Язык нового посетителя: язык браузера → страна по MaxMind → язык домена по умолчанию */
export const detectLocale = ({
acceptLanguage,
country,
enabled,
fallback,
}: {
acceptLanguage: string | null | undefined
country: string | null | undefined
enabled: readonly Locale[]
fallback: Locale
}): Locale =>
matchAcceptLanguage(acceptLanguage, enabled) ?? localeForCountry(country, enabled) ?? fallback
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { localizedPath, pagePath } from './paths'
describe('pagePath', () => {
it('внешний адрес', () => {
expect(pagePath('/')).toBe('/')
expect(pagePath('/pricing')).toBe('/pricing')
expect(pagePath('/ru')).toBe('/')
expect(pagePath('/ru/legal/terms')).toBe('/legal/terms')
})
it('внутренний маршрут сайта и кабинета', () => {
expect(pagePath('/m/dev.veilix.app/ru/rub')).toBe('/')
expect(pagePath('/m/dev.veilix.app/en/usd/pricing')).toBe('/pricing')
expect(pagePath('/c/dev.my.veilix.app/de/keys/abc')).toBe('/keys/abc')
})
it('путь, похожий на внутренний, но без языка, не трогает', () => {
expect(pagePath('/m/anything')).toBe('/m/anything')
})
})
describe('localizedPath', () => {
it('язык по умолчанию — без префикса', () => {
expect(localizedPath('/pricing', 'en', 'en')).toBe('/pricing')
expect(localizedPath('/', 'en', 'en')).toBe('/')
})
it('остальные языки — с префиксом', () => {
expect(localizedPath('/pricing', 'ru', 'en')).toBe('/ru/pricing')
expect(localizedPath('/', 'de', 'en')).toBe('/de')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { isLocale, type Locale } from '@/i18n/locales'
/**
* Сегментов во внутреннем префиксе маршрута:
* /m/{хост}/{язык}/{валюта}/… — маркетинговый сайт, /c/{хост}/{язык}/… — кабинет.
*/
const INTERNAL_PREFIX = { m: 4, c: 3 } as const
const isInternalHead = (segment: string | undefined): segment is keyof typeof INTERNAL_PREFIX =>
segment === 'm' || segment === 'c'
/**
* Путь страницы без языка: «/pricing», «/».
* Понимает и внешний адрес (/ru/pricing), и внутренний маршрут: при статической сборке страница
* видит внутренний путь, а в браузере — внешний, и ссылки должны совпасть.
*/
export const pagePath = (pathname: string): string => {
const segments = pathname.split('/').filter(Boolean)
const head = segments[0]
let rest: string[]
if (isInternalHead(head) && isLocale(segments[2])) {
rest = segments.slice(INTERNAL_PREFIX[head])
} else if (isLocale(head)) {
rest = segments.slice(1)
} else {
rest = segments
}
return `/${rest.join('/')}`
}
/** Адрес страницы на языке: язык домена по умолчанию — без префикса */
export const localizedPath = (path: string, locale: Locale, defaultLocale: Locale): string => {
const normalized = path.startsWith('/') ? path : `/${path}`
if (locale === defaultLocale) return normalized
return normalized === '/' ? `/${locale}` : `/${locale}${normalized}`
}
+12
View File
@@ -0,0 +1,12 @@
/** Бренды: по коду сайта выбираются тема и страницы */
export const SITE_CODES = ['dedicated', 'mass'] as const
export type SiteCode = (typeof SITE_CODES)[number]
export const isSiteCode = (value: unknown): value is SiteCode =>
typeof value === 'string' && (SITE_CODES as readonly string[]).includes(value)
/** Тип фронтенда домена */
export const DOMAIN_APPS = ['marketing', 'cabinet', 'miniapp'] as const
export type DomainApp = (typeof DOMAIN_APPS)[number]
+19
View File
@@ -0,0 +1,19 @@
/** Языки приложения: контент Payload и интерфейс next-intl. Новый язык — правка этого списка и переводы. */
export const LOCALES = ['en', 'ru', 'de', 'fr', 'es'] as const
export type Locale = (typeof LOCALES)[number]
/** Язык по умолчанию и язык отката для непереведённого контента */
export const DEFAULT_LOCALE: Locale = 'en'
/** Самоназвания языков: так они показываются в переключателях и в админке */
export const LOCALE_NAMES: Record<Locale, string> = {
en: 'English',
ru: 'Русский',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
}
export const isLocale = (value: unknown): value is Locale =>
typeof value === 'string' && (LOCALES as readonly string[]).includes(value)
+1
View File
@@ -0,0 +1 @@
{}
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"dedicated": {
"name": "Veilix",
"tagline": "Personal VPN servers with a dedicated IP. Running since 2022."
},
"mass": {
"name": "VeilixVPN",
"tagline": "A VPN you never have to switch off. Running since 2022."
}
},
"nav": {
"main": "Main navigation",
"home": "Home",
"pricing": "Pricing",
"locations": "Locations",
"support": "Support",
"logIn": "Log in",
"getVpn": "Get a VPN",
"menu": "Menu"
},
"footer": {
"service": "Service",
"legal": "Legal",
"terms": "Terms of use",
"privacy": "Privacy policy",
"refund": "Refunds",
"cookies": "Cookie policy",
"copyright": "© {year} {brand}"
},
"switch": {
"language": "Language",
"currency": "Currency",
"theme": "Theme",
"light": "Light theme",
"dark": "Dark theme",
"system": "Match the system"
},
"cabinet": {
"home": "Overview",
"keys": "All keys",
"keysShort": "Keys",
"key": "Key",
"buy": "Buy a key",
"buyShort": "Buy",
"billing": "Billing",
"referral": "Referral program",
"more": "More",
"support": "Support",
"login": "Sign in",
"order": "Checkout",
"terms": "Terms",
"privacy": "Privacy"
},
"stub": {
"text": "This section is coming in the next stages."
},
"notFound": {
"title": "Page not found",
"text": "The page may have moved or never existed.",
"home": "Go to the home page"
}
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"dedicated": {
"name": "Вейликс",
"tagline": "Персональные VPN-серверы с индивидуальным IP. Работаем с 2022 года."
},
"mass": {
"name": "ВейликсВПН",
"tagline": "VPN, который не нужно отключать. Работаем с 2022 года."
}
},
"nav": {
"main": "Основная навигация",
"home": "Главная",
"pricing": "Цены",
"locations": "Локации",
"support": "Поддержка",
"logIn": "Войти",
"getVpn": "Получить VPN",
"menu": "Меню"
},
"footer": {
"service": "Сервис",
"legal": "Документы",
"terms": "Условия использования",
"privacy": "Политика конфиденциальности",
"refund": "Возврат средств",
"cookies": "Политика cookies",
"copyright": "© {year} {brand}"
},
"switch": {
"language": "Язык",
"currency": "Валюта",
"theme": "Тема",
"light": "Светлая тема",
"dark": "Тёмная тема",
"system": "Как в системе"
},
"cabinet": {
"home": "Главная",
"keys": "Список ключей",
"keysShort": "Ключи",
"key": "Ключ",
"buy": "Купить ключ",
"buyShort": "Купить",
"billing": "Счёт и платежи",
"referral": "Реферальная программа",
"more": "Ещё",
"support": "Поддержка",
"login": "Вход в кабинет",
"order": "Оформление заказа",
"terms": "Условия",
"privacy": "Конфиденциальность"
},
"stub": {
"text": "Раздел появится на следующих этапах."
},
"notFound": {
"title": "Страница не найдена",
"text": "Возможно, она переехала или её никогда не было.",
"home": "На главную"
}
}
+3
View File
@@ -0,0 +1,3 @@
/** Часть сообщений для клиентских компонентов: в браузер уходят только нужные разделы */
export const pick = <T extends object, K extends keyof T>(messages: T, keys: K[]): Pick<T, K> =>
Object.fromEntries(keys.map((key) => [key, messages[key]])) as Pick<T, K>
+29
View File
@@ -0,0 +1,29 @@
import { hasLocale } from 'next-intl'
import { getRequestConfig } from 'next-intl/server'
import { DEFAULT_LOCALE, type Locale, LOCALES } from './locales'
type Messages = { [key: string]: string | Messages }
const load = async (locale: Locale): Promise<Messages> =>
(await import(`./messages/${locale}.json`)).default
/** Перевод накладывается на английские сообщения: непереведённый ключ показывается по-английски */
const merge = (base: Messages, override: Messages): Messages => {
const result: Messages = { ...base }
for (const [key, value] of Object.entries(override)) {
const current = result[key]
result[key] =
typeof value === 'object' && typeof current === 'object' ? merge(current, value) : value
}
return result
}
/** Язык приходит из сегмента [locale] внутреннего маршрута (setRequestLocale в layout) */
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(LOCALES, requested) ? requested : DEFAULT_LOCALE
const base = await load(DEFAULT_LOCALE)
const messages = locale === DEFAULT_LOCALE ? base : merge(base, await load(locale))
return { locale, messages }
})
+11
View File
@@ -0,0 +1,11 @@
import type messages from './messages/en.json'
import type { Locale } from './locales'
/** Ключи сообщений и языки проверяются при сборке: источник — en.json */
declare module 'next-intl' {
interface AppConfig {
Locale: Locale
Messages: typeof messages
}
}
+793
View File
@@ -0,0 +1,793 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
admins: AdminAuthOperations;
};
blocks: {};
collections: {
tariffs: Tariff;
locations: Location;
countries: Country;
tags: Tag;
media: Media;
sites: Site;
domains: Domain;
currencies: Currency;
'payment-systems': PaymentSystem;
admins: Admin;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
tariffs: TariffsSelect<false> | TariffsSelect<true>;
locations: LocationsSelect<false> | LocationsSelect<true>;
countries: CountriesSelect<false> | CountriesSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
sites: SitesSelect<false> | SitesSelect<true>;
domains: DomainsSelect<false> | DomainsSelect<true>;
currencies: CurrenciesSelect<false> | CurrenciesSelect<true>;
'payment-systems': PaymentSystemsSelect<false> | PaymentSystemsSelect<true>;
admins: AdminsSelect<false> | AdminsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
fallbackLocale:
| ('false' | 'none' | 'null')
| false
| null
| ('en' | 'ru' | 'de' | 'fr' | 'es')
| ('en' | 'ru' | 'de' | 'fr' | 'es')[];
globals: {};
globalsSelect: {};
locale: 'en' | 'ru' | 'de' | 'fr' | 'es';
widgets: {
collections: CollectionsWidget;
};
user: Admin;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface AdminAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tariffs".
*/
export interface Tariff {
id: string;
site?: (string | null) | Site;
name: string;
code: string;
/**
* dedicated: the country is chosen at purchase; mass: all tariff countries
*/
type: 'dedicated' | 'mass';
description?: string | null;
features?:
| {
text: string;
id?: string | null;
}[]
| null;
period: {
unit: 'month' | 'year';
count: number;
};
devicesLimit: number;
/**
* One row per currency. No row means the tariff is not sold in that currency.
*/
prices?:
| {
currency: string;
/**
* In minor units: 249900 = 2,499.00
*/
firstPeriodAmount: number;
/**
* In minor units: 249900 = 2,499.00
*/
renewalAmount: number;
id?: string | null;
}[]
| null;
countries?: (string | Country)[] | null;
tags?: (string | Tag)[] | null;
/**
* e.g. “best value”
*/
badge?: string | null;
isDefault?: boolean | null;
active?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sites".
*/
export interface Site {
id: string;
code: 'dedicated' | 'mass';
name: string;
orderPrefix: string;
/**
* Each domain of the site shows some of these languages.
*/
locales: ('en' | 'ru' | 'de' | 'fr' | 'es')[];
updatedAt: string;
createdAt: string;
}
/**
* Shared by both brands
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "countries".
*/
export interface Country {
id: string;
iso2: string;
name: string;
/**
* Default point on the map
*/
lat?: number | null;
lng?: number | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* For filtering tariffs and, later, automatic key provisioning
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags".
*/
export interface Tag {
id: string;
name: string;
code: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "locations".
*/
export interface Location {
id: string;
site?: (string | null) | Site;
country: string | Country;
city: string;
/**
* City point on the map
*/
lat?: number | null;
lng?: number | null;
status: 'available' | 'on_demand' | 'soon';
/**
* Optional counter. Whether to show it on the site is decided in stage 1b.
*/
freeServers?: number | null;
visible?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* A new mirror is a record here plus an nginx block and a certificate.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "domains".
*/
export interface Domain {
id: string;
site?: (string | null) | Site;
hostname: string;
app: 'marketing' | 'cabinet' | 'miniapp';
locales: ('en' | 'ru' | 'de' | 'fr' | 'es')[];
/**
* Opens without a URL prefix
*/
defaultLocale: 'en' | 'ru' | 'de' | 'fr' | 'es';
currencies: string[];
defaultCurrency: string;
/**
* Default currency for a language, e.g. ru → RUB. Other languages get the default currency.
*/
localeCurrency?:
| {
locale: 'en' | 'ru' | 'de' | 'fr' | 'es';
currency: string;
id?: string | null;
}[]
| null;
/**
* Used in links and the sitemap
*/
isPrimary?: boolean | null;
indexable?: boolean | null;
active?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "currencies".
*/
export interface Currency {
id: string;
code: string;
symbol: string;
minorUnits: number;
name: string;
active?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* Available in its currencies on every site where they are enabled. Provider keys are in .env only.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payment-systems".
*/
export interface PaymentSystem {
id: string;
name: string;
adapter: 'manual';
/**
* e.g. “Bank card”
*/
title: string;
description?: string | null;
icon?: (string | null) | Media;
currencies: string[];
purposes: ('order' | 'topup')[];
limits?:
| {
currency: string;
/**
* In minor units: 249900 = 2,499.00
*/
min: number;
/**
* In minor units: 249900 = 2,499.00
*/
max: number;
id?: string | null;
}[]
| null;
capabilities?: string | null;
visibleToCustomers?: boolean | null;
mode: 'test' | 'live';
active?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admins".
*/
export interface Admin {
id: string;
name?: string | null;
/**
* Owner and admin see all sites; support and content see only the sites below.
*/
roles?: ('owner' | 'admin' | 'support' | 'content')[] | null;
/**
* Sites available to support and content roles
*/
sites?:
| {
site: string | Site;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
resetPasswordRequestedAt?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'admins';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: string;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'tariffs';
value: string | Tariff;
} | null)
| ({
relationTo: 'locations';
value: string | Location;
} | null)
| ({
relationTo: 'countries';
value: string | Country;
} | null)
| ({
relationTo: 'tags';
value: string | Tag;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'sites';
value: string | Site;
} | null)
| ({
relationTo: 'domains';
value: string | Domain;
} | null)
| ({
relationTo: 'currencies';
value: string | Currency;
} | null)
| ({
relationTo: 'payment-systems';
value: string | PaymentSystem;
} | null)
| ({
relationTo: 'admins';
value: string | Admin;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'admins';
value: string | Admin;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'admins';
value: string | Admin;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tariffs_select".
*/
export interface TariffsSelect<T extends boolean = true> {
site?: T;
name?: T;
code?: T;
type?: T;
description?: T;
features?:
| T
| {
text?: T;
id?: T;
};
period?:
| T
| {
unit?: T;
count?: T;
};
devicesLimit?: T;
prices?:
| T
| {
currency?: T;
firstPeriodAmount?: T;
renewalAmount?: T;
id?: T;
};
countries?: T;
tags?: T;
badge?: T;
isDefault?: T;
active?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "locations_select".
*/
export interface LocationsSelect<T extends boolean = true> {
site?: T;
country?: T;
city?: T;
lat?: T;
lng?: T;
status?: T;
freeServers?: T;
visible?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "countries_select".
*/
export interface CountriesSelect<T extends boolean = true> {
iso2?: T;
name?: T;
lat?: T;
lng?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags_select".
*/
export interface TagsSelect<T extends boolean = true> {
name?: T;
code?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sites_select".
*/
export interface SitesSelect<T extends boolean = true> {
code?: T;
name?: T;
orderPrefix?: T;
locales?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "domains_select".
*/
export interface DomainsSelect<T extends boolean = true> {
site?: T;
hostname?: T;
app?: T;
locales?: T;
defaultLocale?: T;
currencies?: T;
defaultCurrency?: T;
localeCurrency?:
| T
| {
locale?: T;
currency?: T;
id?: T;
};
isPrimary?: T;
indexable?: T;
active?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "currencies_select".
*/
export interface CurrenciesSelect<T extends boolean = true> {
code?: T;
symbol?: T;
minorUnits?: T;
name?: T;
active?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payment-systems_select".
*/
export interface PaymentSystemsSelect<T extends boolean = true> {
name?: T;
adapter?: T;
title?: T;
description?: T;
icon?: T;
currencies?: T;
purposes?: T;
limits?:
| T
| {
currency?: T;
min?: T;
max?: T;
id?: T;
};
capabilities?: T;
visibleToCustomers?: T;
mode?: T;
active?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admins_select".
*/
export interface AdminsSelect<T extends boolean = true> {
name?: T;
roles?: T;
sites?:
| T
| {
site?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
resetPasswordRequestedAt?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
+97
View File
@@ -0,0 +1,97 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { en } from '@payloadcms/translations/languages/en'
import { ru } from '@payloadcms/translations/languages/ru'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildConfig } from 'payload'
import sharp from 'sharp'
import type { Config } from './payload-types'
import { hasRole } from './payload/access/roles'
import { Admins } from './payload/collections/Admins'
import { Countries } from './payload/collections/Countries'
import { Currencies } from './payload/collections/Currencies'
import { Domains } from './payload/collections/Domains'
import { Locations } from './payload/collections/Locations'
import { Media } from './payload/collections/Media'
import { PaymentSystems } from './payload/collections/PaymentSystems'
import { Sites } from './payload/collections/Sites'
import { Tags } from './payload/collections/Tags'
import { Tariffs } from './payload/collections/Tariffs'
import { localization } from './payload/localization'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const adminOrigin = process.env.ADMIN_HOST ? `https://${process.env.ADMIN_HOST}` : undefined
export default buildConfig({
admin: {
user: Admins.slug,
importMap: { baseDir: dirname },
meta: { titleSuffix: ' · Veilix' },
},
// порядок коллекций — порядок в меню админки
collections: [
Tariffs,
Locations,
Countries,
Tags,
Media,
Sites,
Domains,
Currencies,
PaymentSystems,
Admins,
],
// cookie администратора принимаются только с хоста админки
csrf: adminOrigin ? [adminOrigin] : [],
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
idType: 'uuid',
// переводы полей — в таблицах <коллекция>_l10n: имя <коллекция>_locales нужно полю locales
// у сайтов и доменов (по умолчанию оба имени совпали бы)
localesSuffix: '_l10n',
migrationDir: path.resolve(dirname, 'migrations'),
// отдельная схема — только для проверки миграций на пустой базе
...(process.env.DATABASE_SCHEMA ? { schemaName: process.env.DATABASE_SCHEMA } : {}),
}),
editor: lexicalEditor(),
graphQL: { disable: true },
i18n: {
supportedLanguages: { en, ru },
fallbackLanguage: 'en',
},
localization,
plugins: [
multiTenantPlugin<Config>({
tenantsSlug: 'sites',
tenantField: { name: 'site' },
// поле «Сайты» администратора объявлено в Admins.ts: ему нужно условие показа
tenantsArrayField: {
arrayFieldName: 'sites',
arrayTenantFieldName: 'site',
includeDefaultField: false,
},
collections: {
domains: {},
locations: {},
tariffs: {},
},
userHasAccessToAllTenants: (user) => hasRole(user, 'owner', 'admin'),
i18n: {
translations: {
en: { 'nav-tenantSelector-label': 'Site' },
ru: { 'nav-tenantSelector-label': 'Сайт' },
},
},
}),
],
secret: process.env.PAYLOAD_SECRET ?? '',
sharp,
telemetry: false,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
+34
View File
@@ -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
}
+155
View File
@@ -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,
],
}
+75
View File
@@ -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,
],
}
+130
View File
@@ -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,
],
}
+267
View File
@@ -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' },
},
],
}
+149
View File
@@ -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,
],
}
+36
View File
@@ -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', 'Описание изображения'),
},
],
}
+209
View File
@@ -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,
],
}
+123
View File
@@ -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.',
'Каждый домен сайта показывает часть этих языков.',
),
},
},
],
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more