Open buy buttons in CrowdRepublic crowdfunding modal.

Replace donate.stream links with a modal announcing the upcoming campaign on CrowdRepublic, with RU/EN copy and the old buy URL commented out.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-01 15:32:26 +08:00
parent a48e35b839
commit fa7d537ef7
13 changed files with 267 additions and 39 deletions
+8
View File
@@ -34,3 +34,11 @@
.link:hover { .link:hover {
color: var(--color-gold); color: var(--color-gold);
} }
.linkButton {
border: none;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
}
+22 -14
View File
@@ -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 { useCrowdfundingModal } from "../../providers/CrowdfundingModalProvider";
import styles from "./Footer.module.css"; import styles from "./Footer.module.css";
@@ -11,9 +12,11 @@ const FOOTER_LINK_KEYS = [
"support", "support",
] as const; ] 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, download: SITE_LINKS.download,
buy: SITE_LINKS.buy,
faq: SITE_LINKS.faq, faq: SITE_LINKS.faq,
enterKey: SITE_LINKS.enterKey, enterKey: SITE_LINKS.enterKey,
support: SITE_LINKS.support, support: SITE_LINKS.support,
@@ -21,24 +24,29 @@ const FOOTER_HREFS: Record<(typeof FOOTER_LINK_KEYS)[number], string> = {
export function Footer() { export function Footer() {
const { copy } = useLocale(); const { copy } = useLocale();
const { openModal } = useCrowdfundingModal();
return ( return (
<footer className={styles.footer}> <footer className={styles.footer}>
<div className={styles.inner}> <div className={styles.inner}>
<p className={styles.copy}>© TTRPGPlayer</p> <p className={styles.copy}>© TTRPGPlayer</p>
<nav className={styles.links} aria-label={copy.footer.aria}> <nav className={styles.links} aria-label={copy.footer.aria}>
{FOOTER_LINK_KEYS.map((key) => ( {FOOTER_LINK_KEYS.map((key) =>
<a key === "buy" ? (
key={key} <button
className={styles.link} key={key}
href={FOOTER_HREFS[key]} type="button"
{...(key === "buy" className={`${styles.link} ${styles.linkButton}`}
? { target: "_blank", rel: "noopener noreferrer" } onClick={openModal}
: undefined)} >
> {copy.footer.links[key]}
{copy.footer.links[key]} </button>
</a> ) : (
))} <a key={key} className={styles.link} href={FOOTER_HREFS[key]}>
{copy.footer.links[key]}
</a>
),
)}
</nav> </nav>
</div> </div>
</footer> </footer>
+3 -7
View File
@@ -1,6 +1,7 @@
import { NAV_HREFS, NAV_LINK_KEYS, SITE_LINKS } from "../../data/siteContent"; import { NAV_HREFS, NAV_LINK_KEYS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider"; import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { BuyButton } from "../ui/BuyButton";
import { LanguageSwitcher } from "./LanguageSwitcher"; import { LanguageSwitcher } from "./LanguageSwitcher";
import styles from "./Header.module.css"; import styles from "./Header.module.css";
@@ -39,14 +40,9 @@ export function Header() {
> >
{copy.cta.download} {copy.cta.download}
</Button> </Button>
<Button <BuyButton variant="secondary" className={styles.actionBtn}>
href={SITE_LINKS.buy}
variant="secondary"
className={styles.actionBtn}
newTab
>
{copy.cta.buy} {copy.cta.buy}
</Button> </BuyButton>
</div> </div>
</div> </div>
</header> </header>
+2 -3
View File
@@ -1,6 +1,7 @@
import { SITE_LINKS } from "../../data/siteContent"; import { SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider"; import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { BuyButton } from "../ui/BuyButton";
import styles from "./CtaBanner.module.css"; import styles from "./CtaBanner.module.css";
@@ -13,9 +14,7 @@ export function CtaBanner() {
<h2 className={styles.title}>{copy.ctaBanner.title}</h2> <h2 className={styles.title}>{copy.ctaBanner.title}</h2>
<div className={styles.actions}> <div className={styles.actions}>
<Button href={SITE_LINKS.download}>{copy.cta.download}</Button> <Button href={SITE_LINKS.download}>{copy.cta.download}</Button>
<Button href={SITE_LINKS.buy} variant="secondary" newTab> <BuyButton variant="secondary">{copy.cta.buyLicense}</BuyButton>
{copy.cta.buyLicense}
</Button>
</div> </div>
</div> </div>
</section> </section>
+2 -3
View File
@@ -1,6 +1,7 @@
import { HERO_HIGHLIGHT_IDS, SITE_LINKS } from "../../data/siteContent"; import { HERO_HIGHLIGHT_IDS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider"; import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { BuyButton } from "../ui/BuyButton";
import { HeroMockup } from "./HeroMockup"; import { HeroMockup } from "./HeroMockup";
import styles from "./Hero.module.css"; import styles from "./Hero.module.css";
@@ -25,9 +26,7 @@ export function Hero() {
<div className={styles.cta}> <div className={styles.cta}>
<Button href={SITE_LINKS.download}>{copy.cta.downloadFree}</Button> <Button href={SITE_LINKS.download}>{copy.cta.downloadFree}</Button>
<Button href={SITE_LINKS.buy} variant="secondary" newTab> <BuyButton variant="secondary">{copy.cta.buyLicense}</BuyButton>
{copy.cta.buyLicense}
</Button>
</div> </div>
<p className={styles.platforms}>{copy.hero.platformsLine}</p> <p className={styles.platforms}>{copy.hero.platformsLine}</p>
+3 -8
View File
@@ -1,14 +1,13 @@
import { import {
PRICING_TIER_IDS, PRICING_TIER_IDS,
RECOMMENDED_PRICING_TIER, RECOMMENDED_PRICING_TIER,
SITE_LINKS,
} from "../../data/siteContent"; } from "../../data/siteContent";
import { PRICING_TIER_PRICES_RUB } from "../../data/pricing"; import { PRICING_TIER_PRICES_RUB } from "../../data/pricing";
import { useLocale } from "../../i18n/LocaleProvider"; import { useLocale } from "../../i18n/LocaleProvider";
import { formatRubPrice, formatUsdPrice } from "../../lib/exchangeRate"; import { formatRubPrice, formatUsdPrice } from "../../lib/exchangeRate";
import { useExchangeRate } from "../../providers/ExchangeRateProvider"; import { useExchangeRate } from "../../providers/ExchangeRateProvider";
import { Section } from "../layout/Section"; import { Section } from "../layout/Section";
import { Button } from "../ui/Button"; import { BuyButton } from "../ui/BuyButton";
import styles from "./Pricing.module.css"; import styles from "./Pricing.module.css";
@@ -61,13 +60,9 @@ export function Pricing() {
<li key={feature}>{feature}</li> <li key={feature}>{feature}</li>
))} ))}
</ul> </ul>
<Button <BuyButton variant={recommended ? "gold" : "secondary"}>
href={SITE_LINKS.buy}
variant={recommended ? "gold" : "secondary"}
newTab
>
{copy.cta.buy} {copy.cta.buy}
</Button> </BuyButton>
</article> </article>
); );
})} })}
+26
View File
@@ -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 (
<Button variant={variant} className={className} onClick={openModal}>
{children}
</Button>
);
}
+3 -1
View File
@@ -1,3 +1,5 @@
export const CROWDFUNDING_URL = "https://crowdrepublic.ru/" as const;
export const SITE_LINKS = { export const SITE_LINKS = {
features: "#features", features: "#features",
howItWorks: "#how-it-works", howItWorks: "#how-it-works",
@@ -5,7 +7,7 @@ export const SITE_LINKS = {
pricing: "#pricing", pricing: "#pricing",
faq: "#faq", faq: "#faq",
download: "#platforms", download: "#platforms",
buy: "https://donate.stream/ttrpgplayer", // buy: "https://donate.stream/ttrpgplayer",
enterKey: "#pricing", enterKey: "#pricing",
support: "mailto:player.ttrpg@gmail.com", support: "mailto:player.ttrpg@gmail.com",
} as const; } as const;
+12
View File
@@ -245,6 +245,12 @@ const ru = {
ctaBanner: { ctaBanner: {
title: "Готовы вести следующую сессию по-новому?", title: "Готовы вести следующую сессию по-новому?",
}, },
crowdfundingModal: {
title: "Скоро старт краудфандинга",
messageBefore: "Скоро начнётся краудфандинговая кампания на ",
messageAfter: ", следите за новостями.",
close: "Понятно",
},
footer: { footer: {
aria: "Подвал", aria: "Подвал",
links: { links: {
@@ -505,6 +511,12 @@ const en: SiteCopy = {
ctaBanner: { ctaBanner: {
title: "Ready to run your next session differently?", 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: { footer: {
aria: "Footer", aria: "Footer",
links: { links: {
+4 -1
View File
@@ -3,6 +3,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 { ExchangeRateProvider } from "./providers/ExchangeRateProvider"; import { ExchangeRateProvider } from "./providers/ExchangeRateProvider";
import "./styles/global.css"; import "./styles/global.css";
@@ -15,7 +16,9 @@ createRoot(rootEl).render(
<StrictMode> <StrictMode>
<LocaleProvider> <LocaleProvider>
<ExchangeRateProvider> <ExchangeRateProvider>
<App /> <CrowdfundingModalProvider>
<App />
</CrowdfundingModalProvider>
</ExchangeRateProvider> </ExchangeRateProvider>
</LocaleProvider> </LocaleProvider>
</StrictMode>, </StrictMode>,
@@ -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);
}
+107
View File
@@ -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<CrowdfundingModalContextValue | null>(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 (
<CrowdfundingModalContext.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="crowdfunding-modal-title"
>
<h2 id="crowdfunding-modal-title" className={styles.title}>
{copy.crowdfundingModal.title}
</h2>
<p className={styles.message}>
{copy.crowdfundingModal.messageBefore}
<a
href={CROWDFUNDING_URL}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
{CROWDFUNDING_URL}
</a>
{copy.crowdfundingModal.messageAfter}
</p>
<button
type="button"
className={styles.closeBtn}
onClick={closeModal}
>
{copy.crowdfundingModal.close}
</button>
</div>
</div>
) : null}
</CrowdfundingModalContext.Provider>
);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
CROWDFUNDING_URL,
DOWNLOAD_ARTIFACTS, DOWNLOAD_ARTIFACTS,
DOWNLOAD_LINKS, DOWNLOAD_LINKS,
FAQ_IDS, 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.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", () => { it("maps each feature variant to a screenshot asset", () => {