From 3400f58a5a2620f2aed821be207ce12108b46911 Mon Sep 17 00:00:00 2001 From: AP Date: Wed, 21 Oct 2026 03:30:38 +0200 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=200:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 38 + README.md | 7 + design/handoff/README.md | 22 + design/handoff/project/.thumbnail | Bin 0 -> 18992 bytes design/handoff/project/App.dc.html | 991 +++++++++ design/handoff/project/AppTG.dc.html | 815 +++++++ design/handoff/project/Locations.dc.html | 611 ++++++ design/handoff/project/Main.dc.html | 810 +++++++ design/handoff/project/MassApp.dc.html | 984 +++++++++ design/handoff/project/MassAppTG.dc.html | 806 +++++++ design/handoff/project/MassLocations.dc.html | 602 ++++++ design/handoff/project/MassMain.dc.html | 776 +++++++ design/handoff/project/MassOrder.dc.html | 296 +++ design/handoff/project/MassPrice.dc.html | 493 +++++ design/handoff/project/MassSupport.dc.html | 369 ++++ design/handoff/project/Order.dc.html | 281 +++ design/handoff/project/Price.dc.html | 501 +++++ design/handoff/project/Support.dc.html | 367 ++++ design/handoff/project/favicon-mass.svg | 8 + design/handoff/project/favicon.svg | 8 + design/handoff/project/i18n.js | 597 +++++ design/handoff/project/ios-frame.jsx | 352 +++ design/handoff/project/screenshots/footer.png | Bin 0 -> 20317 bytes .../handoff/project/screenshots/massapp.png | Bin 0 -> 32131 bytes design/handoff/project/support.js | 1911 +++++++++++++++++ .../62d86febee91c876f5780a793a29f984.png | Bin 0 -> 13577 bytes .../uploads/pasted-1789712166378-0.png | Bin 0 -> 1248750 bytes .../handoff/project/uploads/Дизайн VEILIX.md | 197 ++ docs/00-index.md | 77 + docs/01-product.md | 155 ++ docs/02-architecture.md | 116 + docs/03-data-model.md | 374 ++++ docs/04-billing.md | 188 ++ docs/05-auth.md | 103 + docs/06-i18n.md | 79 + docs/07-frontend.md | 104 + docs/08-integrations.md | 82 + docs/09-infrastructure.md | 108 + docs/10-security.md | 56 + docs/decisions/D-001-single-app.md | 24 + docs/decisions/D-002-payload-version.md | 21 + docs/decisions/D-003-hosting.md | 22 + docs/decisions/D-004-environments.md | 41 + docs/decisions/D-005-multitenancy.md | 18 + docs/decisions/D-006-localization.md | 20 + docs/decisions/D-007-language-detection.md | 17 + docs/decisions/D-008-uuid.md | 16 + docs/decisions/D-009-billing-model.md | 24 + docs/decisions/D-010-currencies.md | 22 + docs/decisions/D-011-accounts.md | 17 + docs/decisions/D-012-auth.md | 18 + docs/decisions/D-013-captcha.md | 16 + docs/decisions/D-014-email.md | 18 + docs/decisions/D-015-telegram-bots.md | 19 + docs/decisions/D-016-payments.md | 15 + docs/decisions/D-017-static-pages.md | 14 + docs/decisions/D-018-analytics.md | 20 + docs/decisions/D-019-external-services.md | 13 + docs/decisions/D-020-key-models.md | 16 + docs/decisions/D-021-admin-host.md | 15 + docs/decisions/README.md | 27 + docs/history/HUMAN_IDEA.md | 226 ++ docs/stages/stage-0-docs.md | 19 + docs/stages/stage-1a-foundation.md | 39 + docs/stages/stage-1b-marketing.md | 34 + docs/stages/stage-1c-auth.md | 32 + docs/stages/stage-1d-billing.md | 36 + docs/stages/stage-1e-keys.md | 26 + docs/stages/stage-1f-cabinet.md | 24 + docs/stages/stage-1g-referral.md | 23 + docs/stages/stage-1h-launch.md | 26 + docs/stages/stage-2-support-crm.md | 19 + docs/stages/stage-3-telegram.md | 23 + docs/stages/stage-4-payments.md | 26 + docs/stages/stage-5-keys-automation.md | 13 + 75 files changed, 14283 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 design/handoff/README.md create mode 100644 design/handoff/project/.thumbnail create mode 100644 design/handoff/project/App.dc.html create mode 100644 design/handoff/project/AppTG.dc.html create mode 100644 design/handoff/project/Locations.dc.html create mode 100644 design/handoff/project/Main.dc.html create mode 100644 design/handoff/project/MassApp.dc.html create mode 100644 design/handoff/project/MassAppTG.dc.html create mode 100644 design/handoff/project/MassLocations.dc.html create mode 100644 design/handoff/project/MassMain.dc.html create mode 100644 design/handoff/project/MassOrder.dc.html create mode 100644 design/handoff/project/MassPrice.dc.html create mode 100644 design/handoff/project/MassSupport.dc.html create mode 100644 design/handoff/project/Order.dc.html create mode 100644 design/handoff/project/Price.dc.html create mode 100644 design/handoff/project/Support.dc.html create mode 100644 design/handoff/project/favicon-mass.svg create mode 100644 design/handoff/project/favicon.svg create mode 100644 design/handoff/project/i18n.js create mode 100644 design/handoff/project/ios-frame.jsx create mode 100644 design/handoff/project/screenshots/footer.png create mode 100644 design/handoff/project/screenshots/massapp.png create mode 100644 design/handoff/project/support.js create mode 100644 design/handoff/project/uploads/62d86febee91c876f5780a793a29f984.png create mode 100644 design/handoff/project/uploads/pasted-1789712166378-0.png create mode 100644 design/handoff/project/uploads/Дизайн VEILIX.md create mode 100644 docs/00-index.md create mode 100644 docs/01-product.md create mode 100644 docs/02-architecture.md create mode 100644 docs/03-data-model.md create mode 100644 docs/04-billing.md create mode 100644 docs/05-auth.md create mode 100644 docs/06-i18n.md create mode 100644 docs/07-frontend.md create mode 100644 docs/08-integrations.md create mode 100644 docs/09-infrastructure.md create mode 100644 docs/10-security.md create mode 100644 docs/decisions/D-001-single-app.md create mode 100644 docs/decisions/D-002-payload-version.md create mode 100644 docs/decisions/D-003-hosting.md create mode 100644 docs/decisions/D-004-environments.md create mode 100644 docs/decisions/D-005-multitenancy.md create mode 100644 docs/decisions/D-006-localization.md create mode 100644 docs/decisions/D-007-language-detection.md create mode 100644 docs/decisions/D-008-uuid.md create mode 100644 docs/decisions/D-009-billing-model.md create mode 100644 docs/decisions/D-010-currencies.md create mode 100644 docs/decisions/D-011-accounts.md create mode 100644 docs/decisions/D-012-auth.md create mode 100644 docs/decisions/D-013-captcha.md create mode 100644 docs/decisions/D-014-email.md create mode 100644 docs/decisions/D-015-telegram-bots.md create mode 100644 docs/decisions/D-016-payments.md create mode 100644 docs/decisions/D-017-static-pages.md create mode 100644 docs/decisions/D-018-analytics.md create mode 100644 docs/decisions/D-019-external-services.md create mode 100644 docs/decisions/D-020-key-models.md create mode 100644 docs/decisions/D-021-admin-host.md create mode 100644 docs/decisions/README.md create mode 100644 docs/history/HUMAN_IDEA.md create mode 100644 docs/stages/stage-0-docs.md create mode 100644 docs/stages/stage-1a-foundation.md create mode 100644 docs/stages/stage-1b-marketing.md create mode 100644 docs/stages/stage-1c-auth.md create mode 100644 docs/stages/stage-1d-billing.md create mode 100644 docs/stages/stage-1e-keys.md create mode 100644 docs/stages/stage-1f-cabinet.md create mode 100644 docs/stages/stage-1g-referral.md create mode 100644 docs/stages/stage-1h-launch.md create mode 100644 docs/stages/stage-2-support-crm.md create mode 100644 docs/stages/stage-3-telegram.md create mode 100644 docs/stages/stage-4-payments.md create mode 100644 docs/stages/stage-5-keys-automation.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a778c28 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# Veilix — правила для сессий Claude + +Сервис продажи VPN двух брендов на одном бэкенде: Вейликс (dedicated, veilix.app) и ВейликсВПН (mass, veilixvpn.com). Карта документации — [docs/00-index.md](docs/00-index.md). + +## 1. Порядок работы в сессии + +1. Прочитай этот файл, затем [docs/00-index.md](docs/00-index.md) и файл текущего этапа в [docs/stages/](docs/stages/). Остальные документы — по ссылкам из файла этапа. +2. Перед началом работ сверь с пользователем план этапа: что делаем, что не делаем, какие вопросы открыты. Код не пишем, пока план этапа не подтверждён. +3. Нашлось противоречие между документами, макетом и задачей — сначала обсуждаем, потом делаем. +4. Есть отраслевой стандарт лучше предложенного решения или решение получается кастомным — обсуди с пользователем. +5. В конце этапа обнови документацию: статус этапа в [docs/00-index.md](docs/00-index.md), решения в [docs/decisions/](docs/decisions/), изменения модели данных в [docs/03-data-model.md](docs/03-data-model.md). + +## 2. Общение + +1. Язык — русский. +2. Нумерация пунктов в чате и в документации — только цифры и латинские буквы (1, 2, 3 и a, b, c). Кириллические буквы как маркеры не используем: не путаются латинская A и русская А, латинская B и русская В. + +## 3. Сервер и права + +1. Всё, что требует root (пакеты, nginx, сертификаты, роли PostgreSQL, Postfix, DNS, `loginctl`), делает пользователь. Сформулируй, что и зачем нужно сделать, и жди. Обходные пути внутри своего пользователя не ищем. +2. Окружения на одном сервере, под разными пользователями ОС: + a. dev — пользователь vx_dev (под ним работают сессии Claude): `/home/vx_dev/veilix_dev` (рабочая копия git), база `veilix_dev`, домены `dev.*`, порт 3100; + b. prod — пользователь vx_prod: `/home/vx_prod/veilix` (релизы и симлинк `current`), база `veilix_prod`, основные домены, порт 3000. У vx_dev доступа к prod нет. +3. Выкладка в prod — только по явной команде пользователя в текущей сессии и только командой выкладки ([docs/02-architecture.md](docs/02-architecture.md), раздел 5). + +## 4. Технические правила + +1. Стек и версии — [docs/02-architecture.md](docs/02-architecture.md). Payload 3.x; код пишем совместимо с Payload 4: в Local API всегда явно указываем `overrideAccess`. +2. ID — только UUID (`idType: 'uuid'`). Наружу не отдаём последовательных номеров: номера заказов, ключей и реферальные коды — случайные. +3. Деньги — целые числа в минимальных единицах (копейки, центы) плюс код валюты. Никаких float. +4. Секреты — только в `.env` вне git. +5. Тексты интерфейса — через next-intl, контент — локализованные поля Payload. Строк на одном языке в коде компонентов нет. +6. Макеты — в [design/handoff/project/](design/handoff/project/). Воспроизводим их по коду макетов (разметка, CSS-переменные), а не по скриншотам. +7. Коммиты и push в Gitea — по просьбе пользователя. + +## 5. Команды + +Заполняется на этапе 1a. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63769c3 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Veilix + +Сайты, личные кабинеты, Telegram MiniApp и боты двух VPN-брендов на одном бэкенде (Next.js + Payload CMS + PostgreSQL). + +- Документация: [docs/00-index.md](docs/00-index.md) +- Правила для сессий Claude: [CLAUDE.md](CLAUDE.md) +- Макеты: [design/handoff/](design/handoff/) diff --git a/design/handoff/README.md b/design/handoff/README.md new file mode 100644 index 0000000..2cd7895 --- /dev/null +++ b/design/handoff/README.md @@ -0,0 +1,22 @@ +# CODING AGENTS: READ THIS FIRST + +This is a **handoff bundle** from Claude Design (claude.ai/design). + +A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real. + +## What you should do — IMPORTANT + +**Read `veilix/project/AppTG.dc.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing. + +**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing. + +## About the design files + +The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit. + +**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't. + +## Bundle contents + +- `veilix/README.md` — this file +- `veilix/project/` — the `VEILIX` project files (HTML prototypes, assets, components) diff --git a/design/handoff/project/.thumbnail b/design/handoff/project/.thumbnail new file mode 100644 index 0000000000000000000000000000000000000000..fc79624f2a7c280c381673ccc99cd762a6f71650 GIT binary patch literal 18992 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 + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+ + + diff --git a/design/handoff/project/AppTG.dc.html b/design/handoff/project/AppTG.dc.html new file mode 100644 index 0000000..f2ff94c --- /dev/null +++ b/design/handoff/project/AppTG.dc.html @@ -0,0 +1,815 @@ + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + + + + + Вейликс + мини-приложение + + Закрыть +
+ +
+ + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + + +
+
+ +
+
Мини-приложение в Telegram
+

