Этап 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. Команды
Заполняется на этапе 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) | Документация | готово |
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | не начат |
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | готово |
| [1b](stages/stage-1b-marketing.md) | Маркетинговые страницы обоих брендов, контент, формы с ALTCHA, SEO, юр. тексты | не начат |
| [1c](stages/stage-1c-auth.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-09 | Часовой пояс часов поддержки (10–20 в рабочие дни) | МСК | 1b |
| 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)) |
| PostgreSQL | 18 (на хосте) | адаптер `@payloadcms/db-postgres`, `idType: 'uuid'` |
| 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 |
| Nodemailer | через `@payloadcms/email-nodemailer` | транспорты SMTP, sendmail, «только журнал» |
| ALTCHA | 3.x | капча без третьих сторон |
| grammY | 1.x | Telegram-боты, этап 3 |
| 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. Одно приложение на все домены
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. Список доменов кэшируется в памяти и сбрасывается при изменении записи.
3. Proxy переписывает запрос во внутренний маршрут вида `/{тип}/{сайт}/{язык}/…`, для маркетинговых страниц ещё и `/{валюта}/`. Снаружи эти пути недоступны: прямой запрос к ним даёт 404.
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 переписывает запрос во внутренний маршрут по хосту: `/m/{хост}/{язык}/{валюта}/…` — сайт, `/c/{хост}/{язык}/…` — кабинет, `/t/{хост}/…` — MiniApp. Снаружи эти пути недоступны: прямой запрос к ним получает ещё один префикс и попадает в 404. Решения proxy — чистая функция `src/core/routing/decide.ts` с модульными тестами.
4. Хост админки (`plc.veilix.cc`, `dev.plc.veilix.cc`) задаётся в `.env`. Только на нём работают `/admin` и REST API Payload. На доменах брендов эти пути закрыты.
5. Сайтам, кабинетам и MiniApp хост админки не нужен:
a. данные Payload они читают в том же процессе через Local API — это вызов функции, а не HTTP-запрос;
@@ -32,29 +35,38 @@
d. файлы медиатеки отдаются через домены сайтов.
6. Ядро общее (биллинг, ключи, вход, рефералы, почта), UI-кит общий, а страницы у каждого бренда свои: данные в дизайнах представлены по-разному.
## 3. Структура кода (план)
## 3. Структура кода
```
src/
proxy.ts — домен → сайт, тип фронтенда, язык, валюта
proxy.ts — хост → домен, язык, валюта, внутренний маршрут
payload.config.ts
payload-types.ts — типы Payload (pnpm generate:types)
app/
(payload)/ — админка и REST API Payload (стандартные файлы)
(front)/m/[site]/[locale]/[currency]/… — маркетинговые сайты
(front)/c/[site]/[locale]/… — кабинеты и оформление заказа
(front)/t/[site]/… — MiniApp (этап 3)
svc/… — свои эндпоинты: ALTCHA, вебхуки, health
(front)/m/[host]/[locale]/[currency]/(site)/… — маркетинговые сайты
(front)/c/[host]/[locale]/(shell)/… — кабинет с навигацией
(front)/c/[host]/[locale]/(plain)/… — вход и оформление заказа
(front)/t/[host]/… — MiniApp (этап 3)
(front)/context.ts — домен, язык и валюта страницы, данные оболочек
svc/… — свои эндпоинты: health, internal/domains; дальше ALTCHA, вебхуки
payload/
collections/ globals/ hooks/ access/ fields/
collections/ access/ fields/ hooks/ components/
core/ — бизнес-логика без привязки к UI
billing/ keys/ auth/ referral/ email/ payments/ currency/ geo/
sites.ts domains/ routing/ currencies/ payments/ — этап 1a
billing/ keys/ auth/ referral/ email/ geo/ — следующие этапы
brands/
dedicated/ mass/ — страницы и компоненты каждого бренда
ui/ — общие компоненты: кнопки, поля, шиты, переключатели, флаги, карта
i18n/ — конфиг next-intl и messages/{en,ru,de,fr,es}.json
styles/ — Tailwind, токены брендов, темы
dedicated/ mass/ — различия брендов; страницы и компоненты — с этапа 1b
ui/ — общие компоненты: оболочки сайта и кабинета, переключатели, тема, логотип, иконки
i18n/ — языки, конфиг next-intl и messages/{en,ru,de,fr,es}.json
styles/ — Tailwind, токены брендов
seed/ — начальные данные (pnpm seed)
ops/systemd/ — юниты systemd
scripts/ — служебные скрипты (очистка dev-базы)
```
Корневые layout сайта и кабинета дают только документ: `<html>` с `lang`, `data-site` и скриптом темы. Шапка, подвал и навигация — во вложенных группах `(site)`, `(shell)` и `(plain)`.
Бизнес-логика живёт в `core/` и вызывается одинаково из хуков Payload, server actions, вебхуков, фоновых задач и (на этапе 3) бота.
## 4. Окружения
@@ -66,7 +78,7 @@ src/
| Запуск | `pnpm dev` (горячая перезагрузка) | `pnpm start` из `current` после `pnpm build` |
| Порт | 3100 | 3000 |
| База | `veilix_dev` | `veilix_prod` |
| Схема базы | синхронизируется автоматически (push) | миграции Payload, применяются при выкладке |
| Схема базы | синхронизируется автоматически (push); если push задаёт вопросы — `pnpm db:reset` и `pnpm seed` | миграции Payload, применяются при выкладке |
| Домены | `dev.*`, `dev.plc.veilix.cc` | основные, `plc.veilix.cc` |
| Секреты | `/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` |
@@ -77,7 +89,7 @@ src/
## 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). Шаги:
a. взять тег из Gitea (у vx_prod ключ только на чтение) в новую папку `releases/<дата-тег>`;
b. `pnpm install --frozen-lockfile` и `pnpm build`; ссылки на `shared/.env` и `shared/media`;
@@ -89,7 +101,7 @@ src/
## 6. Рендеринг и кэширование
1. Маркетинговые страницы собираются статически во всех вариантах «язык × валюта» и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
1. Маркетинговые страницы собираются статически в вариантах «домен × язык × валюта» при первом запросе (ISR) и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
2. Изменение в админке (цена, статус локации, FAQ) вызывает хук Payload, и Next.js пересобирает затронутые страницы за секунды (on-demand revalidation по тегам). Запасной вариант — плановое обновление раз в 5 минут.
3. При оформлении заказа наличие и цена проверяются заново на сервере. Устаревшая страница не может продать то, чего нет.
4. Кабинет рендерится на сервере (SSR) для каждого запроса, без кэша.
+18 -9
View File
@@ -8,6 +8,8 @@
4. Деньги — целое число в минимальных единицах (`amount`: 249900 = 2 499,00 ₽) плюс `currency` — код ISO 4217 (`RUB`, `USD`). Код проверяется по справочнику «Валюты».
5. Даты — `timestamptz`, в UTC.
6. Секреты в полях (конфиги WG, ссылки подписок, SMTP-пароль, id сохранённых карт) шифруются приложением (AES-256-GCM, ключ в `.env`).
7. Валюта везде хранится кодом ISO 4217 — и в деньгах, и в настройках (валюты домена и платёжной системы, строки цен). Код проверяется по справочнику, в админке это выпадающий список из «Валют».
8. Переводы полей Payload хранятся в таблицах `<коллекция>_l10n` (настройка адаптера `localesSuffix`). Имя по умолчанию `<коллекция>_locales` совпало бы с таблицей поля `locales` у сайтов и доменов.
## 2. Схема связей
@@ -48,6 +50,7 @@ erDiagram
| code | select `dedicated` \| `mass`, уникальное | бренд: по нему выбираются тема и страницы |
| name | text (L) | «Вейликс» / «Veilix» |
| orderPrefix | text | префикс номеров: `VX`, `VV` |
| locales | multi-select из языков конфига | языки контента сайта; en обязателен; домены показывают часть этих языков ([D-023](decisions/D-023-site-locales.md)) |
| supportEmail | email | почта поддержки (Q-08) |
| emailFrom | group: name, address | отправитель писем, напр. «Вейликс» <no-reply@veilix.app> |
| supportBot, mainBot | text | имена ботов (этап 3) |
@@ -60,22 +63,26 @@ erDiagram
| expiringBadgeDays | number, по умолчанию 7 | когда показывать «Истекает» |
| seo | group: titleTemplate (L), description (L), ogImage | значения по умолчанию |
На этапе 1a сделаны `code` (после создания не меняется), `name`, `orderPrefix` и `locales`. Остальные поля добавляют этапы, которые ими пользуются: SEO и контакты — 1b, отправитель писем — 1c, сроки и пресеты — 1d и 1f, рефералы — 1g, боты — 3.
### 3.2 `domains` — Домены (сайт)
| Поле | Тип | Описание |
|---|---|---|
| hostname | text, уникальное | `veilix.app`, `my.veilix.app`, … |
| app | select `marketing` \| `cabinet` \| `miniapp` | тип фронтенда |
| locales | multi-select из языков конфига | включённые языки |
| defaultLocale | select | обычно `en` |
| locales | multi-select из языков конфига | включённые языки — часть языков сайта |
| defaultLocale | select | обычно `en`; открывается без префикса в адресе |
| currencies | multi-select валют | включённые валюты |
| defaultCurrency | select | валюта по умолчанию |
| localeCurrency | array: locale, currency | валюта по умолчанию для языка: ru → RUB |
| isPrimary | checkbox | основной домен этого типа; используется в ссылках и sitemap |
| isPrimary | checkbox | основной домен этого типа (у сайта один на каждый тип); используется в ссылках и sitemap |
| indexable | checkbox | разрешена ли индексация (для кабинета и MiniApp — нет) |
| umamiWebsiteId | text | этап 1h |
| active | checkbox | |
Язык и валюта по умолчанию должны входить в списки домена, язык в правилах «язык → валюта» не повторяется — это проверяется при сохранении.
### 3.3 `currencies` — Валюты
| Поле | Тип | Описание |
@@ -99,10 +106,10 @@ erDiagram
| icon | upload | |
| currencies | multi-select валют | в каких валютах доступна |
| purposes | multi-select `order` \| `topup` | оплата заказа, пополнение |
| visibleToCustomers | checkbox | у `manual` выключено: ей пользуются только администраторы |
| visibleToCustomers | checkbox | у `manual` всегда выключено (проверяется при сохранении): ей пользуются только администраторы |
| mode | select `test` \| `live` | |
| limits | array: currency, min, max | |
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod` |
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod`; объявлены в `src/core/payments/adapters.ts`, в базе не хранятся |
| active, sort | | |
Ключи доступа к провайдерам — только в `.env` (`PAYMENT_<КОД>_…`), не в базе.
@@ -123,7 +130,7 @@ erDiagram
| country | relationship → countries | уникальна в пределах сайта |
| city | text (L) | |
| lat, lng | number | точка города на карте |
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро» |
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро». Чужой для бренда статус не сохранится |
| freeServers | number, необязательное | счётчик (решение о показе — этап 1b) |
| visible, sort | | |
@@ -153,13 +160,13 @@ erDiagram
### 4.1 `admins` — Администраторы
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`.
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`. Первый администратор окружения (экран первого входа) становится owner. Последнего owner нельзя лишить роли и удалить. Роли и сайты меняет только owner, остальные правят только своё имя и пароль.
| Поле | Тип | Описание |
|---|---|---|
| name | text | |
| roles | multi-select `owner` \| `admin` \| `support` \| `content` | см. раздел 8 |
| sites | доступ к тенантам | owner и admin видят все сайты |
| sites | доступ к тенантам | owner и admin видят все сайты, поэтому поле показывается только ролям support и content; на экране первого входа скрыто |
### 4.2 `customers` — Пользователи (сайт)
@@ -362,7 +369,9 @@ QR-коды строятся на лету из `payload`, не хранятся
| Коллекция | owner, admin | support | content | пользователь (через приложение) |
|---|---|---|---|---|
| Настройки и справочники | всё | чтение | чтение | — |
| Настройки и справочники | всё (удаление сайта — только owner) | чтение | чтение | — |
| admins | owner — всё, admin — только себя | только себя | только себя | — |
| media | всё | чтение | всё | файлы открыты всем: их показывают сайты |
| pages, faq, email-templates, локализация | всё | чтение | всё | — |
| customers | всё | чтение, правка заметок, блокировка | — | только свой профиль |
| keys, connections | всё | всё | — | только свои, чтение |
+1 -1
View File
@@ -87,7 +87,7 @@
1. Стандартная авторизация Payload: email и пароль, cookie только на хосте админки.
2. Админка открывается только на `plc.veilix.cc` и `dev.plc.veilix.cc`; nginx пускает туда только с разрешённых IP.
3. 5 неудачных попыток — блокировка на 15 минут.
4. Первый администратор каждого окружения создаётся на экране первого входа Payload.
4. Первый администратор каждого окружения создаётся на экране первого входа Payload и автоматически получает роль owner.
## 8. Cookies
+3 -3
View File
@@ -20,7 +20,7 @@
## 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`.
3. MiniApp: адрес в BotFather один, поэтому язык берётся из профиля пользователя или из Telegram; префикс в URL необязателен.
4. Пути одинаковые для всех языков (английские): `/pricing`, `/locations`, `/support`, `/legal/terms`, `/keys`, `/billing`, `/referral`, `/order`, `/login`.
@@ -29,9 +29,9 @@
## 4. Определение языка
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`. У вошедшего пользователя язык сохраняется ещё и в профиле: на нём уходят письма и сообщения бота.
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
+9 -1
View File
@@ -32,6 +32,7 @@
| `/order` | оформление заказа: шаги «Почта» и «Оплата», сводка |
| `/billing` | баланс, пополнение, карта, ближайшие списания, история |
| `/referral` | реферальная программа |
| `/more` | вкладка «Ещё» на узком экране: счёт, рефералы, поддержка, тема, язык |
| `/r/[код]` | реферальная ссылка: ставит cookie и ведёт на сайт бренда |
Навигация по макету: на широком экране — боковая панель, на узком — плавающая панель вкладок «Главная, Ключи, Купить, Ещё».
@@ -39,7 +40,7 @@
## 4. Темы и токены
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>` прототипов, при расхождении верен прототип.
| Токен | Вейликс светлая | Вейликс тёмная | ВейликсВПН светлая | ВейликсВПН тёмная |
@@ -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`.
В коде токены — `src/styles/tokens.css`, утилиты Tailwind называются так же (`bg-surface`, `text-ink-2`, `bg-sel`); стандартная палитра Tailwind отключена. У Вейликса `--sel` равен `--accent-soft`. Цвета карты Вейликса в `Main` светлее, чем в `Locations`; на этапе 1a взяты из `Locations`, окончательно решаем на этапе 1b.
## 5. Шрифты, иконки, логотип
1. Commissioner 300–600 — весь текст; Unbounded 600 — только надпись логотипа. Подключение через `next/font`: файлы скачиваются при сборке и отдаются с нашего домена, без запросов к Google Fonts. Наборы символов: latin, latin-ext, cyrillic.
@@ -85,6 +88,11 @@
9. Плашки способов оплаты в подвале — по активным платёжным системам сайта.
10. Добавляем то, чего нет в макете: переключатель валюты, `<title>` и мета-описания, H1 на каждой странице, поддержку `prefers-reduced-motion` для анимаций.
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. Состояния, которых нет в макетах
+5 -4
View File
@@ -28,7 +28,7 @@ vx — личный пользователь администратора, к п
## 3. Процессы
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`, перезапуск всегда.
4. `umami.service` (umami): Umami на порту 3300.
5. Фоновые задачи Payload выполняются внутри процессов приложения ([02-architecture.md](02-architecture.md), раздел 7).
@@ -89,12 +89,12 @@ vx — личный пользователь администратора, к п
| № | Этап | Задача | Статус на 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-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано |
| 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) | не сделано: remote в репозитории нет |
| R-02 | 1a | PostgreSQL: роли `vx_dev`, `vx_prod`, `umami` и их базы (раздел 5) | сделано и проверено: vx_dev входит в `veilix_dev`, в `veilix_prod` — отказ |
| R-03 | 1a | pnpm: `corepack enable pnpm` | сделано |
| 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-07 | 1c | Postfix, OpenDKIM, DNS-записи SPF, DKIM, DMARC — или данные SMTP-провайдера | |
| R-08 | 1h | PostgreSQL для prod — если не сделано вместе с R-02 | сделано в R-02 |
@@ -106,3 +106,4 @@ vx — личный пользователь администратора, к п
| R-14 | 4 | Аккаунты платёжных систем (сначала тестовые), адреса вебхуков | |
| 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-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.
3. Блокировка после 5 неудачных попыток входа.
4. Денежные операции, выдача и блокировка ключей, объединение аккаунтов пишутся в журнал действий. Журнал нельзя изменить.
5. GraphQL Payload выключен. Телеметрия Next.js и Payload выключена.
6. Служебный эндпоинт `/svc/internal/domains` отвечает только с токеном `INTERNAL_TOKEN` из `.env`, иначе 404. Cookie администратора — только по HTTPS.
## 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-003](D-003-hosting.md) | Без Docker: всё на хосте, процессы под systemd | принято |
| [D-004](D-004-environments.md) | Dev и prod на одном сервере под разными пользователями | принято |
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято |
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято |
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято |
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято, уточнено в D-022 |
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято, уточнено в D-023 |
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято, уточнено в D-024 |
| [D-008](D-008-uuid.md) | UUID и случайные публичные коды | принято |
| [D-009](D-009-billing-model.md) | Заказы, платежи, движения по счёту; всё через баланс | принято |
| [D-010](D-010-currencies.md) | Валюты и платёжные системы — сущности; платёжные системы привязаны к валютам | принято |
@@ -25,3 +25,6 @@
| [D-019](D-019-external-services.md) | Chatwoot и Twenty CRM на отдельном VPS | принято |
| [D-020](D-020-key-models.md) | Модели ключей dedicated и mass | принято |
| [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. Фундамент
Статус: не начат.
Статус: готово 21.10.2026. Остался R-17 (root, nginx), см. раздел 6.
## 1. Цель
@@ -37,3 +37,23 @@ Root: R-00…R-06 ([09-infrastructure.md](../09-infrastructure.md), раздел
5. В URL админки — UUID.
6. `dev.my.*` показывает оболочку кабинета; `/admin` на доменах брендов — 404.
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