Files
scrabble-game/ui/src/lib/offer.ts
T
Ilia Denisov de5ab9186c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
feat(legal): bilingual (RU + EN) legal pages with a language + theme switcher
Add English versions of the privacy policy, EULA and offer (ui/legal/*_en.md) and render each legal page as one self-contained bilingual document: both language bodies plus a header language toggle and theme toggle (no back), a small inline ES5 script that switches language client-side (no reload, default Russian + persisted) and applies the theme (system default + persisted override) — mirroring the landing. The offer's English view transliterates the Russian product names spliced from the live catalog.

renderLegalHtml now takes both language sources; renderOffer splices the price list into both; the renderer bakes and reads the _en sources and pre-renders the static pages at boot. Also wrap the /offer/ contour probe in the same retry+timeout as the legal probe (the offer page is now bilingual and larger). Docs + renderer/ui tests updated.
2026-07-13 13:22:56 +02:00

256 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { marked } from 'marked';
/** LegalDoc is one language variant of a legal document: its markdown source and page title. */
export interface LegalDoc {
md: string;
title: string;
}
/**
* renderLegalHtml renders a bilingual (RU + EN) legal document as a standalone, self-contained HTML
* page with a header language + theme switcher, mirroring the landing: a 🌐 language toggle and a
* ☼/☾ theme toggle, no "back". Both language bodies are rendered into the page; a small inline
* ES5 script toggles between them client-side (no reload), defaults to Russian (never the browser
* language, like the landing) and persists the choice, and applies the theme (system default plus a
* persisted manual override). On the offer page (`bodyClass === 'offer'`) it also transliterates the
* Cyrillic left in the EN body — the live price list is spliced in Russian, so the product names are
* transliterated for the English view. It backs every public legal page — the offer at `/offer/`, the
* privacy policy at `/privacy/`, the EULA at `/eula/` — served by the render sidecar.
*
* The markdown is trusted repository content plus the backend's own catalog projection, not user
* input, so the rendered HTML is deliberately not sanitised. The page carries its own minimal styling
* and script, so it needs neither the app bundle nor `app.css`.
*/
export function renderLegalHtml(doc: { ru: LegalDoc; en: LegalDoc; canonical: string; bodyClass?: string }): string {
const ruBody = marked.parse(doc.ru.md, { async: false }) as string;
const enBody = marked.parse(doc.en.md, { async: false }) as string;
return `<!doctype html>
<html lang="ru" data-title-ru="${attr(doc.ru.title)}" data-title-en="${attr(doc.en.title)}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${doc.ru.title}</title>
<link rel="canonical" href="${doc.canonical}" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--text: #1a1c20;
--accent: #2563eb;
--rule: #e5e7eb;
--cell-border: #d1d5db;
}
/* Dark palette: the system default (unless the reader forced light) and the manual override. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #0f1115;
--text: #e7e9ee;
--accent: #6ea8fe;
--rule: #242832;
/* A muted grey — the section rules vanish on dark, so table cells get a firmer border. */
--cell-border: #3a4250;
}
}
:root[data-theme='dark'] {
--bg: #0f1115;
--text: #e7e9ee;
--accent: #6ea8fe;
--rule: #242832;
--cell-border: #3a4250;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
/* The language + theme switcher sits at the top and stays reachable down a long document. */
.legalbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 20px;
background: var(--bg);
border-bottom: 1px solid var(--rule);
}
.tgl {
min-width: 40px;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font: 600 14px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: var(--text);
background: transparent;
border: 1px solid var(--cell-border);
border-radius: 8px;
cursor: pointer;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 64px;
}
main[hidden] {
display: none;
}
a {
color: var(--accent);
}
h1 {
font-size: 1.6rem;
text-align: center;
}
h2 {
font-size: 1.2rem;
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--rule);
}
h3 {
font-size: 1.05rem;
margin-top: 1.5em;
}
ul {
padding-left: 1.25em;
}
li {
margin: 0.25em 0;
}
/* Tables span the full content width (the offer price list §4.4, the privacy data table). */
table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0 1.25em;
font-size: 0.95rem;
}
th,
td {
border: 1px solid var(--cell-border);
padding: 6px 10px;
/* Top-align: the privacy data table's prose cells differ in height per row. */
vertical-align: top;
}
/* Offer only: the product-name column shrinks to its content and never wraps; the price columns
share the rest of the width (width:1% is the shrink-to-fit idiom paired with width:100%). Other
legal tables (the privacy data table) keep the default auto layout so their prose cells wrap. */
.offer th:first-child,
.offer td:first-child {
width: 1%;
white-space: nowrap;
}
/* Right-aligned price columns. marked emits the alignment from the "---:" separator; this rule
keeps it applied whether that surfaces as an align attribute or an inline style. */
th[align='right'],
td[align='right'] {
text-align: right;
}
</style>
</head>
<body${doc.bodyClass ? ' class="' + doc.bodyClass + '"' : ''}>
<header class="legalbar">
<button type="button" id="legal-lang" class="tgl"></button>
<button type="button" id="legal-theme" class="tgl" aria-label="Theme"></button>
</header>
<main data-lang="ru">
${ruBody}
</main>
<main data-lang="en" hidden>
${enBody}
</main>
<script>
(function () {
'use strict';
var html = document.documentElement;
var K_LANG = 'erudit.legal.lang', K_THEME = 'erudit.legal.theme';
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
var langBtn = document.getElementById('legal-lang');
var themeBtn = document.getElementById('legal-theme');
// Offer only: the price list is spliced in Russian, so transliterate the Cyrillic that
// survives in the English body (the product names) to keep it in-language.
function translit(s) {
var m = { 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i',
'й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u',
'ф':'f','х':'kh','ц':'ts','ч':'ch','ш':'sh','щ':'shch','ъ':'','ы':'y','ь':'','э':'e',
'ю':'yu','я':'ya' };
return s.replace(/[а-яё]/gi, function (c) {
var lower = c.toLowerCase(), r = m[lower];
if (r === undefined) return c;
return c !== lower && r ? r.charAt(0).toUpperCase() + r.slice(1) : r;
});
}
function translitEn() {
var en = document.querySelector('main[data-lang="en"]');
if (!en || !document.createTreeWalker) return;
var w = document.createTreeWalker(en, NodeFilter.SHOW_TEXT, null, false), n;
while ((n = w.nextNode())) {
if (/[а-яё]/i.test(n.nodeValue)) n.nodeValue = translit(n.nodeValue);
}
}
function applyLang(l) {
html.lang = l;
set(K_LANG, l);
var mains = document.querySelectorAll('main[data-lang]');
for (var i = 0; i < mains.length; i++) {
mains[i].hidden = mains[i].getAttribute('data-lang') !== l;
}
var t = html.getAttribute('data-title-' + l);
if (t) document.title = t;
// The button shows the OTHER language — what a click switches to.
langBtn.textContent = l === 'ru' ? 'English' : 'Русский';
}
function applyTheme(t) {
html.setAttribute('data-theme', t);
set(K_THEME, t);
themeBtn.textContent = t === 'light' ? '☼' : '☾';
}
// Default language is Russian (never the browser language), like the landing; persisted wins.
var lang = get(K_LANG) === 'en' ? 'en' : 'ru';
// Theme: a persisted choice wins, else the system scheme.
var theme = get(K_THEME);
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (document.body.classList.contains('offer')) translitEn();
applyLang(lang);
applyTheme(theme);
langBtn.onclick = function () { applyLang(html.lang === 'ru' ? 'en' : 'ru'); };
themeBtn.onclick = function () { applyTheme(html.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); };
})();
</script>
</body>
</html>
`;
}
/** attr escapes a trusted string for use inside a double-quoted HTML attribute value. */
function attr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
/**
* renderOfferHtml renders the public-offer page (its Russian price list already spliced into both
* language sources) as the standalone `/offer/` page — the offer preset over {@link renderLegalHtml}.
*/
export function renderOfferHtml(ruMd: string, enMd: string): string {
return renderLegalHtml({
ru: { md: ruMd, title: 'Публичная оферта — Эрудит' },
en: { md: enMd, title: 'Public offer — Erudit' },
canonical: 'https://erudit-game.ru/offer/',
bodyClass: 'offer',
});
}