В Telegram приложение работает и как витрина, и как кабинет: человек может попасть сюда, ни разу не открыв сайт. Поэтому первая вкладка — «Купить»: тезисы, все локации с ценами и кнопкой покупки у каждой, форма на индивидуальные условия внизу.

+

Вкладки: Купить, FAQ, Ключи, Ещё. В «Ещё» — счёт и платежи, реферальная программа, индивидуальный заказ, тема и язык.

+
+
+
+ + + diff --git a/design/handoff/project/Locations.dc.html b/design/handoff/project/Locations.dc.html new file mode 100644 index 0000000..a4c82f7 --- /dev/null +++ b/design/handoff/project/Locations.dc.html @@ -0,0 +1,611 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + Вейликс + + +
+ + + + +
+
+ + +
+ +
+ +
+
+
+
+
+
+

Локации

+

30 стран на выбор и российский адрес в комплекте с любым тарифом.

+
+
Нажмите точку на карте — покажем страну и цену
+ +
+ + + Серверы в наличии + + + + Установим за 24 часа + + + + RU-адрес входит в каждый тариф + +
+
+
+ +
+

Выберите страну

+

Базовая цена — 4 900 ₽ первый месяц и 2 499 ₽ далее. В редких локациях она выше и указана на карточке.

+ +
+ +
+ + {{ loc.flag }} + + + + + + + {{ loc.country }} + {{ loc.city }} + + + + {{ loc.status }} + + + + {{ loc.setup }} + первый месяц · далее + {{ loc.month }} + + +
+
+
+
+ +
+
+
+

Российский адрес в каждом тарифе

+

Он работает в обе стороны: из России банки и госуслуги видят обычное местное подключение, из другой страны те же сервисы открываются так, будто вы дома.

+
+
+

Нужны индивидуальные условия

+

Нужной страны нет в списке, нужно много адресов или свой сценарий маршрутизации — напишите, скажем сроки и установим под вас.

+ + +
+
+
+
+ + +
+
+ + + diff --git a/design/handoff/project/Main.dc.html b/design/handoff/project/Main.dc.html new file mode 100644 index 0000000..7773302 --- /dev/null +++ b/design/handoff/project/Main.dc.html @@ -0,0 +1,810 @@ + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + Вейликс + + + + +
+ + + + +
+
+ + +
+ +
+ +
+
+
+
+ + Персональный сервер, не общий +
+
Работаем с 2022 года
+
+

Ваш личный VPN с индивидуальным IP

+

Адрес принадлежит только вам: один и тот же IP на всех устройствах, в любой стране. Без соседей по серверу и их репутации.

+ +
+ +
+
+
+
+
+
+
+
+
+ + Ваш IP закреплён только за вами +
+
+ 4 900 ₽ первый месяц, далее 2 499 ₽ + До 5 устройств + RU-адрес в комплекте +
+
+
+
+ +
+
+

Что это даёт на практике

+
+ +
+
+
+
+
+
+
+ + + + + +

Индивидуальный IP

+
+

Адрес выделен под вас одного. Сервисы видят обычного человека из выбранной страны, а не сотню людей за одним адресом.

+
+
+
Западные AI-сервисы
+
Риск блокировки ниже, чем у публичных VPN: их адреса уже давно в чёрных списках.
+
+
+
Один адрес на всех устройствах
+
Ноутбук, телефон, планшет выглядят одинаково. Вы всегда в одной локации со своим IP.
+
+
+
Меньше проверок и капч
+
На массовых адресах срабатывает антибот-защита. На личном сервисы работают стабильнее.
+
+
+
Трафик под шифрованием
+
Дома, в отеле или в кафе соединение защищено в любой точке мира.
+
+
+
+
+ +
+
+
+ + + + + +
+

До 5 устройств в тарифе

+

Нужно больше или другая страна — добавляете второй тариф со своим отдельным IP.

+
+ +
+
+ + + +
+

Плюс выделенный RU-адрес

+

Идёт к любому тарифу. Работает и как прямое соединение, и как мост в обе стороны.

+
+ +
+
+ + +
+

Два типа подключения

+

Если один способ перестал работать, второй уже настроен. Список дополняется и обновляется.

+
+ WireGuard / Amnezia WG + Xray: VLESS, Hysteria2, XHTTP +
+
+
+
+ +
+

Трафик идёт коротким путём

+

Маршрут выбирается сам, по адресу сайта. Российские сервисы не ломаются, зарубежные открываются, переключать ничего не нужно.

+
+
+
Вы в России
+
+
+ RU-сайты + + напрямую через RU-сервер +
+
+ Остальной мир + + туннелем в вашу локацию +
+
+
+
+
Вы за границей
+
+
+ Мировые сайты + + прямо из вашей локации +
+
+ RU-сайты + + туннелем через RU-сервер +
+
+
Для тех, кто работает с Россией из другой страны.
+
+
+
+
+ +
+
+
+
+
+
+ {{ curSetup }} + первый месяц +
+
далее {{ curMonth }} в месяц
+
+
—Индивидуальный IP в выбранной стране
+
—Выделенный RU-адрес в комплекте
+
—До 5 устройств
+
—2 типа подключения:—WireGuard / Amnezia WG—Xray: VLESS, Hysteria2, XHTTP
+
+
+ +
+
+
Выберите локацию
+
{{ locHint }}
+
+
+ +
+ {{ loc.flag }} + + {{ loc.country }} + {{ loc.city }} + + + {{ loc.setup }} + далее {{ loc.month }} + +
+
+
+
+ Купить + +
+ +
+
+
+
+ +
+

Вопросы

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+
+ + + diff --git a/design/handoff/project/MassApp.dc.html b/design/handoff/project/MassApp.dc.html new file mode 100644 index 0000000..398b0a3 --- /dev/null +++ b/design/handoff/project/MassApp.dc.html @@ -0,0 +1,984 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+ + + diff --git a/design/handoff/project/MassAppTG.dc.html b/design/handoff/project/MassAppTG.dc.html new file mode 100644 index 0000000..2189247 --- /dev/null +++ b/design/handoff/project/MassAppTG.dc.html @@ -0,0 +1,806 @@ + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + + + + + ВейликсВПН + мини-приложение + + Закрыть +
+ +
+ + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+
+ +
+
Мини-приложение в Telegram
+

В Telegram приложение работает и как витрина, и как кабинет: человек может попасть сюда, ни разу не открыв сайт. Первая вкладка — «Купить»: тезисы, месяц и год, все локации подписки и переход на индивидуальный тариф.

+

Вкладки: Купить, FAQ, Подписка, Ещё. В «Ещё» — счёт и платежи, реферальная программа, свой IP-адрес, тема и язык.

+
+
+
+ + + diff --git a/design/handoff/project/MassLocations.dc.html b/design/handoff/project/MassLocations.dc.html new file mode 100644 index 0000000..9d49222 --- /dev/null +++ b/design/handoff/project/MassLocations.dc.html @@ -0,0 +1,602 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + ВейликсВПН + + +
+ + + + +
+
+ + +
+ +
+ +
+
+
+
+
+
+

Локации

+

30 стран и российский адрес входят в подписку.

+
+
Нажмите точку на карте — покажем страну и город
+ +
+ + + Доступно в подписке + + + + Скоро + + + + RU-адрес входит в подписку + +
+
+
+ +
+

Все страны подписки

+

Одна цена за всё: 699 ₽ в месяц или 6 990 ₽ в год.

+ +
+ +
+ + {{ loc.flag }} + + + {{ loc.badge }} + + + + {{ loc.country }} + {{ loc.city }} + +
+
+
+
+ +
+
+
+

Российский адрес в подписке

+

Он работает в обе стороны: из России банки и госуслуги видят обычное местное подключение, из другой страны те же сервисы открываются так, будто вы дома.

+
+
+

Нужны особые условия

+

Нужной страны нет в списке или нужна подписка на команду — напишите, ответим в течение дня.

+ + +
+
+
+
+ + +
+
+ + + diff --git a/design/handoff/project/MassMain.dc.html b/design/handoff/project/MassMain.dc.html new file mode 100644 index 0000000..605b02c --- /dev/null +++ b/design/handoff/project/MassMain.dc.html @@ -0,0 +1,776 @@ + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + ВейликсВПН + + + + +
+ + + + +
+
+ + +
+ +
+ +
+
+
+
+ + Все страны в одной подписке +
+
Работаем с 2022 года
+
+

VPN, который не нужно отключать

+

Тридцать локаций и российский адрес входят в подписку. Переключайтесь между странами сколько угодно, на пяти устройствах сразу.

