Этап 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. Команды
|
||||
|
||||
Заполняется на этапе 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) | Документация | готово |
|
||||
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | не начат |
|
||||
| [1a](stages/stage-1a-foundation.md) | Фундамент: Next + Payload, сайты, домены, языки, валюты, темы | готово |
|
||||
| [1b](stages/stage-1b-marketing.md) | Маркетинговые страницы обоих брендов, контент, формы с ALTCHA, SEO, юр. тексты | не начат |
|
||||
| [1c](stages/stage-1c-auth.md) | Вход по коду, почта, сессии | не начат |
|
||||
| [1d](stages/stage-1d-billing.md) | Биллинг без эквайринга, оформление заказа, продления | не начат |
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
| № | Вопрос | Предложение | Нужно к этапу |
|
||||
|---|---|---|---|
|
||||
| Q-02 | Цены mass в USD, суммы пополнения и реферального бонуса в USD для обоих брендов | — нужны цифры | 1b |
|
||||
| Q-02 | Цены mass в USD, суммы пополнения и реферального бонуса в USD для обоих брендов | Нужны цифры. Пока в seed временные $6.99 и $69.90 (около $1 за 100 ₽, как у dedicated в макете) | 1b |
|
||||
| Q-08 | Email поддержки брендов | `help@veilix.app` и `help@veilixvpn.com` (в макете были домены .cc) | 1b |
|
||||
| Q-09 | Часовой пояс часов поддержки (10–20 в рабочие дни) | МСК | 1b |
|
||||
| Q-10 | Имена Telegram-ботов: 2 основных, 2 поддержки, тестовые | — | 3 |
|
||||
|
||||
+30
-18
@@ -11,19 +11,22 @@
|
||||
| Payload CMS | 3.91.x | Payload 4 в бете; переход после стабильного релиза ([D-002](decisions/D-002-payload-version.md)) |
|
||||
| PostgreSQL | 18 (на хосте) | адаптер `@payloadcms/db-postgres`, `idType: 'uuid'` |
|
||||
| Tailwind CSS | 4.x | токены из макета как CSS-переменные |
|
||||
| TypeScript | 6.0 | не 7.0: typescript-eslint из eslint-config-next поддерживает только TS < 6.1 |
|
||||
| ESLint | 9.39 | не 10: eslint-plugin-react, import и jsx-a11y не заявляют поддержку ESLint 10 |
|
||||
| Vitest | 5.x | модульные тесты |
|
||||
| next-intl | 4.x | переводы интерфейса, префиксы языков в URL |
|
||||
| Nodemailer | через `@payloadcms/email-nodemailer` | транспорты SMTP, sendmail, «только журнал» |
|
||||
| ALTCHA | 3.x | капча без третьих сторон |
|
||||
| grammY | 1.x | Telegram-боты, этап 3 |
|
||||
| Stripe SDK | актуальный | этап 4 |
|
||||
|
||||
Точные версии фиксируются в `package.json` на этапе 1a. Если к старту этапа 1a выйдет стабильный Payload 4, начинаем сразу на нём.
|
||||
Точные версии закреплены в `package.json`: на этапе 1a — Next 16.4.2, Payload 3.91.0, React 19.3.1, next-intl 4.14.9, Tailwind 4.3.3, pnpm 12.14. На старте 1a Payload 4 был в бете (4.0.0-beta.3), поэтому начали на 3.91 ([D-002](decisions/D-002-payload-version.md)). Телеметрия Next.js и Payload выключена.
|
||||
|
||||
## 2. Одно приложение на все домены
|
||||
|
||||
1. Одно Next.js-приложение, Payload установлен внутрь как библиотека: админка, API и фронтенды — один процесс ([D-001](decisions/D-001-single-app.md)).
|
||||
2. Proxy Next.js (`src/proxy.ts`, бывший middleware) по заголовку Host находит запись в коллекции «Домены» и узнаёт сайт (`dedicated` или `mass`) и тип фронтенда: сайт, кабинет или MiniApp. Список доменов кэшируется в памяти и сбрасывается при изменении записи.
|
||||
3. Proxy переписывает запрос во внутренний маршрут вида `/{тип}/{сайт}/{язык}/…`, для маркетинговых страниц ещё и `/{валюта}/`. Снаружи эти пути недоступны: прямой запрос к ним даёт 404.
|
||||
2. Proxy Next.js (`src/proxy.ts`, бывший middleware) по заголовку Host находит домен и узнаёт сайт (`dedicated` или `mass`) и тип фронтенда: сайт, кабинет или MiniApp. Список активных доменов proxy берёт у `/svc/internal/domains` (по токену из `.env`) и держит в памяти 15 секунд: правка домена в админке применяется в пределах 15 секунд ([D-022](decisions/D-022-proxy-routing.md)).
|
||||
3. Proxy переписывает запрос во внутренний маршрут по хосту: `/m/{хост}/{язык}/{валюта}/…` — сайт, `/c/{хост}/{язык}/…` — кабинет, `/t/{хост}/…` — MiniApp. Снаружи эти пути недоступны: прямой запрос к ним получает ещё один префикс и попадает в 404. Решения proxy — чистая функция `src/core/routing/decide.ts` с модульными тестами.
|
||||
4. Хост админки (`plc.veilix.cc`, `dev.plc.veilix.cc`) задаётся в `.env`. Только на нём работают `/admin` и REST API Payload. На доменах брендов эти пути закрыты.
|
||||
5. Сайтам, кабинетам и MiniApp хост админки не нужен:
|
||||
a. данные Payload они читают в том же процессе через Local API — это вызов функции, а не HTTP-запрос;
|
||||
@@ -32,29 +35,38 @@
|
||||
d. файлы медиатеки отдаются через домены сайтов.
|
||||
6. Ядро общее (биллинг, ключи, вход, рефералы, почта), UI-кит общий, а страницы у каждого бренда свои: данные в дизайнах представлены по-разному.
|
||||
|
||||
## 3. Структура кода (план)
|
||||
## 3. Структура кода
|
||||
|
||||
```
|
||||
src/
|
||||
proxy.ts — домен → сайт, тип фронтенда, язык, валюта
|
||||
proxy.ts — хост → домен, язык, валюта, внутренний маршрут
|
||||
payload.config.ts
|
||||
payload-types.ts — типы Payload (pnpm generate:types)
|
||||
app/
|
||||
(payload)/ — админка и REST API Payload (стандартные файлы)
|
||||
(front)/m/[site]/[locale]/[currency]/… — маркетинговые сайты
|
||||
(front)/c/[site]/[locale]/… — кабинеты и оформление заказа
|
||||
(front)/t/[site]/… — MiniApp (этап 3)
|
||||
svc/… — свои эндпоинты: ALTCHA, вебхуки, health
|
||||
(front)/m/[host]/[locale]/[currency]/(site)/… — маркетинговые сайты
|
||||
(front)/c/[host]/[locale]/(shell)/… — кабинет с навигацией
|
||||
(front)/c/[host]/[locale]/(plain)/… — вход и оформление заказа
|
||||
(front)/t/[host]/… — MiniApp (этап 3)
|
||||
(front)/context.ts — домен, язык и валюта страницы, данные оболочек
|
||||
svc/… — свои эндпоинты: health, internal/domains; дальше ALTCHA, вебхуки
|
||||
payload/
|
||||
collections/ globals/ hooks/ access/ fields/
|
||||
collections/ access/ fields/ hooks/ components/
|
||||
core/ — бизнес-логика без привязки к UI
|
||||
billing/ keys/ auth/ referral/ email/ payments/ currency/ geo/
|
||||
sites.ts domains/ routing/ currencies/ payments/ — этап 1a
|
||||
billing/ keys/ auth/ referral/ email/ geo/ — следующие этапы
|
||||
brands/
|
||||
dedicated/ mass/ — страницы и компоненты каждого бренда
|
||||
ui/ — общие компоненты: кнопки, поля, шиты, переключатели, флаги, карта
|
||||
i18n/ — конфиг next-intl и messages/{en,ru,de,fr,es}.json
|
||||
styles/ — Tailwind, токены брендов, темы
|
||||
dedicated/ mass/ — различия брендов; страницы и компоненты — с этапа 1b
|
||||
ui/ — общие компоненты: оболочки сайта и кабинета, переключатели, тема, логотип, иконки
|
||||
i18n/ — языки, конфиг next-intl и messages/{en,ru,de,fr,es}.json
|
||||
styles/ — Tailwind, токены брендов
|
||||
seed/ — начальные данные (pnpm seed)
|
||||
ops/systemd/ — юниты systemd
|
||||
scripts/ — служебные скрипты (очистка dev-базы)
|
||||
```
|
||||
|
||||
Корневые layout сайта и кабинета дают только документ: `<html>` с `lang`, `data-site` и скриптом темы. Шапка, подвал и навигация — во вложенных группах `(site)`, `(shell)` и `(plain)`.
|
||||
|
||||
Бизнес-логика живёт в `core/` и вызывается одинаково из хуков Payload, server actions, вебхуков, фоновых задач и (на этапе 3) бота.
|
||||
|
||||
## 4. Окружения
|
||||
@@ -66,7 +78,7 @@ src/
|
||||
| Запуск | `pnpm dev` (горячая перезагрузка) | `pnpm start` из `current` после `pnpm build` |
|
||||
| Порт | 3100 | 3000 |
|
||||
| База | `veilix_dev` | `veilix_prod` |
|
||||
| Схема базы | синхронизируется автоматически (push) | миграции Payload, применяются при выкладке |
|
||||
| Схема базы | синхронизируется автоматически (push); если push задаёт вопросы — `pnpm db:reset` и `pnpm seed` | миграции Payload, применяются при выкладке |
|
||||
| Домены | `dev.*`, `dev.plc.veilix.cc` | основные, `plc.veilix.cc` |
|
||||
| Секреты | `/home/vx_dev/veilix_dev/.env` | `/home/vx_prod/veilix/shared/.env` |
|
||||
| Файлы (media) | `/home/vx_dev/veilix_dev/media` | `/home/vx_prod/veilix/shared/media` |
|
||||
@@ -77,7 +89,7 @@ src/
|
||||
|
||||
## 5. Выкладка в prod
|
||||
|
||||
1. В dev: изменения схемы оформляются миграцией (`payload migrate:create`), коммит, push в Gitea, тег релиза.
|
||||
1. В dev: изменения схемы оформляются миграцией (`payload migrate:create`), коммит, push в Gitea, тег релиза. До первой выкладки (этап 1h) миграции не накапливаем: перед ней создаём одну начальную по итоговой схеме. Механизм проверен на этапе 1a: миграция, применённая к пустой схеме, создаёт те же таблицы, что push в dev.
|
||||
2. Выкладка — команда `deploy <тег>` пользователя vx_prod, появится на этапе 1h. Запускает её пользователь; по желанию — sudo-правило, разрешающее vx_dev запускать только эту команду (Q-18). Шаги:
|
||||
a. взять тег из Gitea (у vx_prod ключ только на чтение) в новую папку `releases/<дата-тег>`;
|
||||
b. `pnpm install --frozen-lockfile` и `pnpm build`; ссылки на `shared/.env` и `shared/media`;
|
||||
@@ -89,7 +101,7 @@ src/
|
||||
|
||||
## 6. Рендеринг и кэширование
|
||||
|
||||
1. Маркетинговые страницы собираются статически во всех вариантах «язык × валюта» и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
|
||||
1. Маркетинговые страницы собираются статически в вариантах «домен × язык × валюта» при первом запросе (ISR) и кэшируются. Proxy выбирает вариант по префиксу языка и cookie валюты.
|
||||
2. Изменение в админке (цена, статус локации, FAQ) вызывает хук Payload, и Next.js пересобирает затронутые страницы за секунды (on-demand revalidation по тегам). Запасной вариант — плановое обновление раз в 5 минут.
|
||||
3. При оформлении заказа наличие и цена проверяются заново на сервере. Устаревшая страница не может продать то, чего нет.
|
||||
4. Кабинет рендерится на сервере (SSR) для каждого запроса, без кэша.
|
||||
|
||||
+18
-9
@@ -8,6 +8,8 @@
|
||||
4. Деньги — целое число в минимальных единицах (`amount`: 249900 = 2 499,00 ₽) плюс `currency` — код ISO 4217 (`RUB`, `USD`). Код проверяется по справочнику «Валюты».
|
||||
5. Даты — `timestamptz`, в UTC.
|
||||
6. Секреты в полях (конфиги WG, ссылки подписок, SMTP-пароль, id сохранённых карт) шифруются приложением (AES-256-GCM, ключ в `.env`).
|
||||
7. Валюта везде хранится кодом ISO 4217 — и в деньгах, и в настройках (валюты домена и платёжной системы, строки цен). Код проверяется по справочнику, в админке это выпадающий список из «Валют».
|
||||
8. Переводы полей Payload хранятся в таблицах `<коллекция>_l10n` (настройка адаптера `localesSuffix`). Имя по умолчанию `<коллекция>_locales` совпало бы с таблицей поля `locales` у сайтов и доменов.
|
||||
|
||||
## 2. Схема связей
|
||||
|
||||
@@ -48,6 +50,7 @@ erDiagram
|
||||
| code | select `dedicated` \| `mass`, уникальное | бренд: по нему выбираются тема и страницы |
|
||||
| name | text (L) | «Вейликс» / «Veilix» |
|
||||
| orderPrefix | text | префикс номеров: `VX`, `VV` |
|
||||
| locales | multi-select из языков конфига | языки контента сайта; en обязателен; домены показывают часть этих языков ([D-023](decisions/D-023-site-locales.md)) |
|
||||
| supportEmail | email | почта поддержки (Q-08) |
|
||||
| emailFrom | group: name, address | отправитель писем, напр. «Вейликс» <no-reply@veilix.app> |
|
||||
| supportBot, mainBot | text | имена ботов (этап 3) |
|
||||
@@ -60,22 +63,26 @@ erDiagram
|
||||
| expiringBadgeDays | number, по умолчанию 7 | когда показывать «Истекает» |
|
||||
| seo | group: titleTemplate (L), description (L), ogImage | значения по умолчанию |
|
||||
|
||||
На этапе 1a сделаны `code` (после создания не меняется), `name`, `orderPrefix` и `locales`. Остальные поля добавляют этапы, которые ими пользуются: SEO и контакты — 1b, отправитель писем — 1c, сроки и пресеты — 1d и 1f, рефералы — 1g, боты — 3.
|
||||
|
||||
### 3.2 `domains` — Домены (сайт)
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| hostname | text, уникальное | `veilix.app`, `my.veilix.app`, … |
|
||||
| app | select `marketing` \| `cabinet` \| `miniapp` | тип фронтенда |
|
||||
| locales | multi-select из языков конфига | включённые языки |
|
||||
| defaultLocale | select | обычно `en` |
|
||||
| locales | multi-select из языков конфига | включённые языки — часть языков сайта |
|
||||
| defaultLocale | select | обычно `en`; открывается без префикса в адресе |
|
||||
| currencies | multi-select валют | включённые валюты |
|
||||
| defaultCurrency | select | валюта по умолчанию |
|
||||
| localeCurrency | array: locale, currency | валюта по умолчанию для языка: ru → RUB |
|
||||
| isPrimary | checkbox | основной домен этого типа; используется в ссылках и sitemap |
|
||||
| isPrimary | checkbox | основной домен этого типа (у сайта один на каждый тип); используется в ссылках и sitemap |
|
||||
| indexable | checkbox | разрешена ли индексация (для кабинета и MiniApp — нет) |
|
||||
| umamiWebsiteId | text | этап 1h |
|
||||
| active | checkbox | |
|
||||
|
||||
Язык и валюта по умолчанию должны входить в списки домена, язык в правилах «язык → валюта» не повторяется — это проверяется при сохранении.
|
||||
|
||||
### 3.3 `currencies` — Валюты
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
@@ -99,10 +106,10 @@ erDiagram
|
||||
| icon | upload | |
|
||||
| currencies | multi-select валют | в каких валютах доступна |
|
||||
| purposes | multi-select `order` \| `topup` | оплата заказа, пополнение |
|
||||
| visibleToCustomers | checkbox | у `manual` выключено: ей пользуются только администраторы |
|
||||
| visibleToCustomers | checkbox | у `manual` всегда выключено (проверяется при сохранении): ей пользуются только администраторы |
|
||||
| mode | select `test` \| `live` | |
|
||||
| limits | array: currency, min, max | |
|
||||
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod` |
|
||||
| capabilities | только чтение, из адаптера | `recurring`, `refunds`, `saveMethod`; объявлены в `src/core/payments/adapters.ts`, в базе не хранятся |
|
||||
| active, sort | | |
|
||||
|
||||
Ключи доступа к провайдерам — только в `.env` (`PAYMENT_<КОД>_…`), не в базе.
|
||||
@@ -123,7 +130,7 @@ erDiagram
|
||||
| country | relationship → countries | уникальна в пределах сайта |
|
||||
| city | text (L) | |
|
||||
| lat, lng | number | точка города на карте |
|
||||
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро» |
|
||||
| status | select `available` \| `on_demand` \| `soon` | dedicated: «Серверы в наличии» / «Установим за 24 часа»; mass: «Включено» / «Скоро». Чужой для бренда статус не сохранится |
|
||||
| freeServers | number, необязательное | счётчик (решение о показе — этап 1b) |
|
||||
| visible, sort | | |
|
||||
|
||||
@@ -153,13 +160,13 @@ erDiagram
|
||||
|
||||
### 4.1 `admins` — Администраторы
|
||||
|
||||
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`.
|
||||
Стандартная авторизация Payload (email и пароль), `maxLoginAttempts: 5`, `lockTime: 15 мин`. Первый администратор окружения (экран первого входа) становится owner. Последнего owner нельзя лишить роли и удалить. Роли и сайты меняет только owner, остальные правят только своё имя и пароль.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| name | text | |
|
||||
| roles | multi-select `owner` \| `admin` \| `support` \| `content` | см. раздел 8 |
|
||||
| sites | доступ к тенантам | owner и admin видят все сайты |
|
||||
| sites | доступ к тенантам | owner и admin видят все сайты, поэтому поле показывается только ролям support и content; на экране первого входа скрыто |
|
||||
|
||||
### 4.2 `customers` — Пользователи (сайт)
|
||||
|
||||
@@ -362,7 +369,9 @@ QR-коды строятся на лету из `payload`, не хранятся
|
||||
|
||||
| Коллекция | owner, admin | support | content | пользователь (через приложение) |
|
||||
|---|---|---|---|---|
|
||||
| Настройки и справочники | всё | чтение | чтение | — |
|
||||
| Настройки и справочники | всё (удаление сайта — только owner) | чтение | чтение | — |
|
||||
| admins | owner — всё, admin — только себя | только себя | только себя | — |
|
||||
| media | всё | чтение | всё | файлы открыты всем: их показывают сайты |
|
||||
| pages, faq, email-templates, локализация | всё | чтение | всё | — |
|
||||
| customers | всё | чтение, правка заметок, блокировка | — | только свой профиль |
|
||||
| keys, connections | всё | всё | — | только свои, чтение |
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
1. Стандартная авторизация Payload: email и пароль, cookie только на хосте админки.
|
||||
2. Админка открывается только на `plc.veilix.cc` и `dev.plc.veilix.cc`; nginx пускает туда только с разрешённых IP.
|
||||
3. 5 неудачных попыток — блокировка на 15 минут.
|
||||
4. Первый администратор каждого окружения создаётся на экране первого входа Payload.
|
||||
4. Первый администратор каждого окружения создаётся на экране первого входа Payload и автоматически получает роль owner.
|
||||
|
||||
## 8. Cookies
|
||||
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@
|
||||
|
||||
## 3. URL
|
||||
|
||||
1. Английский без префикса, остальные — с префиксом: `veilix.app/pricing`, `veilix.app/ru/pricing`, `veilix.app/de/pricing`.
|
||||
1. Английский без префикса, остальные — с префиксом: `veilix.app/pricing`, `veilix.app/ru/pricing`, `veilix.app/de/pricing`. Точнее, без префикса открывается язык домена по умолчанию (`domains.defaultLocale`, сейчас везде en), а адрес с его префиксом (`/en/pricing`) перенаправляется на адрес без префикса.
|
||||
2. Так же в кабинете: `my.veilix.app/keys`, `my.veilix.app/ru/keys`.
|
||||
3. MiniApp: адрес в BotFather один, поэтому язык берётся из профиля пользователя или из Telegram; префикс в URL необязателен.
|
||||
4. Пути одинаковые для всех языков (английские): `/pricing`, `/locations`, `/support`, `/legal/terms`, `/keys`, `/billing`, `/referral`, `/order`, `/login`.
|
||||
@@ -29,9 +29,9 @@
|
||||
## 4. Определение языка
|
||||
|
||||
1. Порядок: язык из URL → выбранный ранее (cookie `NEXT_LOCALE`, у вошедшего — язык профиля) → язык браузера (`Accept-Language`) среди включённых → страна из заголовка nginx `X-Geo-Country` (MaxMind) → `en`.
|
||||
2. Перенаправление на версию с префиксом — только при первом заходе на адрес без префикса, без cookie, и только если выбран не английский. Поисковых ботов не перенаправляем никогда: так рекомендует Google.
|
||||
2. Перенаправление на версию с префиксом — с адреса без префикса и только если выбран не язык домена по умолчанию ([D-024](decisions/D-024-locale-redirect.md)). Есть cookie `NEXT_LOCALE` — ведём на язык из cookie; cookie нет — определяем по языку браузера и стране. Перенаправляются только GET и HEAD. Поисковых ботов не перенаправляем никогда: так рекомендует Google.
|
||||
3. Переключатель языка ставит cookie `NEXT_LOCALE`. У вошедшего пользователя язык сохраняется ещё и в профиле: на нём уходят письма и сообщения бота.
|
||||
4. Соответствие стран языкам по умолчанию: RU, BY, KZ, KG → ru; DE, AT → de; FR → fr; ES и страны Латинской Америки → es; остальные → en.
|
||||
4. Соответствие стран языкам по умолчанию: RU, BY, KZ, KG → ru; DE, AT → de; FR → fr; ES и испаноязычные страны Латинской Америки → es (Бразилия — en, пока нет pt-br); остальные → en. Таблица — `src/core/routing/locale.ts`.
|
||||
|
||||
## 5. SEO
|
||||
|
||||
|
||||
+9
-1
@@ -32,6 +32,7 @@
|
||||
| `/order` | оформление заказа: шаги «Почта» и «Оплата», сводка |
|
||||
| `/billing` | баланс, пополнение, карта, ближайшие списания, история |
|
||||
| `/referral` | реферальная программа |
|
||||
| `/more` | вкладка «Ещё» на узком экране: счёт, рефералы, поддержка, тема, язык |
|
||||
| `/r/[код]` | реферальная ссылка: ставит cookie и ведёт на сайт бренда |
|
||||
|
||||
Навигация по макету: на широком экране — боковая панель, на узком — плавающая панель вкладок «Главная, Ключи, Купить, Ещё».
|
||||
@@ -39,7 +40,7 @@
|
||||
## 4. Темы и токены
|
||||
|
||||
1. Tailwind v4: токены макета — CSS-переменные, подключённые в `@theme`. Бренд задаёт атрибут `data-site="dedicated|mass"` на `<html>`, тему — `data-theme="light|dark"`. Режим «Как в системе» следует `prefers-color-scheme`.
|
||||
2. Переключатель темы — 3 режима, как в макете. Выбор хранится в localStorage, тема применяется скриптом в `<head>` до отрисовки.
|
||||
2. Переключатель темы — 3 режима, как в макете. Выбор хранится в localStorage (ключ `vx_theme`), тема применяется скриптом в `<head>` до отрисовки. Новый посетитель видит «Как в системе». localStorage у каждого хоста свой: тема, выбранная на сайте, не переходит в кабинет.
|
||||
3. Токены в OKLCH, как в макете. Ниже — для ориентира; точные значения — в `<style>` прототипов, при расхождении верен прототип.
|
||||
|
||||
| Токен | Вейликс светлая | Вейликс тёмная | ВейликсВПН светлая | ВейликсВПН тёмная |
|
||||
@@ -60,6 +61,8 @@
|
||||
|
||||
Общие для брендов: `--warn` (62% .14 62 / 78% .13 62), `--tg` (62% .15 250 / 70% .13 250), `--veil` (белый 55% / 7%), `--shadow`, цвета карты `--land`, `--land-line`, `--ocean`.
|
||||
|
||||
В коде токены — `src/styles/tokens.css`, утилиты Tailwind называются так же (`bg-surface`, `text-ink-2`, `bg-sel`); стандартная палитра Tailwind отключена. У Вейликса `--sel` равен `--accent-soft`. Цвета карты Вейликса в `Main` светлее, чем в `Locations`; на этапе 1a взяты из `Locations`, окончательно решаем на этапе 1b.
|
||||
|
||||
## 5. Шрифты, иконки, логотип
|
||||
|
||||
1. Commissioner 300–600 — весь текст; Unbounded 600 — только надпись логотипа. Подключение через `next/font`: файлы скачиваются при сборке и отдаются с нашего домена, без запросов к Google Fonts. Наборы символов: latin, latin-ext, cyrillic.
|
||||
@@ -85,6 +88,11 @@
|
||||
9. Плашки способов оплаты в подвале — по активным платёжным системам сайта.
|
||||
10. Добавляем то, чего нет в макете: переключатель валюты, `<title>` и мета-описания, H1 на каждой странице, поддержку `prefers-reduced-motion` для анимаций.
|
||||
11. Показывать ли на сайте количество свободных серверов (в MiniApp макет его показывает) — решаем на этапе 1b. По умолчанию — только статус.
|
||||
12. Переключатели при 5 языках и с валютой (в макете только RU/EN, валюты нет). В шапке — выпадающие списки языка и валюты. В подвале, мобильном меню и кабинете — сегменты с кодами языков (EN RU DE FR ES) и символами валют (₽ $). В оболочке кабинета валюты нет: она появится в настройках (1f).
|
||||
13. В подвале четыре документа: добавлена «Политика cookies». Плашек способов оплаты нет, пока нет видимых пользователю платёжных систем (пункт 9).
|
||||
14. Кабинет mass: подписи навигации те же, что у dedicated («Список ключей», «Купить ключ», «Ключи»). Подписи макета MassApp («Подключения», «Выбрать страну», «Подписка») не используем (пункт 4).
|
||||
15. Ширина надписи логотипа — по содержимому: в макете она подогнана под русское название.
|
||||
16. 404. При `notFound()` Next 16 отдаёт пустую оболочку (`<html id="__next_error__">`), а страница 404 в оформлении бренда отрисовывается в браузере; статус ответа — 404. Серверную отрисовку 404 и 500 решаем на этапе 1b вместе с их оформлением: вариант — `global-not-found.js`.
|
||||
|
||||
## 8. Состояния, которых нет в макетах
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ vx — личный пользователь администратора, к п
|
||||
## 3. Процессы
|
||||
|
||||
1. Каждый сервис — под своим пользователем ОС, как systemd user-юнит. Root один раз включает `loginctl enable-linger` для vx_dev, vx_prod и umami, чтобы юниты работали без входа в систему.
|
||||
2. `veilix-dev.service` (vx_dev): `pnpm dev` на порту 3100 из `/home/vx_dev/veilix_dev`, перезапуск при сбое.
|
||||
2. `veilix-dev.service` (vx_dev): `pnpm dev` на 127.0.0.1:3100 из `/home/vx_dev/veilix_dev`, перезапуск при сбое. Файл юнита — `ops/systemd/veilix-dev.service` в репозитории, подключён через `systemctl --user link`.
|
||||
3. `veilix.service` (vx_prod): `pnpm start` на порту 3000 из `/home/vx_prod/veilix/current`, `NODE_ENV=production`, перезапуск всегда.
|
||||
4. `umami.service` (umami): Umami на порту 3300.
|
||||
5. Фоновые задачи Payload выполняются внутри процессов приложения ([02-architecture.md](02-architecture.md), раздел 7).
|
||||
@@ -89,12 +89,12 @@ vx — личный пользователь администратора, к п
|
||||
|
||||
| № | Этап | Задача | Статус на 21.10.2026 |
|
||||
|---|---|---|---|
|
||||
| R-00 | 0 | Переезд разработки: перенести `/home/vx/veilix_dev` в `/home/vx_dev/veilix_dev` (владелец vx_dev); SSH-ключ vx_dev для VSCode; Claude Code под vx_dev | не сделано |
|
||||
| R-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано |
|
||||
| R-00 | 0 | Переезд разработки: перенести `/home/vx/veilix_dev` в `/home/vx_dev/veilix_dev` (владелец vx_dev); SSH-ключ vx_dev для VSCode; Claude Code под vx_dev | сделано |
|
||||
| R-01 | 0 | Репозиторий в Gitea; SSH-ключ vx_dev с правом push; для vx_prod — ключ только на чтение (deploy key) | не сделано: remote в репозитории нет |
|
||||
| R-02 | 1a | PostgreSQL: роли `vx_dev`, `vx_prod`, `umami` и их базы (раздел 5) | сделано и проверено: vx_dev входит в `veilix_dev`, в `veilix_prod` — отказ |
|
||||
| R-03 | 1a | pnpm: `corepack enable pnpm` | сделано |
|
||||
| R-04 | 1a | Пользователи vx_dev, vx_prod, umami; `loginctl enable-linger` для них | сделано |
|
||||
| R-05 | 1a | nginx и сертификаты для всех хостов (раздел 4) | работает; осталось исправить опечатку в `conf/ip_list_admin.conf` и добавить `X-Robots-Tag` для `dev.tg.*` |
|
||||
| R-05 | 1a | nginx и сертификаты для всех хостов (раздел 4) | сделано: опечатка исправлена, `X-Robots-Tag` на всех dev-хостах |
|
||||
| R-06 | 1a | MaxMind: `geoipupdate` (ключ в `/etc/GeoIP.conf`, база в `/var/lib/GeoIP`, таймер `geoipupdate.timer`); отключить старый модуль GeoIP v1; блок `geoip2` (раздел 4, пункт 4) | сделано |
|
||||
| R-07 | 1c | Postfix, OpenDKIM, DNS-записи SPF, DKIM, DMARC — или данные SMTP-провайдера | |
|
||||
| R-08 | 1h | PostgreSQL для prod — если не сделано вместе с R-02 | сделано в R-02 |
|
||||
@@ -106,3 +106,4 @@ vx — личный пользователь администратора, к п
|
||||
| R-14 | 4 | Аккаунты платёжных систем (сначала тестовые), адреса вебхуков | |
|
||||
| R-15 | 1h | По желанию (Q-18): sudo-правило, разрешающее vx_dev запускать от имени vx_prod только команду выкладки | |
|
||||
| R-16 | 1h | По желанию: правило nftables — к 127.0.0.1:3000 подключаются только `www-data` (nginx) и vx_prod ([10-security.md](10-security.md), раздел 7) | |
|
||||
| R-17 | 1a | nginx dev: отдельный `location /_next/webpack-hmr` с `proxy_read_timeout 1h` в server-блоках dev-хостов и `dev.plc.veilix.cc`. Иначе WebSocket горячей перезагрузки закрывается каждые 30 секунд (`proxy_read_timeout 30` в `nginx.conf`) | |
|
||||
@@ -17,6 +17,8 @@
|
||||
2. Роли owner, admin, support, content ([03-data-model.md](03-data-model.md), раздел 10). Поле `debugCode` журнала писем видят только owner и admin.
|
||||
3. Блокировка после 5 неудачных попыток входа.
|
||||
4. Денежные операции, выдача и блокировка ключей, объединение аккаунтов пишутся в журнал действий. Журнал нельзя изменить.
|
||||
5. GraphQL Payload выключен. Телеметрия Next.js и Payload выключена.
|
||||
6. Служебный эндпоинт `/svc/internal/domains` отвечает только с токеном `INTERNAL_TOKEN` из `.env`, иначе 404. Cookie администратора — только по HTTPS.
|
||||
|
||||
## 4. Пользователи
|
||||
|
||||
|
||||
@@ -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-003](D-003-hosting.md) | Без Docker: всё на хосте, процессы под systemd | принято |
|
||||
| [D-004](D-004-environments.md) | Dev и prod на одном сервере под разными пользователями | принято |
|
||||
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято |
|
||||
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято |
|
||||
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято |
|
||||
| [D-005](D-005-multitenancy.md) | Сайты — тенанты, домены в базе, маршрутизация по Host | принято, уточнено в D-022 |
|
||||
| [D-006](D-006-localization.md) | Переводы: поля Payload и next-intl; 5 языков | принято, уточнено в D-023 |
|
||||
| [D-007](D-007-language-detection.md) | Определение языка; MaxMind через nginx | принято, уточнено в D-024 |
|
||||
| [D-008](D-008-uuid.md) | UUID и случайные публичные коды | принято |
|
||||
| [D-009](D-009-billing-model.md) | Заказы, платежи, движения по счёту; всё через баланс | принято |
|
||||
| [D-010](D-010-currencies.md) | Валюты и платёжные системы — сущности; платёжные системы привязаны к валютам | принято |
|
||||
@@ -25,3 +25,6 @@
|
||||
| [D-019](D-019-external-services.md) | Chatwoot и Twenty CRM на отдельном VPS | принято |
|
||||
| [D-020](D-020-key-models.md) | Модели ключей dedicated и mass | принято |
|
||||
| [D-021](D-021-admin-host.md) | Админка на plc.veilix.cc с доступом по IP | принято |
|
||||
| [D-022](D-022-proxy-routing.md) | Proxy: внутренние маршруты по хосту, домены из внутреннего эндпоинта | принято |
|
||||
| [D-023](D-023-site-locales.md) | Языки сайта — поле `sites.locales`, домены показывают их часть | принято |
|
||||
| [D-024](D-024-locale-redirect.md) | Перенаправление на выбранный язык | принято |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 1a. Фундамент
|
||||
|
||||
Статус: не начат.
|
||||
Статус: готово 21.10.2026. Остался R-17 (root, nginx), см. раздел 6.
|
||||
|
||||
## 1. Цель
|
||||
|
||||
@@ -37,3 +37,23 @@ Root: R-00…R-06 ([09-infrastructure.md](../09-infrastructure.md), раздел
|
||||
5. В URL админки — UUID.
|
||||
6. `dev.my.*` показывает оболочку кабинета; `/admin` на доменах брендов — 404.
|
||||
7. `pnpm build`, линтер и проверка типов проходят.
|
||||
|
||||
## 6. Итоги
|
||||
|
||||
Проверено curl через nginx и напрямую на 127.0.0.1:3100: пункты 2, 3, 5, 6 и 7, а также HTTPS, `lang`, `data-site`, скрипт темы в `<head>` и токены обоих брендов в CSS. Внешний вид сайтов и кабинета и админку (пункты 1 и 4) проверил пользователь в браузере, первый администратор создан.
|
||||
|
||||
Решения и отклонения от плана:
|
||||
|
||||
1. Стек: TypeScript 6.0 и ESLint 9.39 вместо последних 7 и 10 — их пока не поддерживают плагины ([02-architecture.md](../02-architecture.md), раздел 1).
|
||||
2. Внутренние маршруты — по хосту, а не по коду сайта; домены proxy берёт у `/svc/internal/domains` с кэшем на 15 секунд ([D-022](../decisions/D-022-proxy-routing.md)).
|
||||
3. Языки сайта — поле `sites.locales` ([D-023](../decisions/D-023-site-locales.md)); перенаправление на язык из cookie ([D-024](../decisions/D-024-locale-redirect.md)).
|
||||
4. В `sites` сделаны только `code`, `name`, `orderPrefix` и `locales`; остальные поля добавят этапы, которые ими пользуются ([03-data-model.md](../03-data-model.md), раздел 3.1).
|
||||
5. Таблицы переводов Payload — с суффиксом `_l10n`; для вопросов push в dev — `pnpm db:reset`.
|
||||
6. Миграции: механизм проверен на пустой схеме; начальную миграцию создаём перед первой выкладкой (1h).
|
||||
7. Mass в USD — временные цены $6.99 и $69.90 до решения Q-02.
|
||||
8. Адаптация макетов: переключатели для 5 языков и валюты, «Политика cookies» в подвале, подписи кабинета mass как у dedicated ([07-frontend.md](../07-frontend.md), раздел 7).
|
||||
|
||||
Осталось:
|
||||
|
||||
1. R-17 (root): `location /_next/webpack-hmr` с большим `proxy_read_timeout` в nginx dev — иначе горячая перезагрузка переподключается каждые 30 секунд.
|
||||
2. На этап 1b: серверная отрисовка 404 и 500 ([07-frontend.md](../07-frontend.md), раздел 7.16), цвета карты, пересборка страниц по событию из Payload.
|
||||
@@ -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