Files
scrabble-game/ui/src/lib/offer.ts
T
Ilia Denisov 25b5ed5516
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
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 1m54s
fix(legal): translate the offer price-list headings in the English view
The price list is spliced in Russian; the client-side offer transliteration turned its section headings and column headers into transliteration too. Translate the known backend-projected strings (the two section headings and the column headers Наименование/Рубли/Голоса в VK/Stars в Telegram/«Фишки») to English and transliterate only the product names, as requested.
2026-07-13 13:49:01 +02:00

268 lines
11 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. Its section headings and column headers
// are translated to English (PRICE_TR — the exact strings the backend projects); the product
// names fall through to transliteration.
var PRICE_TR = {
'Приобретение внутриигровой валюты «Фишка»:': 'Purchase of the «Chip» in-game currency:',
'Использование внутриигровой валюты «Фишка»:': 'Use of the «Chip» in-game currency:',
'Наименование': 'Item',
'Рубли': 'Roubles',
'Голоса в VK': 'VK Votes',
'Stars в Telegram': 'Telegram Stars',
'«Фишки»': '«Chips»'
};
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, tr;
while ((n = w.nextNode())) {
tr = PRICE_TR[n.nodeValue];
if (typeof tr === 'string') { n.nodeValue = tr; continue; }
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',
});
}