feat(legal): add EULA modal in footer and support-project wording (v1.0.3)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-03 22:03:03 +08:00
parent 9013fa0b49
commit cb245bcb7d
13 changed files with 423 additions and 19 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ttrpg-player-landing",
"private": true,
"version": "1.0.2",
"version": "1.0.3",
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
"type": "module",
"scripts": {
+15
View File
@@ -34,3 +34,18 @@
.link:hover {
color: var(--color-gold);
}
.linkButton {
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 14px;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
}
.linkButton:hover {
color: var(--color-gold);
}
+9
View File
@@ -1,5 +1,6 @@
import { SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { useEulaModal } from "../../providers/EulaModalProvider";
import styles from "./Footer.module.css";
@@ -21,6 +22,7 @@ const FOOTER_HREFS: Record<(typeof FOOTER_LINK_KEYS)[number], string> = {
export function Footer() {
const { copy } = useLocale();
const { openModal } = useEulaModal();
return (
<footer className={styles.footer}>
@@ -32,6 +34,13 @@ export function Footer() {
{copy.footer.links[key]}
</a>
))}
<button
type="button"
className={styles.linkButton}
onClick={openModal}
>
{copy.footer.links.eula}
</button>
</nav>
</div>
</footer>
@@ -0,0 +1,42 @@
.root {
font-size: 14px;
line-height: 1.65;
color: var(--color-text-muted);
}
.h1 {
margin: 0 0 16px;
font-size: 18px;
font-weight: 700;
line-height: 1.35;
color: var(--color-text);
}
.h2 {
margin: 24px 0 10px;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
color: var(--color-text);
}
.h2:first-child {
margin-top: 0;
}
.paragraph {
margin: 0 0 12px;
}
.list {
margin: 0 0 12px;
padding-left: 20px;
}
.list li {
margin-bottom: 6px;
}
.list li:last-child {
margin-bottom: 0;
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import styles from "./EulaMarkdown.module.css";
function renderBlock(block: string, index: number): ReactNode {
const trimmed = block.trim();
if (!trimmed) return null;
if (trimmed.startsWith("# ")) {
return (
<h1 key={index} className={styles.h1}>
{trimmed.slice(2)}
</h1>
);
}
if (trimmed.startsWith("## ")) {
return (
<h2 key={index} className={styles.h2}>
{trimmed.slice(3)}
</h2>
);
}
const lines = trimmed.split("\n");
if (lines.every((line) => line.startsWith("- "))) {
return (
<ul key={index} className={styles.list}>
{lines.map((line) => (
<li key={line}>{line.slice(2)}</li>
))}
</ul>
);
}
return (
<p key={index} className={styles.paragraph}>
{lines.join(" ")}
</p>
);
}
export function EulaMarkdown({ markdown }: { markdown: string }) {
const blocks = markdown.split(/\n\n+/u);
return (
<div className={styles.root}>
{blocks.map((block, index) => renderBlock(block, index))}
</div>
);
}
+1 -1
View File
@@ -93,7 +93,7 @@
opacity: 0.9;
}
.donationNote {
.supportNote {
max-width: 820px;
margin: 12px auto 0;
font-size: 12px;
+1 -1
View File
@@ -75,7 +75,7 @@ export function Pricing() {
) : null}
<p className={styles.footnote}>{copy.pricing.footnote}</p>
<p className={styles.donationNote}>{copy.pricing.donationNote}</p>
<p className={styles.supportNote}>{copy.pricing.supportNote}</p>
</Section>
);
}
+24 -12
View File
@@ -24,9 +24,9 @@ const ru = {
navAria: "Основная навигация",
cta: {
download: "Скачать",
buy: "Купить",
buy: "Поддержать",
downloadFree: "Скачать бесплатно",
buyLicense: "Купить лицензию",
buyLicense: "Поддержать проект",
downloadForOs: "Скачать для моей ОС",
},
hero: {
@@ -199,9 +199,9 @@ const ru = {
},
exchangeRateNote: "",
footnote:
"Ключ формата TTRPG-… · Привязка к устройству · Лицензионное соглашение в приложении",
donationNote:
"Оплата оформляется как добровольное пожертвование (donation) через внешний сервис; после подтверждения вы получаете продуктовый ключ TTRPG-….",
"Ключ формата TTRPG-… · Привязка к устройству · Лицензионное соглашение — в футере сайта",
supportNote:
"Вы можете добровольно поддержать проект. В знак благодарности правообладатель вручную выдаёт продуктовый ключ TTRPG-… соответствующего уровня доступа.",
},
comparison: {
title: "От разрозненных вкладок — к пульту мастера",
@@ -251,14 +251,20 @@ const ru = {
messageAfter: ", следите за новостями.",
close: "Понятно",
},
eulaModal: {
title: "Лицензионное соглашение",
localeNote: "",
close: "Закрыть",
},
footer: {
aria: "Подвал",
links: {
download: "Скачать",
buy: "Купить",
buy: "Поддержать",
faq: "FAQ",
enterKey: "Выбрать ключ",
support: "Поддержка",
eula: "Лицензия",
},
},
localeSwitcher: {
@@ -292,9 +298,9 @@ const en: SiteCopy = {
navAria: "Main navigation",
cta: {
download: "Download",
buy: "Buy",
buy: "Support",
downloadFree: "Download free",
buyLicense: "Buy license",
buyLicense: "Support the project",
downloadForOs: "Download for my OS",
},
hero: {
@@ -467,9 +473,9 @@ const en: SiteCopy = {
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.",
"Key format TTRPG-… · Device-bound · License agreement in the site footer",
supportNote:
"You can voluntarily support the project. As a thank-you, the rights holder manually issues a TTRPG-… product key for the corresponding access tier.",
},
comparison: {
title: "From scattered tabs to a GM panel",
@@ -517,14 +523,20 @@ const en: SiteCopy = {
messageAfter: " — stay tuned for updates.",
close: "Got it",
},
eulaModal: {
title: "End User License Agreement",
localeNote: "The binding legal text below is in Russian.",
close: "Close",
},
footer: {
aria: "Footer",
links: {
download: "Download",
buy: "Buy",
buy: "Support",
faq: "FAQ",
enterKey: "Choose key",
support: "Support",
eula: "EULA",
},
},
localeSwitcher: {
+103
View File
@@ -0,0 +1,103 @@
/** Текст EULA (как в приложении). Не заменяет консультацию юриста. */
export const EULA_RU_MARKDOWN = `
# Лицензионное соглашение с конечным пользователем (EULA)
Настоящее соглашение регулирует использование программы «НРИ Плеер» («Программа»).
Правообладатель: Фонтош И.С., физическое лицо.
Используя Программу, устанавливая её, активируя лицензионный ключ или нажимая кнопку принятия условий, пользователь подтверждает, что прочитал и принимает настоящее соглашение.
## 1. Предоставление прав
Правообладатель предоставляет пользователю простую (неисключительную), непередаваемую, ограниченную лицензию на установку и использование Программы в личных некоммерческих целях, связанных с подготовкой и проведением настольных ролевых игр.
Исключительные права на Программу не передаются пользователю. Все права, прямо не предоставленные настоящим соглашением, сохраняются за правообладателем.
## 2. Активация, срок, устройства
Доступ к функциям Программы может предоставляться на основании лицензионного ключа, пробного периода или иного разрешения правообладателя.
Доступные варианты лицензии:
- Базовый ключ: доступ на 1 год, до 3 устройств.
- Ключ мастера: доступ на 2 года, до 5 устройств.
- Легенда НРИ: доступ на 99 лет, до 15 устройств.
- Пробный доступ: предоставляется бесплатно на условиях, указанных правообладателем.
Срок доступа и число устройств определяются выданным лицензионным ключом и отображаются в разделе «О лицензии».
## 3. Поддержка проекта
Пользователь может добровольно поддержать проект. Такая поддержка является добровольным взносом в развитие Программы и не является обязательным условием заключения договора купли-продажи.
В знак благодарности за поддержку проекта правообладатель может вручную предоставить пользователю лицензионный ключ соответствующего уровня доступа. Условия предоставления ключей публикуются правообладателем или сообщаются пользователю отдельно.
## 4. Активация и проверка лицензии
Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор устройства (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub).
Программа не отправляет на сервер имя пользователя, адрес электронной почты, содержимое проектов, сцены, изображения, музыку, заметки, кампании или иные пользовательские материалы.
Лицензионный токен хранится локально на устройстве пользователя с использованием защищённого хранилища операционной системы, если оно доступно.
## 5. Ограничения
Пользователь не вправе:
- передавать, продавать, сдавать в аренду или публиковать лицензионный ключ;
- обходить активацию, технические ограничения или механизмы защиты;
- модифицировать, декомпилировать, дизассемблировать или иным образом пытаться получить исходный код Программы, кроме случаев, прямо разрешённых законом;
- распространять копии Программы от имени правообладателя без разрешения;
- использовать Программу способом, нарушающим законодательство РФ или права третьих лиц.
## 6. Пользовательские материалы
Проекты, сцены, изображения, музыка, тексты и иные материалы, загружаемые пользователем в Программу, остаются у пользователя. Правообладатель не получает прав на такие материалы и не имеет доступа к ним через механизм лицензирования.
Пользователь самостоятельно отвечает за наличие прав на материалы, которые он использует в своих проектах.
## 7. Обновления
Правообладатель может выпускать обновления Программы. Все обновления Программы предоставляются бесплатно, если правообладатель отдельно не объявит иные условия для будущих продуктов или отдельных редакций.
Правообладатель не обязан выпускать обновления в определённые сроки.
## 8. Отзыв и ограничение доступа
Правообладатель вправе отозвать или заблокировать лицензию при нарушении настоящего соглашения, передаче ключа третьим лицам, попытке обхода защиты, злоупотреблении числом устройств или ином недобросовестном использовании.
После отзыва лицензии Программа может ограничить доступ к функциям при наличии сетевого подключения и успешной проверке статуса лицензии.
## 9. Отказ от гарантий
Программа предоставляется «как есть». Правообладатель не гарантирует, что Программа будет работать без ошибок, соответствовать всем ожиданиям пользователя или быть совместимой с любым оборудованием и программным окружением.
Настоящий раздел применяется в пределах, допускаемых законодательством РФ.
## 10. Ограничение ответственности
Правообладатель не несёт ответственности за потерю пользовательских данных, невозможность проведения игры, косвенные убытки, упущенную выгоду или последствия использования пользовательских материалов, если иное прямо не установлено обязательными нормами законодательства РФ.
Ответственность правообладателя в любом случае ограничивается суммой фактически полученной от пользователя поддержки за соответствующий лицензионный ключ, если иное не установлено законом.
## 11. Персональные данные и конфиденциальность
Программа не запрашивает и не передаёт персональные данные пользователя при активации лицензии. Для работы лицензирования используются только технические данные: лицензионный ключ, deviceId, идентификатор лицензии и технические данные сетевого соединения, которые могут автоматически обрабатываться сервером.
Если в будущем Программа или связанные с ней сервисы начнут собирать персональные данные, их обработка будет регулироваться отдельной политикой конфиденциальности.
## 12. Применимое право и споры
К настоящему соглашению применяется право Российской Федерации.
Стороны стремятся решать споры путём переговоров. Если спор не урегулирован, он рассматривается в соответствии с законодательством РФ.
Если пользователь является потребителем, его права, предоставленные императивными нормами законодательства о защите прав потребителей, не ограничиваются настоящим соглашением.
## 13. Изменение соглашения
Правообладатель вправе изменять настоящее соглашение. При существенном изменении условий Программа может запросить повторное принятие новой версии соглашения.
Продолжение использования Программы после принятия новой версии означает согласие пользователя с обновлёнными условиями.
`.trim();
+3
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import { LocaleProvider } from "./i18n/LocaleProvider";
import { CrowdfundingModalProvider } from "./providers/CrowdfundingModalProvider";
import { EulaModalProvider } from "./providers/EulaModalProvider";
import { ExchangeRateProvider } from "./providers/ExchangeRateProvider";
import "./styles/global.css";
@@ -17,7 +18,9 @@ createRoot(rootEl).render(
<LocaleProvider>
<ExchangeRateProvider>
<CrowdfundingModalProvider>
<EulaModalProvider>
<App />
</EulaModalProvider>
</CrowdfundingModalProvider>
</ExchangeRateProvider>
</LocaleProvider>
+74
View File
@@ -0,0 +1,74 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(4, 3, 8, 0.78);
backdrop-filter: blur(6px);
}
.dialog {
display: flex;
flex-direction: column;
width: min(100%, 720px);
max-height: min(88vh, 900px);
padding: 28px 28px 24px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-bg-elevated);
box-shadow:
var(--shadow-glow),
0 24px 48px rgba(0, 0, 0, 0.45);
}
.title {
margin: 0 0 12px;
font-size: 22px;
font-weight: 700;
line-height: 1.3;
}
.localeNote {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-dim);
}
.content {
flex: 1;
min-height: 0;
margin-bottom: 20px;
padding: 16px 18px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
}
.closeBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 12px 22px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-gold-bright);
font: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition:
background 0.2s ease,
transform 0.15s ease;
}
.closeBtn:hover {
background: rgba(200, 161, 102, 0.08);
transform: translateY(-1px);
}
+95
View File
@@ -0,0 +1,95 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { EulaMarkdown } from "../components/legal/EulaMarkdown";
import { EULA_RU_MARKDOWN } from "../legal/eulaRu";
import { useLocale } from "../i18n/LocaleProvider";
import styles from "./EulaModal.module.css";
type EulaModalContextValue = {
openModal: () => void;
};
const EulaModalContext = createContext<EulaModalContextValue | null>(null);
export function useEulaModal(): EulaModalContextValue {
const ctx = useContext(EulaModalContext);
if (!ctx) {
throw new Error("useEulaModal must be used within EulaModalProvider");
}
return ctx;
}
export function EulaModalProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const { copy, locale } = useLocale();
const openModal = useCallback(() => {
setOpen(true);
}, []);
const closeModal = useCallback(() => {
setOpen(false);
}, []);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeModal();
};
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open, closeModal]);
return (
<EulaModalContext.Provider value={{ openModal }}>
{children}
{open ? (
<div
className={styles.overlay}
onClick={(event) => {
if (event.target === event.currentTarget) closeModal();
}}
role="presentation"
>
<div
className={styles.dialog}
role="dialog"
aria-modal="true"
aria-labelledby="eula-modal-title"
>
<h2 id="eula-modal-title" className={styles.title}>
{copy.eulaModal.title}
</h2>
{locale === "en" ? (
<p className={styles.localeNote}>{copy.eulaModal.localeNote}</p>
) : null}
<div className={styles.content}>
<EulaMarkdown markdown={EULA_RU_MARKDOWN} />
</div>
<button
type="button"
className={styles.closeBtn}
onClick={closeModal}
>
{copy.eulaModal.close}
</button>
</div>
</div>
) : null}
</EulaModalContext.Provider>
);
}
+3 -3
View File
@@ -98,11 +98,11 @@ describe("i18n messages", () => {
}
});
it("documents download and buy labels in both locales", () => {
it("documents download and support 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");
assert.equal(MESSAGES.ru.cta.buy, "Поддержать");
assert.equal(MESSAGES.en.cta.buy, "Support");
});
});