Add TTRPG Player marketing landing site.

React/Vite landing with RU/EN i18n, live USD pricing, download links to updates.mailib.ru, and donate.stream checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-06-28 11:58:31 +08:00
commit b0ab6cc729
70 changed files with 9151 additions and 0 deletions
@@ -0,0 +1,38 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-glow);
}
.cardTitle {
margin-bottom: 12px;
font-size: 18px;
font-weight: 700;
}
.cardText {
font-size: 15px;
line-height: 1.6;
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { AUDIENCE_CARD_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Audience.module.css";
export function Audience() {
const { copy } = useLocale();
return (
<Section eyebrow={copy.audience.eyebrow} title={copy.audience.title}>
<div className={styles.grid}>
{AUDIENCE_CARD_IDS.map((id) => {
const card = copy.audience.cards[id];
return (
<article key={id} className={styles.card}>
<h3 className={styles.cardTitle}>{card.title}</h3>
<p className={styles.cardText}>{card.description}</p>
</article>
);
})}
</div>
</Section>
);
}
@@ -0,0 +1,42 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
padding: 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(12, 10, 20, 0.6);
}
.icon {
margin-bottom: 14px;
font-size: 18px;
color: var(--color-purple-bright);
}
.cardTitle {
margin-bottom: 8px;
font-size: 16px;
font-weight: 700;
}
.cardText {
font-size: 14px;
line-height: 1.6;
color: var(--color-text-muted);
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.grid {
grid-template-columns: 1fr;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { BENEFIT_CARD_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Benefits.module.css";
export function Benefits() {
const { copy } = useLocale();
return (
<Section id="features" dark title={copy.benefits.title}>
<div className={styles.grid}>
{BENEFIT_CARD_IDS.map((id) => {
const card = copy.benefits.cards[id];
return (
<article key={id} className={styles.card}>
<div className={styles.icon} aria-hidden="true">
</div>
<h3 className={styles.cardTitle}>{card.title}</h3>
<p className={styles.cardText}>{card.description}</p>
</article>
);
})}
</div>
</Section>
);
}
@@ -0,0 +1,46 @@
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.card {
padding: 32px 28px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
}
.after {
border-color: var(--color-purple);
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.12),
rgba(12, 10, 20, 0.8)
);
}
.label {
margin-bottom: 14px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-gold);
}
.text {
font-size: 16px;
line-height: 1.6;
color: var(--color-text-muted);
}
.after .text {
color: var(--color-text);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Comparison.module.css";
export function Comparison() {
const { copy } = useLocale();
return (
<Section title={copy.comparison.title}>
<div className={styles.grid}>
<article className={styles.card}>
<h3 className={styles.label}>{copy.comparison.beforeLabel}</h3>
<p className={styles.text}>{copy.comparison.beforeText}</p>
</article>
<article className={`${styles.card} ${styles.after}`}>
<h3 className={styles.label}>{copy.comparison.afterLabel}</h3>
<p className={styles.text}>{copy.comparison.afterText}</p>
</article>
</div>
</Section>
);
}
@@ -0,0 +1,40 @@
.banner {
padding: 80px 24px 100px;
}
.inner {
max-width: 720px;
margin: 0 auto;
padding: 48px 40px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-xl);
text-align: center;
background:
radial-gradient(
ellipse at 50% 0%,
rgba(124, 58, 237, 0.2),
transparent 60%
),
var(--color-bg-card);
box-shadow: var(--shadow-glow);
}
.title {
margin-bottom: 28px;
font-size: clamp(26px, 4vw, 34px);
font-weight: 700;
line-height: 1.2;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
}
@media (max-width: 560px) {
.inner {
padding: 36px 24px;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
import styles from "./CtaBanner.module.css";
export function CtaBanner() {
const { copy } = useLocale();
return (
<section className={styles.banner}>
<div className={styles.inner}>
<h2 className={styles.title}>{copy.ctaBanner.title}</h2>
<div className={styles.actions}>
<Button href={SITE_LINKS.download}>{copy.cta.download}</Button>
<Button href={SITE_LINKS.buy} variant="secondary">
{copy.cta.buyLicense}
</Button>
</div>
</div>
</section>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { FAQ_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import { Accordion } from "../ui/Accordion";
export function Faq() {
const { copy } = useLocale();
const items = FAQ_IDS.map((id) => copy.faq.items[id]);
return (
<Section id="faq" dark title={copy.faq.title}>
<Accordion items={items} />
</Section>
);
}
@@ -0,0 +1,46 @@
.frame {
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-bg-elevated);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.frameAtmosphere {
width: fit-content;
max-width: 100%;
justify-self: center;
}
.chrome {
display: flex;
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
background: rgba(0, 0, 0, 0.35);
}
.chrome span {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.screen {
position: relative;
min-height: 200px;
padding: 16px;
}
.screenScreenshot {
min-height: 0;
padding: 0;
background: #0a0810;
line-height: 0;
}
.screenAtmosphere {
min-height: 0;
width: fit-content;
}
+45
View File
@@ -0,0 +1,45 @@
import type { FeatureVariant } from "../../data/siteContent";
import { getEffectsScreenshotDisplaySize } from "../../data/siteAssets";
import { AppScreenshot } from "../ui/AppScreenshot";
import styles from "./FeatureMockup.module.css";
type FeatureMockupProps = {
variant: FeatureVariant;
};
function join(...parts: (string | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
export function FeatureMockup({ variant }: FeatureMockupProps) {
const isAtmosphere = variant === "atmosphere";
const effectsDisplayWidth = isAtmosphere
? getEffectsScreenshotDisplaySize().width
: undefined;
return (
<div
className={join(
styles.frame,
isAtmosphere ? styles.frameAtmosphere : undefined,
)}
data-variant={variant}
>
<div className={styles.chrome}>
<span />
<span />
<span />
</div>
<div
className={join(
styles.screen,
styles.screenScreenshot,
isAtmosphere ? styles.screenAtmosphere : undefined,
)}
>
<AppScreenshot variant={variant} displayWidth={effectsDisplayWidth} />
</div>
</div>
);
}
@@ -0,0 +1,51 @@
.list {
display: grid;
gap: 56px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: center;
}
.reverse {
direction: rtl;
}
.reverse > * {
direction: ltr;
}
.text {
max-width: 480px;
}
.blockTitle {
margin-bottom: 14px;
font-size: 24px;
font-weight: 700;
}
.blockDesc {
margin-bottom: 16px;
font-size: 15px;
line-height: 1.65;
color: var(--color-text-muted);
}
.tags {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-gold);
}
@media (max-width: 800px) {
.row,
.reverse {
grid-template-columns: 1fr;
direction: ltr;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { FEATURE_VARIANTS } from "../../data/siteContent";
import type { FeatureVariant } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import { FeatureMockup } from "./FeatureMockup";
import styles from "./Features.module.css";
function FeatureRow({
variant,
reverse,
}: {
variant: FeatureVariant;
reverse: boolean;
}) {
const { copy } = useLocale();
const block = copy.features.blocks[variant];
return (
<article className={`${styles.row} ${reverse ? styles.reverse : ""}`}>
<div className={styles.text}>
<h3 className={styles.blockTitle}>{block.title}</h3>
<p className={styles.blockDesc}>{block.description}</p>
<p className={styles.tags}>{block.tags}</p>
</div>
<FeatureMockup variant={variant} />
</article>
);
}
export function Features() {
const { copy } = useLocale();
return (
<Section title={copy.features.title}>
<div className={styles.list}>
{FEATURE_VARIANTS.map((variant, index) => (
<FeatureRow
key={variant}
variant={variant}
reverse={index % 2 === 1}
/>
))}
</div>
</Section>
);
}
+104
View File
@@ -0,0 +1,104 @@
.hero {
padding: 48px 24px 80px;
}
.inner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
align-items: center;
max-width: var(--max-width);
margin: 0 auto;
}
.content {
max-width: 520px;
}
.eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 13px;
font-weight: 500;
color: var(--color-gold);
}
.spark {
color: var(--color-purple-bright);
}
.title {
margin-bottom: 8px;
font-size: clamp(40px, 6vw, 56px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #fff 0%, #d4cce8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline {
margin-bottom: 20px;
font-size: clamp(20px, 3vw, 26px);
font-weight: 600;
color: var(--color-purple-bright);
}
.description {
margin-bottom: 28px;
font-size: 17px;
line-height: 1.65;
color: var(--color-text-muted);
}
.cta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.platforms {
margin-bottom: 32px;
font-size: 13px;
color: var(--color-text-dim);
}
.highlights {
display: grid;
gap: 16px;
}
.highlight {
display: grid;
gap: 4px;
padding: 16px 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
}
.highlight strong {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.highlight span {
font-size: 13px;
color: var(--color-text-muted);
}
@media (max-width: 900px) {
.inner {
grid-template-columns: 1fr;
}
.content {
max-width: none;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { HERO_HIGHLIGHT_IDS, SITE_LINKS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Button } from "../ui/Button";
import { HeroMockup } from "./HeroMockup";
import styles from "./Hero.module.css";
export function Hero() {
const { copy } = useLocale();
return (
<section id="top" className={styles.hero}>
<div className={styles.inner}>
<div className={styles.content}>
<p className={styles.eyebrow}>
<span className={styles.spark} aria-hidden="true">
</span>
{copy.hero.eyebrow}
</p>
<h1 className={styles.title}>{copy.brand}</h1>
<p className={styles.tagline}>{copy.hero.tagline}</p>
<p className={styles.description}>{copy.hero.description}</p>
<div className={styles.cta}>
<Button href={SITE_LINKS.download}>{copy.cta.downloadFree}</Button>
<Button href={SITE_LINKS.buy} variant="secondary">
{copy.cta.buyLicense}
</Button>
</div>
<p className={styles.platforms}>{copy.hero.platformsLine}</p>
<div className={styles.highlights}>
{HERO_HIGHLIGHT_IDS.map((id) => {
const item = copy.hero.highlights[id];
return (
<div key={id} className={styles.highlight}>
<strong>{item.title}</strong>
<span>{item.description}</span>
</div>
);
})}
</div>
</div>
<HeroMockup />
</div>
</section>
);
}
@@ -0,0 +1,83 @@
.wrap {
position: relative;
min-height: 420px;
}
.glow {
position: absolute;
inset: 10% 5%;
background: radial-gradient(
circle,
rgba(124, 58, 237, 0.35),
transparent 70%
);
filter: blur(40px);
pointer-events: none;
}
.window {
position: absolute;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-bg-elevated);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.titleBar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
background: rgba(0, 0, 0, 0.3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
}
.winTitle {
margin-left: 8px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
}
.editor {
top: 0;
left: 0;
width: 62%;
z-index: 1;
}
.body {
overflow: hidden;
background: #0a0810;
line-height: 0;
}
.control {
top: 24%;
right: 0;
width: 54%;
z-index: 2;
}
.presentation {
bottom: 0;
left: 10%;
width: 72%;
z-index: 3;
}
@media (max-width: 900px) {
.wrap {
min-height: 360px;
max-width: 480px;
margin: 0 auto;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { AppScreenshot } from "../ui/AppScreenshot";
import { useLocale } from "../../i18n/LocaleProvider";
import styles from "./HeroMockup.module.css";
export function HeroMockup() {
const { copy } = useLocale();
return (
<div className={styles.wrap} aria-hidden="true">
<div className={styles.glow} />
<div className={`${styles.window} ${styles.editor}`}>
<div className={styles.titleBar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.winTitle}>{copy.mockupWindows.editor}</span>
</div>
<div className={styles.body}>
<AppScreenshot variant="editor" decorative />
</div>
</div>
<div className={`${styles.window} ${styles.control}`}>
<div className={styles.titleBar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.winTitle}>{copy.mockupWindows.control}</span>
</div>
<div className={styles.body}>
<AppScreenshot variant="control" decorative />
</div>
</div>
<div className={`${styles.window} ${styles.presentation}`}>
<div className={styles.titleBar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.winTitle}>
{copy.mockupWindows.presentation}
</span>
</div>
<div className={styles.body}>
<AppScreenshot variant="presentation" decorative />
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 28px;
}
.card {
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
text-align: center;
}
.cardTitle {
margin-bottom: 10px;
font-size: 20px;
font-weight: 700;
}
.cardText {
margin-bottom: 20px;
font-size: 14px;
line-height: 1.55;
color: var(--color-text-muted);
}
.downloadBtn {
width: 100%;
}
.note {
margin-bottom: 12px;
font-size: 14px;
text-align: center;
color: var(--color-text-dim);
}
.disclaimer {
max-width: 820px;
margin: 0 auto 24px;
font-size: 12px;
line-height: 1.55;
text-align: center;
color: var(--color-text-dim);
opacity: 0.9;
}
.mainCta {
display: flex;
justify-content: center;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { useMemo } from "react";
import { DOWNLOAD_LINKS, PLATFORM_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import {
detectOs,
resolveDownloadHref,
resolvePlatformDownloadHref,
} from "../../lib/detectOs";
import { Section } from "../layout/Section";
import { Button } from "../ui/Button";
import { PlatformLogo } from "../ui/PlatformLogo";
import styles from "./Platforms.module.css";
function readNavigatorHints(): { userAgent: string; maxTouchPoints: number } {
if (typeof navigator === "undefined") {
return { userAgent: "", maxTouchPoints: 0 };
}
return {
userAgent: navigator.userAgent,
maxTouchPoints: navigator.maxTouchPoints,
};
}
export function Platforms() {
const { copy } = useLocale();
const hints = useMemo(() => readNavigatorHints(), []);
const osDownloadHref = useMemo(() => {
const os = detectOs(hints.userAgent);
return resolveDownloadHref(os, DOWNLOAD_LINKS, hints);
}, [hints]);
const platformDownloadHrefs = useMemo(
() =>
Object.fromEntries(
PLATFORM_IDS.map((id) => [
id,
resolvePlatformDownloadHref(id, DOWNLOAD_LINKS, hints),
]),
) as Record<(typeof PLATFORM_IDS)[number], string>,
[hints],
);
return (
<Section id="platforms" title={copy.platforms.title}>
<div className={styles.grid}>
{PLATFORM_IDS.map((id) => {
const platform = copy.platforms.cards[id];
return (
<article key={id} className={styles.card}>
<PlatformLogo platform={id} />
<h3 className={styles.cardTitle}>{platform.title}</h3>
<p className={styles.cardText}>{platform.description}</p>
<Button
href={platformDownloadHrefs[id]}
variant="ghost"
className={styles.downloadBtn}
>
{copy.cta.download}
</Button>
</article>
);
})}
</div>
<p className={styles.note}>{copy.platforms.note}</p>
<p className={styles.disclaimer}>
{copy.platforms.distributionDisclaimer}
</p>
<div className={styles.mainCta}>
<Button href={osDownloadHref}>{copy.cta.downloadForOs}</Button>
</div>
</Section>
);
}
+114
View File
@@ -0,0 +1,114 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.card {
display: flex;
flex-direction: column;
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(12, 10, 20, 0.65);
}
.recommended {
border-color: var(--color-gold);
box-shadow: 0 0 40px rgba(200, 161, 102, 0.12);
transform: scale(1.02);
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.badge {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-gold-bright);
text-align: right;
white-space: nowrap;
}
.cardTitle {
margin: 0;
font-size: 20px;
font-weight: 700;
min-width: 0;
}
.price {
margin: 0 0 16px;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: var(--color-gold-bright);
font-variant-numeric: tabular-nums;
}
.features {
flex: 1;
margin: 0 0 24px;
padding: 0;
list-style: none;
}
.features li {
position: relative;
padding-left: 18px;
margin-bottom: 10px;
font-size: 14px;
line-height: 1.5;
color: var(--color-text-muted);
}
.features li::before {
content: "·";
position: absolute;
left: 0;
color: var(--color-gold);
font-weight: 700;
}
.footnote {
font-size: 13px;
text-align: center;
color: var(--color-text-dim);
}
.exchangeRateNote {
max-width: 820px;
margin: 0 auto 12px;
font-size: 12px;
line-height: 1.55;
text-align: center;
color: var(--color-text-dim);
opacity: 0.9;
}
.donationNote {
max-width: 820px;
margin: 12px auto 0;
font-size: 12px;
line-height: 1.55;
text-align: center;
color: var(--color-text-dim);
opacity: 0.9;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
.recommended {
transform: none;
}
}
+85
View File
@@ -0,0 +1,85 @@
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 styles from "./Pricing.module.css";
function formatTierPrice(
rub: number,
locale: "ru" | "en",
rubToUsd: number | null,
loading: boolean,
): string {
if (locale === "ru") return formatRubPrice(rub);
if (loading || rubToUsd === null) return "…";
return formatUsdPrice(rub, rubToUsd);
}
export function Pricing() {
const { locale, copy } = useLocale();
const { rubToUsd, loading } = useExchangeRate();
return (
<Section
id="pricing"
dark
title={copy.pricing.title}
subtitle={copy.pricing.subtitle}
>
<div className={styles.grid}>
{PRICING_TIER_IDS.map((id) => {
const tier = copy.pricing.tiers[id];
const recommended = id === RECOMMENDED_PRICING_TIER;
const priceRub = PRICING_TIER_PRICES_RUB[id];
return (
<article
key={id}
className={`${styles.card} ${recommended ? styles.recommended : ""}`}
>
<div className={styles.cardHeader}>
<h3 className={styles.cardTitle}>{tier.title}</h3>
{recommended ? (
<span className={styles.badge}>
{copy.pricing.recommendedBadge}
</span>
) : null}
</div>
<p className={styles.price} aria-live="polite">
{formatTierPrice(priceRub, locale, rubToUsd, loading)}
</p>
<ul className={styles.features}>
{tier.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
<Button
href={SITE_LINKS.buy}
variant={recommended ? "gold" : "secondary"}
>
{copy.cta.buy}
</Button>
</article>
);
})}
</div>
{copy.pricing.exchangeRateNote ? (
<p className={styles.exchangeRateNote}>
{copy.pricing.exchangeRateNote}
</p>
) : null}
<p className={styles.footnote}>{copy.pricing.footnote}</p>
<p className={styles.donationNote}>{copy.pricing.donationNote}</p>
</Section>
);
}
+37
View File
@@ -0,0 +1,37 @@
.list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.item {
padding: 28px 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-card);
}
.number {
display: block;
margin-bottom: 16px;
font-size: 32px;
font-weight: 800;
line-height: 1;
color: var(--color-purple-bright);
opacity: 0.85;
}
.text {
font-size: 15px;
line-height: 1.6;
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.list {
grid-template-columns: 1fr;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { STEP_IDS } from "../../data/siteContent";
import { useLocale } from "../../i18n/LocaleProvider";
import { Section } from "../layout/Section";
import styles from "./Steps.module.css";
const STEP_NUMBERS = ["01", "02", "03"] as const;
export function Steps() {
const { copy } = useLocale();
return (
<Section id="how-it-works" dark title={copy.steps.title}>
<ol className={styles.list}>
{STEP_IDS.map((id, index) => (
<li key={id} className={styles.item}>
<span className={styles.number}>{STEP_NUMBERS[index]}</span>
<p className={styles.text}>{copy.steps.items[id]}</p>
</li>
))}
</ol>
</Section>
);
}