Этап 1a: фундамент
- Next 16.4 и Payload 3.91 в одном приложении, PostgreSQL с UUID, TypeScript 6, ESLint 9, Prettier, Vitest - Payload: администраторы с ролями, multi-tenant по сайтам, локализация en/ru/de/fr/es, интерфейс админки ru/en, языки сайта (sites.locales) - Коллекции sites, domains, currencies, countries, locations, tags, tariffs, payment-systems (manual), media и начальные данные (pnpm seed) - Proxy: хост → домен, язык и валюта, внутренние маршруты по хосту; /admin и /api закрыты на доменах брендов - Токены и темы брендов, оболочки сайта и кабинета, переключатели языка, валюты и темы, заглушки страниц - /svc/health, юнит systemd veilix-dev.service - Документация: решения D-022…D-024, команды в CLAUDE.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3400f58a5a
commit
7e331c2c9d
140 files changed
+15885
-43
No files matched your search
@@ -0,0 +1,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
@@ -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*
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": false,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"tailwindStylesheet": "./src/styles/globals.css"
|
||||||
|
}
|
||||||
@@ -35,4 +35,21 @@
|
|||||||
|
|
||||||
## 5. Команды
|
## 5. Команды
|
||||||
|
|
||||||
Заполняется на этапе 1a.
|
Из `/home/vx_dev/veilix_dev` под vx_dev. Dev-сервер работает под systemd, вручную `pnpm dev` не запускаем: порт 3100 занят.
|
||||||
|
|
||||||
|
| Команда | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `systemctl --user restart veilix-dev` | перезапустить dev-сервер (`pnpm dev`, 127.0.0.1:3100); юнит — `ops/systemd/veilix-dev.service` |
|
||||||
|
| `journalctl --user -u veilix-dev -f` | логи dev-сервера |
|
||||||
|
| `pnpm lint` | ESLint |
|
||||||
|
| `pnpm typecheck` | типы маршрутов Next и `tsc --noEmit` |
|
||||||
|
| `pnpm test` | модульные тесты (Vitest, файлы `*.test.ts`) |
|
||||||
|
| `pnpm build` | production-сборка; dev-серверу не мешает, он пишет в `.next/dev` |
|
||||||
|
| `pnpm format` | Prettier (`pnpm format:check` — только проверка) |
|
||||||
|
| `pnpm seed` | начальные данные dev; повторный запуск обновляет записи и возвращает значения seed. `SEED_ENV=prod` — данные prod |
|
||||||
|
| `pnpm generate:types` | типы Payload в `src/payload-types.ts` после изменения коллекций |
|
||||||
|
| `pnpm generate:importmap` | карта импортов админки после добавления своих компонентов |
|
||||||
|
| `pnpm db:reset` | очистить `veilix_dev` (только её), если push схемы задаёт вопросы (переименование, удаление): без терминала их некому подтвердить. Затем перезапуск dev-сервера и `pnpm seed` |
|
||||||
|
| `pnpm payload migrate:create <имя>` | миграция для prod; до первой выкладки (1h) миграции не создаём |
|
||||||
|
|
||||||
|
Проверка без nginx: `curl -A 'Mozilla/5.0' -H 'Host: dev.veilix.app' http://127.0.0.1:3100/…`. Без браузерного User-Agent curl считается ботом и не перенаправляется; заголовок `X-Geo-Country` так можно подставить, через nginx — нет.
|
||||||
+2
-2
@@ -24,7 +24,7 @@
|
|||||||
| Этап | Содержание | Статус |
|
| Этап | Содержание | Статус |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [0](stages/stage-0-docs.md) | Документация | готово |
|
| [0](stages/stage-0-docs.md) | Документация | готово |
|
||||||
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | не начат |
|
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | готово |
|
||||||
| [1b](stages/stage-1b-marketing.md) | Маркетинговые страницы обоих брендов, контент, формы с ALTCHA, SEO, юр. тексты | не начат |
|
| [1b](stages/stage-1b-marketing.md) | Маркетинговые страницы обоих брендов, контент, формы с ALTCHA, SEO, юр. тексты | не начат |
|
||||||
| [1c](stages/stage-1c-auth.md) | Вход по коду, почта, сессии | не начат |
|
| [1c](stages/stage-1c-auth.md) | Вход по коду, почта, сессии | не начат |
|
||||||
| [1d](stages/stage-1d-billing.md) | Биллинг без эквайринга, оформление заказа, продления | не начат |
|
| [1d](stages/stage-1d-billing.md) | Биллинг без эквайринга, оформление заказа, продления | не начат |
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
| № | Вопрос | Предложение | Нужно к этапу |
|
| № | Вопрос | Предложение | Нужно к этапу |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Q-02 | Цены mass в USD, суммы пополнения и реферального бонуса в USD для обоих брендов | — нужны цифры | 1b |
|
| Q-02 | Цены mass в USD, суммы пополнения и реферального бонуса в USD для обоих брендов | Нужны цифры. Пока в seed временные $6.99 и $69.90 (около $1 за 100 ₽, как у dedicated в макете) | 1b |
|
||||||
| Q-08 | Email поддержки брендов | `help@veilix.app` и `help@veilixvpn.com` (в макете были домены .cc) | 1b |
|
| Q-08 | Email поддержки брендов | `help@veilix.app` и `help@veilixvpn.com` (в макете были домены .cc) | 1b |
|
||||||
| Q-09 | Часовой пояс часов поддержки (10–20 в рабочие дни) | МСК | 1b |
|
| Q-09 | Часовой пояс часов поддержки (10–20 в рабочие дни) | МСК | 1b |
|
||||||
| Q-10 | Имена Telegram-ботов: 2 основных, 2 поддержки, тестовые | — | 3 |
|
| Q-10 | Имена Telegram-ботов: 2 основных, 2 поддержки, тестовые | — | 3 |
|
||||||
|
|||||||
+30
-18
@@ -11,19 +11,22 @@
|
|||||||
| Payload CMS | 3.91.x | Payload 4 в бете; переход после стабильного релиза ([D-002](decisions/D-002-payload-version.md)) |
|
| Payload CMS | 3.91.x | Payload 4 в бете; переход после стабильного релиза ([D-002](decisions/D-002-payload-version.md)) |
|
||||||
| PostgreSQL | 18 (на хосте) | адаптер `@payloadcms/db-postgres`, `idType: 'uuid'` |
|
| PostgreSQL | 18 (на хосте) | адаптер `@payloadcms/db-postgres`, `idType: 'uuid'` |
|
||||||
| Tailwind CSS | 4.x | токены из макета как CSS-переменные |
|
| Tailwind CSS | 4.x | токены из макета как CSS-переменные |
|
||||||
|
| TypeScript | 6.0 | не 7.0: typescript-eslint из eslint-config-next поддерживает только TS < 6.1 |
|
||||||
|
| ESLint | 9.39 | не 10: eslint-plugin-react, import и jsx-a11y не заявляют поддержку ESLint 10 |
|
||||||
|
| Vitest | 5.x | модульные тесты |
|
||||||
| next-intl | 4.x | переводы интерфейса, префиксы языков в URL |
|
| next-intl | 4.x | переводы интерфейса, префиксы языков в URL |
|
||||||
| Nodemailer | через `@payloadcms/email-nodemailer` | транспорты SMTP, sendmail, «только журнал» |
|
| Nodemailer | через `@payloadcms/email-nodemailer` | транспорты SMTP, sendmail, «только журнал» |
|
||||||
| ALTCHA | 3.x | капча без третьих сторон |
|
| ALTCHA | 3.x | капча без третьих сторон |
|
||||||
| grammY | 1.x | Telegram-боты, этап 3 |
|
| grammY | 1.x | Telegram-боты, этап 3 |
|
||||||
| Stripe SDK | актуальный | этап 4 |
|
| Stripe SDK | актуальный | этап 4 |
|
||||||
|
|
||||||
Точные версии фиксируются в `package.json` на этапе 1a. Если к старту этапа 1a выйдет стабильный Payload 4, начинаем сразу на нём.
|
Точные версии закреплены в `package.json`: на этапе 1a — Next 16.4.2, Payload 3.91.0, React 19.3.1, next-intl 4.14.9, Tailwind 4.3.3, pnpm 12.14. На старте 1a Payload 4 был в бете (4.0.0-beta.3), поэтому начали на 3.91 ([D-002](decisions/D-002-payload-version.md)). Телеметрия Next.js и Payload выключена.
|
||||||
|
|
||||||
## 2. Одно приложение на все домены
|
## 2. Одно приложение на все домены
|
||||||
|
|
||||||
1. Одно Next.js-приложение, Payload установлен внутрь как библиотека: админка, API и фронтенды — один процесс ([D-001](decisions/D-001-single-app.md)).
|
1. Одно Next.js-приложение, Payload установлен внутрь как библиотека: админка, API и фронтенды — один процесс ([D-001](decisions/D-001-single-app.md)).
|
||||||
2. Proxy Next.js (`src/proxy.ts`, бывший middleware) по заголовку Host находит запись в коллекции «Домены» и узнаёт сайт (`dedicated` или `mass`) и тип фронтенда: сайт, кабинет или MiniApp. Список доменов кэшируется в памяти и сбрасывается при изменении записи.
|
2. Proxy Next.js (`src/proxy.ts`, бывший middleware) по заголовку Host находит домен и узнаёт сайт (`dedicated` или `mass`) и тип фронтенда: сайт, кабинет или MiniApp. Список активных доменов proxy берёт у `/svc/internal/domains` (по токену из `.env`) и держит в памяти 15 секунд: правка домена в админке применяется в пределах 15 секунд ([D-022](decisions/D-022-proxy-routing.md)).
|
||||||
3. Proxy переписывает запрос во внутренний маршрут вида `/{тип}/{сайт}/{язык}/…`, для маркетинговых страниц ещё и `/{валюта}/`. Снаружи эти пути недоступны: прямой запрос к ним даёт 404.
|
3. Proxy переписывает запрос во внутренний маршрут по хосту: `/m/{хост}/{язык}/{валюта}/…` — сайт, `/c/{хост}/{язык}/…` — кабинет, `/t/{хост}/…` — MiniApp. Снаружи эти пути недоступны: прямой запрос к ним получает ещё один префикс и попадает в 404. Решения proxy — чистая функция `src/core/routing/decide.ts` с модульными тестами.
|
||||||
4. Хост админки (`plc.veilix.cc`, `dev.plc.veilix.cc`) задаётся в `.env`. Только на нём работают `/admin` и REST API Payload. На доменах брендов эти пути закрыты.
|
4. Хост админки (`plc.veilix.cc`, `dev.plc.veilix.cc`) задаётся в `.env`. Только на нём работают `/admin` и REST API Payload. На доменах брендов эти пути закрыты.
|
||||||
5. Сайтам, кабинетам и MiniApp хост админки не нужен:
|
5. Сайтам, кабинетам и MiniApp хост админки не нужен:
|
||||||
a. данные Payload они читают в том же процессе через Local API — это вызов функции, а не HTTP-запрос;
|
a. данные Payload они читают в том же процессе через Local API — это вызов функции, а не HTTP-запрос;
|
||||||
@@ -32,29 +35,38 @@
|
|||||||
d. файлы медиатеки отдаются через домены сайтов.
|
d. файлы медиатеки отдаются через домены сайтов.
|
||||||
6. Ядро общее (биллинг, ключи, вход, рефералы, почта), UI-кит общий, а страницы у каждого бренда свои: данные в дизайнах представлены по-разному.
|
6. Ядро общее (биллинг, ключи, вход, рефералы, почта), UI-кит общий, а страницы у каждого бренда свои: данные в дизайнах представлены по-разному.
|
||||||
|
|
||||||
## 3. Структура кода (план)
|
## 3. Структура кода
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
proxy.ts — домен → сайт, тип фронтенда, язык, валюта
|
proxy.ts — хост → домен, язык, валюта, внутренний маршрут
|
||||||
payload.config.ts
|
payload.config.ts
|
||||||
|
payload-types.ts — типы Payload (pnpm generate:types)
|
||||||
app/
|
app/
|
||||||
(payload)/ — админка и REST API Payload (стандартные файлы)
|
(payload)/ — админка и REST API Payload (стандартные файлы)
|
||||||
(front)/m/[site]/[locale]/[currency]/… — маркетинговые сайты
|
(front)/m/[host]/[locale]/[currency]/(site)/… — маркетинговые сайты
|
||||||
(front)/c/[site]/[locale]/… — кабинеты и оформление заказа
|
(front)/c/[host]/[locale]/(shell)/… — кабинет с навигацией
|
||||||
(front)/t/[site]/… — MiniApp (этап 3)
|
(front)/c/[host]/[locale]/(plain)/… — вход и оформление заказа
|
||||||
svc/… — свои эндпоинты: ALTCHA, вебхуки, health
|
(front)/t/[host]/… — MiniApp (этап 3)
|
||||||
|
(front)/context.ts — домен, язык и валюта страницы, данные оболочек
|
||||||
|
svc/… — свои эндпоинты: health, internal/domains; дальше ALTCHA, вебхуки
|
||||||
payload/
|
payload/
|
||||||
collections/ globals/ hooks/ access/ fields/
|
collections/ access/ fields/ hooks/ components/
|
||||||
core/ — бизнес-логика без привязки к UI
|
core/ — бизнес-логика без привязки к UI
|
||||||
billing/ keys/ auth/ referral/ email/ payments/ currency/ geo/
|
sites.ts domains/ routing/ currencies/ payments/ — этап 1a
|
||||||
|
billing/ keys/ auth/ referral/ email/ geo/ — следующие этапы
|
||||||
brands/
|
brands/
|
||||||
dedicated/ mass/ — страницы и компоненты каждого бренда
|
dedicated/ mass/ — различия брендов; страницы и компоненты — с этапа 1b
|
||||||
ui/ — общие компоненты: кнопки, поля, шиты, переключатели, флаги, карта
|
ui/ — общие компоненты: оболочки сайта и кабинета, переключатели, тема, логотип, иконки
|
||||||
i18n/ — конфиг next-intl и messages/{en,ru,de,fr,es}.json
|
i18n/ — языки, конфиг next-intl и messages/{en,ru,de,fr,es}.json
|
||||||
styles/ — Tailwind, токены брендов, темы
|
styles/ — Tailwind, токены брендов
|
||||||
|
seed/ — начальные данные (pnpm seed)
|
||||||
|
ops/systemd/ — юниты systemd
|
||||||
|
scripts/ — служебные скрипты (очистка dev-базы)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Корневые layout сайта и кабинета дают только документ: `<html>` с `lang`, `data-site` и скриптом темы. Шапка, подвал и навигация — во вложенных группах `(site)`, `(shell)` и `(plain)`.
|
||||||
|
|
||||||
Бизнес-логика живёт в `core/` и вызывается одинаково из хуков Payload, server actions, вебхуков, фоновых задач и (на этапе 3) бота.
|
Бизнес-логика живёт в `core/` и вызывается одинаково из хуков Payload, server actions, вебхуков, фоновых задач и (на этапе 3) бота.
|
||||||
|
|
||||||
## 4. Окружения
|
## 4. Окружения
|
||||||
@@ -66,7 +78,7 @@ src/
|
|||||||
| Запуск | `pnpm dev` (горячая перезагрузка) | `pnpm start` из `current` после `pnpm build` |
|
| Запуск | `pnpm dev` (горячая перезагрузка) | `pnpm start` из `current` после `pnpm build` |
|
||||||
| Порт | 3100 | 3000 |
|
| Порт | 3100 | 3000 |
|
||||||
| База | `veilix_dev` | `veilix_prod` |
|
| База | `veilix_dev` | `veilix_prod` |
|
||||||
| Схема базы | синхронизируется автоматически (push) | миграции Payload, применяются при выкладке |
|
| Схема базы | синхронизируется автоматически (push); если push задаёт вопросы — `pnpm db:reset` и `pnpm seed` | миграции Payload, применяются при выкладке |
|
||||||
| Домены | `dev.*`, `dev.plc.veilix.cc` | основные, `plc.veilix.cc` |
|
| Домены | `dev.*`, `dev.plc.veilix.cc` | основные, `plc.veilix.cc` |
|
||||||
| Секреты | `/home/vx_dev/veilix_dev/.env` | `/home/vx_prod/veilix/shared/.env` |
|
| Секреты | `/home/vx_dev/veilix_dev/.env` | `/home/vx_prod/veilix/shared/.env` |
|
||||||
| Файлы (media) | `/home/vx_dev/veilix_dev/media` | `/home/vx_prod/veilix/shared/media` |
|
| Файлы (media) | `/home/vx_dev/veilix_dev/media` | `/home/vx_prod/veilix/shared/media` |
|
||||||
@@ -77,7 +89,7 @@ src/
|
|||||||
|
|
||||||
## 5. Выкладка в prod
|
## 5. Выкладка в prod
|
||||||
|
|
||||||
1. В dev: изменения схемы оформляются миграцией (`payload migrate:create`), коммит, push в Gitea, тег релиза.
|
1. В dev: изменения схемы оформляются миграцией (`payload migrate:create`), коммит, push в Gitea, тег релиза. До первой выкладки (этап 1h) миграции не накапливаем: перед ней создаём одну начальную по итоговой схеме. Механизм проверен на этапе 1a: миграция, применённая к пустой схеме, создаёт те же таблицы, что push в dev.
|
||||||
2. Выкладка — команда `deploy <тег>` пользователя vx_prod, появится на этапе 1h. Запускает её пользователь; по желанию — sudo-правило, разрешающее vx_dev запускать только эту команду (Q-18). Шаги:
|
2. Выкладка — команда `deploy <тег>` пользователя vx_prod, появится на этапе 1h. Запускает её пользователь; по желанию — sudo-правило, разрешающее vx_dev запускать только эту команду (Q-18). Шаги:
|
||||||
a. взять тег из Gitea (у vx_prod ключ только на чтение) в новую папку `releases/<дата-тег>`;
|
a. взять тег из Gitea (у vx_prod ключ только на чтение) в новую папку `releases/<дата-тег>`;
|
||||||
b. `pnpm install --frozen-lockfile` и `pnpm build`; ссылки на `shared/.env` и `shared/media`;
|
b. `pnpm install --frozen-lockfile` и `pnpm build`; ссылки на `shared/.env` и `shared/media`;
|
||||||
@@ -89,7 +101,7 @@ src/
|
|||||||
|
|
||||||
## 6. Рендеринг и кэширование
|
## 6. Рендеринг и кэширование
|
||||||
|
|
||||||
1. Маркетинговые страницы собираются статически во всех вариантах «язык × валюта» и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
|
1. Маркетинговые страницы собираются статически в вариантах «домен × язык × валюта» при первом запросе (ISR) и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
|
||||||
2. Изменение в админке (цена, статус локации, FAQ) вызывает хук Payload, и Next.js пересобирает затронутые страницы за секунды (on-demand revalidation по тегам). Запасной вариант — плановое обновление раз в 5 минут.
|
2. Изменение в админке (цена, статус локации, FAQ) вызывает хук Payload, и Next.js пересобирает затронутые страницы за секунды (on-demand revalidation по тегам). Запасной вариант — плановое обновление раз в 5 минут.
|
||||||
3. При оформлении заказа наличие и цена проверяются заново на сервере. Устаревшая страница не может продать то, чего нет.
|
3. При оформлении заказа наличие и цена проверяются заново на сервере. Устаревшая страница не может продать то, чего нет.
|
||||||
4. Кабинет рендерится на сервере (SSR) для каждого запроса, без кэша.
|
4. Кабинет рендерится на сервере (SSR) для каждого запроса, без кэша.
|
||||||
|
|||||||
+18
-9
@@ -8,6 +8,8 @@
|
|||||||
4. Деньги — целое число в минимальных единицах (`amount`: 249900 = 2 499,00 ₽) плюс `currency` — код ISO 4217 (`RUB`, `USD`). Код проверяется по справочнику «Валюты».
|
4. Деньги — целое число в минимальных единицах (`amount`: 249900 = 2 499,00 ₽) плюс `currency` — код ISO 4217 (`RUB`, `USD`). Код проверяется по справочнику «Валюты».
|
||||||
5. Даты — `timestamptz`, в UTC.
|
5. Даты — `timestamptz`, в UTC.
|
||||||
6. Секреты в полях (конфиги WG, ссылки подписок, SMTP-пароль, id сохранённых карт) шифруются приложением (AES-256-GCM, ключ в `.env`).
|
6. Секреты в полях (конфиги WG, ссылки подписок, SMTP-пароль, id сохранённых карт) шифруются приложением (AES-256-GCM, ключ в `.env`).
|
||||||
|
7. Валюта везде хранится кодом ISO 4217 — и в деньгах, и в настройках (валюты домена и платёжной системы, строки цен). Код проверяется по справочнику, в админке это выпадающий список из «Валют».
|
||||||
|
8. Переводы полей Payload хранятся в таблицах `<коллекция>_l10n` (настройка адаптера `localesSuffix`). Имя по умолчанию `<коллекция>_locales` совпало бы с таблицей поля `locales` у сайтов и доменов.
|
||||||
|
|
||||||
## 2. Схема связей
|
## 2. Схема связей
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ erDiagram
|
|||||||
| code | select `dedicated` \| `mass`, уникальное | бренд: по нему выбираются тема и страницы |
|
| code | select `dedicated` \| `mass`, уникальное | бренд: по нему выбираются тема и страницы |
|
||||||
| name | text (L) | «Вейликс» / «Veilix» |
|
| name | text (L) | «Вейликс» / «Veilix» |
|
||||||
| orderPrefix | text | префикс номеров: `VX`, `VV` |
|
| orderPrefix | text | префикс номеров: `VX`, `VV` |
|
||||||
|
| locales | multi-select из языков конфига | языки контента сайта; en обязателен; домены показывают часть этих языков ([D-023](decisions/D-023-site-locales.md)) |
|
||||||
| supportEmail | email | почта поддержки (Q-08) |
|
| supportEmail | email | почта поддержки (Q-08) |
|
||||||
| emailFrom | group: name, address | отправитель писем, напр. «Вейликс» <no-reply@veilix.app> |
|
| emailFrom | group: name, address | отправитель писем, напр. «Вейликс» <no-reply@veilix.app> |
|
||||||
| supportBot, mainBot | text | имена ботов (этап 3) |
|
| supportBot, mainBot | text | имена ботов (этап 3) |
|
||||||
@@ -60,22 +63,26 @@ erDiagram
|
|||||||
| expiringBadgeDays | number, по умолчанию 7 | когда показывать «Истекает» |
|
| expiringBadgeDays | number, по умолчанию 7 | когда показывать «Истекает» |
|
||||||
| seo | group: titleTemplate (L), description (L), ogImage | значения по умолчанию |
|
| seo | group: titleTemplate (L), description (L), ogImage | значения по умолчанию |
|
||||||
|
|
||||||
|
На этапе 1a сделаны `code` (после создания не меняется), `name`, `orderPrefix` и `locales`. Остальные поля добавляют этапы, которые ими пользуются: SEO и контакты — 1b, отправитель писем — 1c, сроки и пресеты — 1d и 1f, рефералы — 1g, боты — 3.
|
||||||
|
|
||||||
### 3.2 `domains` — Домены (сайт)
|
### 3.2 `domains` — Домены (сайт)
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
| Поле | Тип | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| hostname | text, уникальное | `veilix.app`, `my.veilix.app`, … |
|
| hostname | text, уникальное | `veilix.app`, `my.veilix.app`, … |
|
||||||
| app | select `marketing` \| `cabinet` \| `miniapp` | тип фронтенда |
|
| app | select `marketing` \| `cabinet` \| `miniapp` | тип фронтенда |
|
||||||
| locales | multi-select из языков конфига | включённые языки |
|
| locales | multi-select из языков конфига | включённые языки — часть языков сайта |
|
||||||
| defaultLocale | select | обычно `en` |
|
| defaultLocale | select | обычно `en`; открывается без префикса в адресе |
|
||||||
| currencies | multi-select валют | включённые валюты |
|
| currencies | multi-select валют | включённые валюты |
|
||||||
| defaultCurrency | select | валюта по умолчанию |
|
| defaultCurrency | select | валюта по умолчанию |
|
||||||
| localeCurrency | array: locale, currency | валюта по умолчанию для языка: ru → RUB |
|
| localeCurrency | array: locale, currency | валюта по умолчанию для языка: ru → RUB |
|
||||||
| isPrimary | checkbox | основной домен этого типа; используется в ссылках и sitemap |
|
| isPrimary | checkbox | основной домен этого типа (у сайта один на каждый тип); используется в ссылках и sitemap |
|
||||||
| indexable | checkbox | разрешена ли индексация (для кабинета и MiniApp — нет) |
|
| indexable | checkbox | разрешена ли индексация (для кабинета и MiniApp — нет) |
|
||||||
| umamiWebsiteId | text | этап 1h |
|
| umamiWebsiteId | text | этап 1h |
|
||||||
| active | checkbox | |
|
| active | checkbox | |
|
||||||
|
|
||||||
|
Язык и валюта по умолчанию должны входить в списки домена, язык в правилах «язык → валюта» не повторяется — это проверяется при сохранении.
|
||||||
|
|
||||||
### 3.3 `currencies` — Валюты
|
### 3.3 `currencies` — Валюты
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
| Поле | Тип | Описание |
|
||||||
@@ -99,10 +106,10 @@ erDiagram
|
|||||||
| icon | upload | |
|
| icon | upload | |
|
||||||
| currencies | multi-select валют | в каких валютах доступна |
|
| currencies | multi-select валют | в каких валютах доступна |
|
||||||
| purposes | multi-select `order` \| `topup` | оплата заказа, пополнение |
|
| purposes | multi-select `order` \| `topup` | оплата заказа, пополнение |
|
||||||
| visibleToCustomers | checkbox | у `manual` выключено: ей пользуются только администраторы |
|
| visibleToCustomers | checkbox | у `manual` всегда выключено (проверяется при сохранении): ей пользуются только администраторы |
|
||||||
| mode | select `test` \| `live` | |
|
| mode | select `test` \| `live` | |
|
||||||
| limits | array: currency, min, max | |
|
| limits | array: currency, min, max | |
|
||||||
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod` |
|
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod`; объявлены в `src/core/payments/adapters.ts`, в базе не хранятся |
|
||||||
| active, sort | | |
|
| active, sort | | |
|
||||||
|
|
||||||
Ключи доступа к провайдерам — только в `.env` (`PAYMENT_<КОД>_…`), не в базе.
|
Ключи доступа к провайдерам — только в `.env` (`PAYMENT_<КОД>_…`), не в базе.
|
||||||
@@ -123,7 +130,7 @@ erDiagram
|
|||||||
| country | relationship → countries | уникальна в пределах сайта |
|
| country | relationship → countries | уникальна в пределах сайта |
|
||||||
| city | text (L) | |
|
| city | text (L) | |
|
||||||
| lat, lng | number | точка города на карте |
|
| lat, lng | number | точка города на карте |
|
||||||
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро» |
|
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро». Чужой для бренда статус не сохранится |
|
||||||
| freeServers | number, необязательное | счётчик (решение о показе — этап 1b) |
|
| freeServers | number, необязательное | счётчик (решение о показе — этап 1b) |
|
||||||
| visible, sort | | |
|
| visible, sort | | |
|
||||||
|
|
||||||
@@ -153,13 +160,13 @@ erDiagram
|
|||||||
|
|
||||||
### 4.1 `admins` — Администраторы
|
### 4.1 `admins` — Администраторы
|
||||||
|
|
||||||
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`.
|
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`. Первый администратор окружения (экран первого входа) становится owner. Последнего owner нельзя лишить роли и удалить. Роли и сайты меняет только owner, остальные правят только своё имя и пароль.
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
| Поле | Тип | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| name | text | |
|
| name | text | |
|
||||||
| roles | multi-select `owner` \| `admin` \| `support` \| `content` | см. раздел 8 |
|
| roles | multi-select `owner` \| `admin` \| `support` \| `content` | см. раздел 8 |
|
||||||
| sites | доступ к тенантам | owner и admin видят все сайты |
|
| sites | доступ к тенантам | owner и admin видят все сайты, поэтому поле показывается только ролям support и content; на экране первого входа скрыто |
|
||||||
|
|
||||||
### 4.2 `customers` — Пользователи (сайт)
|
### 4.2 `customers` — Пользователи (сайт)
|
||||||
|
|
||||||
@@ -362,7 +369,9 @@ QR-коды строятся на лету из `payload`, не хранятся
|
|||||||
|
|
||||||
| Коллекция | owner, admin | support | content | пользователь (через приложение) |
|
| Коллекция | owner, admin | support | content | пользователь (через приложение) |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Настройки и справочники | всё | чтение | чтение | — |
|
| Настройки и справочники | всё (удаление сайта — только owner) | чтение | чтение | — |
|
||||||
|
| admins | owner — всё, admin — только себя | только себя | только себя | — |
|
||||||
|
| media | всё | чтение | всё | файлы открыты всем: их показывают сайты |
|
||||||
| pages, faq, email-templates, локализация | всё | чтение | всё | — |
|
| pages, faq, email-templates, локализация | всё | чтение | всё | — |
|
||||||
| customers | всё | чтение, правка заметок, блокировка | — | только свой профиль |
|
| customers | всё | чтение, правка заметок, блокировка | — | только свой профиль |
|
||||||
| keys, connections | всё | всё | — | только свои, чтение |
|
| keys, connections | всё | всё | — | только свои, чтение |
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@
|
|||||||
1. Стандартная авторизация Payload: email и пароль, cookie только на хосте админки.
|
1. Стандартная авторизация Payload: email и пароль, cookie только на хосте админки.
|
||||||
2. Админка открывается только на `plc.veilix.cc` и `dev.plc.veilix.cc`; nginx пускает туда только с разрешённых IP.
|
2. Админка открывается только на `plc.veilix.cc` и `dev.plc.veilix.cc`; nginx пускает туда только с разрешённых IP.
|
||||||
3. 5 неудачных попыток — блокировка на 15 минут.
|
3. 5 неудачных попыток — блокировка на 15 минут.
|
||||||
4. Первый администратор каждого окружения создаётся на экране первого входа Payload.
|
4. Первый администратор каждого окружения создаётся на экране первого входа Payload и автоматически получает роль owner.
|
||||||
|
|
||||||
## 8. Cookies
|
## 8. Cookies
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
## 3. URL
|
## 3. URL
|
||||||
|
|
||||||
1. Английский без префикса, остальные — с префиксом: `veilix.app/pricing`, `veilix.app/ru/pricing`, `veilix.app/de/pricing`.
|
1. Английский без префикса, остальные — с префиксом: `veilix.app/pricing`, `veilix.app/ru/pricing`, `veilix.app/de/pricing`. Точнее, без префикса открывается язык домена по умолчанию (`domains.defaultLocale`, сейчас везде en), а адрес с его префиксом (`/en/pricing`) перенаправляется на адрес без префикса.
|
||||||
2. Так же в кабинете: `my.veilix.app/keys`, `my.veilix.app/ru/keys`.
|
2. Так же в кабинете: `my.veilix.app/keys`, `my.veilix.app/ru/keys`.
|
||||||
3. MiniApp: адрес в BotFather один, поэтому язык берётся из профиля пользователя или из Telegram; префикс в URL необязателен.
|
3. MiniApp: адрес в BotFather один, поэтому язык берётся из профиля пользователя или из Telegram; префикс в URL необязателен.
|
||||||
4. Пути одинаковые для всех языков (английские): `/pricing`, `/locations`, `/support`, `/legal/terms`, `/keys`, `/billing`, `/referral`, `/order`, `/login`.
|
4. Пути одинаковые для всех языков (английские): `/pricing`, `/locations`, `/support`, `/legal/terms`, `/keys`, `/billing`, `/referral`, `/order`, `/login`.
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
## 4. Определение языка
|
## 4. Определение языка
|
||||||
|
|
||||||
1. Порядок: язык из URL → выбранный ранее (cookie `NEXT_LOCALE`, у вошедшего — язык профиля) → язык браузера (`Accept-Language`) среди включённых → страна из заголовка nginx `X-Geo-Country` (MaxMind) → `en`.
|
1. Порядок: язык из URL → выбранный ранее (cookie `NEXT_LOCALE`, у вошедшего — язык профиля) → язык браузера (`Accept-Language`) среди включённых → страна из заголовка nginx `X-Geo-Country` (MaxMind) → `en`.
|
||||||
2. Перенаправление на версию с префиксом — только при первом заходе на адрес без префикса, без cookie, и только если выбран не английский. Поисковых ботов не перенаправляем никогда: так рекомендует Google.
|
2. Перенаправление на версию с префиксом — с адреса без префикса и только если выбран не язык домена по умолчанию ([D-024](decisions/D-024-locale-redirect.md)). Есть cookie `NEXT_LOCALE` — ведём на язык из cookie; cookie нет — определяем по языку браузера и стране. Перенаправляются только GET и HEAD. Поисковых ботов не перенаправляем никогда: так рекомендует Google.
|
||||||
3. Переключатель языка ставит cookie `NEXT_LOCALE`. У вошедшего пользователя язык сохраняется ещё и в профиле: на нём уходят письма и сообщения бота.
|
3. Переключатель языка ставит cookie `NEXT_LOCALE`. У вошедшего пользователя язык сохраняется ещё и в профиле: на нём уходят письма и сообщения бота.
|
||||||
4. Соответствие стран языкам по умолчанию: RU, BY, KZ, KG → ru; DE, AT → de; FR → fr; ES и страны Латинской Америки → es; остальные → en.
|
4. Соответствие стран языкам по умолчанию: RU, BY, KZ, KG → ru; DE, AT → de; FR → fr; ES и испаноязычные страны Латинской Америки → es (Бразилия — en, пока нет pt-br); остальные → en. Таблица — `src/core/routing/locale.ts`.
|
||||||
|
|
||||||
## 5. SEO
|
## 5. SEO
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -32,6 +32,7 @@
|
|||||||
| `/order` | оформление заказа: шаги «Почта» и «Оплата», сводка |
|
| `/order` | оформление заказа: шаги «Почта» и «Оплата», сводка |
|
||||||
| `/billing` | баланс, пополнение, карта, ближайшие списания, история |
|
| `/billing` | баланс, пополнение, карта, ближайшие списания, история |
|
||||||
| `/referral` | реферальная программа |
|
| `/referral` | реферальная программа |
|
||||||
|
| `/more` | вкладка «Ещё» на узком экране: счёт, рефералы, поддержка, тема, язык |
|
||||||
| `/r/[код]` | реферальная ссылка: ставит cookie и ведёт на сайт бренда |
|
| `/r/[код]` | реферальная ссылка: ставит cookie и ведёт на сайт бренда |
|
||||||
|
|
||||||
Навигация по макету: на широком экране — боковая панель, на узком — плавающая панель вкладок «Главная, Ключи, Купить, Ещё».
|
Навигация по макету: на широком экране — боковая панель, на узком — плавающая панель вкладок «Главная, Ключи, Купить, Ещё».
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
## 4. Темы и токены
|
## 4. Темы и токены
|
||||||
|
|
||||||
1. Tailwind v4: токены макета — CSS-переменные, подключённые в `@theme`. Бренд задаёт атрибут `data-site="dedicated|mass"` на `<html>`, тему — `data-theme="light|dark"`. Режим «Как в системе» следует `prefers-color-scheme`.
|
1. Tailwind v4: токены макета — CSS-переменные, подключённые в `@theme`. Бренд задаёт атрибут `data-site="dedicated|mass"` на `<html>`, тему — `data-theme="light|dark"`. Режим «Как в системе» следует `prefers-color-scheme`.
|
||||||
2. Переключатель темы — 3 режима, как в макете. Выбор хранится в localStorage, тема применяется скриптом в `<head>` до отрисовки.
|
2. Переключатель темы — 3 режима, как в макете. Выбор хранится в localStorage (ключ `vx_theme`), тема применяется скриптом в `<head>` до отрисовки. Новый посетитель видит «Как в системе». localStorage у каждого хоста свой: тема, выбранная на сайте, не переходит в кабинет.
|
||||||
3. Токены в OKLCH, как в макете. Ниже — для ориентира; точные значения — в `<style>` прототипов, при расхождении верен прототип.
|
3. Токены в OKLCH, как в макете. Ниже — для ориентира; точные значения — в `<style>` прототипов, при расхождении верен прототип.
|
||||||
|
|
||||||
| Токен | Вейликс светлая | Вейликс тёмная | ВейликсВПН светлая | ВейликсВПН тёмная |
|
| Токен | Вейликс светлая | Вейликс тёмная | ВейликсВПН светлая | ВейликсВПН тёмная |
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
|
|
||||||
Общие для брендов: `--warn` (62% .14 62 / 78% .13 62), `--tg` (62% .15 250 / 70% .13 250), `--veil` (белый 55% / 7%), `--shadow`, цвета карты `--land`, `--land-line`, `--ocean`.
|
Общие для брендов: `--warn` (62% .14 62 / 78% .13 62), `--tg` (62% .15 250 / 70% .13 250), `--veil` (белый 55% / 7%), `--shadow`, цвета карты `--land`, `--land-line`, `--ocean`.
|
||||||
|
|
||||||
|
В коде токены — `src/styles/tokens.css`, утилиты Tailwind называются так же (`bg-surface`, `text-ink-2`, `bg-sel`); стандартная палитра Tailwind отключена. У Вейликса `--sel` равен `--accent-soft`. Цвета карты Вейликса в `Main` светлее, чем в `Locations`; на этапе 1a взяты из `Locations`, окончательно решаем на этапе 1b.
|
||||||
|
|
||||||
## 5. Шрифты, иконки, логотип
|
## 5. Шрифты, иконки, логотип
|
||||||
|
|
||||||
1. Commissioner 300–600 — весь текст; Unbounded 600 — только надпись логотипа. Подключение через `next/font`: файлы скачиваются при сборке и отдаются с нашего домена, без запросов к Google Fonts. Наборы символов: latin, latin-ext, cyrillic.
|
1. Commissioner 300–600 — весь текст; Unbounded 600 — только надпись логотипа. Подключение через `next/font`: файлы скачиваются при сборке и отдаются с нашего домена, без запросов к Google Fonts. Наборы символов: latin, latin-ext, cyrillic.
|
||||||
@@ -85,6 +88,11 @@
|
|||||||
9. Плашки способов оплаты в подвале — по активным платёжным системам сайта.
|
9. Плашки способов оплаты в подвале — по активным платёжным системам сайта.
|
||||||
10. Добавляем то, чего нет в макете: переключатель валюты, `<title>` и мета-описания, H1 на каждой странице, поддержку `prefers-reduced-motion` для анимаций.
|
10. Добавляем то, чего нет в макете: переключатель валюты, `<title>` и мета-описания, H1 на каждой странице, поддержку `prefers-reduced-motion` для анимаций.
|
||||||
11. Показывать ли на сайте количество свободных серверов (в MiniApp макет его показывает) — решаем на этапе 1b. По умолчанию — только статус.
|
11. Показывать ли на сайте количество свободных серверов (в MiniApp макет его показывает) — решаем на этапе 1b. По умолчанию — только статус.
|
||||||
|
12. Переключатели при 5 языках и с валютой (в макете только RU/EN, валюты нет). В шапке — выпадающие списки языка и валюты. В подвале, мобильном меню и кабинете — сегменты с кодами языков (EN RU DE FR ES) и символами валют (₽ $). В оболочке кабинета валюты нет: она появится в настройках (1f).
|
||||||
|
13. В подвале четыре документа: добавлена «Политика cookies». Плашек способов оплаты нет, пока нет видимых пользователю платёжных систем (пункт 9).
|
||||||
|
14. Кабинет mass: подписи навигации те же, что у dedicated («Список ключей», «Купить ключ», «Ключи»). Подписи макета MassApp («Подключения», «Выбрать страну», «Подписка») не используем (пункт 4).
|
||||||
|
15. Ширина надписи логотипа — по содержимому: в макете она подогнана под русское название.
|
||||||
|
16. 404. При `notFound()` Next 16 отдаёт пустую оболочку (`<html id="__next_error__">`), а страница 404 в оформлении бренда отрисовывается в браузере; статус ответа — 404. Серверную отрисовку 404 и 500 решаем на этапе 1b вместе с их оформлением: вариант — `global-not-found.js`.
|
||||||
|
|
||||||
## 8. Состояния, которых нет в макетах
|
## 8. Состояния, которых нет в макетах
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vx — личный пользователь администратора, к п
|
|||||||
## 3. Процессы
|
## 3. Процессы
|
||||||
|
|
||||||
1. Каждый сервис — под своим пользователем ОС, как systemd user-юнит. Root один раз включает `loginctl enable-linger` для vx_dev, vx_prod и umami, чтобы юниты работали без входа в систему.
|
1. Каждый сервис — под своим пользователем ОС, как systemd user-юнит. Root один раз включает `loginctl enable-linger` для vx_dev, vx_prod и umami, чтобы юниты работали без входа в систему.
|
||||||
2. `veilix-dev.service` (vx_dev): `pnpm dev` на порту 3100 из `/home/vx_dev/veilix_dev`, перезапуск при сбое.
|
2. `veilix-dev.service` (vx_dev): `pnpm dev` на 127.0.0.1:3100 из `/home/vx_dev/veilix_dev`, перезапуск при сбое. Файл юнита — `ops/systemd/veilix-dev.service` в репозитории, подключён через `systemctl --user link`.
|
||||||
3. `veilix.service` (vx_prod): `pnpm start` на порту 3000 из `/home/vx_prod/veilix/current`, `NODE_ENV=production`, перезапуск всегда.
|
3. `veilix.service` (vx_prod): `pnpm start` на порту 3000 из `/home/vx_prod/veilix/current`, `NODE_ENV=production`, перезапуск всегда.
|
||||||
4. `umami.service` (umami): Umami на порту 3300.
|
4. `umami.service` (umami): Umami на порту 3300.
|
||||||
5. Фоновые задачи Payload выполняются внутри процессов приложения ([02-architecture.md](02-architecture.md), раздел 7).
|
5. Фоновые задачи Payload выполняются внутри процессов приложения ([02-architecture.md](02-architecture.md), раздел 7).
|
||||||
@@ -89,12 +89,12 @@ vx — личный пользователь администратора, к п
|
|||||||
|
|
||||||
| № | Этап | Задача | Статус на 21.10.2026 |
|
| № | Этап | Задача | Статус на 21.10.2026 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R-00 | 0 | Переезд разработки: перенести `/home/vx/veilix_dev` в `/home/vx_dev/veilix_dev` (владелец vx_dev); SSH-ключ vx_dev для VSCode; Claude Code под vx_dev | не сделано |
|
| R-00 | 0 | Переезд разработки: перенести `/home/vx/veilix_dev` в `/home/vx_dev/veilix_dev` (владелец vx_dev); SSH-ключ vx_dev для VSCode; Claude Code под vx_dev | сделано |
|
||||||
| R-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано |
|
| R-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано: remote в репозитории нет |
|
||||||
| R-02 | 1a | PostgreSQL: роли `vx_dev`, `vx_prod`, `umami` и их базы (раздел 5) | сделано и проверено: vx_dev входит в `veilix_dev`, в `veilix_prod` — отказ |
|
| R-02 | 1a | PostgreSQL: роли `vx_dev`, `vx_prod`, `umami` и их базы (раздел 5) | сделано и проверено: vx_dev входит в `veilix_dev`, в `veilix_prod` — отказ |
|
||||||
| R-03 | 1a | pnpm: `corepack enable pnpm` | сделано |
|
| R-03 | 1a | pnpm: `corepack enable pnpm` | сделано |
|
||||||
| R-04 | 1a | Пользователи vx_dev, vx_prod, umami; `loginctl enable-linger` для них | сделано |
|
| R-04 | 1a | Пользователи vx_dev, vx_prod, umami; `loginctl enable-linger` для них | сделано |
|
||||||
| R-05 | 1a | nginx и сертификаты для всех хостов (раздел 4) | работает; осталось исправить опечатку в `conf/ip_list_admin.conf` и добавить `X-Robots-Tag` для `dev.tg.*` |
|
| R-05 | 1a | nginx и сертификаты для всех хостов (раздел 4) | сделано: опечатка исправлена, `X-Robots-Tag` на всех dev-хостах |
|
||||||
| R-06 | 1a | MaxMind: `geoipupdate` (ключ в `/etc/GeoIP.conf`, база в `/var/lib/GeoIP`, таймер `geoipupdate.timer`); отключить старый модуль GeoIP v1; блок `geoip2` (раздел 4, пункт 4) | сделано |
|
| R-06 | 1a | MaxMind: `geoipupdate` (ключ в `/etc/GeoIP.conf`, база в `/var/lib/GeoIP`, таймер `geoipupdate.timer`); отключить старый модуль GeoIP v1; блок `geoip2` (раздел 4, пункт 4) | сделано |
|
||||||
| R-07 | 1c | Postfix, OpenDKIM, DNS-записи SPF, DKIM, DMARC — или данные SMTP-провайдера | |
|
| R-07 | 1c | Postfix, OpenDKIM, DNS-записи SPF, DKIM, DMARC — или данные SMTP-провайдера | |
|
||||||
| R-08 | 1h | PostgreSQL для prod — если не сделано вместе с R-02 | сделано в R-02 |
|
| R-08 | 1h | PostgreSQL для prod — если не сделано вместе с R-02 | сделано в R-02 |
|
||||||
@@ -106,3 +106,4 @@ vx — личный пользователь администратора, к п
|
|||||||
| R-14 | 4 | Аккаунты платёжных систем (сначала тестовые), адреса вебхуков | |
|
| R-14 | 4 | Аккаунты платёжных систем (сначала тестовые), адреса вебхуков | |
|
||||||
| R-15 | 1h | По желанию (Q-18): sudo-правило, разрешающее vx_dev запускать от имени vx_prod только команду выкладки | |
|
| R-15 | 1h | По желанию (Q-18): sudo-правило, разрешающее vx_dev запускать от имени vx_prod только команду выкладки | |
|
||||||
| R-16 | 1h | По желанию: правило nftables — к 127.0.0.1:3000 подключаются только `www-data` (nginx) и vx_prod ([10-security.md](10-security.md), раздел 7) | |
|
| R-16 | 1h | По желанию: правило nftables — к 127.0.0.1:3000 подключаются только `www-data` (nginx) и vx_prod ([10-security.md](10-security.md), раздел 7) | |
|
||||||
|
| R-17 | 1a | nginx dev: отдельный `location /_next/webpack-hmr` с `proxy_read_timeout 1h` в server-блоках dev-хостов и `dev.plc.veilix.cc`. Иначе WebSocket горячей перезагрузки закрывается каждые 30 секунд (`proxy_read_timeout 30` в `nginx.conf`) | |
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
2. Роли owner, admin, support, content ([03-data-model.md](03-data-model.md), раздел 10). Поле `debugCode` журнала писем видят только owner и admin.
|
2. Роли owner, admin, support, content ([03-data-model.md](03-data-model.md), раздел 10). Поле `debugCode` журнала писем видят только owner и admin.
|
||||||
3. Блокировка после 5 неудачных попыток входа.
|
3. Блокировка после 5 неудачных попыток входа.
|
||||||
4. Денежные операции, выдача и блокировка ключей, объединение аккаунтов пишутся в журнал действий. Журнал нельзя изменить.
|
4. Денежные операции, выдача и блокировка ключей, объединение аккаунтов пишутся в журнал действий. Журнал нельзя изменить.
|
||||||
|
5. GraphQL Payload выключен. Телеметрия Next.js и Payload выключена.
|
||||||
|
6. Служебный эндпоинт `/svc/internal/domains` отвечает только с токеном `INTERNAL_TOKEN` из `.env`, иначе 404. Cookie администратора — только по HTTPS.
|
||||||
|
|
||||||
## 4. Пользователи
|
## 4. Пользователи
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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, их можно включить одному бренду: редакторы второго не увидят пустых языков.
|
||||||
@@ -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. Выбор английского в переключателе тоже запоминается и отключает перенаправление по языку браузера.
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
| [D-002](D-002-payload-version.md) | Payload 3, переход на 4 после стабильного релиза | принято |
|
| [D-002](D-002-payload-version.md) | Payload 3, переход на 4 после стабильного релиза | принято |
|
||||||
| [D-003](D-003-hosting.md) | Без Docker: всё на хосте, процессы под systemd | принято |
|
| [D-003](D-003-hosting.md) | Без Docker: всё на хосте, процессы под systemd | принято |
|
||||||
| [D-004](D-004-environments.md) | Dev и prod на одном сервере под разными пользователями | принято |
|
| [D-004](D-004-environments.md) | Dev и prod на одном сервере под разными пользователями | принято |
|
||||||
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято |
|
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято, уточнено в D-022 |
|
||||||
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято |
|
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято, уточнено в D-023 |
|
||||||
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято |
|
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято, уточнено в D-024 |
|
||||||
| [D-008](D-008-uuid.md) | UUID и случайные публичные коды | принято |
|
| [D-008](D-008-uuid.md) | UUID и случайные публичные коды | принято |
|
||||||
| [D-009](D-009-billing-model.md) | Заказы, платежи, движения по счёту; всё через баланс | принято |
|
| [D-009](D-009-billing-model.md) | Заказы, платежи, движения по счёту; всё через баланс | принято |
|
||||||
| [D-010](D-010-currencies.md) | Валюты и платёжные системы — сущности; платёжные системы привязаны к валютам | принято |
|
| [D-010](D-010-currencies.md) | Валюты и платёжные системы — сущности; платёжные системы привязаны к валютам | принято |
|
||||||
@@ -25,3 +25,6 @@
|
|||||||
| [D-019](D-019-external-services.md) | Chatwoot и Twenty CRM на отдельном VPS | принято |
|
| [D-019](D-019-external-services.md) | Chatwoot и Twenty CRM на отдельном VPS | принято |
|
||||||
| [D-020](D-020-key-models.md) | Модели ключей dedicated и mass | принято |
|
| [D-020](D-020-key-models.md) | Модели ключей dedicated и mass | принято |
|
||||||
| [D-021](D-021-admin-host.md) | Админка на plc.veilix.cc с доступом по IP | принято |
|
| [D-021](D-021-admin-host.md) | Админка на plc.veilix.cc с доступом по IP | принято |
|
||||||
|
| [D-022](D-022-proxy-routing.md) | Proxy: внутренние маршруты по хосту, домены из внутреннего эндпоинта | принято |
|
||||||
|
| [D-023](D-023-site-locales.md) | Языки сайта — поле `sites.locales`, домены показывают их часть | принято |
|
||||||
|
| [D-024](D-024-locale-redirect.md) | Перенаправление на выбранный язык | принято |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Этап 1a. Фундамент
|
# Этап 1a. Фундамент
|
||||||
|
|
||||||
Статус: не начат.
|
Статус: готово 21.10.2026. Остался R-17 (root, nginx), см. раздел 6.
|
||||||
|
|
||||||
## 1. Цель
|
## 1. Цель
|
||||||
|
|
||||||
@@ -37,3 +37,23 @@ Root: R-00…R-06 ([09-infrastructure.md](../09-infrastructure.md), раздел
|
|||||||
5. В URL админки — UUID.
|
5. В URL админки — UUID.
|
||||||
6. `dev.my.*` показывает оболочку кабинета; `/admin` на доменах брендов — 404.
|
6. `dev.my.*` показывает оболочку кабинета; `/admin` на доменах брендов — 404.
|
||||||
7. `pnpm build`, линтер и проверка типов проходят.
|
7. `pnpm build`, линтер и проверка типов проходят.
|
||||||
|
|
||||||
|
## 6. Итоги
|
||||||
|
|
||||||
|
Проверено curl через nginx и напрямую на 127.0.0.1:3100: пункты 2, 3, 5, 6 и 7, а также HTTPS, `lang`, `data-site`, скрипт темы в `<head>` и токены обоих брендов в CSS. Внешний вид сайтов и кабинета и админку (пункты 1 и 4) проверил пользователь в браузере, первый администратор создан.
|
||||||
|
|
||||||
|
Решения и отклонения от плана:
|
||||||
|
|
||||||
|
1. Стек: TypeScript 6.0 и ESLint 9.39 вместо последних 7 и 10 — их пока не поддерживают плагины ([02-architecture.md](../02-architecture.md), раздел 1).
|
||||||
|
2. Внутренние маршруты — по хосту, а не по коду сайта; домены proxy берёт у `/svc/internal/domains` с кэшем на 15 секунд ([D-022](../decisions/D-022-proxy-routing.md)).
|
||||||
|
3. Языки сайта — поле `sites.locales` ([D-023](../decisions/D-023-site-locales.md)); перенаправление на язык из cookie ([D-024](../decisions/D-024-locale-redirect.md)).
|
||||||
|
4. В `sites` сделаны только `code`, `name`, `orderPrefix` и `locales`; остальные поля добавят этапы, которые ими пользуются ([03-data-model.md](../03-data-model.md), раздел 3.1).
|
||||||
|
5. Таблицы переводов Payload — с суффиксом `_l10n`; для вопросов push в dev — `pnpm db:reset`.
|
||||||
|
6. Миграции: механизм проверен на пустой схеме; начальную миграцию создаём перед первой выкладкой (1h).
|
||||||
|
7. Mass в USD — временные цены $6.99 и $69.90 до решения Q-02.
|
||||||
|
8. Адаптация макетов: переключатели для 5 языков и валюты, «Политика cookies» в подвале, подписи кабинета mass как у dedicated ([07-frontend.md](../07-frontend.md), раздел 7).
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. R-17 (root): `location /_next/webpack-hmr` с большим `proxy_read_timeout` в nginx dev — иначе горячая перезагрузка переподключается каждые 30 секунд.
|
||||||
|
2. На этап 1b: серверная отрисовка 404 и 500 ([07-frontend.md](../07-frontend.md), раздел 7.16), цвета карты, пересборка страниц по событию из Payload.
|
||||||
@@ -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',
|
||||||
|
]),
|
||||||
|
])
|
||||||
@@ -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 })
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9178
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': false
|
||||||
|
'@swc/core': false
|
||||||
|
esbuild: true
|
||||||
|
unrs-resolver: true
|
||||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' } })
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Brand } from '../types'
|
||||||
|
|
||||||
|
/** ВейликсВПН: классический VPN. Тексты — brand.mass в сообщениях next-intl */
|
||||||
|
export const mass: Brand = {
|
||||||
|
favicon: '/static/brands/mass/favicon.svg',
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type Brand = {
|
||||||
|
favicon: string
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Валюта для переключателя: код ISO 4217, символ и название на языке страницы */
|
||||||
|
export type CurrencyOption = {
|
||||||
|
code: string
|
||||||
|
symbol: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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": "На главную"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
Vendored
+11
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Access, FieldAccess } from 'payload'
|
||||||
|
|
||||||
|
export const ROLES = ['owner', 'admin', 'support', 'content'] as const
|
||||||
|
|
||||||
|
export type Role = (typeof ROLES)[number]
|
||||||
|
|
||||||
|
type AdminUser = { collection: 'admins'; id: string; roles?: Role[] | null }
|
||||||
|
|
||||||
|
export const isAdminUser = (user: unknown): user is AdminUser =>
|
||||||
|
typeof user === 'object' && user !== null && 'collection' in user && user.collection === 'admins'
|
||||||
|
|
||||||
|
export const hasRole = (user: unknown, ...roles: Role[]): boolean =>
|
||||||
|
isAdminUser(user) && (user.roles ?? []).some((role) => roles.includes(role))
|
||||||
|
|
||||||
|
/** Любой администратор с хотя бы одной ролью */
|
||||||
|
export const anyRole: Access = ({ req }) =>
|
||||||
|
isAdminUser(req.user) && (req.user.roles ?? []).length > 0
|
||||||
|
|
||||||
|
/** owner и admin: правка настроек и справочников */
|
||||||
|
export const managers: Access = ({ req }) => hasRole(req.user, 'owner', 'admin')
|
||||||
|
|
||||||
|
export const owners: Access = ({ req }) => hasRole(req.user, 'owner')
|
||||||
|
|
||||||
|
/** owner, admin и content: правка контента */
|
||||||
|
export const contentEditors: Access = ({ req }) => hasRole(req.user, 'owner', 'admin', 'content')
|
||||||
|
|
||||||
|
export const ownersField: FieldAccess = ({ req }) => hasRole(req.user, 'owner')
|
||||||
|
|
||||||
|
/** owner видит всех, остальные — только себя */
|
||||||
|
export const ownerOrSelf: Access = ({ req }) => {
|
||||||
|
if (hasRole(req.user, 'owner')) return true
|
||||||
|
if (isAdminUser(req.user)) return { id: { equals: req.user.id } }
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
|
||||||
|
import type {
|
||||||
|
ArrayField,
|
||||||
|
CollectionBeforeChangeHook,
|
||||||
|
CollectionBeforeDeleteHook,
|
||||||
|
CollectionConfig,
|
||||||
|
Field,
|
||||||
|
PayloadRequest,
|
||||||
|
} from 'payload'
|
||||||
|
import { APIError } from 'payload'
|
||||||
|
|
||||||
|
import { ownerOrSelf, owners, ownersField, type Role } from '../access/roles'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
|
||||||
|
const roleOptions: { value: Role; label: { en: string; ru: string } }[] = [
|
||||||
|
{ value: 'owner', label: label('Owner', 'Владелец') },
|
||||||
|
{ value: 'admin', label: label('Admin', 'Администратор') },
|
||||||
|
{ value: 'support', label: label('Support', 'Поддержка') },
|
||||||
|
{ value: 'content', label: label('Content', 'Контент') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Первый администратор окружения (экран первого входа Payload) становится owner */
|
||||||
|
const firstAdminIsOwner: CollectionBeforeChangeHook = async ({ data, operation, req }) => {
|
||||||
|
if (operation !== 'create') return data
|
||||||
|
const { totalDocs } = await req.payload.count({
|
||||||
|
collection: 'admins',
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
return totalDocs === 0 ? { ...data, roles: ['owner'] } : data
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherOwnersCount = async (req: PayloadRequest, id: number | string) => {
|
||||||
|
const { totalDocs } = await req.payload.count({
|
||||||
|
collection: 'admins',
|
||||||
|
where: { and: [{ roles: { in: ['owner'] } }, { id: { not_equals: id } }] },
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
return totalDocs
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastOwnerError = (req: PayloadRequest) =>
|
||||||
|
new APIError(
|
||||||
|
message(req, {
|
||||||
|
en: 'This is the last owner. Make someone else an owner first.',
|
||||||
|
ru: 'Это последний владелец. Сначала назначьте владельцем кого-то ещё.',
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Без владельца админкой некому управлять: последний owner не теряет роль */
|
||||||
|
const keepLastOwner: CollectionBeforeChangeHook = async ({ data, operation, originalDoc, req }) => {
|
||||||
|
if (operation !== 'update' || !originalDoc?.roles?.includes('owner')) return data
|
||||||
|
const roles: Role[] | undefined = data.roles
|
||||||
|
if (!roles || roles.includes('owner')) return data
|
||||||
|
if ((await otherOwnersCount(req, originalDoc.id)) === 0) throw lastOwnerError(req)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepLastOwnerOnDelete: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||||
|
const doc = await req.payload.findByID({
|
||||||
|
collection: 'admins',
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
if (doc.roles?.includes('owner') && (await otherOwnersCount(req, id)) === 0) {
|
||||||
|
throw lastOwnerError(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginSites = tenantsArrayField({
|
||||||
|
tenantsArrayFieldName: 'sites',
|
||||||
|
tenantsArrayTenantFieldName: 'site',
|
||||||
|
tenantsCollectionSlug: 'sites',
|
||||||
|
arrayFieldAccess: { create: ownersField, update: ownersField },
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сайты, которые видит администратор (поле плагина multi-tenant). Нужно только поддержке
|
||||||
|
* и контенту: owner и admin видят все сайты. На экране первого входа пользователя ещё нет,
|
||||||
|
* а первый администратор и так становится owner, поэтому там поле скрыто.
|
||||||
|
*/
|
||||||
|
const sitesField: ArrayField = {
|
||||||
|
...pluginSites,
|
||||||
|
label: label('Sites', 'Сайты'),
|
||||||
|
labels: { singular: label('Site', 'Сайт'), plural: label('Sites', 'Сайты') },
|
||||||
|
fields: pluginSites.fields.map((field: Field) =>
|
||||||
|
'name' in field && field.name === 'site' ? { ...field, label: label('Site', 'Сайт') } : field,
|
||||||
|
) as ArrayField['fields'],
|
||||||
|
admin: {
|
||||||
|
condition: (data, _siblingData, { user }) =>
|
||||||
|
Boolean(user) &&
|
||||||
|
!(data.roles ?? []).some((role: Role) => role === 'owner' || role === 'admin'),
|
||||||
|
description: label(
|
||||||
|
'Sites available to support and content roles',
|
||||||
|
'Сайты, доступные ролям «Поддержка» и «Контент»',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Admins: CollectionConfig = {
|
||||||
|
slug: 'admins',
|
||||||
|
labels: {
|
||||||
|
singular: label('Administrator', 'Администратор'),
|
||||||
|
plural: label('Administrators', 'Администраторы'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.settings,
|
||||||
|
useAsTitle: 'email',
|
||||||
|
defaultColumns: ['email', 'name', 'roles'],
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
maxLoginAttempts: 5,
|
||||||
|
lockTime: 15 * 60 * 1000,
|
||||||
|
// админка открывается только по HTTPS через nginx
|
||||||
|
cookies: { secure: true, sameSite: 'Lax' },
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ownerOrSelf,
|
||||||
|
create: owners,
|
||||||
|
update: ownerOrSelf,
|
||||||
|
delete: owners,
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [firstAdminIsOwner, keepLastOwner],
|
||||||
|
beforeDelete: [keepLastOwnerOnDelete],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
label: label('Name', 'Имя'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'roles',
|
||||||
|
type: 'select',
|
||||||
|
hasMany: true,
|
||||||
|
options: roleOptions,
|
||||||
|
label: label('Roles', 'Роли'),
|
||||||
|
access: { create: ownersField, update: ownersField },
|
||||||
|
admin: {
|
||||||
|
description: label(
|
||||||
|
'Owner and admin see all sites; support and content see only the sites below.',
|
||||||
|
'Владелец и администратор видят все сайты, поддержка и контент — только сайты ниже.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sitesField,
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { CollectionConfig, FieldHook, TextFieldSingleValidation } from 'payload'
|
||||||
|
|
||||||
|
import { anyRole, managers } from '../access/roles'
|
||||||
|
import { sortField } from '../fields/common'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
|
||||||
|
const upperCase: FieldHook = ({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toUpperCase() : value
|
||||||
|
|
||||||
|
const validateIso2: TextFieldSingleValidation = (value, { req }) =>
|
||||||
|
typeof value === 'string' && /^[A-Z]{2}$/.test(value)
|
||||||
|
? true
|
||||||
|
: message(req, {
|
||||||
|
en: 'Two capital letters ISO 3166-1, e.g. NL',
|
||||||
|
ru: 'Две заглавные буквы ISO 3166-1, например NL',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Countries: CollectionConfig = {
|
||||||
|
slug: 'countries',
|
||||||
|
labels: {
|
||||||
|
singular: label('Country', 'Страна'),
|
||||||
|
plural: label('Countries', 'Страны'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.catalog,
|
||||||
|
useAsTitle: 'name',
|
||||||
|
defaultColumns: ['name', 'iso2', 'sort'],
|
||||||
|
description: label('Shared by both brands', 'Общий справочник обоих брендов'),
|
||||||
|
},
|
||||||
|
defaultSort: 'sort',
|
||||||
|
access: {
|
||||||
|
read: anyRole,
|
||||||
|
create: managers,
|
||||||
|
update: managers,
|
||||||
|
delete: managers,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'iso2',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
label: label('Code (ISO 3166-1)', 'Код (ISO 3166-1)'),
|
||||||
|
hooks: { beforeValidate: [upperCase] },
|
||||||
|
validate: validateIso2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
localized: true,
|
||||||
|
label: label('Name', 'Название'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'lat',
|
||||||
|
type: 'number',
|
||||||
|
min: -90,
|
||||||
|
max: 90,
|
||||||
|
label: label('Latitude', 'Широта'),
|
||||||
|
admin: { description: label('Default point on the map', 'Точка на карте по умолчанию') },
|
||||||
|
},
|
||||||
|
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sortField,
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type {
|
||||||
|
CollectionBeforeDeleteHook,
|
||||||
|
CollectionConfig,
|
||||||
|
FieldHook,
|
||||||
|
TextFieldSingleValidation,
|
||||||
|
} from 'payload'
|
||||||
|
import { APIError } from 'payload'
|
||||||
|
|
||||||
|
import { anyRole, managers } from '../access/roles'
|
||||||
|
import { integerField, sortField } from '../fields/common'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
|
||||||
|
const upperCase: FieldHook = ({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toUpperCase() : value
|
||||||
|
|
||||||
|
const validateCode: TextFieldSingleValidation = (value, { req }) =>
|
||||||
|
typeof value === 'string' && /^[A-Z]{3}$/.test(value)
|
||||||
|
? true
|
||||||
|
: message(req, {
|
||||||
|
en: 'Three capital letters ISO 4217, e.g. USD',
|
||||||
|
ru: 'Три заглавные буквы ISO 4217, например USD',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Используемую валюту не удаляем: на её код ссылаются домены, тарифы и платёжные системы */
|
||||||
|
const keepUsedCurrency: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||||
|
const { code } = await req.payload.findByID({
|
||||||
|
collection: 'currencies',
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
const domains = await req.payload.count({
|
||||||
|
collection: 'domains',
|
||||||
|
where: { or: [{ currencies: { in: [code] } }, { defaultCurrency: { equals: code } }] },
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
const paymentSystems = await req.payload.count({
|
||||||
|
collection: 'payment-systems',
|
||||||
|
where: { currencies: { in: [code] } },
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
const tariffs = await req.payload.count({
|
||||||
|
collection: 'tariffs',
|
||||||
|
where: { 'prices.currency': { equals: code } },
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
if (domains.totalDocs + paymentSystems.totalDocs + tariffs.totalDocs > 0) {
|
||||||
|
throw new APIError(
|
||||||
|
message(req, {
|
||||||
|
en: 'The currency is used by domains, tariffs or payment systems. Turn it off instead.',
|
||||||
|
ru: 'Валюта используется в доменах, тарифах или платёжных системах. Вместо удаления выключите её.',
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Currencies: CollectionConfig = {
|
||||||
|
slug: 'currencies',
|
||||||
|
labels: {
|
||||||
|
singular: label('Currency', 'Валюта'),
|
||||||
|
plural: label('Currencies', 'Валюты'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.settings,
|
||||||
|
useAsTitle: 'code',
|
||||||
|
defaultColumns: ['code', 'symbol', 'name', 'active', 'sort'],
|
||||||
|
},
|
||||||
|
defaultSort: 'sort',
|
||||||
|
access: {
|
||||||
|
read: anyRole,
|
||||||
|
create: managers,
|
||||||
|
update: managers,
|
||||||
|
delete: managers,
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeDelete: [keepUsedCurrency],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
label: label('Code (ISO 4217)', 'Код (ISO 4217)'),
|
||||||
|
hooks: { beforeValidate: [upperCase] },
|
||||||
|
validate: validateCode,
|
||||||
|
// на код ссылаются денежные записи, после создания не меняется
|
||||||
|
access: { update: () => false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'symbol',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
label: label('Symbol', 'Символ'),
|
||||||
|
},
|
||||||
|
integerField('minorUnits', label('Minor units', 'Знаков после запятой'), {
|
||||||
|
required: true,
|
||||||
|
defaultValue: 2,
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
localized: true,
|
||||||
|
label: label('Name', 'Название'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'active',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: label('Active', 'Включена'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
sortField,
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import type {
|
||||||
|
CollectionBeforeChangeHook,
|
||||||
|
CollectionConfig,
|
||||||
|
FieldHook,
|
||||||
|
TextFieldSingleValidation,
|
||||||
|
Where,
|
||||||
|
} from 'payload'
|
||||||
|
import { ValidationError } from 'payload'
|
||||||
|
import { text } from 'payload/shared'
|
||||||
|
|
||||||
|
import { anyRole, managers } from '../access/roles'
|
||||||
|
import { currenciesField, currencyField } from '../fields/currency'
|
||||||
|
import { localeOptions } from '../fields/locales'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
import { relationId } from '../utils'
|
||||||
|
|
||||||
|
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
|
||||||
|
|
||||||
|
const normalizeHostname: FieldHook = ({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : value
|
||||||
|
|
||||||
|
const validateHostname: TextFieldSingleValidation = (value, args) => {
|
||||||
|
const base = text(value, args)
|
||||||
|
if (base !== true) return base
|
||||||
|
return typeof value === 'string' && HOSTNAME.test(value)
|
||||||
|
? true
|
||||||
|
: message(args.req, {
|
||||||
|
en: 'Hostname without protocol and path, e.g. veilix.app',
|
||||||
|
ru: 'Имя хоста без протокола и пути, например veilix.app',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocaleCurrencyRow = { locale?: string | null; currency?: string | null }
|
||||||
|
|
||||||
|
/** Проверки, которым нужны несколько полей сразу и сайт домена */
|
||||||
|
const validateDomain: CollectionBeforeChangeHook = async ({
|
||||||
|
collection,
|
||||||
|
data,
|
||||||
|
originalDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
const doc = { ...originalDoc, ...data }
|
||||||
|
const locales: string[] = doc.locales ?? []
|
||||||
|
const currencies: string[] = doc.currencies ?? []
|
||||||
|
const errors: { path: string; message: string }[] = []
|
||||||
|
const notInLocales = message(req, {
|
||||||
|
en: 'Not among the domain languages',
|
||||||
|
ru: 'Не входит в языки домена',
|
||||||
|
})
|
||||||
|
const notInCurrencies = message(req, {
|
||||||
|
en: 'Not among the domain currencies',
|
||||||
|
ru: 'Не входит в валюты домена',
|
||||||
|
})
|
||||||
|
|
||||||
|
const siteId = relationId(doc.site)
|
||||||
|
if (siteId) {
|
||||||
|
const site = await req.payload.findByID({
|
||||||
|
collection: 'sites',
|
||||||
|
id: siteId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
const extra = locales.filter((locale) => !(site.locales ?? []).includes(locale as never))
|
||||||
|
if (extra.length > 0) {
|
||||||
|
errors.push({
|
||||||
|
path: 'locales',
|
||||||
|
message: message(req, {
|
||||||
|
en: `The site has no content in these languages: ${extra.join(', ')}`,
|
||||||
|
ru: `У сайта нет контента на этих языках: ${extra.join(', ')}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.defaultLocale && !locales.includes(doc.defaultLocale)) {
|
||||||
|
errors.push({ path: 'defaultLocale', message: notInLocales })
|
||||||
|
}
|
||||||
|
if (doc.defaultCurrency && !currencies.includes(doc.defaultCurrency)) {
|
||||||
|
errors.push({ path: 'defaultCurrency', message: notInCurrencies })
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const rows: LocaleCurrencyRow[] = doc.localeCurrency ?? []
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
if (row.locale && !locales.includes(row.locale)) {
|
||||||
|
errors.push({ path: `localeCurrency.${index}.locale`, message: notInLocales })
|
||||||
|
} else if (row.locale && seen.has(row.locale)) {
|
||||||
|
errors.push({
|
||||||
|
path: `localeCurrency.${index}.locale`,
|
||||||
|
message: message(req, { en: 'Language is listed twice', ru: 'Язык указан дважды' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (row.locale) seen.add(row.locale)
|
||||||
|
if (row.currency && !currencies.includes(row.currency)) {
|
||||||
|
errors.push({ path: `localeCurrency.${index}.currency`, message: notInCurrencies })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (doc.isPrimary && siteId) {
|
||||||
|
const where: Where = {
|
||||||
|
and: [
|
||||||
|
{ site: { equals: siteId } },
|
||||||
|
{ app: { equals: doc.app } },
|
||||||
|
{ isPrimary: { equals: true } },
|
||||||
|
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const { docs } = await req.payload.find({
|
||||||
|
collection: 'domains',
|
||||||
|
where,
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
if (docs[0]) {
|
||||||
|
errors.push({
|
||||||
|
path: 'isPrimary',
|
||||||
|
message: message(req, {
|
||||||
|
en: `The site already has a primary domain of this type: ${docs[0].hostname}`,
|
||||||
|
ru: `У сайта уже есть основной домен этого типа: ${docs[0].hostname}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Domains: CollectionConfig = {
|
||||||
|
slug: 'domains',
|
||||||
|
labels: {
|
||||||
|
singular: label('Domain', 'Домен'),
|
||||||
|
plural: label('Domains', 'Домены'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.settings,
|
||||||
|
useAsTitle: 'hostname',
|
||||||
|
defaultColumns: ['hostname', 'app', 'locales', 'currencies', 'active'],
|
||||||
|
description: label(
|
||||||
|
'A new mirror is a record here plus an nginx block and a certificate.',
|
||||||
|
'Новое зеркало — запись здесь плюс блок nginx и сертификат.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: anyRole,
|
||||||
|
create: managers,
|
||||||
|
update: managers,
|
||||||
|
delete: managers,
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [validateDomain],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'hostname',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
label: label('Hostname', 'Хост'),
|
||||||
|
hooks: { beforeValidate: [normalizeHostname] },
|
||||||
|
validate: validateHostname,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'app',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'marketing', label: label('Website', 'Сайт') },
|
||||||
|
{ value: 'cabinet', label: label('Account and checkout', 'Кабинет и оформление заказа') },
|
||||||
|
{ value: 'miniapp', label: label('Telegram MiniApp', 'MiniApp в Telegram') },
|
||||||
|
],
|
||||||
|
label: label('Frontend', 'Фронтенд'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'locales',
|
||||||
|
type: 'select',
|
||||||
|
hasMany: true,
|
||||||
|
required: true,
|
||||||
|
options: localeOptions,
|
||||||
|
label: label('Languages', 'Языки'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'defaultLocale',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'en',
|
||||||
|
options: localeOptions,
|
||||||
|
label: label('Default language', 'Язык по умолчанию'),
|
||||||
|
admin: {
|
||||||
|
description: label('Opens without a URL prefix', 'Открывается без префикса в адресе'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
currenciesField({
|
||||||
|
name: 'currencies',
|
||||||
|
label: label('Currencies', 'Валюты'),
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
currencyField({
|
||||||
|
name: 'defaultCurrency',
|
||||||
|
label: label('Default currency', 'Валюта по умолчанию'),
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'localeCurrency',
|
||||||
|
type: 'array',
|
||||||
|
label: label('Currency by language', 'Валюта для языка'),
|
||||||
|
labels: { singular: label('Rule', 'Правило'), plural: label('Rules', 'Правила') },
|
||||||
|
admin: {
|
||||||
|
description: label(
|
||||||
|
'Default currency for a language, e.g. ru → RUB. Other languages get the default currency.',
|
||||||
|
'Валюта по умолчанию для языка, например ru → RUB. Для остальных языков — валюта по умолчанию.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'locale',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: localeOptions,
|
||||||
|
label: label('Language', 'Язык'),
|
||||||
|
},
|
||||||
|
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'isPrimary',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: label('Primary domain of this type', 'Основной домен этого типа'),
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
description: label('Used in links and the sitemap', 'Используется в ссылках и sitemap'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'indexable',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: label('Allow search indexing', 'Разрешить индексацию'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'active',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: label('Active', 'Включён'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
|
||||||
|
import { ValidationError } from 'payload'
|
||||||
|
|
||||||
|
import type { SiteCode } from '@/core/sites'
|
||||||
|
|
||||||
|
import { anyRole, managers } from '../access/roles'
|
||||||
|
import { integerField, sortField } from '../fields/common'
|
||||||
|
import { uniqueInSite } from '../hooks/uniqueInSite'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
import { relationId } from '../utils'
|
||||||
|
|
||||||
|
export const LOCATION_STATUSES = ['available', 'on_demand', 'soon'] as const
|
||||||
|
|
||||||
|
/** dedicated: «Серверы в наличии» и «Установим за 24 часа»; mass: «Включено» и «Скоро» */
|
||||||
|
const STATUSES_BY_SITE: Record<SiteCode, readonly string[]> = {
|
||||||
|
dedicated: ['available', 'on_demand'],
|
||||||
|
mass: ['available', 'soon'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateStatus: CollectionBeforeChangeHook = async ({
|
||||||
|
collection,
|
||||||
|
data,
|
||||||
|
originalDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
const doc = { ...originalDoc, ...data }
|
||||||
|
const siteId = relationId(doc.site)
|
||||||
|
if (!siteId || !doc.status) return data
|
||||||
|
|
||||||
|
const site = await req.payload.findByID({
|
||||||
|
collection: 'sites',
|
||||||
|
id: siteId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
if (!STATUSES_BY_SITE[site.code].includes(doc.status)) {
|
||||||
|
throw new ValidationError({
|
||||||
|
collection: collection.slug,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
path: 'status',
|
||||||
|
message: message(req, {
|
||||||
|
en: 'This status is not used by the brand of the site',
|
||||||
|
ru: 'Этот статус не используется у бренда сайта',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Locations: CollectionConfig = {
|
||||||
|
slug: 'locations',
|
||||||
|
labels: {
|
||||||
|
singular: label('Location', 'Локация'),
|
||||||
|
plural: label('Locations', 'Локации'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.catalog,
|
||||||
|
useAsTitle: 'city',
|
||||||
|
defaultColumns: ['city', 'country', 'status', 'visible', 'sort'],
|
||||||
|
},
|
||||||
|
defaultSort: 'sort',
|
||||||
|
access: {
|
||||||
|
read: anyRole,
|
||||||
|
create: managers,
|
||||||
|
update: managers,
|
||||||
|
delete: managers,
|
||||||
|
},
|
||||||
|
indexes: [{ fields: ['site', 'country'], unique: true }],
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [
|
||||||
|
validateStatus,
|
||||||
|
uniqueInSite('country', {
|
||||||
|
en: 'The site already has a location in this country',
|
||||||
|
ru: 'У сайта уже есть локация в этой стране',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'country',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'countries',
|
||||||
|
required: true,
|
||||||
|
label: label('Country', 'Страна'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'city',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
localized: true,
|
||||||
|
label: label('City', 'Город'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'lat',
|
||||||
|
type: 'number',
|
||||||
|
min: -90,
|
||||||
|
max: 90,
|
||||||
|
label: label('Latitude', 'Широта'),
|
||||||
|
admin: { description: label('City point on the map', 'Точка города на карте') },
|
||||||
|
},
|
||||||
|
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'available', label: label('Available now', 'Доступна сейчас') },
|
||||||
|
{
|
||||||
|
value: 'on_demand',
|
||||||
|
label: label('Ready within 24 hours (Veilix)', 'Установим за 24 часа (Вейликс)'),
|
||||||
|
},
|
||||||
|
{ value: 'soon', label: label('Coming soon (VeilixVPN)', 'Скоро (ВейликсВПН)') },
|
||||||
|
],
|
||||||
|
label: label('Status', 'Статус'),
|
||||||
|
},
|
||||||
|
integerField('freeServers', label('Free servers', 'Свободных серверов'), {
|
||||||
|
min: 0,
|
||||||
|
admin: {
|
||||||
|
description: label(
|
||||||
|
'Optional counter. Whether to show it on the site is decided in stage 1b.',
|
||||||
|
'Необязательный счётчик. Показывать ли его на сайте — решаем на этапе 1b.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'visible',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: label('Shown on the site', 'Показывать на сайте'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
sortField,
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
import { contentEditors } from '../access/roles'
|
||||||
|
import { groups, label } from '../i18n'
|
||||||
|
|
||||||
|
export const Media: CollectionConfig = {
|
||||||
|
slug: 'media',
|
||||||
|
labels: {
|
||||||
|
singular: label('File', 'Файл'),
|
||||||
|
plural: label('Media', 'Медиатека'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.content,
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
// файлы открыты всем: их показывают сайты брендов
|
||||||
|
read: () => true,
|
||||||
|
create: contentEditors,
|
||||||
|
update: contentEditors,
|
||||||
|
delete: contentEditors,
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
// папка media в корне проекта; в prod это ссылка на shared/media
|
||||||
|
staticDir: path.resolve(process.cwd(), 'media'),
|
||||||
|
mimeTypes: ['image/*'],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'alt',
|
||||||
|
type: 'text',
|
||||||
|
localized: true,
|
||||||
|
label: label('Alt text', 'Описание изображения'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { CollectionBeforeChangeHook, CollectionConfig, FieldHook } from 'payload'
|
||||||
|
import { ValidationError } from 'payload'
|
||||||
|
|
||||||
|
import {
|
||||||
|
isPaymentAdapterCode,
|
||||||
|
PAYMENT_ADAPTER_CODES,
|
||||||
|
paymentAdapters,
|
||||||
|
} from '@/core/payments/adapters'
|
||||||
|
|
||||||
|
import { anyRole, managers } from '../access/roles'
|
||||||
|
import { amountField, sortField } from '../fields/common'
|
||||||
|
import { currenciesField, currencyField } from '../fields/currency'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
|
||||||
|
type LimitRow = { currency?: string | null; min?: number | null; max?: number | null }
|
||||||
|
|
||||||
|
const validatePaymentSystem: CollectionBeforeChangeHook = ({
|
||||||
|
collection,
|
||||||
|
data,
|
||||||
|
originalDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
const doc = { ...originalDoc, ...data }
|
||||||
|
const currencies: string[] = doc.currencies ?? []
|
||||||
|
const errors: { path: string; message: string }[] = []
|
||||||
|
|
||||||
|
// ручным платежом пользуются только администраторы
|
||||||
|
if (doc.adapter === 'manual' && doc.visibleToCustomers) {
|
||||||
|
errors.push({
|
||||||
|
path: 'visibleToCustomers',
|
||||||
|
message: message(req, {
|
||||||
|
en: 'Manual payments are for administrators only',
|
||||||
|
ru: 'Ручным платежом пользуются только администраторы',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: LimitRow[] = doc.limits ?? []
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
if (row.currency && !currencies.includes(row.currency)) {
|
||||||
|
errors.push({
|
||||||
|
path: `limits.${index}.currency`,
|
||||||
|
message: message(req, {
|
||||||
|
en: 'Not among the payment system currencies',
|
||||||
|
ru: 'Не входит в валюты платёжной системы',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof row.min === 'number' && typeof row.max === 'number' && row.min > row.max) {
|
||||||
|
errors.push({
|
||||||
|
path: `limits.${index}.max`,
|
||||||
|
message: message(req, {
|
||||||
|
en: 'Maximum is less than minimum',
|
||||||
|
ru: 'Максимум меньше минимума',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возможности не хранятся: их объявляет адаптер в коде */
|
||||||
|
const readCapabilities: FieldHook = ({ siblingData }) => {
|
||||||
|
const adapter: unknown = siblingData?.adapter
|
||||||
|
if (!isPaymentAdapterCode(adapter)) return null
|
||||||
|
const capabilities: readonly string[] = paymentAdapters[adapter].capabilities
|
||||||
|
return capabilities.length > 0 ? capabilities.join(', ') : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentSystems: CollectionConfig = {
|
||||||
|
slug: 'payment-systems',
|
||||||
|
labels: {
|
||||||
|
singular: label('Payment system', 'Платёжная система'),
|
||||||
|
plural: label('Payment systems', 'Платёжные системы'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.settings,
|
||||||
|
useAsTitle: 'name',
|
||||||
|
defaultColumns: ['name', 'adapter', 'currencies', 'mode', 'active'],
|
||||||
|
description: label(
|
||||||
|
'Available in its currencies on every site where they are enabled. Provider keys are in .env only.',
|
||||||
|
'Доступна в своих валютах на всех сайтах, где они включены. Ключи провайдеров — только в .env.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
defaultSort: 'sort',
|
||||||
|
access: {
|
||||||
|
read: anyRole,
|
||||||
|
create: managers,
|
||||||
|
update: managers,
|
||||||
|
delete: managers,
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [validatePaymentSystem],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
label: label('Name in admin', 'Название в админке'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'adapter',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: PAYMENT_ADAPTER_CODES.map((code) => ({ value: code, label: code })),
|
||||||
|
label: label('Adapter', 'Адаптер'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
localized: true,
|
||||||
|
label: label('Title for customers', 'Название для пользователя'),
|
||||||
|
admin: { description: label('e.g. “Bank card”', 'например, «Банковская карта»') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'text',
|
||||||
|
localized: true,
|
||||||
|
label: label('Hint for customers', 'Подсказка для пользователя'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'icon',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: label('Icon', 'Иконка'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
currenciesField({
|
||||||
|
name: 'currencies',
|
||||||
|
label: label('Currencies', 'Валюты'),
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'purposes',
|
||||||
|
type: 'select',
|
||||||
|
hasMany: true,
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'order', label: label('Order payment', 'Оплата заказа') },
|
||||||
|
{ value: 'topup', label: label('Balance top-up', 'Пополнение баланса') },
|
||||||
|
],
|
||||||
|
label: label('Purposes', 'Назначения'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'limits',
|
||||||
|
type: 'array',
|
||||||
|
label: label('Limits', 'Лимиты'),
|
||||||
|
labels: { singular: label('Limit', 'Лимит'), plural: label('Limits', 'Лимиты') },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
|
||||||
|
amountField('min', label('Minimum', 'Минимум')),
|
||||||
|
amountField('max', label('Maximum', 'Максимум')),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'capabilities',
|
||||||
|
type: 'text',
|
||||||
|
virtual: true,
|
||||||
|
label: label('Capabilities (from the adapter)', 'Возможности (из адаптера)'),
|
||||||
|
admin: { readOnly: true },
|
||||||
|
hooks: { afterRead: [readCapabilities] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'visibleToCustomers',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: label('Visible to customers', 'Видна пользователям'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mode',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'test',
|
||||||
|
options: [
|
||||||
|
{ value: 'test', label: label('Test', 'Тестовый') },
|
||||||
|
{ value: 'live', label: label('Live', 'Боевой') },
|
||||||
|
],
|
||||||
|
label: label('Mode', 'Режим'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'active',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: label('Active', 'Включена'),
|
||||||
|
admin: { position: 'sidebar' },
|
||||||
|
},
|
||||||
|
sortField,
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type {
|
||||||
|
CollectionConfig,
|
||||||
|
SelectFieldManyValidation,
|
||||||
|
TextFieldSingleValidation,
|
||||||
|
} from 'payload'
|
||||||
|
import { select } from 'payload/shared'
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/locales'
|
||||||
|
|
||||||
|
import { anyRole, managers, owners } from '../access/roles'
|
||||||
|
import { localeOptions } from '../fields/locales'
|
||||||
|
import { groups, label, message } from '../i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Языки сайта — языки его контента. Домен показывает любую их часть.
|
||||||
|
* Английский обязателен: на него откатывается непереведённый контент.
|
||||||
|
*/
|
||||||
|
const validateLocales: SelectFieldManyValidation = async (value, args) => {
|
||||||
|
const base = select(value, args)
|
||||||
|
if (base !== true) return base
|
||||||
|
|
||||||
|
const locales = value ?? []
|
||||||
|
if (!locales.includes(DEFAULT_LOCALE)) {
|
||||||
|
return message(args.req, {
|
||||||
|
en: 'English is required: untranslated content falls back to it',
|
||||||
|
ru: 'Английский обязателен: на него откатывается непереведённый контент',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
const { docs } = await args.req.payload.find({
|
||||||
|
collection: 'domains',
|
||||||
|
where: { site: { equals: args.id } },
|
||||||
|
depth: 0,
|
||||||
|
pagination: false,
|
||||||
|
overrideAccess: true,
|
||||||
|
req: args.req,
|
||||||
|
})
|
||||||
|
const used = new Set(docs.flatMap((domain) => domain.locales ?? []))
|
||||||
|
const missing = [...used].filter((locale) => !locales.includes(locale))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return message(args.req, {
|
||||||
|
en: `These languages are enabled on the site's domains, remove them there first: ${missing.join(', ')}`,
|
||||||
|
ru: `Эти языки включены на доменах сайта, сначала уберите их там: ${missing.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateOrderPrefix: TextFieldSingleValidation = (value, { req }) =>
|
||||||
|
typeof value === 'string' && /^[A-Z]{2,4}$/.test(value)
|
||||||
|
? true
|
||||||
|
: message(req, {
|
||||||
|
en: '2–4 capital Latin letters, e.g. VX',
|
||||||
|
ru: '2–4 заглавные латинские буквы, например VX',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Sites: CollectionConfig = {
|
||||||
|
slug: 'sites',
|
||||||
|
labels: {
|
||||||
|
singular: label('Site', 'Сайт'),
|
||||||
|
plural: label('Sites', 'Сайты'),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: groups.settings,
|
||||||
|
useAsTitle: 'name',
|
||||||
|
defaultColumns: ['name', 'code', 'orderPrefix', 'locales'],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: anyRole,
|
||||||
|
create: managers,
|
||||||
|
update: managers,
|
||||||
|
// вместе с сайтом плагин удаляет все его документы
|
||||||
|
delete: owners,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'dedicated', label: label('Veilix (dedicated)', 'Вейликс (dedicated)') },
|
||||||
|
{ value: 'mass', label: label('VeilixVPN (mass)', 'ВейликсВПН (mass)') },
|
||||||
|
],
|
||||||
|
label: label('Brand', 'Бренд'),
|
||||||
|
// по коду выбираются тема и страницы бренда, после создания не меняется
|
||||||
|
access: { update: () => false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
localized: true,
|
||||||
|
label: label('Name', 'Название'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orderPrefix',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
label: label('Order number prefix', 'Префикс номеров'),
|
||||||
|
validate: validateOrderPrefix,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'locales',
|
||||||
|
type: 'select',
|
||||||
|
hasMany: true,
|
||||||
|
required: true,
|
||||||
|
options: localeOptions,
|
||||||
|
defaultValue: [...LOCALES],
|
||||||
|
label: label('Content languages', 'Языки контента'),
|
||||||
|
validate: validateLocales,
|
||||||
|
admin: {
|
||||||
|
description: label(
|
||||||
|
'Each domain of the site shows some of these languages.',
|
||||||
|
'Каждый домен сайта показывает часть этих языков.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user