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:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ttrpg-player-landing",
|
"name": "ttrpg-player-landing",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
|
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -34,3 +34,18 @@
|
|||||||
.link:hover {
|
.link:hover {
|
||||||
color: var(--color-gold);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SITE_LINKS } from "../../data/siteContent";
|
import { SITE_LINKS } from "../../data/siteContent";
|
||||||
import { useLocale } from "../../i18n/LocaleProvider";
|
import { useLocale } from "../../i18n/LocaleProvider";
|
||||||
|
import { useEulaModal } from "../../providers/EulaModalProvider";
|
||||||
|
|
||||||
import styles from "./Footer.module.css";
|
import styles from "./Footer.module.css";
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const FOOTER_HREFS: Record<(typeof FOOTER_LINK_KEYS)[number], string> = {
|
|||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { copy } = useLocale();
|
const { copy } = useLocale();
|
||||||
|
const { openModal } = useEulaModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
@@ -32,6 +34,13 @@ export function Footer() {
|
|||||||
{copy.footer.links[key]}
|
{copy.footer.links[key]}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.linkButton}
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
{copy.footer.links.eula}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donationNote {
|
.supportNote {
|
||||||
max-width: 820px;
|
max-width: 820px;
|
||||||
margin: 12px auto 0;
|
margin: 12px auto 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function Pricing() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<p className={styles.footnote}>{copy.pricing.footnote}</p>
|
<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>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-12
@@ -24,9 +24,9 @@ const ru = {
|
|||||||
navAria: "Основная навигация",
|
navAria: "Основная навигация",
|
||||||
cta: {
|
cta: {
|
||||||
download: "Скачать",
|
download: "Скачать",
|
||||||
buy: "Купить",
|
buy: "Поддержать",
|
||||||
downloadFree: "Скачать бесплатно",
|
downloadFree: "Скачать бесплатно",
|
||||||
buyLicense: "Купить лицензию",
|
buyLicense: "Поддержать проект",
|
||||||
downloadForOs: "Скачать для моей ОС",
|
downloadForOs: "Скачать для моей ОС",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
@@ -199,9 +199,9 @@ const ru = {
|
|||||||
},
|
},
|
||||||
exchangeRateNote: "",
|
exchangeRateNote: "",
|
||||||
footnote:
|
footnote:
|
||||||
"Ключ формата TTRPG-… · Привязка к устройству · Лицензионное соглашение в приложении",
|
"Ключ формата TTRPG-… · Привязка к устройству · Лицензионное соглашение — в футере сайта",
|
||||||
donationNote:
|
supportNote:
|
||||||
"Оплата оформляется как добровольное пожертвование (donation) через внешний сервис; после подтверждения вы получаете продуктовый ключ TTRPG-….",
|
"Вы можете добровольно поддержать проект. В знак благодарности правообладатель вручную выдаёт продуктовый ключ TTRPG-… соответствующего уровня доступа.",
|
||||||
},
|
},
|
||||||
comparison: {
|
comparison: {
|
||||||
title: "От разрозненных вкладок — к пульту мастера",
|
title: "От разрозненных вкладок — к пульту мастера",
|
||||||
@@ -251,14 +251,20 @@ const ru = {
|
|||||||
messageAfter: ", следите за новостями.",
|
messageAfter: ", следите за новостями.",
|
||||||
close: "Понятно",
|
close: "Понятно",
|
||||||
},
|
},
|
||||||
|
eulaModal: {
|
||||||
|
title: "Лицензионное соглашение",
|
||||||
|
localeNote: "",
|
||||||
|
close: "Закрыть",
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
aria: "Подвал",
|
aria: "Подвал",
|
||||||
links: {
|
links: {
|
||||||
download: "Скачать",
|
download: "Скачать",
|
||||||
buy: "Купить",
|
buy: "Поддержать",
|
||||||
faq: "FAQ",
|
faq: "FAQ",
|
||||||
enterKey: "Выбрать ключ",
|
enterKey: "Выбрать ключ",
|
||||||
support: "Поддержка",
|
support: "Поддержка",
|
||||||
|
eula: "Лицензия",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localeSwitcher: {
|
localeSwitcher: {
|
||||||
@@ -292,9 +298,9 @@ const en: SiteCopy = {
|
|||||||
navAria: "Main navigation",
|
navAria: "Main navigation",
|
||||||
cta: {
|
cta: {
|
||||||
download: "Download",
|
download: "Download",
|
||||||
buy: "Buy",
|
buy: "Support",
|
||||||
downloadFree: "Download free",
|
downloadFree: "Download free",
|
||||||
buyLicense: "Buy license",
|
buyLicense: "Support the project",
|
||||||
downloadForOs: "Download for my OS",
|
downloadForOs: "Download for my OS",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
@@ -467,9 +473,9 @@ const en: SiteCopy = {
|
|||||||
exchangeRateNote:
|
exchangeRateNote:
|
||||||
"USD equivalent at the current RUB exchange rate (updated when you open the site).",
|
"USD equivalent at the current RUB exchange rate (updated when you open the site).",
|
||||||
footnote:
|
footnote:
|
||||||
"Key format TTRPG-… · Device-bound · License agreement in the app",
|
"Key format TTRPG-… · Device-bound · License agreement in the site footer",
|
||||||
donationNote:
|
supportNote:
|
||||||
"Payment is processed as a voluntary donation through an external service; after confirmation you receive a TTRPG-… product key.",
|
"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: {
|
comparison: {
|
||||||
title: "From scattered tabs to a GM panel",
|
title: "From scattered tabs to a GM panel",
|
||||||
@@ -517,14 +523,20 @@ const en: SiteCopy = {
|
|||||||
messageAfter: " — stay tuned for updates.",
|
messageAfter: " — stay tuned for updates.",
|
||||||
close: "Got it",
|
close: "Got it",
|
||||||
},
|
},
|
||||||
|
eulaModal: {
|
||||||
|
title: "End User License Agreement",
|
||||||
|
localeNote: "The binding legal text below is in Russian.",
|
||||||
|
close: "Close",
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
aria: "Footer",
|
aria: "Footer",
|
||||||
links: {
|
links: {
|
||||||
download: "Download",
|
download: "Download",
|
||||||
buy: "Buy",
|
buy: "Support",
|
||||||
faq: "FAQ",
|
faq: "FAQ",
|
||||||
enterKey: "Choose key",
|
enterKey: "Choose key",
|
||||||
support: "Support",
|
support: "Support",
|
||||||
|
eula: "EULA",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localeSwitcher: {
|
localeSwitcher: {
|
||||||
|
|||||||
@@ -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();
|
||||||
+4
-1
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { LocaleProvider } from "./i18n/LocaleProvider";
|
import { LocaleProvider } from "./i18n/LocaleProvider";
|
||||||
import { CrowdfundingModalProvider } from "./providers/CrowdfundingModalProvider";
|
import { CrowdfundingModalProvider } from "./providers/CrowdfundingModalProvider";
|
||||||
|
import { EulaModalProvider } from "./providers/EulaModalProvider";
|
||||||
import { ExchangeRateProvider } from "./providers/ExchangeRateProvider";
|
import { ExchangeRateProvider } from "./providers/ExchangeRateProvider";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
|
|
||||||
@@ -17,7 +18,9 @@ createRoot(rootEl).render(
|
|||||||
<LocaleProvider>
|
<LocaleProvider>
|
||||||
<ExchangeRateProvider>
|
<ExchangeRateProvider>
|
||||||
<CrowdfundingModalProvider>
|
<CrowdfundingModalProvider>
|
||||||
<App />
|
<EulaModalProvider>
|
||||||
|
<App />
|
||||||
|
</EulaModalProvider>
|
||||||
</CrowdfundingModalProvider>
|
</CrowdfundingModalProvider>
|
||||||
</ExchangeRateProvider>
|
</ExchangeRateProvider>
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.ru.cta.download, "Скачать");
|
||||||
assert.equal(MESSAGES.en.cta.download, "Download");
|
assert.equal(MESSAGES.en.cta.download, "Download");
|
||||||
assert.equal(MESSAGES.ru.cta.buy, "Купить");
|
assert.equal(MESSAGES.ru.cta.buy, "Поддержать");
|
||||||
assert.equal(MESSAGES.en.cta.buy, "Buy");
|
assert.equal(MESSAGES.en.cta.buy, "Support");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user