diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index c7a2aed..811bcfa 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -497,6 +497,22 @@ jobs:
docker logs --tail 50 scrabble-backend || true
exit 1
+ - name: Probe the /offer/ public offer page is served
+ run: |
+ set -u
+ # /offer/ is a static page baked into the landing image (rendered from
+ # ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
+ # silently falls through to the landing shell (also 200) — so assert offer-specific
+ # content, never just the status.
+ out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
+ if echo "$out" | grep -q "290210610742"; then
+ echo "ok: /offer/ serves the public offer page"
+ else
+ echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
+ docker logs --tail 50 scrabble-landing || true
+ exit 1
+ fi
+
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
diff --git a/deploy/landing/Caddyfile b/deploy/landing/Caddyfile
index 92ff744..d8747b7 100644
--- a/deploy/landing/Caddyfile
+++ b/deploy/landing/Caddyfile
@@ -18,10 +18,25 @@
@shell not path /assets/*
header @shell Cache-Control "no-cache"
+ # The static public offer page, rendered from ui/legal/offer_ru.md at build
+ # time into dist/offer/index.html (vite emit-offer plugin). Served with its own
+ # index so /offer/ resolves to /srv/offer/index.html rather than falling to the
+ # landing shell below (whose index is landing.html). A bare /offer redirects in.
+ handle /offer {
+ redir * /offer/ permanent
+ }
+ handle /offer/* {
+ file_server {
+ index index.html
+ }
+ }
+
# An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below.
- try_files {path} /landing.html
- file_server {
- index landing.html
+ handle {
+ try_files {path} /landing.html
+ file_server {
+ index landing.html
+ }
}
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 1dccaa8..10c6621 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1328,7 +1328,8 @@ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth a
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
-catch-all — notably the landing at `/` — goes to the landing container. The
+catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
+(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts
index ebb3fe4..4ce19bf 100644
--- a/ui/e2e/landing.spec.ts
+++ b/ui/e2e/landing.spec.ts
@@ -58,3 +58,14 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn
expect(await web.getAttribute('href')).toContain('/app/');
await expect(page.getByText('Веб-версия')).toBeVisible();
});
+
+// The footer carries a public-offer link to the static /offer/ page (rendered from
+// ui/legal/offer_ru.md at build; the legal document a purchase accepts).
+test('the landing footer links to the public offer at /offer/', async ({ page }) => {
+ await page.goto('/landing.html');
+ await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
+
+ const offer = page.getByRole('link', { name: 'Публичная оферта' });
+ await expect(offer).toBeVisible();
+ expect(await offer.getAttribute('href')).toBe('/offer/');
+});
diff --git a/ui/legal/offer_ru.md b/ui/legal/offer_ru.md
new file mode 100644
index 0000000..39af269
--- /dev/null
+++ b/ui/legal/offer_ru.md
@@ -0,0 +1,145 @@
+# Публичная оферта
+
+Публичная оферта о заключении договора купли-продажи.
+
+## 1. Общие положения
+
+В настоящей Публичной оферте содержатся условия заключения Договора купли-продажи (далее по тексту — «Договор купли-продажи» и/или «Договор»). Настоящей офертой признается предложение, адресованное одному или нескольким конкретным лицам, которое достаточно определенно и выражает намерение лица, сделавшего предложение, считать себя заключившим Договор с адресатом, которым будет принято предложение.
+
+Совершение указанных в настоящей Оферте действий является подтверждением согласия обеих Сторон заключить Договор купли-продажи на условиях, в порядке и объеме, изложенных в настоящей Оферте.
+
+Нижеизложенный текст Публичной оферты является официальным публичным предложением Продавца, адресованный заинтересованному кругу лиц заключить Договор купли-продажи в соответствии с положениями пункта 2 статьи 437 Гражданского кодекса РФ.
+
+Договор купли-продажи считается заключенным и приобретает силу с момента совершения Сторонами действий, предусмотренных в настоящей Оферте, и, означающих безоговорочное, а также полное принятие всех условий настоящей Оферты без каких-либо изъятий или ограничений на условиях присоединения.
+
+### Термины и определения
+
+**Договор** — текст настоящей Оферты с Приложениями, являющимися неотъемлемой частью настоящей Оферты, акцептованный Покупателем путем совершения конклюдентных действий, предусмотренных настоящей Офертой.
+
+**Конклюдентные действия** — это поведение, которое выражает согласие с предложением контрагента заключить, изменить или расторгнуть договор. Действия состоят в полном или частичном выполнении условий, которые предложил контрагент.
+
+**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`.
+
+**Стороны Договора (Стороны)** — Продавец и Покупатель.
+
+**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ.
+
+## 2. Предмет Договора
+
+**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму.
+
+**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`.
+
+**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности:
+
+- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи;
+- путем составления и заполнения заявки на оформление заказа Товара;
+- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя;
+- оплаты Товара Покупателем.
+
+Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента.
+
+## 3. Права и обязанности Сторон
+
+### 3.1. Права и обязанности Продавца
+
+**3.1.1.** Продавец вправе требовать оплаты Товаров и их доставки в порядке и на условиях, предусмотренных Договором;
+
+**3.1.2.** Отказать в заключении Договора на основании настоящей Оферты Покупателю в случае его недобросовестного поведения, в частности, в случае:
+
+- более 2 (Двух) отказов от Товаров надлежащего качества в течение года;
+- предоставления заведомо недостоверной персональной информации;
+- возврата испорченного Покупателем Товара или Товара, бывшего в употреблении;
+- иных случаях недобросовестного поведения, свидетельствующих о заключении Покупателем Договора с целью злоупотребления правами, и отсутствия обычной экономической цели Договора — приобретения Товара.
+
+**3.1.3.** Продавец обязуется передать Покупателю Товар надлежащего качества и в надлежащей упаковке;
+
+**3.1.4.** Передать Товар свободным от прав третьих лиц;
+
+**3.1.5.** Организовать доставку Товаров Покупателю;
+
+**3.1.6.** Предоставить Покупателю всю необходимую информацию в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
+
+### 3.2. Права и обязанности Покупателя
+
+**3.2.1.** Покупатель вправе требовать передачи Товара в порядке и на условиях, предусмотренных Договором.
+
+**3.2.2.** Требовать предоставления всей необходимой информации в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
+
+**3.2.3.** Отказаться от Товара по основаниям, предусмотренным Договором и действующим законодательством Российской Федерации.
+
+**3.2.4.** Покупатель обязуется предоставить Продавцу достоверную информацию, необходимую для надлежащего исполнения Договора;
+
+**3.2.5.** Принять и оплатить Товар в соответствии с условиями Договора;
+
+**3.2.6.** Покупатель гарантирует, что все условия Договора ему понятны; Покупатель принимает условия без оговорок, а также в полном объеме.
+
+## 4. Цена и порядок расчетов
+
+**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`.
+
+**4.2.** Все расчеты по Договору производятся в безналичном порядке.
+
+## 5. Обмен и возврат Товара
+
+**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463.
+
+**5.2.** Требование Покупателя об обмене либо о возврате Товара рассматривается индивидуально при условии неиспользования приобретенного товара и наличии уважительных причин (технический сбой, ошибка в описании товара).
+
+## 6. Конфиденциальность и безопасность
+
+**6.1.** При реализации настоящего Договора Стороны обеспечивают конфиденциальность и безопасность персональных данных в соответствии с актуальной редакцией ФЗ от 27.07.2006 г. № 152-ФЗ «О персональных данных» и ФЗ от 27.07.2006 г. № 149-ФЗ «Об информации, информационных технологиях и о защите информации».
+
+**6.2.** Стороны обязуются сохранять конфиденциальность информации, полученной в ходе исполнения настоящего Договора, и принять все возможные меры, чтобы предохранить полученную информацию от разглашения.
+
+**6.3.** Под конфиденциальной информацией понимается любая информация, передаваемая Продавцом и Покупателем в процессе реализации Договора и подлежащая защите, исключения указаны ниже.
+
+**6.4.** Такая информация может содержаться в предоставляемых Продавцом локальных нормативных актах, договорах, письмах, отчетах, аналитических материалах, результатах исследований, схемах, графиках, спецификациях и других документах, оформленных как на бумажных, так и на электронных носителях.
+
+## 7. Форс-мажор
+
+**7.1.** Стороны освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по Договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, под которыми понимаются: запретные действия властей, эпидемии, блокада, эмбарго, землетрясения, наводнения, пожары или другие стихийные бедствия.
+
+**7.2.** В случае наступления этих обстоятельств Сторона обязана в течение 30 (Тридцати) рабочих дней уведомить об этом другую Сторону.
+
+**7.3.** Документ, выданный уполномоченным государственным органом, является достаточным подтверждением наличия и продолжительности действия непреодолимой силы.
+
+**7.4.** Если обстоятельства непреодолимой силы продолжают действовать более 60 (Шестидесяти) рабочих дней, то каждая Сторона вправе отказаться от настоящего Договора в одностороннем порядке.
+
+## 8. Ответственность Сторон
+
+**8.1.** В случае неисполнения и/или ненадлежащего исполнения своих обязательств по Договору, Стороны несут ответственность в соответствии с условиями настоящей Оферты.
+
+**8.2.** Сторона, не исполнившая или ненадлежащим образом исполнившая обязательства по Договору, обязана возместить другой Стороне причиненные такими нарушениями убытки.
+
+## 9. Срок действия настоящей Оферты
+
+**9.1.** Оферта вступает в силу с момента размещения на Сайте Продавца и действует до момента её отзыва Продавцом.
+
+**9.2.** Продавец оставляет за собой право внести изменения в условия Оферты и/или отозвать Оферту в любой момент по своему усмотрению. Сведения об изменении или отзыве Оферты доводятся до Покупателя по выбору Продавца посредством размещения на сайте Продавца в сети «Интернет», в Личном кабинете Покупателя, либо путем направления соответствующего уведомления на электронный или почтовый адрес, указанный Покупателем при заключении Договора или в ходе его исполнения.
+
+**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору.
+
+**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме.
+
+## 10. Дополнительные условия
+
+**10.1.** Договор, его заключение и исполнение регулируется действующим законодательством Российской Федерации. Все вопросы, не урегулированные настоящей Офертой или урегулированные не полностью, регулируются в соответствии с материальным правом Российской Федерации.
+
+**10.2.** В случае возникновения спора, который может возникнуть между Сторонами в ходе исполнения ими своих обязательств по Договору, заключенному на условиях настоящей Оферты, Стороны обязаны урегулировать спор мирным путем до начала судебного разбирательства.
+
+Судебное разбирательство осуществляется в соответствии с законодательством Российской Федерации.
+
+Споры или разногласия, по которым Стороны не достигли договоренности, подлежат разрешению в соответствии с законодательством РФ. Досудебный порядок урегулирования спора является обязательным.
+
+**10.3.** В качестве языка Договора, заключаемого на условиях настоящей Оферты, а также языка, используемого при любом взаимодействии Сторон (включая ведение переписки, предоставление требований / уведомлений / разъяснений, предоставление документов и т. д.), Стороны определили русский язык.
+
+**10.4.** Все документы, подлежащие предоставлению в соответствии с условиями настоящей Оферты, должны быть составлены на русском языке либо иметь перевод на русский язык, удостоверенный в установленном порядке.
+
+**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем.
+
+**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
+
+## 11. Реквизиты Продавца
+
+Денисов Илья Аркадьевич, ИНН 290210610742.
diff --git a/ui/package.json b/ui/package.json
index 448202f..2af78a8 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -28,6 +28,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
+ "marked": "^18.0.5",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 996bd0e..d819061 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -39,6 +39,9 @@ importers:
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
+ marked:
+ specifier: ^18.0.5
+ version: 18.0.5
svelte:
specifier: ^5.15.0
version: 5.56.0
@@ -1628,6 +1631,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ marked@18.0.5:
+ resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -3877,6 +3885,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ marked@18.0.5: {}
+
math-intrinsics@1.1.0: {}
minimatch@10.2.5:
diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte
index 9f4bf3b..44f918a 100644
--- a/ui/src/Landing.svelte
+++ b/ui/src/Landing.svelte
@@ -123,7 +123,10 @@
-
+
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 8f5f431..3924ed3 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -275,6 +275,7 @@ export const en = {
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Web',
+ 'landing.offer': 'Public offer',
'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index ed1004c..4c58d99 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -275,6 +275,7 @@ export const ru: Record = {
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия',
+ 'landing.offer': 'Публичная оферта',
'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
diff --git a/ui/src/lib/offer.test.ts b/ui/src/lib/offer.test.ts
new file mode 100644
index 0000000..a9da8c3
--- /dev/null
+++ b/ui/src/lib/offer.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'vitest';
+import { renderOfferHtml } from './offer';
+
+describe('renderOfferHtml', () => {
+ it('wraps rendered markdown in a standalone Russian HTML document', () => {
+ const html = renderOfferHtml('# Публичная оферта\n\nо заключении договора купли-продажи');
+ expect(html).toContain('');
+ expect(html).toContain('lang="ru"');
+ expect(html).toContain('Публичная оферта — Эрудит');
+ // The markdown heading is rendered, not left as literal source.
+ expect(html).toContain('
Публичная оферта
');
+ expect(html).not.toContain('# Публичная оферта');
+ // A back link to the landing root is always present.
+ expect(html).toContain('href="/"');
+ });
+
+ it('renders headings, bold clause numbers and lists', () => {
+ const html = renderOfferHtml('## 2. Предмет\n\n**2.1.** Текст пункта.\n\n- первый\n- второй');
+ expect(html).toContain('
');
+ });
+});
diff --git a/ui/src/lib/offer.ts b/ui/src/lib/offer.ts
new file mode 100644
index 0000000..f6cecd9
--- /dev/null
+++ b/ui/src/lib/offer.ts
@@ -0,0 +1,93 @@
+import { marked } from 'marked';
+
+/**
+ * renderOfferHtml renders the public-offer markdown source into a standalone,
+ * self-contained HTML document served statically at `/offer/`. The build emits
+ * the result as `dist/offer/index.html` (see the `emit-offer` plugin in
+ * `vite.config.ts`), which the landing container serves.
+ *
+ * The input `markdown` is trusted repository content (the owner-edited
+ * `ui/legal/offer_ru.md`), not user input, so the rendered HTML is deliberately
+ * not sanitised. The page carries its own minimal light/dark styling so it needs
+ * neither the app bundle nor `app.css`.
+ */
+export function renderOfferHtml(markdown: string): string {
+ const body = marked.parse(markdown, { async: false }) as string;
+ return `
+
+
+
+
+ Публичная оферта — Эрудит
+
+
+
+
+
+
+
+ ← На главную
+ ${body}
+
+
+
+`;
+}
diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json
index 379848a..578faea 100644
--- a/ui/tsconfig.node.json
+++ b/ui/tsconfig.node.json
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"types": ["node"]
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "src/lib/offer.ts"]
}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 5a36909..62a6217 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa';
+import { renderOfferHtml } from './src/lib/offer';
/**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
@@ -38,6 +39,22 @@ function emitPolyfills(): Plugin {
};
}
+/**
+ * emitOffer renders the public offer markdown (legal/offer_ru.md) to a standalone
+ * static page and emits it as dist/offer/index.html, which the landing container
+ * serves at /offer/ (deploy/landing/Caddyfile). The markdown is the owner-editable
+ * source of truth, so the page is regenerated from it on every build.
+ */
+function emitOffer(): Plugin {
+ return {
+ name: 'emit-offer',
+ generateBundle() {
+ const md = readFileSync(resolve(import.meta.dirname, 'legal/offer_ru.md'), 'utf8');
+ this.emitFile({ type: 'asset', fileName: 'offer/index.html', source: renderOfferHtml(md) });
+ },
+ };
+}
+
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -60,6 +77,7 @@ export default defineConfig(({ mode }) => ({
plugins: [
svelte(),
emitPolyfills(),
+ emitOffer(),
injectBootVersion(),
// App-shell precache for the offline mode: a custom (injectManifest) service worker precaches
// index.html + the hashed assets so the installed web PWA cold-launches with no network. It