+ +
+ +
+
+
+
+
+
+
+
+
+ + 30 стран, переключение в один тап +
+
+ 699 ₽ в месяц или 6 990 ₽ в год + До 5 устройств + RU-адрес включён +
+
+
+
+ +
+
+

Что это даёт на практике

+
+ +
+
+
+
+
+
+
+ + + + + +

Все локации в подписке

+
+

Список стран открыт целиком. Выбираете страну в приложении, соединение перестраивается за пару секунд, доплачивать за переключение не нужно.

+
+
+
Тридцать стран
+
Европа, США, Азия, Ближний Восток и Южная Америка. Список пополняется.
+
+
+
Пять устройств сразу
+
Телефон, ноутбук, планшет и ещё два устройства работают одновременно.
+
+
+
Российские сайты напрямую
+
Банки и госуслуги работают как обычно, отключать VPN не нужно.
+
+
+
Трафик под шифрованием
+
Дома, в отеле или в кафе соединение защищено в любой точке мира.
+
+
+
+
+ +
+
+
+ + + + + +
+

Пять устройств в подписке

+

Одна подписка на всю технику. Устройства работают одновременно и в разных странах.

+
+ +
+
+ + + +
+

Российский адрес включён

+

Российская локация входит в подписку: банки, госуслуги и стриминг открываются как дома.

+
+ +
+
+ + +
+

Современный протокол XRAY

+

Трафик неотличим от обычного HTTPS, поэтому подключение держится там, где старые протоколы уже не работают.

+
+ VLESS + Hysteria2 + XHTTP +
+
+
+
+ +
+

Трафик идёт коротким путём

+

Маршрут выбирается сам, по адресу сайта. Российские сервисы не ломаются, зарубежные открываются, переключать ничего не нужно.

+
+
+
Вы в России
+
+
+ RU-сайты + + напрямую через RU-сервер +
+
+ Остальной мир + + туннелем в выбранную локацию +
+
+
+
+
Вы за границей
+
+
+ Мировые сайты + + прямо из выбранной локации +
+
+ RU-сайты + + туннелем через RU-сервер +
+
+
Для тех, кто работает с Россией из другой страны.
+
+
+
+
+ +
+
+
+
+
+

Одна подписка, все страны

+

Тридцать локаций, российский адрес и пять устройств.

+
+
—Все 30 стран и российский адрес
+
—До 5 устройств одновременно
+
—Современный протокол XRAY
+
+
Страны в подписке
+
+ + {{ c.name }} + +
+
+ +
+
+ + Месяц + 699 ₽ + + Списание раз в месяц, отмена в любой момент +
+
+ + Год + выгоднее + + 6 990 ₽ + + 582 ₽ в месяц, экономия 1 398 ₽ за год +
+ Оформить подписку +
Карты российских и зарубежных банков, криптовалюта. Доступ открывается сразу после оплаты.
+
+
+
+
+ +
+
+
+

Нужен адрес, закреплённый только за вами

+

Здесь сервер общий: он дешевле и этого достаточно большинству. Если сервисы требуют постоянный IP, посмотрите индивидуальные тарифы Вейликс.

+
+ Индивидуальный IP +
+
+ +
+

Вопросы

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+ + + diff --git a/design/handoff/project/MassOrder.dc.html b/design/handoff/project/MassOrder.dc.html new file mode 100644 index 0000000..316fc2f --- /dev/null +++ b/design/handoff/project/MassOrder.dc.html @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + ВейликсВПН + +
+ + Оформление заказа + Отменить +
+
+
+ +
+ +
+ +
+
+ 1 + Почта + + +
+ + +
+ +
+
+ 2 + Оплата +
+ + +
+
+ + +
+
+
+ + + diff --git a/design/handoff/project/MassPrice.dc.html b/design/handoff/project/MassPrice.dc.html new file mode 100644 index 0000000..7968142 --- /dev/null +++ b/design/handoff/project/MassPrice.dc.html @@ -0,0 +1,493 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + ВейликсВПН + + +
+ + + + +
+
+ + +
+ +
+ +
+
+
+
+
+
+ 699 ₽ + в месяц +
+
или 6 990 ₽ в год — 582 ₽ в месяц
+

Одна цена за всё. Отменить продление можно в кабинете.

+ Оформить подписку +
+
+
Что входит
+
+
—Все 30 стран, переключение в любой момент
+
—Российская локация: прямое соединение и мост в обе стороны
+
—До 5 устройств одновременно
+
—Современный протокол XRAY
+
+
+
+
+ +
+
+

Страны в подписке

+
+

Все локации доступны сразу после оплаты и входят в одну цену.

+ +
+ +
+ + {{ loc.flag }} + + + {{ loc.badge }} + + + + {{ loc.country }} + {{ loc.city }} + +
+
+
+ +
+ +
+
+
+

Нужен адрес, закреплённый только за вами

+

Здесь сервер общий: он дешевле и этого достаточно большинству. Если сервисы требуют постоянный IP, посмотрите индивидуальные тарифы Вейликс.

+ Индивидуальный IP +
+
+
+
+
+
+

Нужны особые условия

+

Подписки на команду, оплата по счёту или редкая страна. Оставьте заявку — ответим в течение дня.

+ + +
+
+
+
+
+ + +
+
+ + + diff --git a/design/handoff/project/MassSupport.dc.html b/design/handoff/project/MassSupport.dc.html new file mode 100644 index 0000000..cbcfd38 --- /dev/null +++ b/design/handoff/project/MassSupport.dc.html @@ -0,0 +1,369 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + ВейликсВПН + + +
+ + + + +
+
+ + +
+ +
+ +
+

Поддержка

+
+ + +

Отвечаем с 10 до 20 в рабочие дни.

+
+ +
+

Частые вопросы

+ +
+ + + +
+ +
+ +
+ + +
+
+
+
+ +
+
+
+

Написать нам

+

Если подписка уже оформлена, укажите почту, на которую она оформлена — так мы сразу увидим вашу конфигурацию.

+
+
+ + + + +
+
+
+
+ + +
+
+ + + diff --git a/design/handoff/project/Order.dc.html b/design/handoff/project/Order.dc.html new file mode 100644 index 0000000..98fdf6e --- /dev/null +++ b/design/handoff/project/Order.dc.html @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + Вейликс + +
+ + Оформление заказа + Отменить +
+
+
+ +
+ +
+ +
+
+ 1 + Почта + + +
+ + +
+ +
+
+ 2 + Оплата +
+ + +
+
+ + +
+
+
+ + + diff --git a/design/handoff/project/Price.dc.html b/design/handoff/project/Price.dc.html new file mode 100644 index 0000000..1c3aafa --- /dev/null +++ b/design/handoff/project/Price.dc.html @@ -0,0 +1,501 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + Вейликс + + +
+ + + + +
+
+ + +
+ +
+ +
+
+
+
+
+
+ 4 900 ₽ + первый месяц +
+
далее 2 499 ₽ в месяц
+

В первый месяц входит подготовка сервера под вас. Дальше — только абонентская плата, отменить можно в кабинете.

+
+
+
Что входит
+
+
—Индивидуальный IP в выбранной стране, только ваш
+
—Выделенный RU-адрес: прямое соединение и мост в обе стороны
+
—До 5 устройств с одним и тем же адресом
+
—Два типа подключения: WireGuard и Xray
+
—Умная маршрутизация RU / Глобал без переключений
+
+
+
+
+ +
+
+

Выберите локацию

+
+

Где-то сервер уже стоит и доступ выдаётся сразу. Если свободного нет, мы устанавливаем сервер лично под вас — на это уходит до 24 часов, и вы видите этот статус честно, до оплаты.

+

Базовая цена — 4 900 ₽ первый месяц и 2 499 ₽ далее. В редких локациях она выше и указана на карточке.

+ +
+ +
+ + {{ loc.flag }} + + + + + + + {{ loc.country }} + {{ loc.city }} + + + + {{ loc.status }} + + + + {{ loc.setup }} + первый месяц · далее + {{ loc.month }} + + +
+
+
+ +
+ +
+
+
+

Нужно несколько локаций

+

Каждый тариф — отдельный индивидуальный IP. Берёте второй тариф в другой стране, оба ключа лежат в одном кабинете и продлеваются независимо.

+
+
+
+
+
+
+

Нужны индивидуальные условия

+

Команда, много адресов, редкая страна или свой сценарий маршрутизации. Оставьте заявку — ответим в течение дня.

+ + +
+
+
+
+
+ + +
+
+ + + diff --git a/design/handoff/project/Support.dc.html b/design/handoff/project/Support.dc.html new file mode 100644 index 0000000..0722ae7 --- /dev/null +++ b/design/handoff/project/Support.dc.html @@ -0,0 +1,367 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + Вейликс + + +
+ + + + +
+
+ + +
+ +
+ +
+

Поддержка

+
+ + +

Отвечаем с 10 до 20 в рабочие дни.

+
+ +
+

Частые вопросы

+ +
+ + + +
+ +
+ +
+ + +
+
+
+
+ +
+
+
+

Написать нам

+

Если уже купили ключ, укажите почту, на которую он оформлен — так мы сразу увидим вашу конфигурацию.

