diff --git a/src/components/layout/Footer.module.css b/src/components/layout/Footer.module.css
index 4c731ac..ba3e995 100644
--- a/src/components/layout/Footer.module.css
+++ b/src/components/layout/Footer.module.css
@@ -34,3 +34,11 @@
.link:hover {
color: var(--color-gold);
}
+
+.linkButton {
+ border: none;
+ background: none;
+ padding: 0;
+ font: inherit;
+ cursor: pointer;
+}
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx
index d0eb1d9..1ca6a81 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 { useCrowdfundingModal } from "../../providers/CrowdfundingModalProvider";
import styles from "./Footer.module.css";
@@ -11,9 +12,11 @@ const FOOTER_LINK_KEYS = [
"support",
] as const;
-const FOOTER_HREFS: Record<(typeof FOOTER_LINK_KEYS)[number], string> = {
+const FOOTER_HREFS: Record<
+ Exclude<(typeof FOOTER_LINK_KEYS)[number], "buy">,
+ string
+> = {
download: SITE_LINKS.download,
- buy: SITE_LINKS.buy,
faq: SITE_LINKS.faq,
enterKey: SITE_LINKS.enterKey,
support: SITE_LINKS.support,
@@ -21,24 +24,29 @@ const FOOTER_HREFS: Record<(typeof FOOTER_LINK_KEYS)[number], string> = {
export function Footer() {
const { copy } = useLocale();
+ const { openModal } = useCrowdfundingModal();
return (
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 63000c5..86f957b 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,6 +1,7 @@
import { NAV_HREFS, NAV_LINK_KEYS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
+import { BuyButton } from "../ui/BuyButton";
import { LanguageSwitcher } from "./LanguageSwitcher";
import styles from "./Header.module.css";
@@ -39,14 +40,9 @@ export function Header() {
>
{copy.cta.download}
-
+
diff --git a/src/components/sections/CtaBanner.tsx b/src/components/sections/CtaBanner.tsx
index 7a802c6..06ef91c 100644
--- a/src/components/sections/CtaBanner.tsx
+++ b/src/components/sections/CtaBanner.tsx
@@ -1,6 +1,7 @@
import { SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
+import { BuyButton } from "../ui/BuyButton";
import styles from "./CtaBanner.module.css";
@@ -13,9 +14,7 @@ export function CtaBanner() {
{copy.ctaBanner.title}
-
+ {copy.cta.buyLicense}
diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx
index b4fe398..de3c576 100644
--- a/src/components/sections/Hero.tsx
+++ b/src/components/sections/Hero.tsx
@@ -1,6 +1,7 @@
import { HERO_HIGHLIGHT_IDS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
+import { BuyButton } from "../ui/BuyButton";
import { HeroMockup } from "./HeroMockup";
import styles from "./Hero.module.css";
@@ -25,9 +26,7 @@ export function Hero() {
-
+ {copy.cta.buyLicense}
{copy.hero.platformsLine}
diff --git a/src/components/sections/Pricing.tsx b/src/components/sections/Pricing.tsx
index be8f22a..c005bba 100644
--- a/src/components/sections/Pricing.tsx
+++ b/src/components/sections/Pricing.tsx
@@ -1,14 +1,13 @@
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 { BuyButton } from "../ui/BuyButton";
import styles from "./Pricing.module.css";
@@ -61,13 +60,9 @@ export function Pricing() {
{feature}
))}
-
+
);
})}
diff --git a/src/components/ui/BuyButton.tsx b/src/components/ui/BuyButton.tsx
new file mode 100644
index 0000000..9952824
--- /dev/null
+++ b/src/components/ui/BuyButton.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+
+import { useCrowdfundingModal } from "../../providers/CrowdfundingModalProvider";
+import { Button } from "./Button";
+
+type BuyButtonVariant = "primary" | "secondary" | "ghost" | "gold";
+
+type BuyButtonProps = {
+ children: ReactNode;
+ variant?: BuyButtonVariant;
+ className?: string;
+};
+
+export function BuyButton({
+ children,
+ variant = "secondary",
+ className,
+}: BuyButtonProps) {
+ const { openModal } = useCrowdfundingModal();
+
+ return (
+
+ );
+}
diff --git a/src/data/siteContent.ts b/src/data/siteContent.ts
index ca0b650..fefe4ee 100644
--- a/src/data/siteContent.ts
+++ b/src/data/siteContent.ts
@@ -1,3 +1,5 @@
+export const CROWDFUNDING_URL = "https://crowdrepublic.ru/" as const;
+
export const SITE_LINKS = {
features: "#features",
howItWorks: "#how-it-works",
@@ -5,7 +7,7 @@ export const SITE_LINKS = {
pricing: "#pricing",
faq: "#faq",
download: "#platforms",
- buy: "https://donate.stream/ttrpgplayer",
+ // buy: "https://donate.stream/ttrpgplayer",
enterKey: "#pricing",
support: "mailto:player.ttrpg@gmail.com",
} as const;
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 7456ac8..7598268 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -245,6 +245,12 @@ const ru = {
ctaBanner: {
title: "Готовы вести следующую сессию по-новому?",
},
+ crowdfundingModal: {
+ title: "Скоро старт краудфандинга",
+ messageBefore: "Скоро начнётся краудфандинговая кампания на ",
+ messageAfter: ", следите за новостями.",
+ close: "Понятно",
+ },
footer: {
aria: "Подвал",
links: {
@@ -505,6 +511,12 @@ const en: SiteCopy = {
ctaBanner: {
title: "Ready to run your next session differently?",
},
+ crowdfundingModal: {
+ title: "Crowdfunding coming soon",
+ messageBefore: "A crowdfunding campaign will start soon on ",
+ messageAfter: " — stay tuned for updates.",
+ close: "Got it",
+ },
footer: {
aria: "Footer",
links: {
diff --git a/src/main.tsx b/src/main.tsx
index 0cac578..3dac449 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import { LocaleProvider } from "./i18n/LocaleProvider";
+import { CrowdfundingModalProvider } from "./providers/CrowdfundingModalProvider";
import { ExchangeRateProvider } from "./providers/ExchangeRateProvider";
import "./styles/global.css";
@@ -15,7 +16,9 @@ createRoot(rootEl).render(
-
+
+
+
,
diff --git a/src/providers/CrowdfundingModal.module.css b/src/providers/CrowdfundingModal.module.css
new file mode 100644
index 0000000..8e71d1b
--- /dev/null
+++ b/src/providers/CrowdfundingModal.module.css
@@ -0,0 +1,72 @@
+.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 {
+ width: min(100%, 480px);
+ padding: 32px 28px 28px;
+ 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 16px;
+ font-size: 22px;
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+.message {
+ margin: 0 0 24px;
+ font-size: 16px;
+ line-height: 1.65;
+ color: var(--color-text-muted);
+}
+
+.link {
+ color: var(--color-gold-bright);
+ text-decoration: underline;
+ text-decoration-color: var(--color-gold-dim);
+ text-underline-offset: 3px;
+ word-break: break-all;
+}
+
+.link:hover {
+ color: var(--color-gold);
+}
+
+.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/CrowdfundingModalProvider.tsx b/src/providers/CrowdfundingModalProvider.tsx
new file mode 100644
index 0000000..54d4656
--- /dev/null
+++ b/src/providers/CrowdfundingModalProvider.tsx
@@ -0,0 +1,107 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+
+import { CROWDFUNDING_URL } from "../data/siteContent";
+import { useLocale } from "../i18n/LocaleProvider";
+
+import styles from "./CrowdfundingModal.module.css";
+
+type CrowdfundingModalContextValue = {
+ openModal: () => void;
+};
+
+const CrowdfundingModalContext =
+ createContext(null);
+
+export function useCrowdfundingModal(): CrowdfundingModalContextValue {
+ const ctx = useContext(CrowdfundingModalContext);
+ if (!ctx) {
+ throw new Error(
+ "useCrowdfundingModal must be used within CrowdfundingModalProvider",
+ );
+ }
+ return ctx;
+}
+
+export function CrowdfundingModalProvider({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ const { copy } = 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.crowdfundingModal.title}
+
+
+ {copy.crowdfundingModal.messageBefore}
+
+ {CROWDFUNDING_URL}
+
+ {copy.crowdfundingModal.messageAfter}
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/siteContent.test.ts b/src/siteContent.test.ts
index a5b8eef..4ea9563 100644
--- a/src/siteContent.test.ts
+++ b/src/siteContent.test.ts
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
+ CROWDFUNDING_URL,
DOWNLOAD_ARTIFACTS,
DOWNLOAD_LINKS,
FAQ_IDS,
@@ -68,9 +69,9 @@ describe("siteContent", () => {
);
});
- it("wires download anchor and external buy link", () => {
+ it("wires download anchor and CrowdRepublic crowdfunding url", () => {
assert.equal(SITE_LINKS.download, "#platforms");
- assert.equal(SITE_LINKS.buy, "https://donate.stream/ttrpgplayer");
+ assert.equal(CROWDFUNDING_URL, "https://crowdrepublic.ru/");
});
it("maps each feature variant to a screenshot asset", () => {