diff --git a/package.json b/package.json index fa6b414..d9d6864 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/layout/Footer.module.css b/src/components/layout/Footer.module.css index 4c731ac..cfcf712 100644 --- a/src/components/layout/Footer.module.css +++ b/src/components/layout/Footer.module.css @@ -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); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index dfa0369..707e748 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -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 ( diff --git a/src/components/legal/EulaMarkdown.module.css b/src/components/legal/EulaMarkdown.module.css new file mode 100644 index 0000000..49c24a4 --- /dev/null +++ b/src/components/legal/EulaMarkdown.module.css @@ -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; +} diff --git a/src/components/legal/EulaMarkdown.tsx b/src/components/legal/EulaMarkdown.tsx new file mode 100644 index 0000000..53b8df4 --- /dev/null +++ b/src/components/legal/EulaMarkdown.tsx @@ -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 ( +

+ {trimmed.slice(2)} +

+ ); + } + + if (trimmed.startsWith("## ")) { + return ( +

+ {trimmed.slice(3)} +

+ ); + } + + const lines = trimmed.split("\n"); + if (lines.every((line) => line.startsWith("- "))) { + return ( + + ); + } + + return ( +

+ {lines.join(" ")} +

+ ); +} + +export function EulaMarkdown({ markdown }: { markdown: string }) { + const blocks = markdown.split(/\n\n+/u); + + return ( +
+ {blocks.map((block, index) => renderBlock(block, index))} +
+ ); +} diff --git a/src/components/sections/Pricing.module.css b/src/components/sections/Pricing.module.css index 5722da9..3b93c45 100644 --- a/src/components/sections/Pricing.module.css +++ b/src/components/sections/Pricing.module.css @@ -93,7 +93,7 @@ opacity: 0.9; } -.donationNote { +.supportNote { max-width: 820px; margin: 12px auto 0; font-size: 12px; diff --git a/src/components/sections/Pricing.tsx b/src/components/sections/Pricing.tsx index c005bba..2f75f8f 100644 --- a/src/components/sections/Pricing.tsx +++ b/src/components/sections/Pricing.tsx @@ -75,7 +75,7 @@ export function Pricing() { ) : null}

{copy.pricing.footnote}

-

{copy.pricing.donationNote}

+

{copy.pricing.supportNote}

); } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 7598268..74e3950 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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: { diff --git a/src/legal/eulaRu.ts b/src/legal/eulaRu.ts new file mode 100644 index 0000000..26edcf8 --- /dev/null +++ b/src/legal/eulaRu.ts @@ -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(); diff --git a/src/main.tsx b/src/main.tsx index 3dac449..da62ec5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + diff --git a/src/providers/EulaModal.module.css b/src/providers/EulaModal.module.css new file mode 100644 index 0000000..06f00e6 --- /dev/null +++ b/src/providers/EulaModal.module.css @@ -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); +} diff --git a/src/providers/EulaModalProvider.tsx b/src/providers/EulaModalProvider.tsx new file mode 100644 index 0000000..6d587a4 --- /dev/null +++ b/src/providers/EulaModalProvider.tsx @@ -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(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 ( + + {children} + {open ? ( +
{ + if (event.target === event.currentTarget) closeModal(); + }} + role="presentation" + > +
+

+ {copy.eulaModal.title} +

+ {locale === "en" ? ( +

{copy.eulaModal.localeNote}

+ ) : null} +
+ +
+ +
+
+ ) : null} +
+ ); +} diff --git a/src/siteContent.test.ts b/src/siteContent.test.ts index e1b5b56..be72b32 100644 --- a/src/siteContent.test.ts +++ b/src/siteContent.test.ts @@ -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"); }); });