+
+
+ + + + +
+
+
+
+ + +
+
+ + + diff --git a/design/handoff/project/favicon-mass.svg b/design/handoff/project/favicon-mass.svg new file mode 100644 index 0000000..78c4f9a --- /dev/null +++ b/design/handoff/project/favicon-mass.svg @@ -0,0 +1,8 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpkYjhkMjAzOC0wZTdmLTQ5ZDEtYmIyZi02MjU4ZDI5YjFjOGQAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaM919rkJOu+r56vDmbDSuogAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphNDg3ZjVlYS0yNGMwLTRmMWMtYjU3Mi1jZTk0ZmNhOWRlMzVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNou3TLKnEXX9d3aOK364c3gAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggh2uOqYcsUCfLLJlcsUZW5XDb53hqcmG3yNeaOfhXo82kZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaMSFmG4KKe0hfDzBQkq0FWAAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCClLIpUxJ//UabRp4m2GHMZs3IWyl6ZrhiOD7Ks5Zus2GRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBh7Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmRiOGQyMDM4LTBlN2YtNDlkMS1iYjJmLTYyNThkMjliMWM4ZC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjI1OTJhMmM5LTM5ZjgtNDA2My05ZGVjLTc3MTYzNWRkYzM0ZXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCHa46phyxQJ8ssmVyxRlblcNvneGpyYbfI15o5+FejzaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggMi0M7Zd5/U7IYIcq59ZD8cEknO87z1xU61ChlcYW6FaiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggsul0+GJ+xq2xeDU2smw5zoclumj5sx2mCDioTMkW60Z0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQGJsCkGaxNR8ZoGvG+B73+wSzSsHuR1/Hooy3SttkjCfiKenweecF1o+vObbDF6QmpYoCpLZ3RdvC+z7J0y4FFc= + + + + + + + \ No newline at end of file diff --git a/design/handoff/project/favicon.svg b/design/handoff/project/favicon.svg new file mode 100644 index 0000000..b8021e6 --- /dev/null +++ b/design/handoff/project/favicon.svg @@ -0,0 +1,8 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYToyYmQ5ZmU0Yy1lYTYwLTQyZTgtYTYwMC1kM2YxN2ZlMWZmNWQAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaP7mE7KwatMQ9m/EB5bQf0sAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDoxODIyYmQwYy01MDA2LTQxNGMtOGI3Yi0xMDlhZTA2OTQ5N2ZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoYTB+WI41NX8SM923oLhJ1wAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgg3E108VpPF0A89PCiPkJvU0xFRCjEsYXd9w6fJa/3hpSkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaP0IC7BR054ns2mFgoQ6hCcAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCC92+BR8qFvAXAhWyDvg9hz/npVNqNBZTe8aAHtqKCqXmRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBh7Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjJiZDlmZTRjLWVhNjAtNDJlOC1hNjAwLWQzZjE3ZmUxZmY1ZC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjA3MzMyY2RhLTljZWMtNGExZC1hYWJmLWU2OWQ0NTI5MDgwZHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCDcTXTxWk8XQDz08KI+Qm9TTEVEKMSxhd33Dp8lr/eGlKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggAOYiBKaqhWClbcPbmxFMjwITDHZ752RjpbEazyMlcrmiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgge2AxpUBp9srweE9dMa1k1kbdgI4m5KetgQH8+8GInxB0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQBe32i4W5jd/TimTKCG8h3t74hYqSxbctsM5iAHeg5wBmfKWChJKtMIKAoYI9Gxw2o0w9+5nQySSvvnZwJBRSWA= + + + + + + + \ No newline at end of file diff --git a/design/handoff/project/i18n.js b/design/handoff/project/i18n.js new file mode 100644 index 0000000..95ffd4c --- /dev/null +++ b/design/handoff/project/i18n.js @@ -0,0 +1,597 @@ +// Veilix — словарь RU → EN. Источник перевода для фронтенда. +// Ключ — строка как она написана в разметке, значение — английский вариант. +window.VEILIX_I18N = { + // Навигация и общее + "Цены": "Pricing", + "Локации": "Locations", + "Поддержка": "Support", + "Войти": "Log in", + "Получить VPN": "Get a VPN", + "Купить": "Buy", + "Отменить": "Cancel", + "Шесть стран на выбор и российский адрес в комплекте с любым тарифом.": "Six countries to choose from, with a Russian address included in every plan.", + "RU-адрес входит в каждый тариф": "A RU address comes with every plan", + "Выберите страну": "Choose a country", + "Российский адрес в каждом тарифе": "A Russian address in every plan", + "Он работает в обе стороны: из России банки и госуслуги видят обычное местное подключение, из другой страны те же сервисы открываются так, будто вы дома.": "It works both ways: from Russia, banks and government services see an ordinary local connection; from abroad, those same services open as if you were at home.", + "Нужной страны нет в списке": "Your country is not on the list", + "Мы устанавливаем серверы и в других странах. Напишите, какая нужна, — скажем сроки и установим под вас.": "We set up servers in other countries too. Tell us which one you need and we will give you the timing and build it for you.", + "Условия": "Terms", + "Конфиденциальность": "Privacy", + "Реферальная программа": "Referral program", + "Ваша ссылка": "Your link", + "Ожидает подтверждения": "Awaiting confirmation", + "Месяц начисляется вам и другу после его первой оплаты и применяется к ближайшему списанию.": "The month is credited after your friend's first payment and applies to the next charge.", + "Приглашённые": "Invitees", + "Месяц начислен": "Month credited", + "Ждём оплаты": "Awaiting payment", + "Реферальная ссылка": "Referral link", + "Приведите друга — вы оба получите по месяцу в подарок.": "Invite a friend and you both get a free month.", + "Приглашено": "Invited", + "Месяцев начислено": "Months earned", + "Поделиться": "Share", + "Ссылка скопирована": "Link copied", + "Копировать ссылку": "Copy link", + "Сервис": "Service", + "Документы": "Legal", + "Тема": "Theme", + "Язык": "Language", + "Условия использования": "Terms of use", + "Политика конфиденциальности": "Privacy policy", + "Возврат средств": "Refunds", + "Вейликс": "Veilix", + "Адрес принадлежит только вам: один и тот же IP на всех устройствах, в любой стране. Без соседей по серверу и их репутации.": "The address belongs to you alone: the same IP on every device, in any country. No neighbours on the server and no reputation to inherit.", + "Российский адрес в подписке": "Russian address in the subscription", + "Нужной страны нет в списке или нужна подписка на команду — напишите, ответим в течение дня.": "If the country you need is missing or you want a team subscription, write to us and we will reply within a day.", + "Скоро": "Coming soon", + "Доступно в подписке": "Included in the subscription", + "VPN, который не нужно отключать. Работаем с 2022 года.": "A VPN you never have to switch off. Running since 2022.", + "30 стран и российский адрес входят в подписку.": "Thirty countries and a Russian address come with the subscription.", + "Одна цена за всё: 699 ₽ в месяц или 6 990 ₽ в год.": "One price for everything: 699 ₽ a month or 6,990 ₽ a year.", + "Одна цена за всё. Отменить продление можно в кабинете.": "One price for everything. You can cancel renewal in your account.", + "Все локации доступны сразу после оплаты и входят в одну цену.": "Every location is available right after payment and included in one price.", + "Тридцать локаций и российский адрес входят в подписку. Переключайтесь между странами сколько угодно, на пяти устройствах сразу.": "Thirty locations and a Russian address come with the subscription. Switch countries as often as you like, on five devices at once.", + "VPN, который не нужно отключать": "A VPN you never have to switch off", + "Российские сайты напрямую": "Russian sites go direct", + "Банки и госуслуги работают как обычно, отключать VPN не нужно.": "Banks and government services keep working as usual, with no need to switch the VPN off.", + "Современный протокол XRAY": "Modern XRAY protocol", + "Трафик неотличим от обычного HTTPS, поэтому подключение держится там, где старые протоколы уже не работают.": "The traffic is indistinguishable from ordinary HTTPS, so the connection holds where older protocols no longer work.", + "На каком протоколе работает подключение?": "Which protocol does the connection use?", + "На XRAY — это современный протокол с VLESS, Hysteria2 и XHTTP. Трафик выглядит как обычный HTTPS, поэтому соединение держится там, где старые протоколы блокируются.": "XRAY — a modern protocol with VLESS, Hysteria2 and XHTTP. The traffic looks like ordinary HTTPS, so the connection holds where older protocols get blocked.", + "Все тридцать стран, включая российскую локацию, и пять устройств. Отдельной платы за страну или устройство нет.": "All thirty countries including the Russian location, and five devices. There is no separate charge per country or device.", + "Конфигурации XRAY": "XRAY configurations", + "Ссылка подключения": "Connection link", + "Для Happ и других клиентов XRAY": "For Happ and other XRAY clients", + "Быстрый способ подключить новое устройство. Скопируйте ссылку или отсканируйте код — настройки те же.": "A quick way to add a new device. Copy the link or scan the code — the settings are the same.", + "На компьютере скачайте файл и откройте его в клиенте XRAY. На телефоне — отсканируйте QR-код.": "On a computer download the file and open it in an XRAY client. On a phone, scan the QR code.", + "Откройте подключение, скачайте любую свободную конфигурацию XRAY или отсканируйте её QR-код. Каждая конфигурация рассчитана на одно устройство.": "Open the connection, download any free XRAY configuration or scan its QR code. Each configuration covers one device.", + "Обновите конфигурацию в кабинете или смените локацию — это занимает пару секунд. Если не помогло, напишите в поддержку, ответим в течение часа.": "Refresh the configuration in your account or change location — it takes a couple of seconds. If that doesn't help, write to support and we will reply within an hour.", + "Подключение работает на XRAY: откройте кабинет и обновите конфигурацию или смените локацию. Настройки протокола мы обновляем по мере блокировок.": "The connection runs on XRAY: open your account and refresh the configuration or change location. We update the protocol settings as blocks appear.", + "Зайдите в кабинет по почте, выберите страну и скачайте конфигурацию XRAY или отсканируйте QR-код с телефона. Маршрутизация уже настроена, вручную ничего вписывать не нужно.": "Sign in with your email, choose a country and download the XRAY configuration or scan the QR code with your phone. Routing is already set up, nothing to enter by hand.", + "Наведите камеру телефона на код в приложении Happ или другом клиенте XRAY.": "Point your phone camera at the code in Happ or another XRAY client.", + "Откройте подключение, скачайте любую свободную конфигурацию WireGuard или отсканируйте её QR-код. Каждая конфигурация рассчитана на одно устройство.": "Open the connection, download any free WireGuard configuration or scan its QR code. Each configuration covers one device.", + "Переключитесь на Xray в той же локации — он настроен заранее. Если не помогло, напишите в поддержку, ответим в течение часа.": "Switch to Xray in the same location — it is set up in advance. If that doesn't help, write to support and we will reply within an hour.", + "В разделе оплаты нажмите «Изменить карту» и отвяжите её. Подписка доработает оплаченный период.": "In the payment section tap “Change card” and unlink it. The subscription runs to the end of the paid period.", + "VPN со всеми странами в одной подписке. Работаем с 2022 года.": "A VPN with every country in one subscription. Running since 2022.", + "или 6 990 ₽ в год — 582 ₽ в месяц": "or 6,990 ₽ a year — 582 ₽ per month", + "Одна цена за всё: страны не тарифицируются отдельно, платы за подключение нет. Отменить продление можно в кабинете.": "One price for everything: no per-country charges and no setup fee. You can cancel renewal in your account.", + "Все 30 стран, переключение в любой момент": "All 30 countries, switch any time", + "Российская локация: прямое соединение и мост в обе стороны": "Russian location: direct connection and a bridge both ways", + "Все локации доступны сразу после оплаты и входят в одну цену. Переключаться между ними можно сколько угодно раз.": "Every location is available right after payment and included in one price. Switch between them as often as you like.", + "Включено": "Included", + "30 стран и российский адрес входят в подписку. Переключайтесь между ними сколько угодно раз.": "Thirty countries and a Russian address come with the subscription. Switch between them as often as you like.", + "Нажмите точку на карте — покажем страну и город": "Tap a dot on the map to see the country and city", + "Доступно сразу": "Available now", + "RU-адрес входит в подписку": "Russian address included", + "Все страны подписки": "Every country in the subscription", + "Одна цена за всё: 699 ₽ в месяц или 6 990 ₽ в год. Страны не тарифицируются отдельно.": "One price for everything: 699 ₽ a month or 6,990 ₽ a year. Countries are not charged separately.", + "Локации и подписка": "Locations and subscription", + "Тема: локации и подписка": "Topic: locations and subscription", + "Если подписка уже оформлена, укажите почту, на которую она оформлена — так мы сразу увидим вашу конфигурацию.": "If you already have a subscription, give the email it is registered to and we will see your configuration right away.", + "Нужны особые условия": "Need special terms", + "Подписки на команду, оплата по счёту или редкая страна. Оставьте заявку — ответим в течение дня.": "Team subscriptions, invoice payment or a rare country. Leave a request and we will reply within a day.", + "Подписка ВейликсВПН": "VeilixVPN subscription", + "30 стран и российский адрес": "30 countries and a Russian address", + "Доступ открывается сразу после оплаты": "Access opens right after payment", + "Месяц · 699 ₽": "Monthly · 699 ₽", + "Год · 6 990 ₽": "Yearly · 6,990 ₽", + "все 30": "all 30", + "Ваша подписка": "Your subscription", + "Тридцать стран и российский адрес, до пяти устройств.": "Thirty countries and a Russian address, up to five devices.", + "Хотите другую страну": "Want a different country", + "Все локации уже в подписке, переключение бесплатное.": "Every location is already in the subscription and switching is free.", + "Сменить страну": "Change country", + "Подключения": "Connections", + "← Все подключения": "← All connections", + "699 ₽ в месяц": "699 ₽ per month", + "Каждое в своей стране": "Each in its own country", + "Выбрать страну": "Choose a country", + "Все тридцать локаций входят в подписку. Переключение бесплатное и занимает пару секунд.": "All thirty locations come with the subscription. Switching is free and takes a couple of seconds.", + "Выбрать": "Choose", + "Все страны входят в подписку, переключение бесплатное": "Every country is included, switching is free", + "Переключиться": "Switch", + "Смена локации применяется сразу, платить ничего не нужно.": "The new location applies immediately and costs nothing.", + "Подписка": "Subscription", + "Страны": "Countries", + "Активна": "Active", + "Готова": "Ready", + "Оплачено. Подписка уже в кабинете — ": "Paid. Your subscription is in the account — ", + "Сколько стоит подписка?": "How much does the subscription cost?", + "699 ₽ в месяц или 6 990 ₽ в год. Годовая выходит по 582 ₽ в месяц и экономит 1 398 ₽. Платы за подключение и доплат за страну нет.": "699 ₽ a month or 6,990 ₽ a year. The yearly plan works out at 582 ₽ a month and saves 1,398 ₽. There is no setup fee and no per-country charge.", + "Как продлевается подписка?": "How is the subscription renewed?", + "Списание раз в месяц или раз в год с привязанной карты. Дату окончания и карту видно в кабинете, там же оплату можно поменять или остановить.": "Charged monthly or yearly to the linked card. The end date and the card are shown in your account, where you can change or stop payment.", + "Сколько стран входит в подписку?": "How many countries are included?", + "Тридцать, включая российскую локацию. Все они доступны сразу и не тарифицируются отдельно.": "Thirty, including the Russian location. All of them are available immediately and are not charged separately.", + "Чем это отличается от индивидуального IP?": "How is this different from a dedicated IP?", + "Здесь адрес общий: на сервере несколько человек, поэтому подписка стоит заметно дешевле. Если нужен адрес, закреплённый только за вами, это отдельные тарифы Вейликс с индивидуальным IP.": "The address here is shared: several people use one server, so the subscription costs much less. If you need an address reserved for you alone, Veilix has separate dedicated-IP plans.", + "Зачем в подписке российский адрес?": "Why does the subscription include a Russian address?", + "До пяти на одну подписку. Страны у устройств могут быть разные: каждое подключается к своей локации.": "Up to five per subscription. Devices can use different countries: each connects to its own location.", + "Локация доступна сразу, переключение бесплатное": "The location is available now and switching is free", + "ВейликсВПН": "VeilixVPN", + "© 2026 ВейликсВПН": "© 2026 VeilixVPN", + "Все страны в одной подписке": "Every country in one subscription", + "Быстрый VPN со всеми странами сразу": "A fast VPN with every country included", + "Тридцать локаций и российский адрес входят в подписку. Переключайтесь между странами сколько угодно, на пяти устройствах сразу, без лимитов трафика и скорости.": "Thirty locations and a Russian address come with the subscription. Switch countries as often as you like, on five devices at once, with no traffic or speed limits.", + "699 ₽ в месяц или 6 990 ₽ в год": "699 ₽ a month or 6,990 ₽ a year", + "RU-адрес включён": "Russian address included", + "30 стран, переключение в один тап": "30 countries, one tap to switch", + "Все локации в подписке": "Every location in the subscription", + "Список стран открыт целиком. Выбираете страну в приложении, соединение перестраивается за пару секунд, доплачивать за переключение не нужно.": "The whole country list is open. Pick a country in the app, the connection rebuilds in a couple of seconds, and switching costs nothing extra.", + "Тридцать стран": "Thirty countries", + "Европа, США, Азия, Ближний Восток и Южная Америка. Список пополняется.": "Europe, the US, Asia, the Middle East and South America. The list keeps growing.", + "Пять устройств сразу": "Five devices at once", + "Телефон, ноутбук, планшет и ещё два устройства работают одновременно.": "Phone, laptop, tablet and two more devices all run at the same time.", + "Без лимитов трафика": "No traffic limits", + "Скорость не режем, объём не считаем, ограничений по времени нет.": "We don't throttle speed, count traffic or limit time.", + "Пять устройств в подписке": "Five devices per subscription", + "Одна подписка на всю технику. Устройства работают одновременно и в разных странах.": "One subscription for all your devices. They run at the same time and in different countries.", + "Российский адрес включён": "Russian address included", + "Российская локация входит в подписку: банки, госуслуги и стриминг открываются как дома.": "The Russian location is part of the subscription: banks, government services and streaming open as if you were home.", + "туннелем в выбранную локацию": "tunnelled to the chosen location", + "прямо из выбранной локации": "straight from the chosen location", + "Одна подписка, все страны": "One subscription, every country", + "Тридцать локаций, российский адрес и пять устройств.": "Thirty locations, a Russian address and five devices.", + "Все 30 стран и российский адрес": "All 30 countries and a Russian address", + "До 5 устройств одновременно": "Up to 5 devices at once", + "Без лимитов трафика и скорости": "No traffic or speed limits", + "Оба типа подключения на выбор": "Both connection types available", + "Страны в подписке": "Countries included", + "Месяц": "Monthly", + "699 ₽": "699 ₽", + "Списание раз в месяц, отмена в любой момент": "Charged monthly, cancel any time", + "Год": "Yearly", + "выгоднее": "better value", + "6 990 ₽": "6,990 ₽", + "582 ₽ в месяц, экономия 1 398 ₽ за год": "582 ₽ a month, saving 1,398 ₽ a year", + "Оформить подписку": "Get the subscription", + "Карты российских и зарубежных банков, криптовалюта. Доступ открывается сразу после оплаты.": "Russian and international cards, plus crypto. Access opens right after payment.", + "Нужен адрес, закреплённый только за вами": "Need an address that is yours alone", + "Здесь сервер общий: он дешевле и этого достаточно большинству. Если сервисы требуют постоянный IP, посмотрите индивидуальные тарифы Вейликс.": "The server here is shared: it costs less and is enough for most people. If a service needs a permanent IP, look at Veilix dedicated plans.", + "Что входит в подписку?": "What is included in the subscription?", + "Все тридцать стран, включая российскую локацию, пять устройств и оба типа подключения. Отдельной платы за страну или устройство нет.": "All thirty countries including the Russian location, five devices and both connection types. There is no separate charge per country or device.", + "Можно менять страну сколько угодно?": "Can I change the country as often as I want?", + "Да. Переключение занимает пару секунд и ничего не стоит: список локаций открыт целиком на всё время подписки.": "Yes. Switching takes a couple of seconds and costs nothing: the full location list stays open for the whole subscription.", + "Нет. В кабинете вы скачиваете готовый файл или сканируете QR-код с телефона. Российские сайты идут напрямую, зарубежные — через выбранную локацию.": "No. In your account you download a ready file or scan a QR code with your phone. Russian sites go direct, the rest travel through the chosen location.", + "Есть ограничения по скорости и трафику?": "Are there speed or traffic limits?", + "Нет. Объём не считаем и скорость не режем: каналов хватает на видео в высоком качестве и звонки.": "No. We don't count traffic or throttle speed: the channels handle high-quality video and calls.", + "Чем это отличается от индивидуального тарифа?": "How is this different from a dedicated plan?", + "Здесь адрес общий: на сервере несколько человек, поэтому подписка стоит заметно дешевле. Если нужен адрес, закреплённый только за вами, это отдельные тарифы с индивидуальным IP.": "The address here is shared: several people use one server, which makes the subscription much cheaper. If you need an address reserved for you alone, that is the dedicated IP plan.", + "Как оплачивается и продлевается?": "How is it paid and renewed?", + "699 ₽ в месяц или 6 990 ₽ в год со списанием с привязанной карты. Годовая подписка дешевле помесячной на 1 398 ₽. Отменить продление можно в кабинете в любой момент.": "699 ₽ a month or 6,990 ₽ a year, charged to the linked card. The yearly plan is 1,398 ₽ cheaper than paying monthly. You can cancel renewal in your account at any time.", + "© 2026 Вейликс": "© 2026 Veilix", + "Персональные VPN-серверы с индивидуальным IP. Работаем с 2022 года.": "Personal VPN servers with a dedicated IP. Running since 2022.", + "Русский": "Russian", + "English": "English", + "скоро": "soon", + + // Главная — hero + "Персональный сервер, не общий": "A personal server, not a shared one", + "Работаем с 2022 года": "Running since 2022", + "Ваш личный VPN с индивидуальным IP": "Your private VPN with a dedicated IP", + "Адрес принадлежит только вам: один и тот же IP на всех устройствах, в любой стране. Без соседей по серверу, без общих подсетей и их репутации.": "The address belongs to you alone: the same IP on every device, in any country. No neighbours on the server, no shared subnets and their reputation.", + "Посмотреть локации": "See locations", + "4 900 ₽ первый месяц, далее 2 499 ₽": "$49 first month, then $24", + "4 900 ₽": "$49", + "2 900 ₽": "$24", + "2 499 ₽": "$24", + "490 ₽": "$5", + "До 5 устройств": "Up to 5 devices", + "RU-адрес в комплекте": "RU address included", + "Ваш IP закреплён только за вами": "Your IP is reserved for you alone", + + // Главная — преимущества + "Что это даёт на практике": "What this means in practice", + "Индивидуальный IP": "Dedicated IP", + "Адрес выделен под вас одного. Сервисы видят обычного человека из выбранной страны, а не сотню людей за одним адресом.": "The address is assigned to you alone. Services see one ordinary person from the country you chose, not a hundred people behind a single address.", + "Западные AI-сервисы": "Western AI services", + "Риск блокировки ниже, чем у публичных VPN: их адреса уже давно в чёрных списках.": "Lower risk of being blocked than with public VPNs, whose addresses have long been blacklisted.", + "Один адрес на всех устройствах": "One address across all devices", + "Ноутбук, телефон, планшет выглядят одинаково. Вы всегда в одной локации со своим IP.": "Laptop, phone and tablet all look the same. You always stay in one location with your own IP.", + "Меньше проверок и капч": "Fewer checks and captchas", + "На массовых адресах срабатывает антибот-защита. На личном сервисы работают стабильнее.": "Mass addresses trigger anti-bot protection. On a personal one, services behave more predictably.", + "Трафик под шифрованием": "Encrypted traffic", + "Дома, в отеле или в кафе соединение защищено в любой точке мира.": "At home, in a hotel or in a café, the connection is protected anywhere in the world.", + "До 5 устройств в тарифе": "Up to 5 devices per plan", + "Нужно больше или другая страна — добавляете второй тариф со своим отдельным IP.": "Need more, or another country — add a second plan with its own separate IP.", + "Плюс выделенный RU-адрес": "Plus a dedicated RU address", + "Идёт к любому тарифу. Работает и как прямое соединение, и как мост в обе стороны.": "Comes with every plan. Works both as a direct connection and as a two-way bridge.", + "Два типа подключения": "Two connection types", + "Если один способ перестал работать, второй уже настроен. Список дополняется и обновляется.": "If one method stops working, the second is already set up. The list is extended and updated over time.", + "Трафик идёт коротким путём": "Traffic takes the short route", + "Маршрут выбирается сам, по адресу сайта. Российские сервисы не ломаются, зарубежные открываются, переключать ничего не нужно.": "The route is chosen automatically by the site address. Russian services keep working, foreign ones open, and you never switch anything.", + "Вы в России": "You are in Russia", + "Вы за границей": "You are abroad", + "RU-сайты": "RU sites", + "Остальной мир": "The rest of the world", + "Мировые сайты": "Global sites", + "напрямую через RU-сервер": "directly through the RU server", + "туннелем в вашу локацию": "tunnelled to your location", + "прямо из вашей локации": "straight from your location", + "туннелем через RU-сервер": "tunnelled through the RU server", + "Для тех, кто работает с Россией из другой страны.": "For people who work with Russia from another country.", + + // Главная — цены и вопросы + "Один тариф, без уровней": "One plan, no tiers", + "первый месяц": "first month", + "далее 2 499 ₽ в месяц": "then $24 per month", + "Индивидуальный IP в выбранной стране": "Dedicated IP in the country you choose", + "Выделенный RU-адрес в комплекте": "Dedicated RU address included", + "До 5 устройств": "Up to 5 devices", + "2 типа подключения:": "2 connection types:", + "WireGuard / Amnezia WG": "WireGuard / Amnezia WG", + "Xray: VLESS, Hysteria2, XHTTP": "Xray: VLESS, Hysteria2, XHTTP", + "Выберите локацию": "Choose a location", + "Свободные серверы есть сейчас": "Servers are available right now", + "Сервер установим под вас за 24 часа": "We will set up a server for you within 24 hours", + "Нидерланды · Амстердам": "Netherlands · Amsterdam", + "Германия · Франкфурт": "Germany · Frankfurt", + "США · Нью-Йорк": "United States · New York", + "Финляндия · Хельсинки": "Finland · Helsinki", + "Турция · Стамбул": "Türkiye · Istanbul", + "Япония · Токио": "Japan · Tokyo", + "Нидерланды": "Netherlands", + "Германия": "Germany", + "США": "United States", + "Финляндия": "Finland", + "Турция": "Türkiye", + "Япония": "Japan", + "Амстердам": "Amsterdam", + "Франкфурт": "Frankfurt", + "Нью-Йорк": "New York", + "Хельсинки": "Helsinki", + "Стамбул": "Istanbul", + "Токио": "Tokyo", + "3 сервера в наличии": "3 servers available", + "2 сервера в наличии": "2 servers available", + "1 сервер в наличии": "1 server available", + "готово за 24 часа": "ready within 24 hours", + "Установим за 24 часа": "Ready within 24 hours", + "Серверы в наличии": "Servers available", + "Нужны индивидуальные условия": "Need custom terms", + "Вопросы": "Questions", + "Чем это отличается от обычного VPN за 300 рублей?": "How is this different from an ordinary 300-rouble VPN?", + "В массовом VPN ваш адрес делят сотни людей. Если кто-то из них попал в чёрный список, ограничения касаются всех. У вас адрес один и только ваш, поэтому сервисы относятся к нему как к домашнему интернету.": "In a mass-market VPN hundreds of people share your address. If any of them ends up blacklisted, the restrictions apply to everyone. Here the address is yours alone, so services treat it like a home connection.", + "Почему некоторые локации готовы только через 24 часа?": "Why are some locations only ready in 24 hours?", + "Мы не держим пул общих адресов. Если в нужной стране свободных серверов нет, мы устанавливаем сервер лично под вас и выдаём доступ в течение суток. Оплата за первый месяц начинается с момента выдачи.": "We do not keep a pool of shared addresses. If there is no free server in the country you need, we set one up for you personally and hand over access within a day. The first month starts when access is issued.", + "Нужно ли что-то настраивать самому?": "Do I have to configure anything myself?", + "Нет. В личном кабинете вы скачиваете готовый файл или сканируете QR-код с телефона. Маршрутизация уже настроена: российские сайты идут напрямую, зарубежные — через ваш сервер.": "No. In your account you download a ready-made file or scan a QR code with your phone. Routing is already set up: Russian sites go direct, foreign ones through your server.", + "Сколько устройств можно подключить?": "How many devices can I connect?", + "До пяти на один тариф, все с одним и тем же адресом. Нужно больше устройств или вторая страна — берёте дополнительный тариф с отдельным индивидуальным IP.": "Up to five per plan, all sharing the same address. If you need more devices or a second country, take an additional plan with its own dedicated IP.", + "Зачем мне ещё и российский адрес?": "Why do I also need a Russian address?", + "Он работает в обе стороны. Из России — чтобы банки, госуслуги и стриминг видели обычное местное подключение. Из другой страны — чтобы те же сервисы открывались так, будто вы дома.": "It works both ways. From Russia, so that banks, government services and streaming see an ordinary local connection. From abroad, so the same services open as if you were at home.", + "Что будет, если способ подключения перестанет работать?": "What if a connection method stops working?", + "На вашем сервере одновременно работают два типа подключения. Если один способ начал блокироваться, вы переключаетесь на второй в том же кабинете. Набор протоколов мы дополняем и обновляем.": "Your server runs both connection types at once. If one starts getting blocked, you switch to the other in the same account. We keep extending and updating the set of protocols.", + + // Цены + "Одна цена, без уровней и апсейлов": "One price, no tiers and no upsells", + "В первый месяц входит подготовка сервера под вас. Дальше — только абонентская плата, отменить можно в кабинете.": "The first month covers preparing the server for you. After that it is only the subscription fee, and you can cancel in your account.", + "Что входит": "What is included", + "Индивидуальный IP в выбранной стране, только ваш": "A dedicated IP in the country you choose, yours alone", + "Выделенный RU-адрес: прямое соединение и мост в обе стороны": "A dedicated RU address: direct connection and a two-way bridge", + "До 5 устройств с одним и тем же адресом": "Up to 5 devices sharing the same address", + "Два типа подключения: WireGuard и Xray": "Two connection types: WireGuard and Xray", + "Умная маршрутизация RU / Глобал без переключений": "Smart RU / global routing with no switching", + "Где-то сервер уже стоит и доступ выдаётся сразу. Если свободного нет, мы устанавливаем сервер лично под вас — на это уходит до 24 часов, и вы видите этот статус честно, до оплаты.": "In some countries a server is already running and access is issued immediately. If none is free, we set one up for you personally — that takes up to 24 hours, and you see this status honestly, before paying.", + "Сервер свободен — доступ выдаём сразу после оплаты": "A server is free — access is issued right after payment", + "Свободного сервера нет: установим лично под вас, доступ в течение 24 часов": "No free server: we will set one up for you personally, access within 24 hours", + "4 900 ₽ · далее 2 499 ₽": "$49 · then $24", + "Нужно несколько локаций": "Need several locations", + "Каждый тариф — отдельный индивидуальный IP. Берёте второй тариф в другой стране, оба ключа лежат в одном кабинете и продлеваются независимо.": "Each plan is a separate dedicated IP. Take a second plan in another country and both keys live in one account, renewing independently.", + "Команда, много адресов, редкая страна или свой сценарий маршрутизации. Оставьте заявку — ответим в течение дня.": "A team, many addresses, a rare country or your own routing scenario. Leave a request and we will reply within a day.", + "Оставить заявку": "Send a request", + "Почта или @username в Telegram": "Email or @username on Telegram", + "Коротко о задаче": "Briefly about your case", + "Отправить": "Send", + "Заявка отправлена": "Request sent", + "Общий сервер": "Shared server", + "Персональный IP": "Dedicated IP", + "Общий сервер, адрес делится с другими пользователями": "A shared server: the address is shared with other users", + "в месяц": "per month", + "или 4 900 ₽ за год": "or $49 per year", + "Подключение сразу после оплаты: сервер уже работает, вы просто получаете доступ. Отменить можно в кабинете.": "You connect right after payment: the server is already running, you simply get access. You can cancel in your account.", + "Общий IP в выбранной стране": "Shared IP in the country you choose", + "Доступ сразу после оплаты": "Access right after payment", + "До 5 устройств": "Up to 5 devices", + "Общие серверы уже работают во всех странах из списка — доступ выдаём сразу после оплаты.": "Shared servers are already running in every country on the list — access is issued right after payment.", + "490 ₽ в месяц": "$5 per month", + "Чем персональный IP лучше общего": "Why a dedicated IP beats a shared one", + "На общем сервере адрес делят другие пользователи, и ограничения, которые получил любой из них, касаются всех. Персональный IP принадлежит только вам — сервисы относятся к нему как к домашнему интернету.": "On a shared server the address is used by other people, and any restriction one of them earns applies to everyone. A dedicated IP belongs to you alone — services treat it like a home connection.", + + // Поддержка + "Быстрее всего. Можно писать голосом и скидывать скриншоты.": "The fastest way. Voice messages and screenshots welcome.", + "Почта": "Email", + "Для счетов, возвратов и всего, что нужно письменно.": "For invoices, refunds and anything that needs to be in writing.", + "Частые вопросы": "Frequent questions", + "Подключение": "Connecting", + "Оплата и продление": "Payment and renewal", + "Локации и IP": "Locations and IP", + "Что делать сразу после оплаты?": "What do I do right after paying?", + "Зайдите в кабинет по почте, откройте свой ключ и скачайте конфигурацию или отсканируйте QR-код с телефона. Маршрутизация уже настроена, вручную ничего вписывать не нужно.": "Sign in to your account by email, open your key and download the configuration or scan the QR code with your phone. Routing is already configured, nothing to type in by hand.", + "До пяти на один ключ, все с одним и тем же адресом. Если нужно больше устройств или вторая страна — берёте дополнительный тариф с отдельным индивидуальным IP.": "Up to five per key, all sharing the same address. If you need more devices or a second country, take an additional plan with its own dedicated IP.", + "Перестал работать один способ подключения.": "One connection method stopped working.", + "На вашем сервере всегда настроены оба типа: WireGuard и Xray. Откройте кабинет и подключитесь по второй конфигурации, ничего запрашивать не нужно. Набор протоколов мы дополняем и обновляем.": "Both types are always configured on your server: WireGuard and Xray. Open your account and connect with the second configuration, no request needed. We keep extending and updating the set of protocols.", + "Нужно ли что-то настраивать вручную?": "Do I need to configure anything manually?", + "Нет. В конфигурации уже прописано, что российские сайты идут напрямую, а зарубежные — через ваш сервер. Переключать режимы во время работы не нужно.": "No. The configuration already states that Russian sites go direct and foreign ones go through your server. There are no modes to switch while you work.", + "Почему первый месяц дороже?": "Why is the first month more expensive?", + "В 4 900 ₽ входит подготовка сервера: выделение адреса, настройка обоих протоколов и маршрутизации. Дальше вы платите только абонентскую плату 2 499 ₽ в месяц.": "The $49 covers preparing the server: assigning the address and configuring both protocols and the routing. After that you only pay the $24 monthly fee.", + "Как продлевается ключ?": "How does renewal work?", + "Списание раз в месяц с привязанной карты. Дату окончания и карту видно на странице ключа в кабинете, там же оплату можно поменять или остановить.": "A monthly charge to the card on file. The expiry date and the card are shown on the key page in your account, where you can change or stop the payment.", + "Можно вернуть деньги?": "Can I get a refund?", + "Если сервер не заработал по нашей вине, возвращаем полностью в течение первых семи дней. Если вы просто передумали — возвращаем остаток неиспользованного месяца.": "If the server failed to work through our fault, we refund in full within the first seven days. If you simply changed your mind, we refund the unused part of the month.", + "Чем можно платить?": "What can I pay with?", + "Карты российских и зарубежных банков, а также криптовалюта. Все способы доступны на странице оформления заказа.": "Cards from Russian and foreign banks, as well as cryptocurrency. All methods are available at checkout.", + "Чем индивидуальный IP отличается от обычного VPN?": "How does a dedicated IP differ from an ordinary VPN?", + "В массовом VPN один адрес делят сотни людей, и ограничения, которые получил любой из них, касаются всех. Ваш адрес принадлежит только вам, поэтому сервисы относятся к нему как к домашнему интернету.": "In a mass-market VPN hundreds of people share one address, and any restriction one of them earns applies to everyone. Your address belongs to you alone, so services treat it like a home connection.", + "Почему часть локаций готова только через 24 часа?": "Why are some locations only ready in 24 hours?", + "Мы не держим пул общих адресов. Если в нужной стране свободного сервера нет, мы устанавливаем его лично под вас. Оплата первого месяца начинается с момента выдачи доступа.": "We do not keep a pool of shared addresses. If there is no free server in the country you need, we set one up for you personally. The first month starts when access is issued.", + "Зачем к тарифу российский адрес?": "Why does the plan include a Russian address?", + "Можно сменить страну позже?": "Can I change the country later?", + "Ключ привязан к своему серверу, поэтому страну у него не меняем. Если нужна другая локация, возьмите второй ключ: он появится в том же кабинете и будет продлеваться отдельно. Первый можно оставить или отключить в любой момент.": "A key is tied to its own server, so we don't change its country. If you need another location, take a second key: it appears in the same account and renews separately. Keep the first one or cancel it whenever you like.", + "Написать нам": "Write to us", + "Если уже купили ключ, укажите почту, на которую он оформлен — так мы сразу увидим вашу конфигурацию.": "If you already bought a key, use the email it is registered to so we can see your configuration straight away.", + "Ваша почта": "Your email", + "Тема: подключение": "Topic: connecting", + "Тема: оплата и продление": "Topic: payment and renewal", + "Тема: локации и IP": "Topic: locations and IP", + "Тема: другое": "Topic: something else", + "Что произошло": "What happened", + "Отправлено, ответим на почту": "Sent, we will reply by email", + "Выберите удобный способ связи.": "Choose whichever way suits you.", + + // Оформление заказа + "Оформление заказа": "Checkout", + "Оплата": "Payment", + "Изменить": "Change", + "Введите почту — пришлём одноразовый код. Отдельной регистрации нет: если вы у нас уже были, попадёте в свой кабинет, если нет — он создастся сам.": "Enter your email and we will send a one-time code. There is no separate sign-up: if you have been here before you land in your account, if not it is created for you.", + "Прислать код": "Send the code", + "6 цифр": "6 digits", + "Продолжить": "Continue", + "Другая почта": "Use another email", + "Карта": "Card", + "Карта МИР": "MIR card", + "Криптовалюта": "Cryptocurrency", + "Номер карты": "Card number", + "ММ / ГГ": "MM / YY", + "Карта сохранится для продления. Отменить или заменить её можно в кабинете в любой момент.": "The card is saved for renewals. You can cancel or replace it in your account at any time.", + "USDT, BTC или ETH": "USDT, BTC or ETH", + "После нажатия покажем адрес и сумму. При оплате криптой продление не автоматическое — пришлём напоминание за три дня до конца месяца.": "We will show the address and amount after you press the button. With crypto, renewal is not automatic — we send a reminder three days before the month ends.", + "Согласен с условиями использования и политикой конфиденциальности": "I agree to the terms of use and the privacy policy", + "Ваш заказ": "Your order", + "Персональный VPN": "Personal VPN", + "Сервер свободен, доступ сразу после оплаты": "A server is free, access right after payment", + "Первый месяц": "First month", + "RU-адрес": "RU address", + "включён": "included", + "Устройств": "Devices", + "до 5": "up to 5", + "К оплате сегодня": "Due today", + "Оплатить 4 900 ₽": "Pay $49", + "Показать адрес для оплаты": "Show the payment address", + "Оплачено": "Paid", + "Оплачено. Ключ уже в кабинете —": "Paid. The key is already in your account —", + "открыть": "open it", + "Возврат в течение семи дней, если сервер не заработал по нашей вине. Вопросы —": "A refund within seven days if the server failed through our fault. Questions —", + "поддержка": "support", + + // Кабинет + "Вход в кабинет": "Sign in", + "Введите почту, пришлём одноразовый код. Отдельной регистрации нет — кабинет создастся сам, если вы здесь впервые.": "Enter your email and we will send a one-time code. There is no separate sign-up — the account is created for you if this is your first visit.", + "Главная": "Overview", + "Список ключей": "All keys", + "Купить ключ": "Buy a key", + "Ваши ключи": "Your keys", + "Два активных тарифа, каждый со своим индивидуальным IP.": "Two active plans, each with its own dedicated IP.", + "Действует до": "Active until", + "Активен": "Active", + "Истекает": "Expiring", + "3 из 5 устройств": "3 of 5 devices", + "1 из 5 устройств": "1 of 5 devices", + "Карта ···· 4242": "Card ···· 4242", + "Карта ···· 8801": "Card ···· 8801", + "Нужен ещё один IP или другая страна": "Need another IP or a different country", + "Новый ключ появится здесь же, оплата отдельная.": "The new key appears right here, billed separately.", + "Короткие ответы": "Quick answers", + "Как подключить новое устройство?": "How do I connect a new device?", + "Откройте ключ, скачайте любую свободную конфигурацию WireGuard или отсканируйте её QR-код. Каждая конфигурация рассчитана на одно устройство.": "Open the key, download any free WireGuard configuration or scan its QR code. Each configuration is meant for one device.", + "Что делать, если подключение перестало работать?": "What if the connection stops working?", + "Переключитесь на Xray в том же ключе — он настроен заранее. Если не помогло, напишите в поддержку, ответим в течение часа.": "Switch to Xray within the same key — it is configured in advance. If that does not help, write to support and we will reply within an hour.", + "Как отменить продление?": "How do I cancel renewal?", + "На странице ключа нажмите «Изменить карту» и отвяжите её. Ключ доработает оплаченный месяц.": "On the key page press “Change card” and detach it. The key keeps working until the paid month ends.", + "← Все ключи": "← All keys", + "Изменить карту": "Change card", + "2 499 ₽ в месяц": "$24 per month", + "Устройства": "Devices", + "Один и тот же IP на всех": "The same IP on all of them", + "Продлится автоматически через 31 день": "Renews automatically in 31 days", + "Осталось 8 дней": "8 days left", + "5 конфигураций — по одной на устройство": "5 configurations — one per device", + "На компьютере скачайте файл и откройте его в приложении. На телефоне — отсканируйте QR-код.": "On a computer, download the file and open it in the app. On a phone, scan the QR code.", + "Ноутбук": "Laptop", + "Телефон": "Phone", + "Планшет": "Tablet", + "Рабочий ноутбук": "Work laptop", + "Свободный слот": "Free slot", + "подключено": "connected", + "не занят": "unused", + "Скачать": "Download", + "Для Happ и других клиентов": "For Happ and other clients", + "Запасной способ подключения. Скопируйте ссылку или отсканируйте код — настройки те же.": "A backup way to connect. Copy the link or scan the code — the settings are the same.", + "Копировать": "Copy", + "Копировать ключ": "Copy key", + "Скопировано": "Copied", + "Цена одна для любой страны: 4 900 ₽ первый месяц, далее 2 499 ₽.": "One price for every country: $49 the first month, then $24.", + "Доступ выдадим сразу после оплаты": "Access is issued right after payment", + "Установим сервер под вас, доступ в течение 24 часов": "We will set up a server for you, access within 24 hours", + "Откройте приложение на телефоне и наведите камеру на код.": "Open the app on your phone and point the camera at the code.", + "Выйти · anna@example.com": "Sign out · anna@example.com", + "Светлая тема": "Light theme", + "Тёмная тема": "Dark theme", + "Как в системе": "Match the system", + "Язык: русский": "Language: Russian", + "Язык: английский": "Language: English", + + // MiniApp + "мини-приложение": "mini app", + "Закрыть": "Close", + "Нажмите, чтобы открыть подключение": "Tap to open the connection", + "Купить ещё один ключ": "Buy another key", + "← Ключи": "← Keys", + "Ключи": "Keys", + "Ещё": "More", + "5 устройств": "5 devices", + "Файл": "File", + "Happ и другие": "Happ and others", + "4 900 ₽ первый месяц, далее 2 499 ₽": "$49 first month, then $24", + "Оплата пройдёт внутри Telegram, ключ появится в списке.": "Payment happens inside Telegram, the key appears in the list.", + "Написать в поддержку": "Message support", + "Почта аккаунта": "Account email", + "По умолчанию подстраивается под тему Telegram.": "By default it follows the Telegram theme.", + "Наведите камеру телефона на код в приложении WireGuard или Happ.": "Point your phone camera at the code in WireGuard or Happ.", + "Мини-приложение в Telegram": "Telegram mini app", + "Тот же кабинет в парадигме телефона: список ключей, страница ключа с QR-кодами и покупка без выхода в браузер. Вход не нужен — Telegram уже знает, кто вы.": "The same account in phone form: the list of keys, a key page with QR codes and buying without leaving Telegram. No sign-in needed — Telegram already knows who you are.", + "Нижние вкладки повторяют сайдбар веб-кабинета: Ключи, Купить, Ещё.": "The bottom tabs mirror the web sidebar: Keys, Buy, More.", + + // Локации — 30 направлений + "Великобритания": "United Kingdom", + "Лондон": "London", + "Франция": "France", + "Париж": "Paris", + "Швеция": "Sweden", + "Стокгольм": "Stockholm", + "Норвегия": "Norway", + "Осло": "Oslo", + "Дания": "Denmark", + "Копенгаген": "Copenhagen", + "Швейцария": "Switzerland", + "Цюрих": "Zurich", + "Австрия": "Austria", + "Вена": "Vienna", + "Испания": "Spain", + "Мадрид": "Madrid", + "Италия": "Italy", + "Милан": "Milan", + "Польша": "Poland", + "Варшава": "Warsaw", + "Чехия": "Czechia", + "Прага": "Prague", + "Румыния": "Romania", + "Бухарест": "Bucharest", + "Литва": "Lithuania", + "Вильнюс": "Vilnius", + "Латвия": "Latvia", + "Рига": "Riga", + "Эстония": "Estonia", + "Таллин": "Tallinn", + "Казахстан": "Kazakhstan", + "Алматы": "Almaty", + "Армения": "Armenia", + "Ереван": "Yerevan", + "Грузия": "Georgia", + "Тбилиси": "Tbilisi", + "ОАЭ": "UAE", + "Дубай": "Dubai", + "Сингапур": "Singapore", + "Южная Корея": "South Korea", + "Сеул": "Seoul", + "Канада": "Canada", + "Торонто": "Toronto", + "Австралия": "Australia", + "Сидней": "Sydney", + "Бразилия": "Brazil", + "Сан-Паулу": "São Paulo", + + // Цены локаций + "6 900 ₽": "$69", + "3 499 ₽": "$34", + "9 900 ₽": "$99", + "4 999 ₽": "$49", + "первый месяц · далее": "first month · then", + "далее": "then", + "в месяц": "per month", + "Персональный сервер с индивидуальным IP": "A personal server with a dedicated IP", + "30 стран на выбор и российский адрес в комплекте с любым тарифом.": "30 countries to choose from, with a Russian address included in every plan.", + "Базовая цена — 4 900 ₽ первый месяц и 2 499 ₽ далее. В редких локациях она выше и указана на карточке.": "The base price is $49 for the first month and $24 after that. Rare locations cost more, and the price is shown on the card.", + "Нажмите точку на карте — покажем страну и цену": "Tap a dot on the map to see the country and its price", + "Базовая цена: 4 900 ₽ первый месяц, далее 2 499 ₽. В редких локациях — выше.": "Base price: $49 the first month, then $24. Rare locations cost more.", + + "Имя": "Name", + "Нужны индивидуальные условия": "Need custom terms", + + "Отвечаем с 10 до 20 в рабочие дни.": "We reply from 10:00 to 20:00 on business days.", + + "Нужной страны нет в списке, нужно много адресов или свой сценарий маршрутизации — напишите, скажем сроки и установим под вас.": "If your country is missing, you need many addresses, or you have a custom routing setup — write to us and we will give you timing and set it up.", + + // Даты и единицы, встречающиеся в демо-данных + "14 октября 2026": "14 October 2026", + "21 сентября 2026": "21 September 2026", + "14 октября": "14 October", + "21 сентября": "21 September" +}; + +// Переключение языка: подменяет текстовые узлы и подписи внутри корня. +window.VEILIX_APPLY_LANG = function (root, lang) { + const dict = window.VEILIX_I18N || {}; + const el = root || document.body; + if (!el) return; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + nodes.forEach(node => { + const parent = node.parentNode; + if (parent && (parent.nodeName === "SCRIPT" || parent.nodeName === "STYLE")) return; + // Кэш валиден, только если узел с тех пор не менялся снаружи (React мог перерисовать текст). + const cached = node.__veilixOut != null && node.nodeValue === node.__veilixOut; + if (lang === "en") { + const raw = cached ? node.__veilixRu : node.nodeValue; + const key = raw.trim(); + if (!key || !dict[key]) { node.__veilixRu = null; node.__veilixOut = null; return; } + node.__veilixRu = raw; + node.nodeValue = raw.replace(key, dict[key]); + node.__veilixOut = node.nodeValue; + } else if (cached && node.__veilixRu != null) { + node.nodeValue = node.__veilixRu; + node.__veilixRu = null; + node.__veilixOut = null; + } + }); + ["placeholder", "title", "aria-label"].forEach(attr => { + el.querySelectorAll("[" + attr + "]").forEach(target => { + const store = "__veilix_" + attr; + const out = "__veilixOut_" + attr; + const current = target.getAttribute(attr); + const cached = target[out] != null && current === target[out]; + if (lang === "en") { + const raw = cached ? target[store] : current; + const key = (raw || "").trim(); + if (!key || !dict[key]) { target[store] = null; target[out] = null; return; } + target[store] = raw; + target.setAttribute(attr, dict[key]); + target[out] = dict[key]; + } else if (cached && target[store] != null) { + target.setAttribute(attr, target[store]); + target[store] = null; + target[out] = null; + } + }); + }); +}; diff --git a/design/handoff/project/ios-frame.jsx b/design/handoff/project/ios-frame.jsx new file mode 100644 index 0000000..d6f5686 --- /dev/null +++ b/design/handoff/project/ios-frame.jsx @@ -0,0 +1,352 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). + +/* BEGIN USAGE */ +// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame +// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps. +// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard +// +// Usage — wrap your screen content in to get the bezel, status bar +// and home indicator (props: title, dark, keyboard): +// +// +// ...your screen content... +// +// … +/* END USAGE */ + +// ───────────────────────────────────────────────────────────── +// Status bar +// ───────────────────────────────────────────────────────────── +function IOSStatusBar({ dark = false, time = '9:41' }) { + const c = dark ? '#fff' : '#000'; + return ( +
+
+ {time} +
+
+ + + + + + + + + + + + + + + + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Liquid glass pill — blur + tint + shine +// ───────────────────────────────────────────────────────────── +function IOSGlassPill({ children, dark = false, style = {} }) { + return ( +
+ {/* blur + tint */} +
+ {/* shine */} +
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Navigation bar — glass pills + large title +// ───────────────────────────────────────────────────────────── +function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) { + const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040'; + const text = dark ? '#fff' : '#000'; + const pillIcon = (content) => ( + +
+ {content} +
+
+ ); + return ( +
+
+ {/* back chevron */} + {pillIcon( + + + + )} + {/* trailing ellipsis */} + {trailingIcon && pillIcon( + + + + + + )} +
+ {/* large title */} +
{title}
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Grouped list (inset card, r:26) + row (52px) +// ───────────────────────────────────────────────────────────── +function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) { + const text = dark ? '#fff' : '#000'; + const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)'; + const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)'; + const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)'; + return ( +
+ {icon && ( +
+ )} +
{title}
+ {detail && {detail}} + {chevron && ( + + + + )} + {!isLast && ( +
+ )} +
+ ); +} + +function IOSList({ header, children, dark = false }) { + const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)'; + const bg = dark ? '#1C1C1E' : '#fff'; + return ( +
+ {header && ( +
{header}
+ )} +
{children}
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Device frame +// ───────────────────────────────────────────────────────────── +function IOSDevice({ + children, width = 402, height = 874, dark = false, + title, keyboard = false, +}) { + return ( + // data-om-starter: inert presence marker — Claude Design's starter-usage + // probe reads it; it renders nothing. Keep it on this root element. +
+ {/* dynamic island */} +
+ {/* status bar (absolute) */} +
+ +
+ {/* nav + content */} +
+ {title !== undefined && } +
{children}
+ {keyboard && } +
+ {/* home indicator — always on top */} +
+
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// Keyboard — iOS 26 liquid glass +// ───────────────────────────────────────────────────────────── +function IOSKeyboard({ dark = false }) { + const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959'; + const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333'; + const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)'; + + // special-key icons + const icons = { + shift: , + del: , + ret: , + }; + + const key = (content, { w, flex, ret, fs = 25, k } = {}) => ( +
{content}
+ ); + + const row = (keys, pad = 0) => ( +
+ {keys.map(l => key(l, { flex: true, k: l }))} +
+ ); + + return ( +
+ {/* liquid glass bg — same recipe as nav pills */} +
+
+ + {/* autocorrect bar */} +
+ {['"The"', 'the', 'to'].map((w, i) => ( + + {i > 0 &&
} +
{w}
+ + ))} +
+ + {/* key layout */} +
+ {row(['q','w','e','r','t','y','u','i','o','p'])} + {row(['a','s','d','f','g','h','j','k','l'], 20)} +
+ {key(icons.shift, { w: 45, k: 'shift' })} +
+ {['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))} +
+ {key(icons.del, { w: 45, k: 'del' })} +
+
+ {key('ABC', { w: 92.25, fs: 18, k: 'abc' })} + {key('', { flex: true, k: 'space' })} + {key(icons.ret, { w: 92.25, ret: true, k: 'ret' })} +
+
+ + {/* bottom spacer (emoji+mic area, icons omitted) */} +
+
+ ); +} + +Object.assign(window, { + IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard, +}); diff --git a/design/handoff/project/screenshots/footer.png b/design/handoff/project/screenshots/footer.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb0ef0d03fca25d53a0db9e4bbe8496849b564f GIT binary patch literal 20317 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 MAn+$re53z=0ZwglT>t<8 literal 0 HcmV?d00001 diff --git a/design/handoff/project/screenshots/massapp.png b/design/handoff/project/screenshots/massapp.png new file mode 100644 index 0000000000000000000000000000000000000000..c42f4a13d175c11e385a92533a22f1dcb6e072aa GIT binary patch literal 32131 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 literal 0 HcmV?d00001 diff --git a/design/handoff/project/support.js b/design/handoff/project/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/design/handoff/project/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: