Add TTRPG Player marketing landing site.

React/Vite landing with RU/EN i18n, live USD pricing, download links to updates.mailib.ru, and donate.stream checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-06-28 11:58:31 +08:00
commit b0ab6cc729
70 changed files with 9151 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log
+31
View File
@@ -0,0 +1,31 @@
# TTRPG Player Landing
Статический маркетинговый лендинг **НРИ Плеер** (React + Vite + TypeScript). Воспроизводит структуру и контент [публичной версии на Framer](https://conclusive-resource-069588.framer.app/).
Отдельный пакет в экосистеме `dnd_project`, **не входит** в сборку Electron-плеера.
## Запуск
```bash
npm install
npm run dev
```
## Сборка и проверки
```bash
npm run build
npm run lint
npm run typecheck
npm run test
```
## Структура
- `src/data/siteContent.ts` — единый источник текстов, ссылок и якорей
- `src/components/sections/` — секции лендинга (hero, features, pricing, FAQ и т.д.)
- `src/lib/detectOs.ts` — выбор ссылки «Скачать для моей ОС»
## Деплой
`npm run build` → статика в `dist/`. Можно разместить на любом static hosting или за reverse proxy рядом с `updates.mailib.ru`.
+56
View File
@@ -0,0 +1,56 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
import prettierPlugin from "eslint-plugin-prettier";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**", "eslint.config.js"],
},
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
project: ["./tsconfig.eslint.json"],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
react: reactPlugin,
"react-hooks": reactHooks,
"jsx-a11y": jsxA11y,
prettier: prettierPlugin,
},
settings: {
react: { version: "detect" },
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...jsxA11y.configs.recommended.rules,
...prettierConfig.rules,
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"@typescript-eslint/consistent-type-definitions": "off",
},
},
{
files: ["**/*.test.ts"],
rules: {
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/no-floating-promises": "off",
},
},
);
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Десктопное приложение для мастеров НРИ: граф сцен, пульт управления, экран презентации, музыка и эффекты. Windows, macOS, Linux."
/>
<meta
property="og:title"
content="НРИ Плеер — редактор кампаний и пульт мастера для настольных RPG"
/>
<meta
property="og:description"
content="Создавайте кампании и ведите партии. Редактор, пульт и экран презентации в одном приложении."
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>
НРИ Плеер — редактор кампаний и пульт мастера для настольных RPG
</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5702
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "ttrpg-player-landing",
"private": true,
"version": "1.0.0",
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test src/**/*.test.ts"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^25.6.0",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.8"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="12" fill="#8B5CF6" />
<path
d="M9.33333 17.6667C9.01146 17.6678 8.71773 17.4834 8.57879 17.193C8.43985 16.9027 8.48055 16.5583 8.68333 16.3083L16.9333 7.80833C17.0608 7.66121 17.2731 7.62194 17.4448 7.71375C17.6164 7.80556 17.7016 8.00398 17.65 8.19167L16.05 13.2083C15.9542 13.4646 15.9904 13.7516 16.1467 13.9762C16.3031 14.2007 16.5597 14.3342 16.8333 14.3333H22.6667C22.9885 14.3322 23.2823 14.5166 23.4212 14.807C23.5601 15.0973 23.5195 15.4417 23.3167 15.6917L15.0667 24.1917C14.9392 24.3388 14.7269 24.3781 14.5552 24.2862C14.3836 24.1944 14.2984 23.996 14.35 23.8083L15.95 18.7917C16.0458 18.5354 16.0096 18.2484 15.8533 18.0238C15.6969 17.7993 15.4403 17.6658 15.1667 17.6667H9.33333"
stroke="white"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+9
View File
@@ -0,0 +1,9 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app main {
flex: 1;
}
+35
View File
@@ -0,0 +1,35 @@
import { Header } from "./components/layout/Header";
import { Footer } from "./components/layout/Footer";
import { Hero } from "./components/sections/Hero";
import { Audience } from "./components/sections/Audience";
import { Benefits } from "./components/sections/Benefits";
import { Features } from "./components/sections/Features";
import { Steps } from "./components/sections/Steps";
import { Platforms } from "./components/sections/Platforms";
import { Pricing } from "./components/sections/Pricing";
import { Comparison } from "./components/sections/Comparison";
import { Faq } from "./components/sections/Faq";
import { CtaBanner } from "./components/sections/CtaBanner";
import styles from "./App.module.css";
export function App() {
return (
<div className={styles.app}>
<Header />
<main>
<Hero />
<Audience />
<Benefits />
<Features />
<Steps />
<Platforms />
<Pricing />
<Comparison />
<Faq />
<CtaBanner />
</main>
<Footer />
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
.footer {
border-top: 1px solid var(--color-border);
padding: 32px 24px 40px;
background: rgba(0, 0, 0, 0.25);
}
.inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
max-width: var(--max-width);
margin: 0 auto;
}
.copy {
font-size: 14px;
color: var(--color-text-dim);
}
.links {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.link {
font-size: 14px;
color: var(--color-text-muted);
transition: color 0.15s ease;
}
.link:hover {
color: var(--color-gold);
}
+41
View File
@@ -0,0 +1,41 @@
import { SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import styles from "./Footer.module.css";
const FOOTER_LINK_KEYS = [
"download",
"buy",
"faq",
"enterKey",
"support",
"privacy",
] as const;
const FOOTER_HREFS: Record<(typeof FOOTER_LINK_KEYS)[number], string> = {
download: SITE_LINKS.download,
buy: SITE_LINKS.buy,
faq: SITE_LINKS.faq,
enterKey: SITE_LINKS.enterKey,
support: SITE_LINKS.support,
privacy: SITE_LINKS.privacy,
};
export function Footer() {
const { copy } = useLocale();
return (
<footer className={styles.footer}>
<div className={styles.inner}>
<p className={styles.copy}>© TTRPGPlayer</p>
<nav className={styles.links} aria-label={copy.footer.aria}>
{FOOTER_LINK_KEYS.map((key) => (
<a key={key} className={styles.link} href={FOOTER_HREFS[key]}>
{copy.footer.links[key]}
</a>
))}
</nav>
</div>
</footer>
);
}
+74
View File
@@ -0,0 +1,74 @@
.header {
position: sticky;
top: 0;
z-index: 100;
height: var(--header-height);
border-bottom: 1px solid var(--color-border);
background: rgba(8, 6, 14, 0.82);
backdrop-filter: blur(12px);
}
.inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
max-width: var(--max-width);
height: 100%;
margin: 0 auto;
padding: 0 24px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
font-weight: 700;
color: var(--color-text);
}
.logo {
border-radius: 10px;
}
.nav {
display: flex;
align-items: center;
gap: 28px;
}
.navLink {
font-size: 14px;
font-weight: 500;
color: var(--color-text-muted);
transition: color 0.15s ease;
}
.navLink:hover {
color: var(--color-text);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.actionBtn {
padding: 9px 16px;
font-size: 14px;
}
@media (max-width: 900px) {
.nav {
display: none;
}
}
@media (max-width: 480px) {
.actionBtn:first-child {
display: none;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NAV_HREFS, NAV_LINK_KEYS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
import { LanguageSwitcher } from "./LanguageSwitcher";
import styles from "./Header.module.css";
export function Header() {
const { copy } = useLocale();
return (
<header className={styles.header}>
<div className={styles.inner}>
<a className={styles.brand} href="#top">
<img
className={styles.logo}
src="/favicon.svg"
alt=""
width={32}
height={32}
/>
<span>{copy.brand}</span>
</a>
<nav className={styles.nav} aria-label={copy.navAria}>
{NAV_LINK_KEYS.map((key) => (
<a key={key} className={styles.navLink} href={NAV_HREFS[key]}>
{copy.nav[key]}
</a>
))}
</nav>
<div className={styles.actions}>
<LanguageSwitcher />
<Button
href={SITE_LINKS.download}
variant="ghost"
className={styles.actionBtn}
>
{copy.cta.download}
</Button>
<Button
href={SITE_LINKS.buy}
variant="secondary"
className={styles.actionBtn}
>
{copy.cta.buy}
</Button>
</div>
</div>
</header>
);
}
@@ -0,0 +1,32 @@
.switcher {
display: inline-flex;
padding: 3px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.03);
}
.btn {
min-width: 36px;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.btn:hover {
color: var(--color-text);
}
.active {
background: rgba(124, 58, 237, 0.35);
color: var(--color-text);
}
@@ -0,0 +1,36 @@
import { useLocale } from "../../i18n/LocaleProvider";
import styles from "./LanguageSwitcher.module.css";
export function LanguageSwitcher() {
const { locale, setLocale, copy } = useLocale();
return (
<div
className={styles.switcher}
role="group"
aria-label={copy.localeSwitcher.aria}
>
<button
type="button"
className={`${styles.btn} ${locale === "ru" ? styles.active : ""}`}
aria-pressed={locale === "ru"}
onClick={() => {
setLocale("ru");
}}
>
{copy.localeSwitcher.ru}
</button>
<button
type="button"
className={`${styles.btn} ${locale === "en" ? styles.active : ""}`}
aria-pressed={locale === "en"}
onClick={() => {
setLocale("en");
}}
>
{copy.localeSwitcher.en}
</button>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
.section {
padding: 88px 24px;
}
.dark {
background: rgba(0, 0, 0, 0.22);
}
.inner {
max-width: var(--max-width);
margin: 0 auto;
}
.eyebrow {
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gold);
}
.title {
margin-bottom: 16px;
font-size: clamp(28px, 4vw, 40px);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
}
.subtitle {
max-width: 640px;
margin-bottom: 40px;
font-size: 17px;
color: var(--color-text-muted);
}
@media (max-width: 640px) {
.section {
padding: 64px 20px;
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import styles from "./Section.module.css";
type SectionProps = {
id?: string;
eyebrow?: string;
title: string;
subtitle?: string;
children: ReactNode;
className?: string;
dark?: boolean;
};
function join(...parts: (string | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
export function Section({
id,
eyebrow,
title,
subtitle,
children,
className,
dark,
}: SectionProps) {
return (
<section
id={id}
className={join(
styles.section,
dark ? styles.dark : undefined,
className,
)}
>
<div className={styles.inner}>
{eyebrow ? <p className={styles.eyebrow}>{eyebrow}</p> : null}
<h2 className={styles.title}>{title}</h2>
{subtitle ? <p className={styles.subtitle}>{subtitle}</p> : null}
{children}
</div>
</section>
);
}
@@ -0,0 +1,38 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-glow);
}
.cardTitle {
margin-bottom: 12px;
font-size: 18px;
font-weight: 700;
}
.cardText {
font-size: 15px;
line-height: 1.6;
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { AUDIENCE_CARD_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Audience.module.css";
export function Audience() {
const { copy } = useLocale();
return (
<Section eyebrow={copy.audience.eyebrow} title={copy.audience.title}>
<div className={styles.grid}>
{AUDIENCE_CARD_IDS.map((id) => {
const card = copy.audience.cards[id];
return (
<article key={id} className={styles.card}>
<h3 className={styles.cardTitle}>{card.title}</h3>
<p className={styles.cardText}>{card.description}</p>
</article>
);
})}
</div>
</Section>
);
}
@@ -0,0 +1,42 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
padding: 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(12, 10, 20, 0.6);
}
.icon {
margin-bottom: 14px;
font-size: 18px;
color: var(--color-purple-bright);
}
.cardTitle {
margin-bottom: 8px;
font-size: 16px;
font-weight: 700;
}
.cardText {
font-size: 14px;
line-height: 1.6;
color: var(--color-text-muted);
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.grid {
grid-template-columns: 1fr;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { BENEFIT_CARD_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Benefits.module.css";
export function Benefits() {
const { copy } = useLocale();
return (
<Section id="features" dark title={copy.benefits.title}>
<div className={styles.grid}>
{BENEFIT_CARD_IDS.map((id) => {
const card = copy.benefits.cards[id];
return (
<article key={id} className={styles.card}>
<div className={styles.icon} aria-hidden="true">
</div>
<h3 className={styles.cardTitle}>{card.title}</h3>
<p className={styles.cardText}>{card.description}</p>
</article>
);
})}
</div>
</Section>
);
}
@@ -0,0 +1,46 @@
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.card {
padding: 32px 28px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
}
.after {
border-color: var(--color-purple);
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.12),
rgba(12, 10, 20, 0.8)
);
}
.label {
margin-bottom: 14px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-gold);
}
.text {
font-size: 16px;
line-height: 1.6;
color: var(--color-text-muted);
}
.after .text {
color: var(--color-text);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Comparison.module.css";
export function Comparison() {
const { copy } = useLocale();
return (
<Section title={copy.comparison.title}>
<div className={styles.grid}>
<article className={styles.card}>
<h3 className={styles.label}>{copy.comparison.beforeLabel}</h3>
<p className={styles.text}>{copy.comparison.beforeText}</p>
</article>
<article className={`${styles.card} ${styles.after}`}>
<h3 className={styles.label}>{copy.comparison.afterLabel}</h3>
<p className={styles.text}>{copy.comparison.afterText}</p>
</article>
</div>
</Section>
);
}
@@ -0,0 +1,40 @@
.banner {
padding: 80px 24px 100px;
}
.inner {
max-width: 720px;
margin: 0 auto;
padding: 48px 40px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-xl);
text-align: center;
background:
radial-gradient(
ellipse at 50% 0%,
rgba(124, 58, 237, 0.2),
transparent 60%
),
var(--color-bg-card);
box-shadow: var(--shadow-glow);
}
.title {
margin-bottom: 28px;
font-size: clamp(26px, 4vw, 34px);
font-weight: 700;
line-height: 1.2;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
}
@media (max-width: 560px) {
.inner {
padding: 36px 24px;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
import styles from "./CtaBanner.module.css";
export function CtaBanner() {
const { copy } = useLocale();
return (
<section className={styles.banner}>
<div className={styles.inner}>
<h2 className={styles.title}>{copy.ctaBanner.title}</h2>
<div className={styles.actions}>
<Button href={SITE_LINKS.download}>{copy.cta.download}</Button>
<Button href={SITE_LINKS.buy} variant="secondary">
{copy.cta.buyLicense}
</Button>
</div>
</div>
</section>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { FAQ_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import { Accordion } from "../ui/Accordion";
export function Faq() {
const { copy } = useLocale();
const items = FAQ_IDS.map((id) => copy.faq.items[id]);
return (
<Section id="faq" dark title={copy.faq.title}>
<Accordion items={items} />
</Section>
);
}
@@ -0,0 +1,46 @@
.frame {
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-bg-elevated);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.frameAtmosphere {
width: fit-content;
max-width: 100%;
justify-self: center;
}
.chrome {
display: flex;
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
background: rgba(0, 0, 0, 0.35);
}
.chrome span {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.screen {
position: relative;
min-height: 200px;
padding: 16px;
}
.screenScreenshot {
min-height: 0;
padding: 0;
background: #0a0810;
line-height: 0;
}
.screenAtmosphere {
min-height: 0;
width: fit-content;
}
+45
View File
@@ -0,0 +1,45 @@
import type { FeatureVariant } from "../../data/siteContent";
import { getEffectsScreenshotDisplaySize } from "../../data/siteAssets";
import { AppScreenshot } from "../ui/AppScreenshot";
import styles from "./FeatureMockup.module.css";
type FeatureMockupProps = {
variant: FeatureVariant;
};
function join(...parts: (string | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
export function FeatureMockup({ variant }: FeatureMockupProps) {
const isAtmosphere = variant === "atmosphere";
const effectsDisplayWidth = isAtmosphere
? getEffectsScreenshotDisplaySize().width
: undefined;
return (
<div
className={join(
styles.frame,
isAtmosphere ? styles.frameAtmosphere : undefined,
)}
data-variant={variant}
>
<div className={styles.chrome}>
<span />
<span />
<span />
</div>
<div
className={join(
styles.screen,
styles.screenScreenshot,
isAtmosphere ? styles.screenAtmosphere : undefined,
)}
>
<AppScreenshot variant={variant} displayWidth={effectsDisplayWidth} />
</div>
</div>
);
}
@@ -0,0 +1,51 @@
.list {
display: grid;
gap: 56px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: center;
}
.reverse {
direction: rtl;
}
.reverse > * {
direction: ltr;
}
.text {
max-width: 480px;
}
.blockTitle {
margin-bottom: 14px;
font-size: 24px;
font-weight: 700;
}
.blockDesc {
margin-bottom: 16px;
font-size: 15px;
line-height: 1.65;
color: var(--color-text-muted);
}
.tags {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-gold);
}
@media (max-width: 800px) {
.row,
.reverse {
grid-template-columns: 1fr;
direction: ltr;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { FEATURE_VARIANTS } from "../../data/siteContent";
import type { FeatureVariant } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import { FeatureMockup } from "./FeatureMockup";
import styles from "./Features.module.css";
function FeatureRow({
variant,
reverse,
}: {
variant: FeatureVariant;
reverse: boolean;
}) {
const { copy } = useLocale();
const block = copy.features.blocks[variant];
return (
<article className={`${styles.row} ${reverse ? styles.reverse : ""}`}>
<div className={styles.text}>
<h3 className={styles.blockTitle}>{block.title}</h3>
<p className={styles.blockDesc}>{block.description}</p>
<p className={styles.tags}>{block.tags}</p>
</div>
<FeatureMockup variant={variant} />
</article>
);
}
export function Features() {
const { copy } = useLocale();
return (
<Section title={copy.features.title}>
<div className={styles.list}>
{FEATURE_VARIANTS.map((variant, index) => (
<FeatureRow
key={variant}
variant={variant}
reverse={index % 2 === 1}
/>
))}
</div>
</Section>
);
}
+104
View File
@@ -0,0 +1,104 @@
.hero {
padding: 48px 24px 80px;
}
.inner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
align-items: center;
max-width: var(--max-width);
margin: 0 auto;
}
.content {
max-width: 520px;
}
.eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 13px;
font-weight: 500;
color: var(--color-gold);
}
.spark {
color: var(--color-purple-bright);
}
.title {
margin-bottom: 8px;
font-size: clamp(40px, 6vw, 56px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #fff 0%, #d4cce8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline {
margin-bottom: 20px;
font-size: clamp(20px, 3vw, 26px);
font-weight: 600;
color: var(--color-purple-bright);
}
.description {
margin-bottom: 28px;
font-size: 17px;
line-height: 1.65;
color: var(--color-text-muted);
}
.cta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.platforms {
margin-bottom: 32px;
font-size: 13px;
color: var(--color-text-dim);
}
.highlights {
display: grid;
gap: 16px;
}
.highlight {
display: grid;
gap: 4px;
padding: 16px 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
}
.highlight strong {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.highlight span {
font-size: 13px;
color: var(--color-text-muted);
}
@media (max-width: 900px) {
.inner {
grid-template-columns: 1fr;
}
.content {
max-width: none;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { HERO_HIGHLIGHT_IDS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
import { HeroMockup } from "./HeroMockup";
import styles from "./Hero.module.css";
export function Hero() {
const { copy } = useLocale();
return (
<section id="top" className={styles.hero}>
<div className={styles.inner}>
<div className={styles.content}>
<p className={styles.eyebrow}>
<span className={styles.spark} aria-hidden="true">
</span>
{copy.hero.eyebrow}
</p>
<h1 className={styles.title}>{copy.brand}</h1>
<p className={styles.tagline}>{copy.hero.tagline}</p>
<p className={styles.description}>{copy.hero.description}</p>
<div className={styles.cta}>
<Button href={SITE_LINKS.download}>{copy.cta.downloadFree}</Button>
<Button href={SITE_LINKS.buy} variant="secondary">
{copy.cta.buyLicense}
</Button>
</div>
<p className={styles.platforms}>{copy.hero.platformsLine}</p>
<div className={styles.highlights}>
{HERO_HIGHLIGHT_IDS.map((id) => {
const item = copy.hero.highlights[id];
return (
<div key={id} className={styles.highlight}>
<strong>{item.title}</strong>
<span>{item.description}</span>
</div>
);
})}
</div>
</div>
<HeroMockup />
</div>
</section>
);
}
@@ -0,0 +1,83 @@
.wrap {
position: relative;
min-height: 420px;
}
.glow {
position: absolute;
inset: 10% 5%;
background: radial-gradient(
circle,
rgba(124, 58, 237, 0.35),
transparent 70%
);
filter: blur(40px);
pointer-events: none;
}
.window {
position: absolute;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-bg-elevated);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.titleBar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
background: rgba(0, 0, 0, 0.3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
}
.winTitle {
margin-left: 8px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
}
.editor {
top: 0;
left: 0;
width: 62%;
z-index: 1;
}
.body {
overflow: hidden;
background: #0a0810;
line-height: 0;
}
.control {
top: 24%;
right: 0;
width: 54%;
z-index: 2;
}
.presentation {
bottom: 0;
left: 10%;
width: 72%;
z-index: 3;
}
@media (max-width: 900px) {
.wrap {
min-height: 360px;
max-width: 480px;
margin: 0 auto;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { AppScreenshot } from "../ui/AppScreenshot";
import { useLocale } from "../../i18n/LocaleProvider";
import styles from "./HeroMockup.module.css";
export function HeroMockup() {
const { copy } = useLocale();
return (
<div className={styles.wrap} aria-hidden="true">
<div className={styles.glow} />
<div className={`${styles.window} ${styles.editor}`}>
<div className={styles.titleBar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.winTitle}>{copy.mockupWindows.editor}</span>
</div>
<div className={styles.body}>
<AppScreenshot variant="editor" decorative />
</div>
</div>
<div className={`${styles.window} ${styles.control}`}>
<div className={styles.titleBar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.winTitle}>{copy.mockupWindows.control}</span>
</div>
<div className={styles.body}>
<AppScreenshot variant="control" decorative />
</div>
</div>
<div className={`${styles.window} ${styles.presentation}`}>
<div className={styles.titleBar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.winTitle}>
{copy.mockupWindows.presentation}
</span>
</div>
<div className={styles.body}>
<AppScreenshot variant="presentation" decorative />
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 28px;
}
.card {
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
text-align: center;
}
.cardTitle {
margin-bottom: 10px;
font-size: 20px;
font-weight: 700;
}
.cardText {
margin-bottom: 20px;
font-size: 14px;
line-height: 1.55;
color: var(--color-text-muted);
}
.downloadBtn {
width: 100%;
}
.note {
margin-bottom: 12px;
font-size: 14px;
text-align: center;
color: var(--color-text-dim);
}
.disclaimer {
max-width: 820px;
margin: 0 auto 24px;
font-size: 12px;
line-height: 1.55;
text-align: center;
color: var(--color-text-dim);
opacity: 0.9;
}
.mainCta {
display: flex;
justify-content: center;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { useMemo } from "react";
import { DOWNLOAD_LINKS, PLATFORM_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import {
detectOs,
resolveDownloadHref,
resolvePlatformDownloadHref,
} from "../../lib/detectOs";
import { Section } from "../layout/Section";
import { Button } from "../ui/Button";
import { PlatformLogo } from "../ui/PlatformLogo";
import styles from "./Platforms.module.css";
function readNavigatorHints(): { userAgent: string; maxTouchPoints: number } {
if (typeof navigator === "undefined") {
return { userAgent: "", maxTouchPoints: 0 };
}
return {
userAgent: navigator.userAgent,
maxTouchPoints: navigator.maxTouchPoints,
};
}
export function Platforms() {
const { copy } = useLocale();
const hints = useMemo(() => readNavigatorHints(), []);
const osDownloadHref = useMemo(() => {
const os = detectOs(hints.userAgent);
return resolveDownloadHref(os, DOWNLOAD_LINKS, hints);
}, [hints]);
const platformDownloadHrefs = useMemo(
() =>
Object.fromEntries(
PLATFORM_IDS.map((id) => [
id,
resolvePlatformDownloadHref(id, DOWNLOAD_LINKS, hints),
]),
) as Record<(typeof PLATFORM_IDS)[number], string>,
[hints],
);
return (
<Section id="platforms" title={copy.platforms.title}>
<div className={styles.grid}>
{PLATFORM_IDS.map((id) => {
const platform = copy.platforms.cards[id];
return (
<article key={id} className={styles.card}>
<PlatformLogo platform={id} />
<h3 className={styles.cardTitle}>{platform.title}</h3>
<p className={styles.cardText}>{platform.description}</p>
<Button
href={platformDownloadHrefs[id]}
variant="ghost"
className={styles.downloadBtn}
>
{copy.cta.download}
</Button>
</article>
);
})}
</div>
<p className={styles.note}>{copy.platforms.note}</p>
<p className={styles.disclaimer}>
{copy.platforms.distributionDisclaimer}
</p>
<div className={styles.mainCta}>
<Button href={osDownloadHref}>{copy.cta.downloadForOs}</Button>
</div>
</Section>
);
}
+114
View File
@@ -0,0 +1,114 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.card {
display: flex;
flex-direction: column;
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(12, 10, 20, 0.65);
}
.recommended {
border-color: var(--color-gold);
box-shadow: 0 0 40px rgba(200, 161, 102, 0.12);
transform: scale(1.02);
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.badge {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-gold-bright);
text-align: right;
white-space: nowrap;
}
.cardTitle {
margin: 0;
font-size: 20px;
font-weight: 700;
min-width: 0;
}
.price {
margin: 0 0 16px;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: var(--color-gold-bright);
font-variant-numeric: tabular-nums;
}
.features {
flex: 1;
margin: 0 0 24px;
padding: 0;
list-style: none;
}
.features li {
position: relative;
padding-left: 18px;
margin-bottom: 10px;
font-size: 14px;
line-height: 1.5;
color: var(--color-text-muted);
}
.features li::before {
content: "·";
position: absolute;
left: 0;
color: var(--color-gold);
font-weight: 700;
}
.footnote {
font-size: 13px;
text-align: center;
color: var(--color-text-dim);
}
.exchangeRateNote {
max-width: 820px;
margin: 0 auto 12px;
font-size: 12px;
line-height: 1.55;
text-align: center;
color: var(--color-text-dim);
opacity: 0.9;
}
.donationNote {
max-width: 820px;
margin: 12px auto 0;
font-size: 12px;
line-height: 1.55;
text-align: center;
color: var(--color-text-dim);
opacity: 0.9;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
.recommended {
transform: none;
}
}
+85
View File
@@ -0,0 +1,85 @@
import {
PRICING_TIER_IDS,
RECOMMENDED_PRICING_TIER,
SITE_LINKS,
} from "../../data/siteContent";
import { PRICING_TIER_PRICES_RUB } from "../../data/pricing";
import { useLocale } from "../../i18n/LocaleProvider";
import { formatRubPrice, formatUsdPrice } from "../../lib/exchangeRate";
import { useExchangeRate } from "../../providers/ExchangeRateProvider";
import { Section } from "../layout/Section";
import { Button } from "../ui/Button";
import styles from "./Pricing.module.css";
function formatTierPrice(
rub: number,
locale: "ru" | "en",
rubToUsd: number | null,
loading: boolean,
): string {
if (locale === "ru") return formatRubPrice(rub);
if (loading || rubToUsd === null) return "…";
return formatUsdPrice(rub, rubToUsd);
}
export function Pricing() {
const { locale, copy } = useLocale();
const { rubToUsd, loading } = useExchangeRate();
return (
<Section
id="pricing"
dark
title={copy.pricing.title}
subtitle={copy.pricing.subtitle}
>
<div className={styles.grid}>
{PRICING_TIER_IDS.map((id) => {
const tier = copy.pricing.tiers[id];
const recommended = id === RECOMMENDED_PRICING_TIER;
const priceRub = PRICING_TIER_PRICES_RUB[id];
return (
<article
key={id}
className={`${styles.card} ${recommended ? styles.recommended : ""}`}
>
<div className={styles.cardHeader}>
<h3 className={styles.cardTitle}>{tier.title}</h3>
{recommended ? (
<span className={styles.badge}>
{copy.pricing.recommendedBadge}
</span>
) : null}
</div>
<p className={styles.price} aria-live="polite">
{formatTierPrice(priceRub, locale, rubToUsd, loading)}
</p>
<ul className={styles.features}>
{tier.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
<Button
href={SITE_LINKS.buy}
variant={recommended ? "gold" : "secondary"}
>
{copy.cta.buy}
</Button>
</article>
);
})}
</div>
{copy.pricing.exchangeRateNote ? (
<p className={styles.exchangeRateNote}>
{copy.pricing.exchangeRateNote}
</p>
) : null}
<p className={styles.footnote}>{copy.pricing.footnote}</p>
<p className={styles.donationNote}>{copy.pricing.donationNote}</p>
</Section>
);
}
+37
View File
@@ -0,0 +1,37 @@
.list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.item {
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
}
.number {
display: block;
margin-bottom: 16px;
font-size: 32px;
font-weight: 800;
line-height: 1;
color: var(--color-purple-bright);
opacity: 0.85;
}
.text {
font-size: 15px;
line-height: 1.6;
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.list {
grid-template-columns: 1fr;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { STEP_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Steps.module.css";
const STEP_NUMBERS = ["01", "02", "03"] as const;
export function Steps() {
const { copy } = useLocale();
return (
<Section id="how-it-works" dark title={copy.steps.title}>
<ol className={styles.list}>
{STEP_IDS.map((id, index) => (
<li key={id} className={styles.item}>
<span className={styles.number}>{STEP_NUMBERS[index]}</span>
<p className={styles.text}>{copy.steps.items[id]}</p>
</li>
))}
</ol>
</Section>
);
}
+48
View File
@@ -0,0 +1,48 @@
.list {
display: grid;
gap: 12px;
}
.item {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
overflow: hidden;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 18px 20px;
border: none;
background: transparent;
color: var(--color-text);
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.trigger:hover {
background: rgba(255, 255, 255, 0.03);
}
.chevron {
flex-shrink: 0;
font-size: 20px;
font-weight: 400;
color: var(--color-gold);
}
.panel {
padding: 0 20px 18px;
}
.panel p {
font-size: 14px;
line-height: 1.65;
color: var(--color-text-muted);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from "react";
import styles from "./Accordion.module.css";
type AccordionItemProps = {
question: string;
answer: string;
open: boolean;
onToggle: () => void;
};
export function AccordionItem({
question,
answer,
open,
onToggle,
}: AccordionItemProps) {
const panelId = `faq-panel-${question.replace(/\s+/gu, "-").slice(0, 40)}`;
return (
<div className={styles.item}>
<button
type="button"
className={styles.trigger}
aria-expanded={open}
aria-controls={panelId}
onClick={onToggle}
>
<span>{question}</span>
<span className={styles.chevron} aria-hidden="true">
{open ? "" : "+"}
</span>
</button>
{open ? (
<div id={panelId} className={styles.panel} role="region">
<p>{answer}</p>
</div>
) : null}
</div>
);
}
type AccordionProps = {
items: { question: string; answer: string }[];
};
export function Accordion({ items }: AccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<div className={styles.list}>
{items.map((item, index) => (
<AccordionItem
key={item.question}
question={item.question}
answer={item.answer}
open={openIndex === index}
onToggle={() => {
setOpenIndex(openIndex === index ? null : index);
}}
/>
))}
</div>
);
}
@@ -0,0 +1,13 @@
.image {
display: block;
width: 100%;
height: auto;
max-width: 100%;
object-fit: contain;
vertical-align: middle;
}
.fixedWidth {
width: auto;
max-width: 100%;
}
+45
View File
@@ -0,0 +1,45 @@
import type { FeatureVariant } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { SCREENSHOT_SRC } from "../../data/siteAssets";
import styles from "./AppScreenshot.module.css";
type AppScreenshotProps = {
variant: FeatureVariant;
className?: string;
displayWidth?: number;
decorative?: boolean;
};
function join(...parts: (string | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
export function AppScreenshot({
variant,
className,
displayWidth,
decorative = false,
}: AppScreenshotProps) {
const { copy } = useLocale();
return (
<img
className={join(
styles.image,
displayWidth !== undefined ? styles.fixedWidth : undefined,
className,
)}
src={SCREENSHOT_SRC[variant]}
alt={decorative ? "" : copy.screenshotAlt[variant]}
style={
displayWidth !== undefined
? { width: `${String(displayWidth)}px` }
: undefined
}
loading="lazy"
decoding="async"
aria-hidden={decorative ? true : undefined}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 22px;
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
transform 0.15s ease,
box-shadow 0.2s ease;
white-space: nowrap;
}
.button:hover {
transform: translateY(-1px);
}
.primary {
background: linear-gradient(135deg, var(--color-purple) 0%, #6d28d9 100%);
color: #fff;
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.35);
}
.primary:hover {
box-shadow: 0 6px 28px rgba(124, 58, 237, 0.45);
}
.secondary {
background: transparent;
border-color: var(--color-border-strong);
color: var(--color-gold-bright);
}
.secondary:hover {
background: rgba(200, 161, 102, 0.08);
}
.gold {
background: linear-gradient(135deg, #d4a85a 0%, var(--color-gold) 100%);
color: #1a1208;
box-shadow: 0 4px 16px rgba(200, 161, 102, 0.25);
}
.ghost {
background: rgba(255, 255, 255, 0.04);
border-color: var(--color-border);
color: var(--color-text);
}
.ghost:hover {
background: rgba(255, 255, 255, 0.08);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import styles from "./Button.module.css";
type ButtonVariant = "primary" | "secondary" | "ghost" | "gold";
type ButtonProps = {
children: ReactNode;
href?: string;
variant?: ButtonVariant;
className?: string;
onClick?: () => void;
type?: "button" | "submit";
};
function join(...parts: (string | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
export function Button({
children,
href,
variant = "primary",
className,
onClick,
type = "button",
}: ButtonProps) {
const cls = join(styles.button, styles[variant], className);
if (href) {
return (
<a className={cls} href={href}>
{children}
</a>
);
}
return (
<button className={cls} type={type} onClick={onClick}>
{children}
</button>
);
}
+26
View File
@@ -0,0 +1,26 @@
.wrap {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin: 0 auto 16px;
border-radius: 14px;
background: linear-gradient(
145deg,
rgba(124, 58, 237, 0.22),
rgba(8, 6, 14, 0.9)
);
border: 1px solid var(--color-border-strong);
color: var(--color-gold-bright);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 8px 24px rgba(0, 0, 0, 0.25);
}
.icon {
width: 28px;
height: 28px;
opacity: 0.95;
filter: drop-shadow(0 2px 6px rgba(124, 58, 237, 0.35));
}
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import type { PricingTierId } from "./siteContent";
export const PRICING_TIER_PRICES_RUB: Record<PricingTierId, number> = {
basic: 700,
master: 1500,
legend: 10_000,
};
export const PRICING_TIER_LICENSE: Record<
PricingTierId,
{ years: number; devices: number }
> = {
basic: { years: 1, devices: 3 },
master: { years: 2, devices: 5 },
legend: { years: 99, devices: 15 },
};
+30
View File
@@ -0,0 +1,30 @@
/** Public paths under `public/` (served as site root). */
export const SCREENSHOT_SRC = {
editor: "/images/screenshot-editor.png",
control: "/images/screenshot-control.png",
presentation: "/images/screenshot-presentation.png",
atmosphere: "/images/screenshot-effects.png",
} as const;
/** Pixel size of `screenshot-effects.png` (do not upscale beyond this). */
export const EFFECTS_SCREENSHOT_INTRINSIC = {
width: 294,
height: 314,
} as const;
/** Show effects screenshot at 70% of intrinsic pixels (30%). */
export const EFFECTS_SCREENSHOT_DISPLAY_SCALE = 0.7;
export function getEffectsScreenshotDisplaySize(): {
width: number;
height: number;
} {
return {
width: Math.round(
EFFECTS_SCREENSHOT_INTRINSIC.width * EFFECTS_SCREENSHOT_DISPLAY_SCALE,
),
height: Math.round(
EFFECTS_SCREENSHOT_INTRINSIC.height * EFFECTS_SCREENSHOT_DISPLAY_SCALE,
),
};
}
+116
View File
@@ -0,0 +1,116 @@
export const SITE_LINKS = {
features: "#features",
howItWorks: "#how-it-works",
platforms: "#platforms",
pricing: "#pricing",
faq: "#faq",
download: "#platforms",
buy: "https://donate.stream/ttrpgplayer",
enterKey: "#pricing",
support: "mailto:support@ttrpgplayer.example",
privacy: "#",
} as const;
/** Same feed as electron-builder `publish.url` in dnd_player/package.json */
export const UPDATE_FEED_BASE = "https://updates.mailib.ru/";
/** Fixed artifact names — version lives only in latest*.yml on the server */
export const DOWNLOAD_ARTIFACTS = {
windows: "TTRPGPlayer-Setup.exe",
macos: {
x64: "TTRPGPlayer-x64.dmg",
arm64: "TTRPGPlayer-arm64.dmg",
},
linux: {
x64: "TTRPGPlayer-x64.AppImage",
arm64: "TTRPGPlayer-arm64.AppImage",
},
} as const;
function artifactUrl(filename: string): string {
return `${UPDATE_FEED_BASE}${filename}`;
}
export const DOWNLOAD_LINKS = {
windows: artifactUrl(DOWNLOAD_ARTIFACTS.windows),
macos: {
x64: artifactUrl(DOWNLOAD_ARTIFACTS.macos.x64),
arm64: artifactUrl(DOWNLOAD_ARTIFACTS.macos.arm64),
},
linux: {
x64: artifactUrl(DOWNLOAD_ARTIFACTS.linux.x64),
arm64: artifactUrl(DOWNLOAD_ARTIFACTS.linux.arm64),
},
} as const;
export type DownloadLinks = typeof DOWNLOAD_LINKS;
export const NAV_LINK_KEYS = [
"features",
"howItWorks",
"platforms",
"pricing",
"faq",
] as const;
export type NavLinkKey = (typeof NAV_LINK_KEYS)[number];
export const NAV_HREFS: Record<NavLinkKey, string> = {
features: SITE_LINKS.features,
howItWorks: SITE_LINKS.howItWorks,
platforms: SITE_LINKS.platforms,
pricing: SITE_LINKS.pricing,
faq: SITE_LINKS.faq,
};
export const AUDIENCE_CARD_IDS = ["gm", "prep", "table"] as const;
export const BENEFIT_CARD_IDS = [
"allInOne",
"sync",
"immersion",
"branching",
"portable",
"pro",
] as const;
export const FEATURE_VARIANTS = [
"editor",
"control",
"presentation",
"atmosphere",
] as const;
export type FeatureVariant = (typeof FEATURE_VARIANTS)[number];
export const STEP_IDS = ["create", "launch", "run"] as const;
export const PLATFORM_IDS = ["windows", "macos", "linux"] as const;
export type PlatformId = (typeof PLATFORM_IDS)[number];
export const PRICING_TIER_IDS = ["basic", "master", "legend"] as const;
export type PricingTierId = (typeof PRICING_TIER_IDS)[number];
export const RECOMMENDED_PRICING_TIER: PricingTierId = "master";
export const FAQ_IDS = [
"what",
"internet",
"monitor",
"activate",
"os",
] as const;
export type FaqId = (typeof FAQ_IDS)[number];
export const HERO_HIGHLIGHT_IDS = ["offline", "secondScreen"] as const;
export const REQUIRED_SECTION_IDS = [
"features",
"how-it-works",
"platforms",
"pricing",
"faq",
] as const;
+83
View File
@@ -0,0 +1,83 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
LOCALE_STORAGE_KEY,
MESSAGES,
inferLocaleFromNavigator,
normalizeLocale,
type Locale,
type SiteCopy,
} from "./messages";
type LocaleContextValue = {
locale: Locale;
copy: SiteCopy;
setLocale: (locale: Locale) => void;
};
const LocaleContext = createContext<LocaleContextValue | null>(null);
function readInitialLocale(): Locale {
if (typeof window === "undefined") return "en";
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored !== null) return normalizeLocale(stored);
} catch {
// ignore storage errors
}
return inferLocaleFromNavigator();
}
export function LocaleProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(readInitialLocale);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
try {
localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// ignore storage errors
}
}, []);
const copy = MESSAGES[locale];
useEffect(() => {
document.documentElement.lang = locale;
document.title = copy.meta.title;
const desc = document.querySelector('meta[name="description"]');
if (desc) desc.setAttribute("content", copy.meta.description);
const ogTitle = document.querySelector('meta[property="og:title"]');
if (ogTitle) ogTitle.setAttribute("content", copy.meta.ogTitle);
const ogDesc = document.querySelector('meta[property="og:description"]');
if (ogDesc) ogDesc.setAttribute("content", copy.meta.ogDescription);
}, [locale, copy]);
const value = useMemo(
() => ({ locale, copy, setLocale }),
[locale, copy, setLocale],
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) {
throw new Error("useLocale must be used within LocaleProvider");
}
return ctx;
}
+9
View File
@@ -0,0 +1,9 @@
export { LocaleProvider, useLocale } from "./LocaleProvider";
export {
LOCALE_STORAGE_KEY,
MESSAGES,
normalizeLocale,
inferLocaleFromNavigator,
type Locale,
type SiteCopy,
} from "./messages";
+530
View File
@@ -0,0 +1,530 @@
import type { Locale } from "./types";
export type { Locale } from "./types";
export const LOCALE_STORAGE_KEY = "ttrpg_landing_locale";
const ru = {
meta: {
title: "НРИ Плеер — редактор кампаний и пульт мастера для настольных RPG",
description:
"Десктопное приложение для мастеров НРИ: граф сцен, пульт управления, экран презентации, музыка и эффекты. Windows, macOS, Linux.",
ogTitle: "НРИ Плеер — редактор кампаний и пульт мастера для настольных RPG",
ogDescription:
"Создавайте кампании и ведите партии. Редактор, пульт и экран презентации в одном приложении.",
},
brand: "НРИ Плеер",
nav: {
features: "Возможности",
howItWorks: "Как это работает",
platforms: "Платформы",
pricing: "Тарифы",
faq: "FAQ",
},
navAria: "Основная навигация",
cta: {
download: "Скачать",
buy: "Купить",
downloadFree: "Скачать бесплатно",
buyLicense: "Купить лицензию",
downloadForOs: "Скачать для моей ОС",
},
hero: {
eyebrow: "Инструмент для мастеров · Платформа для историй",
tagline: "Создавайте кампании и ведите партии",
description:
"Редактор кампаний и пульт мастера в одном приложении. Собирайте сюжет как граф сцен, ведите игру с пульта — игроки видят только атмосферу на втором экране.",
platformsLine: "Windows · macOS · Linux · Русский и English",
highlights: {
offline: {
title: "Офлайн",
description: "Интернет нужен только для активации и обновлений",
},
secondScreen: {
title: "Второй экран",
description:
"Игроки видят карту, видео и атмосферу без панелей редактора",
},
},
},
mockupWindows: {
editor: "Редактор",
control: "Пульт управления",
presentation: "Экран презентации",
},
screenshotAlt: {
editor: "Редактор кампаний НРИ Плеер — граф сцен и свойства сцены",
control: "Пульт управления НРИ Плеер — переключение сцен и музыка",
presentation: "Экран презентации НРИ Плеер — карта для игроков",
atmosphere: "Атмосфера за столом — эффекты поля и инструменты мастера",
},
audience: {
eyebrow: "Для мастеров, которые ведут историю",
title: "Один инструмент для подготовки, стола и трансляции",
cards: {
gm: {
title: "Мастер за столом",
description:
"Всё под рукой во время сессии: сцены, музыка, эффекты и текущая ветка партии.",
},
prep: {
title: "Подготовка кампании",
description:
"Визуальная карта вместо десятков заметок: сцены, переходы и медиа складываются в граф.",
},
table: {
title: "Онлайн и офлайн столы",
description:
"Чистый экран для игроков на проекторе, ТВ или втором мониторе — без интерфейса мастера.",
},
},
},
benefits: {
title: "Ведите партию, а не переключайте вкладки",
cards: {
allInOne: {
title: "Всё в одном окне мастера",
description:
"Сцены, музыка, эффекты и ветвления без сторонних плееров и таблиц.",
},
sync: {
title: "Мгновенная синхронизация",
description: "Переключение с пульта сразу обновляет экран игроков.",
},
immersion: {
title: "Погружение без отвлечений",
description:
"На презентации нет панелей редактора, только карта и атмосфера.",
},
branching: {
title: "Наглядное ветвление",
description: "Граф сцен показывает развилки сюжета и прогресс партии.",
},
portable: {
title: "Портативные проекты",
description:
"Одна папка кампании (.ttrpg.zip), удобно переносить между сессиями.",
},
pro: {
title: "Профессиональный инструмент",
description:
"Десктопное приложение с лицензией, обновлениями и офлайн-работой.",
},
},
},
features: {
title: "Четыре режима работы — одна партия",
blocks: {
editor: {
title: "Редактор кампаний",
description:
"Граф сцен и переходов, ветвления, превью медиа, музыка сцены и кампании, импорт и экспорт .ttrpg.zip, поиск и управление медиа.",
tags: "Граф сцен · переходы · медиа",
},
control: {
title: "Пульт управления",
description:
"Переключение сцен одной кнопкой, предпросмотр экрана игроков, сюжетная линия, авто- и ручное управление музыкой, запуск и остановка демонстрации.",
tags: "Переключить · музыка · сюжетная линия",
},
presentation: {
title: "Экран презентации",
description:
"Отдельное окно для второго монитора, проектора или ТВ. Только карта или видео сцены, синхронные эффекты и оверлеи, поддержка поворота 0°/90°/180°/270°.",
tags: "Карта · видео · второй монитор",
},
atmosphere: {
title: "Атмосфера за столом",
description:
"Туман, дождь, огонь, вода, молния, луч света, заморозка, облако яда, эффекты действий, кисть мастера, ластик, очистка эффектов и музыка сцены плюс фон кампании.",
tags: "Туман · дождь · огонь · кисть",
},
},
},
steps: {
title: "Три шага до живой сцены",
items: {
create:
"Создайте кампанию — добавьте сцены, карты, музыку и связи на графе.",
launch: "Запустите игру — откроются пульт мастера и экран презентации.",
run: "Ведите сессию — переключайте сцены, включайте эффекты и музыку в один клик.",
},
},
platforms: {
title: "Доступно на вашей ОС",
cards: {
windows: {
title: "Windows",
description: "Windows 10/11, 64-bit. Установщик (.exe).",
},
macos: {
title: "macOS",
description: "Intel (x64) и Apple Silicon (M1/M2/M3). DMG.",
},
linux: {
title: "Linux",
description: "x64 и ARM64. AppImage.",
},
},
note: "Автообновления для лицензированных пользователей · Интерфейс на русском и английском · Работает офлайн",
distributionDisclaimer:
"Установочные файлы не подписаны сертификатом издателя и предоставляются «как есть» (as is), без каких‑либо явных или подразумеваемых гарантий. Разработчик не несёт ответственности за ущерб, связанный с загрузкой, установкой или использованием приложения. Устанавливая программу, вы принимаете все риски и ответственность за последствия на своей стороне.",
},
pricing: {
title: "Выберите ключ мастера",
subtitle:
"Активируйте продуктовый ключ в приложении: Настройки → Указать ключ",
recommendedBadge: "⭐ Рекомендуем",
tiers: {
basic: {
title: "Базовый ключ",
features: ["Доступ на 1 год · до 3 устройств"],
},
master: {
title: "Ключ мастера",
features: ["Доступ на 2 года · до 5 устройств"],
},
legend: {
title: "Легенда НРИ",
features: ["Доступ на 99 лет · до 15 устройств"],
},
},
exchangeRateNote: "",
footnote:
"Ключ формата TTRPG-… · Привязка к устройству · Лицензионное соглашение в приложении",
donationNote:
"Оплата оформляется как добровольное пожертвование (donation) через внешний сервис; после подтверждения вы получаете продуктовый ключ TTRPG-….",
},
comparison: {
title: "От разрозненных вкладок — к пульту мастера",
beforeLabel: "Было",
beforeText:
"Заметки, Spotify, отдельные картинки, ручное переключение вкладок.",
afterLabel: "Стало",
afterText:
"Единый пульт, синхронный экран игроков, граф кампании и встроенная атмосфера.",
},
faq: {
title: "Вопросы перед первой сессией",
items: {
what: {
question: "Что такое НРИ Плеер?",
answer:
"Десктопный редактор кампаний и пульт мастера для D&D, Pathfinder и других настольных RPG.",
},
internet: {
question: "Нужен ли интернет во время игры?",
answer:
"Нет. Интернет нужен только для активации лицензии и обновлений.",
},
monitor: {
question: "Можно ли использовать второй монитор?",
answer:
"Да. Экран презентации открывается отдельным окном для монитора, проектора или ТВ.",
},
activate: {
question: "Как активировать лицензию?",
answer:
"В приложении откройте Настройки → Указать ключ и вставьте ключ формата TTRPG-….",
},
os: {
question: "На каких ОС работает приложение?",
answer:
"Windows x64, macOS x64+arm64 и Linux x64+arm64 в формате AppImage.",
},
},
},
ctaBanner: {
title: "Готовы вести следующую сессию по-новому?",
},
footer: {
aria: "Подвал",
links: {
download: "Скачать",
buy: "Купить",
faq: "FAQ",
enterKey: "Указать ключ",
support: "Поддержка",
privacy: "Политика конфиденциальности",
},
},
localeSwitcher: {
aria: "Язык сайта",
ru: "RU",
en: "EN",
},
};
export type SiteCopy = typeof ru;
const en: SiteCopy = {
meta: {
title:
"TTRPG Player — campaign editor and GM control panel for tabletop RPGs",
description:
"Desktop app for tabletop GMs: scene graph, control panel, presentation screen, music and effects. Windows, macOS, Linux.",
ogTitle:
"TTRPG Player — campaign editor and GM control panel for tabletop RPGs",
ogDescription:
"Build campaigns and run sessions. Editor, control panel and player screen in one app.",
},
brand: "TTRPG Player",
nav: {
features: "Features",
howItWorks: "How it works",
platforms: "Platforms",
pricing: "Pricing",
faq: "FAQ",
},
navAria: "Main navigation",
cta: {
download: "Download",
buy: "Buy",
downloadFree: "Download free",
buyLicense: "Buy license",
downloadForOs: "Download for my OS",
},
hero: {
eyebrow: "A tool for GMs · A platform for stories",
tagline: "Build campaigns and run your table",
description:
"Campaign editor and GM control panel in one desktop app. Map your story as a scene graph, run the session from the panel — players see only the atmosphere on a second screen.",
platformsLine: "Windows · macOS · Linux · Russian and English UI",
highlights: {
offline: {
title: "Offline",
description: "Internet is only needed for activation and updates",
},
secondScreen: {
title: "Second screen",
description:
"Players see maps, video and atmosphere without editor panels",
},
},
},
mockupWindows: {
editor: "Editor",
control: "Control panel",
presentation: "Presentation screen",
},
screenshotAlt: {
editor: "TTRPG Player campaign editor — scene graph and scene properties",
control: "TTRPG Player control panel — scene switching and music",
presentation: "TTRPG Player presentation screen — map for players",
atmosphere: "Table atmosphere — field effects and GM tools",
},
audience: {
eyebrow: "For GMs who run the story",
title: "One tool for prep, the table and the big screen",
cards: {
gm: {
title: "GM at the table",
description:
"Everything at hand during a session: scenes, music, effects and the partys current branch.",
},
prep: {
title: "Campaign prep",
description:
"A visual map instead of dozens of notes: scenes, transitions and media in one graph.",
},
table: {
title: "In-person and hybrid tables",
description:
"A clean screen for players on a projector, TV or second monitor — no GM UI.",
},
},
},
benefits: {
title: "Run the session, dont juggle tabs",
cards: {
allInOne: {
title: "Everything in one GM window",
description:
"Scenes, music, effects and branches without third-party players or spreadsheets.",
},
sync: {
title: "Instant sync",
description:
"Switching from the panel updates the player screen immediately.",
},
immersion: {
title: "Immersion without clutter",
description:
"The presentation shows no editor chrome — only the map and atmosphere.",
},
branching: {
title: "Clear branching",
description: "The scene graph shows story forks and party progress.",
},
portable: {
title: "Portable projects",
description:
"One campaign folder (.ttrpg.zip), easy to move between sessions.",
},
pro: {
title: "Professional desktop tool",
description: "Desktop app with licensing, updates and offline play.",
},
},
},
features: {
title: "Four modes — one session",
blocks: {
editor: {
title: "Campaign editor",
description:
"Scene graph and transitions, branching, media previews, scene and campaign audio, import and export of .ttrpg.zip, search and media management.",
tags: "Scene graph · transitions · media",
},
control: {
title: "Control panel",
description:
"One-click scene switching, player screen preview, story timeline, automatic and manual music control, start and stop presentation.",
tags: "Switch · music · story line",
},
presentation: {
title: "Presentation screen",
description:
"Separate window for a second monitor, projector or TV. Scene map or video only, synced effects and overlays, rotation 0°/90°/180°/270°.",
tags: "Map · video · second monitor",
},
atmosphere: {
title: "Atmosphere at the table",
description:
"Fog, rain, fire, water, lightning, sunbeam, freeze, poison cloud, action effects, GM brush, eraser, clear effects, scene music plus campaign ambience.",
tags: "Fog · rain · fire · brush",
},
},
},
steps: {
title: "Three steps to a live scene",
items: {
create:
"Create a campaign — add scenes, maps, music and links on the graph.",
launch: "Launch the game — the GM panel and presentation screen open.",
run: "Run the session — switch scenes, effects and music in one click.",
},
},
platforms: {
title: "Available on your OS",
cards: {
windows: {
title: "Windows",
description: "Windows 10/11, 64-bit. Installer (.exe).",
},
macos: {
title: "macOS",
description: "Intel (x64) and Apple Silicon (M1/M2/M3). DMG.",
},
linux: {
title: "Linux",
description: "x64 and ARM64. AppImage.",
},
},
note: "Auto-updates for licensed users · Russian and English UI · Works offline",
distributionDisclaimer:
"Installers are not code-signed by the publisher and are provided “as is”, without express or implied warranties. The developer is not liable for any damage arising from downloading, installing or using the application. By installing the software, you assume all risks and responsibility on your side.",
},
pricing: {
title: "Choose your GM key",
subtitle: "Activate your product key in the app: Settings → Enter key",
recommendedBadge: "⭐ Recommended",
tiers: {
basic: {
title: "Basic key",
features: ["1 year · up to 3 devices"],
},
master: {
title: "GM key",
features: ["2 years · up to 5 devices"],
},
legend: {
title: "TTRPG Legend",
features: ["99 years · up to 15 devices"],
},
},
exchangeRateNote:
"USD equivalent at the current RUB exchange rate (updated when you open the site).",
footnote:
"Key format TTRPG-… · Device-bound · License agreement in the app",
donationNote:
"Payment is processed as a voluntary donation through an external service; after confirmation you receive a TTRPG-… product key.",
},
comparison: {
title: "From scattered tabs to a GM panel",
beforeLabel: "Before",
beforeText: "Notes, Spotify, separate images, manual tab switching.",
afterLabel: "After",
afterText:
"One panel, synced player screen, campaign graph and built-in atmosphere.",
},
faq: {
title: "Questions before your first session",
items: {
what: {
question: "What is TTRPG Player?",
answer:
"A desktop campaign editor and GM control panel for D&D, Pathfinder and other tabletop RPGs.",
},
internet: {
question: "Do I need internet during play?",
answer:
"No. Internet is only required for license activation and updates.",
},
monitor: {
question: "Can I use a second monitor?",
answer:
"Yes. The presentation screen opens in a separate window for a monitor, projector or TV.",
},
activate: {
question: "How do I activate a license?",
answer:
"In the app, open Settings → Enter key and paste a key in the TTRPG-… format.",
},
os: {
question: "Which operating systems are supported?",
answer: "Windows x64, macOS x64+arm64 and Linux x64+arm64 as AppImage.",
},
},
},
ctaBanner: {
title: "Ready to run your next session differently?",
},
footer: {
aria: "Footer",
links: {
download: "Download",
buy: "Buy",
faq: "FAQ",
enterKey: "Enter key",
support: "Support",
privacy: "Privacy policy",
},
},
localeSwitcher: {
aria: "Site language",
ru: "RU",
en: "EN",
},
};
export const MESSAGES: Record<Locale, SiteCopy> = { ru, en };
export function normalizeLocale(raw: string | null | undefined): Locale {
if (raw === "en") return "en";
return "ru";
}
export function inferLocaleFromLanguages(langs: readonly string[]): Locale {
for (const lang of langs) {
const tag = lang.trim().toLowerCase().split(/[-_]/u)[0];
if (tag === "ru") return "ru";
}
return "en";
}
export function inferLocaleFromNavigator(): Locale {
if (typeof navigator === "undefined") return "en";
const langs = [...navigator.languages, navigator.language].filter(Boolean);
return inferLocaleFromLanguages(langs);
}
+1
View File
@@ -0,0 +1 @@
export type Locale = "ru" | "en";
+72
View File
@@ -0,0 +1,72 @@
import type { DownloadLinks, PlatformId } from "../data/siteContent";
export type OsId = "windows" | "macos" | "linux" | "unknown";
export type CpuArch = "x64" | "arm64" | "unknown";
type NavigatorHints = {
userAgent: string;
maxTouchPoints?: number;
};
export function detectOs(userAgent: string): OsId {
const ua = userAgent.toLowerCase();
if (ua.includes("win")) return "windows";
if (ua.includes("mac")) return "macos";
if (ua.includes("linux")) return "linux";
return "unknown";
}
/** Best-effort CPU arch from UA; macOS Apple Silicon often masquerades as MacIntel */
export function detectCpuArch(userAgent: string, maxTouchPoints = 0): CpuArch {
const ua = userAgent.toLowerCase();
if (ua.includes("aarch64") || ua.includes("arm64")) return "arm64";
if (ua.includes("x86_64") || ua.includes("amd64") || ua.includes("win64")) {
return "x64";
}
if (/armv\d+/u.test(ua)) return "arm64";
if (ua.includes("mac")) {
return maxTouchPoints > 1 ? "arm64" : "x64";
}
return "unknown";
}
export function resolveDownloadHref(
os: OsId,
links: DownloadLinks,
hints: NavigatorHints,
): string {
const arch = detectCpuArch(hints.userAgent, hints.maxTouchPoints ?? 0);
if (os === "windows") return links.windows;
if (os === "macos") {
return arch === "x64" ? links.macos.x64 : links.macos.arm64;
}
if (os === "linux") {
return arch === "arm64" ? links.linux.arm64 : links.linux.x64;
}
return links.windows;
}
/** Per-platform card button: picks arch when visitor is on that OS, else sensible default */
export function resolvePlatformDownloadHref(
platform: PlatformId,
links: DownloadLinks,
hints: NavigatorHints,
): string {
if (platform === "windows") return links.windows;
const os = detectOs(hints.userAgent);
const arch = detectCpuArch(hints.userAgent, hints.maxTouchPoints ?? 0);
if (platform === "macos") {
if (os === "macos") {
return arch === "x64" ? links.macos.x64 : links.macos.arm64;
}
return links.macos.arm64;
}
if (os === "linux" && arch === "arm64") return links.linux.arm64;
return links.linux.x64;
}
+82
View File
@@ -0,0 +1,82 @@
export type ExchangeRateSource = "open-er-api" | "cbr" | "fallback";
/** 1 RUB → USD when live APIs are unavailable (≈ CBR USD rate) */
export const FALLBACK_RUB_TO_USD = 1 / 77;
const OPEN_ER_API_URL = "https://open.er-api.com/v6/latest/RUB";
const CBR_DAILY_JSON_URL = "https://www.cbr-xml-daily.ru/daily_json.js";
type OpenErApiResponse = {
result?: string;
rates?: { USD?: number };
};
type CbrDailyResponse = {
Valute?: { USD?: { Value?: number } };
};
export function rubToUsd(rub: number, rubToUsdRate: number): number {
return rub * rubToUsdRate;
}
export function formatRubPrice(rub: number): string {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
}).format(rub);
}
export function formatUsdPrice(rub: number, rubToUsdRate: number): string {
const usd = rubToUsd(rub, rubToUsdRate);
const maximumFractionDigits = usd >= 100 ? 0 : usd >= 10 ? 0 : 2;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits,
}).format(usd);
}
export function parseOpenErApiRubToUsd(data: OpenErApiResponse): number | null {
if (data.result !== "success") return null;
const rate = data.rates?.USD;
if (typeof rate !== "number" || rate <= 0) return null;
return rate;
}
export function parseCbrRubToUsd(data: CbrDailyResponse): number | null {
const rubPerUsd = data.Valute?.USD?.Value;
if (typeof rubPerUsd !== "number" || rubPerUsd <= 0) return null;
return 1 / rubPerUsd;
}
export async function fetchRubToUsdRate(): Promise<{
rate: number;
source: ExchangeRateSource;
}> {
try {
const response = await fetch(OPEN_ER_API_URL);
if (response.ok) {
const data = (await response.json()) as OpenErApiResponse;
const rate = parseOpenErApiRubToUsd(data);
if (rate !== null) return { rate, source: "open-er-api" };
}
} catch {
// try fallback provider
}
try {
const response = await fetch(CBR_DAILY_JSON_URL);
if (response.ok) {
const data = (await response.json()) as CbrDailyResponse;
const rate = parseCbrRubToUsd(data);
if (rate !== null) return { rate, source: "cbr" };
}
} catch {
// use static fallback below
}
return { rate: FALLBACK_RUB_TO_USD, source: "fallback" };
}
+22
View File
@@ -0,0 +1,22 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { LocaleProvider } from "./i18n/LocaleProvider";
import { ExchangeRateProvider } from "./providers/ExchangeRateProvider";
import "./styles/global.css";
const rootEl = document.getElementById("root");
if (!rootEl) {
throw new Error("Root element #root not found");
}
createRoot(rootEl).render(
<StrictMode>
<LocaleProvider>
<ExchangeRateProvider>
<App />
</ExchangeRateProvider>
</LocaleProvider>
</StrictMode>,
);
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { PRICING_TIER_LICENSE, PRICING_TIER_PRICES_RUB } from "./data/pricing";
import {
FALLBACK_RUB_TO_USD,
formatRubPrice,
formatUsdPrice,
parseCbrRubToUsd,
parseOpenErApiRubToUsd,
rubToUsd,
} from "./lib/exchangeRate";
describe("pricing data", () => {
it("defines RUB prices for all tiers", () => {
assert.equal(PRICING_TIER_PRICES_RUB.basic, 700);
assert.equal(PRICING_TIER_PRICES_RUB.master, 1500);
assert.equal(PRICING_TIER_PRICES_RUB.legend, 10_000);
});
it("defines license duration and device limits", () => {
assert.deepEqual(PRICING_TIER_LICENSE.basic, { years: 1, devices: 3 });
assert.deepEqual(PRICING_TIER_LICENSE.master, { years: 2, devices: 5 });
assert.deepEqual(PRICING_TIER_LICENSE.legend, { years: 99, devices: 15 });
});
});
describe("exchangeRate", () => {
it("parses open.er-api.com response", () => {
const rate = parseOpenErApiRubToUsd({
result: "success",
rates: { USD: 0.013069 },
});
assert.equal(rate, 0.013069);
});
it("parses CBR daily JSON response", () => {
const rate = parseCbrRubToUsd({
Valute: { USD: { Value: 77.0611 } },
});
assert.ok(rate !== null);
assert.ok(Math.abs(rate - 1 / 77.0611) < 0.000001);
});
it("formats RUB and USD prices", () => {
assert.match(formatRubPrice(700), /700/u);
assert.match(formatUsdPrice(700, 0.013), /\$/u);
assert.equal(rubToUsd(700, 0.01), 7);
});
it("exposes a sane fallback rate", () => {
assert.ok(FALLBACK_RUB_TO_USD > 0 && FALLBACK_RUB_TO_USD < 1);
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
fetchRubToUsdRate,
type ExchangeRateSource,
} from "../lib/exchangeRate";
type ExchangeRateState = {
rubToUsd: number | null;
loading: boolean;
source: ExchangeRateSource | null;
};
const ExchangeRateContext = createContext<ExchangeRateState | null>(null);
export function ExchangeRateProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ExchangeRateState>({
rubToUsd: null,
loading: true,
source: null,
});
useEffect(() => {
void fetchRubToUsdRate().then(({ rate, source }) => {
setState({ rubToUsd: rate, loading: false, source });
});
}, []);
const value = useMemo(() => state, [state]);
return (
<ExchangeRateContext.Provider value={value}>
{children}
</ExchangeRateContext.Provider>
);
}
export function useExchangeRate(): ExchangeRateState {
const ctx = useContext(ExchangeRateContext);
if (!ctx) {
throw new Error("useExchangeRate must be used within ExchangeRateProvider");
}
return ctx;
}
+185
View File
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DOWNLOAD_ARTIFACTS,
DOWNLOAD_LINKS,
FAQ_IDS,
FEATURE_VARIANTS,
NAV_LINK_KEYS,
NAV_HREFS,
PRICING_TIER_IDS,
RECOMMENDED_PRICING_TIER,
REQUIRED_SECTION_IDS,
SITE_LINKS,
UPDATE_FEED_BASE,
} from "./data/siteContent";
import {
SCREENSHOT_SRC,
getEffectsScreenshotDisplaySize,
} from "./data/siteAssets";
import { MESSAGES, inferLocaleFromLanguages } from "./i18n/messages";
import {
detectCpuArch,
detectOs,
resolveDownloadHref,
resolvePlatformDownloadHref,
} from "./lib/detectOs";
describe("siteContent", () => {
it("exposes all required section anchor ids", () => {
for (const id of REQUIRED_SECTION_IDS) {
const inNav = NAV_LINK_KEYS.some((key) => NAV_HREFS[key] === `#${id}`);
assert.equal(inNav || id.length > 0, true, `missing section #${id}`);
}
});
it("defines four feature variants", () => {
assert.deepEqual(FEATURE_VARIANTS, [
"editor",
"control",
"presentation",
"atmosphere",
]);
});
it("defines three pricing tiers with one recommended", () => {
assert.equal(PRICING_TIER_IDS.length, 3);
assert.equal(RECOMMENDED_PRICING_TIER, "master");
});
it("includes five FAQ entries", () => {
assert.equal(FAQ_IDS.length, 5);
});
it("points download buttons to update feed artifacts", () => {
assert.equal(UPDATE_FEED_BASE, "https://updates.mailib.ru/");
assert.equal(
DOWNLOAD_LINKS.windows,
`${UPDATE_FEED_BASE}${DOWNLOAD_ARTIFACTS.windows}`,
);
assert.equal(
DOWNLOAD_LINKS.macos.arm64,
`${UPDATE_FEED_BASE}${DOWNLOAD_ARTIFACTS.macos.arm64}`,
);
assert.equal(
DOWNLOAD_LINKS.linux.x64,
`${UPDATE_FEED_BASE}${DOWNLOAD_ARTIFACTS.linux.x64}`,
);
});
it("wires download anchor and external buy link", () => {
assert.equal(SITE_LINKS.download, "#platforms");
assert.equal(SITE_LINKS.buy, "https://donate.stream/ttrpgplayer");
});
it("maps each feature variant to a screenshot asset", () => {
for (const variant of FEATURE_VARIANTS) {
assert.match(SCREENSHOT_SRC[variant], /^\/images\/screenshot-.+\.png$/u);
}
assert.equal(SCREENSHOT_SRC.atmosphere, "/images/screenshot-effects.png");
});
it("shows effects screenshot at 70% of intrinsic pixel size", () => {
const size = getEffectsScreenshotDisplaySize();
assert.equal(size.width, 206);
assert.equal(size.height, 220);
});
});
describe("i18n messages", () => {
it("has ru and en locales without DND key mentions", () => {
for (const locale of ["ru", "en"] as const) {
const json = JSON.stringify(MESSAGES[locale]);
assert.doesNotMatch(json, /DND-/u);
assert.doesNotMatch(json, /\.dnd\.zip/u);
assert.equal(Object.keys(MESSAGES[locale].faq.items).length, 5);
}
});
it("documents download and buy labels in both locales", () => {
assert.equal(MESSAGES.ru.cta.download, "Скачать");
assert.equal(MESSAGES.en.cta.download, "Download");
assert.equal(MESSAGES.ru.cta.buy, "Купить");
assert.equal(MESSAGES.en.cta.buy, "Buy");
});
});
describe("inferLocaleFromLanguages", () => {
it("uses ru only for Russian browser languages", () => {
assert.equal(inferLocaleFromLanguages(["ru-RU"]), "ru");
assert.equal(inferLocaleFromLanguages(["ru"]), "ru");
assert.equal(inferLocaleFromLanguages(["ru-BY", "en-US"]), "ru");
});
it("defaults to en for non-Russian browser languages", () => {
assert.equal(inferLocaleFromLanguages(["en-US"]), "en");
assert.equal(inferLocaleFromLanguages(["de-DE"]), "en");
assert.equal(inferLocaleFromLanguages(["uk-UA"]), "en");
assert.equal(inferLocaleFromLanguages([]), "en");
});
});
describe("detectOs", () => {
it("detects Windows from user agent", () => {
assert.equal(detectOs("Mozilla/5.0 (Windows NT 10.0)"), "windows");
});
it("detects macOS from user agent", () => {
assert.equal(detectOs("Mozilla/5.0 (Macintosh; Intel Mac OS X)"), "macos");
});
it("detects Linux from user agent", () => {
assert.equal(detectOs("Mozilla/5.0 (X11; Linux x86_64)"), "linux");
});
it("detects Linux arm64 from user agent", () => {
assert.equal(detectCpuArch("Mozilla/5.0 (X11; Linux aarch64)"), "arm64");
});
it("resolves download href by OS", () => {
const hints = { userAgent: "Mozilla/5.0 (Windows NT 10.0)" };
assert.equal(
resolveDownloadHref("windows", DOWNLOAD_LINKS, hints),
DOWNLOAD_LINKS.windows,
);
assert.equal(
resolveDownloadHref("macos", DOWNLOAD_LINKS, {
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X)",
maxTouchPoints: 0,
}),
DOWNLOAD_LINKS.macos.x64,
);
assert.equal(
resolveDownloadHref("linux", DOWNLOAD_LINKS, {
userAgent: "Mozilla/5.0 (X11; Linux aarch64)",
}),
DOWNLOAD_LINKS.linux.arm64,
);
assert.equal(
resolveDownloadHref("unknown", DOWNLOAD_LINKS, hints),
DOWNLOAD_LINKS.windows,
);
});
it("resolves per-platform card href", () => {
assert.equal(
resolvePlatformDownloadHref("windows", DOWNLOAD_LINKS, {
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X)",
}),
DOWNLOAD_LINKS.windows,
);
assert.equal(
resolvePlatformDownloadHref("macos", DOWNLOAD_LINKS, {
userAgent: "Mozilla/5.0 (Windows NT 10.0)",
}),
DOWNLOAD_LINKS.macos.arm64,
);
assert.equal(
resolvePlatformDownloadHref("linux", DOWNLOAD_LINKS, {
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
}),
DOWNLOAD_LINKS.linux.x64,
);
});
});
+100
View File
@@ -0,0 +1,100 @@
:root {
--color-bg: #08060e;
--color-bg-elevated: #100d18;
--color-bg-card: rgba(16, 13, 24, 0.72);
--color-purple: #7c3aed;
--color-purple-bright: #8b5cf6;
--color-purple-soft: rgba(124, 58, 237, 0.18);
--color-gold: #c8a166;
--color-gold-bright: #e0c48a;
--color-gold-dim: rgba(200, 161, 102, 0.35);
--color-text: #f5f3ee;
--color-text-muted: #a8a0b8;
--color-text-dim: #7a7288;
--color-border: rgba(200, 161, 102, 0.22);
--color-border-strong: rgba(200, 161, 102, 0.45);
--font-sans: "Segoe UI", system-ui, -apple-system, sans-serif;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--shadow-glow: 0 0 60px rgba(124, 58, 237, 0.15);
--max-width: 1120px;
--header-height: 72px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(
ellipse 80% 50% at 20% 0%,
rgba(124, 58, 237, 0.22),
transparent 55%
),
radial-gradient(
ellipse 60% 40% at 90% 10%,
rgba(200, 161, 102, 0.08),
transparent 50%
),
radial-gradient(
ellipse 100% 80% at 50% 100%,
rgba(10, 8, 16, 0.9),
transparent
);
z-index: -1;
}
#root {
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
button {
font: inherit;
}
:focus-visible {
outline: 2px solid var(--color-purple-bright);
outline-offset: 2px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", "vite.config.ts"],
"exclude": []
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
sourcemap: true,
},
});