Add Donor and Activist guide pages with privacy-focused content

Help page now opens with an amber disclaimer that Agora is recommended
only for above-ground activism, followed by two large buttons routing to
new /help/donors and /help/activists guide pages.

The guides cover how on-chain donations work on Agora, why they're
publicly visible on the Bitcoin blockchain and Nostr, and the main paths
for protecting donor privacy or cashing out privately (non-KYC purchase,
coinjoin, Lightning swaps via Boltz, peer-to-peer exchanges like Bisq
and RoboSats). Each tradeoff section is rendered as Pros/Cons bullets.

Also adds an 'About Agora' FAQ category to the existing accordion
covering the design rationale for not using Lightning, silent payments,
or server-rotated addresses.

The inline-markup renderer used by FAQ answers is extracted to
src/lib/helpMarkup.tsx so it can be reused by the guide pages.
This commit is contained in:
mkfain
2026-05-20 14:41:12 -05:00
parent 75a3453daa
commit e7f7d9419d
9 changed files with 566 additions and 88 deletions
+4
View File
@@ -56,6 +56,8 @@ const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").the
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
@@ -299,6 +301,8 @@ export function AppRouter() {
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/help/donors" element={<DonorGuidePage />} />
<Route path="/help/activists" element={<ActivistGuidePage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
+52
View File
@@ -0,0 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type GuideSection } from '@/lib/helpContent';
import { renderInlineMarkup } from '@/lib/helpMarkup';
/**
* Renders a single {@link GuideSection} as a Card. Used by the Donor Guide
* and Activist Guide pages.
*
* Paragraphs accept the same inline markup as FAQ answers (**bold** and
* [link](url)). Optional `pros` / `cons` arrays render as colored bullet
* lists beneath the paragraphs.
*/
export function GuideSectionCard({ section }: { section: GuideSection }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{section.heading}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-relaxed text-foreground/80">
{section.paragraphs.map((p, i) => (
<p key={i}>{renderInlineMarkup(p)}</p>
))}
{section.pros && section.pros.length > 0 && (
<div className="pt-1">
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-1">
Pros
</p>
<ul className="list-disc pl-5 space-y-1">
{section.pros.map((p, i) => (
<li key={i}>{renderInlineMarkup(p)}</li>
))}
</ul>
</div>
)}
{section.cons && section.cons.length > 0 && (
<div className="pt-1">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 mb-1">
Cons
</p>
<ul className="list-disc pl-5 space-y-1">
{section.cons.map((c, i) => (
<li key={i}>{renderInlineMarkup(c)}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
}
+1 -48
View File
@@ -8,54 +8,7 @@ import {
} from '@/components/ui/accordion';
import { useAppContext } from '@/hooks/useAppContext';
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
// ── Inline markup renderer ────────────────────────────────────────────────────
/**
* Very lightweight inline markup: **bold** and [text](url).
* Returns an array of React nodes.
*/
function renderInlineMarkup(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
// Match **bold** or [text](url)
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Push text before this match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
// **bold**
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
} else if (match[2] !== undefined && match[3] !== undefined) {
// [text](url)
nodes.push(
<a
key={match.index}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
>
{match[2]}
</a>,
);
}
lastIndex = match.index + match[0].length;
}
// Trailing text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
import { renderInlineMarkup } from '@/lib/helpMarkup';
// ── Component ─────────────────────────────────────────────────────────────────
+1 -36
View File
@@ -4,42 +4,7 @@ import { Link } from 'react-router-dom';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppContext } from '@/hooks/useAppContext';
import { getFAQItem } from '@/lib/helpContent';
/**
* Renders **bold** and [text](url) markup in FAQ answer strings.
*/
function renderInlineMarkup(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
} else if (match[2] !== undefined && match[3] !== undefined) {
nodes.push(
<a
key={match.index}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
>
{match[2]}
</a>,
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
import { renderInlineMarkup } from '@/lib/helpMarkup';
// ── Component ─────────────────────────────────────────────────────────────────
+245
View File
@@ -345,6 +345,61 @@ const FAQ_TEMPLATE: FAQCategory[] = [
},
],
},
// ── About Agora (design rationale) ──────────────────────────────────────
{
id: 'agora-design',
label: 'About Agora',
items: [
{
id: 'what-is-agora',
question: 'What is {appName} for?',
answer: [
'{appName} is a Nostr platform for sending on-chain Bitcoin donations directly to activists. No middleman, no payment processor, no account to freeze.',
],
},
{
id: 'donations-are-public-general',
question: 'Are donations on {appName} public?',
answer: [
'Yes. Every donation \u2014 given or received \u2014 is recorded on the public Bitcoin blockchain and on Nostr. Anyone can see the amounts, the timing, and the addresses involved.',
'Read the **Donor Guide** and **Activist Guide** for what this means in practice and how to protect your privacy if you need to.',
],
},
{
id: 'why-not-lightning',
question: 'Why doesn\'t {appName} use Lightning?',
answer: [
'Lightning requires a Lightning wallet. The easiest ones (like Wallet of Satoshi) are **custodial** \u2014 a company holds the funds and can be shut down, pressured, or pulled offline by a bad actor. Non-custodial Lightning is technically demanding and unreliable for newcomers.',
'We want {appName} to work for someone whose only Bitcoin experience is Cash App. On-chain Bitcoin works with every wallet on the planet.',
],
},
{
id: 'why-not-silent-payments',
question: 'Why doesn\'t {appName} use silent payments?',
answer: [
'Silent payments only work when the **sender\'s** wallet supports them. Most popular wallets \u2014 Cash App, Strike, and nearly every custodial wallet \u2014 do not.',
'Asking donors to install new software is a barrier we won\'t put in front of activists who need support.',
],
},
{
id: 'why-not-rotating-addresses',
question: 'Why doesn\'t {appName} generate a new address for every donation?',
answer: [
'Generating a fresh address per donation would require {appName} to run a server that signs and serves addresses. That server becomes a single point of failure \u2014 someone could shut it down to silence activists.',
'{appName} derives each user\'s donation address from their Nostr public key. No server is required, and the platform itself can\'t be turned off to censor anyone.',
],
},
{
id: 'why-onchain',
question: 'Why on-chain Bitcoin?',
answer: [
'On-chain Bitcoin is the most widely supported and censorship-resistant payment rail in the world. Every Bitcoin wallet can send it.',
'The tradeoff is that on-chain transactions are public and pay a miner fee. The Donor and Activist guides explain how to handle both.',
],
},
],
},
];
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -404,3 +459,193 @@ export function getFAQItem(appName: string, itemId: string): FAQItem | undefined
* canonical constant directly.
*/
export { TEAM_SOAPBOX as TEAM_SOAPBOX_PACK } from '@/lib/agoraDefaults';
// ── Donor / Activist guide content ────────────────────────────────────────────
/**
* A single section inside a long-form guide page (Donor Guide / Activist
* Guide). Each section renders as a Card on the guide page.
*
* `paragraphs` accept the same inline markup as FAQ answers (**bold** and
* [link](url)), rendered by `renderInlineMarkup` from `@/lib/helpMarkup`.
*
* `pros` / `cons` are optional and render as a bullet pair underneath the
* paragraphs. They are used for tradeoff-heavy topics like cash-out methods.
*/
export interface GuideSection {
/** Stable key, used for React keys and potential deep-linking. */
id: string;
/** Section heading. */
heading: string;
/** Body paragraphs, in order. */
paragraphs: string[];
/** Optional positives, rendered as a green-flavored bullet list. */
pros?: string[];
/** Optional negatives / caveats, rendered as an amber-flavored bullet list. */
cons?: string[];
}
const DONOR_GUIDE_TEMPLATE: GuideSection[] = [
{
id: 'how-donating-works',
heading: 'How donating works',
paragraphs: [
'You send real Bitcoin on-chain directly to the activist. {appName} doesn\'t hold or route the money \u2014 the address you\'re paying is derived from the activist\'s Nostr key, so there\'s no middleman in between.',
'You pay a small network fee to Bitcoin miners. Once the transaction is broadcast, it\'s public and irreversible.',
],
},
{
id: 'why-public',
heading: 'Why your donation is public',
paragraphs: [
'Bitcoin is a public ledger. Anyone can look up an activist\'s address and see every donation \u2014 the amount, the time, and the address it came from.',
'Your sending address can usually be traced back to wherever you bought the Bitcoin (Cash App, Coinbase, Strike, etc.). That link is what ties a donation to your real identity.',
],
},
{
id: 'privacy-non-kyc',
heading: 'For privacy: use non-KYC Bitcoin',
paragraphs: [
'Buy Bitcoin peer-to-peer so it isn\'t linked to your government ID. Marketplaces like [Bisq](https://bisq.network), [RoboSats](https://learn.robosats.com), and [HodlHodl](https://hodlhodl.com) let you trade directly with another person.',
],
pros: ['No exchange knows who you are.', 'Strongest privacy starting point.'],
cons: ['Slower and harder than Cash App.', 'Requires finding a counterparty.'],
},
{
id: 'privacy-coinjoin',
heading: 'For privacy: coinjoin before donating',
paragraphs: [
'A coinjoin mixes your Bitcoin with other people\'s coins so the output can\'t be linked back to the input. Wallets like [Wasabi](https://wasabiwallet.io), [Sparrow](https://sparrowwallet.com), and [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver) support this.',
],
pros: ['Breaks the on-chain trail from your KYC purchase.', 'Non-custodial \u2014 you keep your keys.'],
cons: ['Costs fees and takes time.', 'Fewer maintained tools after the Samourai shutdown.'],
},
{
id: 'fresh-wallet',
heading: 'Use a fresh wallet',
paragraphs: [
'Donate from a wallet that has never touched a KYC exchange or your main identity. Even one shared transaction input can link the wallet back to you.',
'Free options include [Sparrow](https://sparrowwallet.com) on desktop and [BlueWallet](https://bluewallet.io) on mobile.',
],
},
{
id: 'vary-amounts',
heading: 'Vary amounts and timing',
paragraphs: [
'Round numbers ($50, $100) and recurring donations create a pattern that\'s easy to fingerprint. Send unusual amounts at irregular times if you want to be harder to track.',
],
},
{
id: 'what-cash-app-cant-do',
heading: 'What Cash App and similar apps can\'t do',
paragraphs: [
'Cash App, Strike, and most custodial wallets are convenient but tied to your real identity. They can\'t make a donation truly anonymous, no matter how you send it.',
'If anonymity matters to you, use a non-custodial wallet you control.',
],
},
];
const ACTIVIST_GUIDE_TEMPLATE: GuideSection[] = [
{
id: 'how-receiving-works',
heading: 'How receiving works',
paragraphs: [
'Your {appName} donation address is derived from your Nostr public key. Donors send on-chain Bitcoin directly to it. No one stands between you and the funds, and no server can be shut down to stop the donations.',
],
},
{
id: 'why-public',
heading: 'Why incoming donations are public',
paragraphs: [
'Bitcoin is a public ledger. Anyone can look up your address and see every donation \u2014 the amount, the time, and the sending address. Your supporters\' addresses are visible too.',
],
},
{
id: 'dont-keep-funds',
heading: 'Don\'t keep funds at your {appName} address',
paragraphs: [
'Move funds to a wallet you control as soon as practical. Treat your {appName} address like a mailbox, not a savings account.',
'Good self-custody wallets to move funds into: [Sparrow](https://sparrowwallet.com), [BlueWallet](https://bluewallet.io), or [Phoenix](https://phoenix.acinq.co) (Lightning).',
],
},
{
id: 'cashout-overview',
heading: 'Cashing out privately \u2014 overview',
paragraphs: [
'To spend donations without revealing who you are, you have to break the on-chain trail before converting to cash. The next sections cover the main paths. Each has tradeoffs in custody, privacy, difficulty, and fees.',
],
},
{
id: 'cashout-lightning-swap',
heading: 'Lightning swap (Boltz, Bolt.exchange)',
paragraphs: [
'Services like [Boltz](https://boltz.exchange) atomic-swap your on-chain Bitcoin into Lightning. Lightning payments are private by default \u2014 they don\'t appear on the public blockchain.',
],
pros: ['Instant and non-custodial.', 'Lightning payments aren\'t publicly traceable.'],
cons: ['Per-swap limits and swap fees.', 'Depends on the swap service being online.'],
},
{
id: 'cashout-coinjoin',
heading: 'Coinjoin',
paragraphs: [
'A coinjoin mixes your Bitcoin with other users\' coins so the output can\'t be linked to the input. [Wasabi](https://wasabiwallet.io) and [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver) are the main maintained options after the Samourai shutdown.',
],
pros: ['Strong on-chain unlinkability.', 'Non-custodial.'],
cons: ['Fees and wait time.', 'Steeper learning curve than a swap.'],
},
{
id: 'cashout-p2p',
heading: 'Peer-to-peer exchange',
paragraphs: [
'Trade Bitcoin for fiat directly with another person on [Bisq](https://bisq.network), [RoboSats](https://learn.robosats.com), or [HodlHodl](https://hodlhodl.com). No exchange records your identity.',
],
pros: ['Cash in hand without KYC.', 'No central exchange knows you.'],
cons: ['Slower than an exchange.', 'Requires a willing counterparty.', 'Some learning curve.'],
},
{
id: 'cashout-tumblers',
heading: 'Tumblers and centralized mixers',
paragraphs: [
'**Generally not recommended.** Centralized tumblers are custodial \u2014 you have to trust the operator not to steal your coins or log who sent what. Many are scams or law-enforcement honeypots.',
'Coinjoin is the non-custodial alternative and is almost always the better choice.',
],
},
{
id: 'cashout-comparison',
heading: 'Quick comparison',
paragraphs: [
'**Lightning swap (Boltz):** non-custodial \u00b7 medium privacy \u00b7 easy \u00b7 low fees.',
'**Coinjoin (Wasabi, JoinMarket):** non-custodial \u00b7 high privacy \u00b7 medium difficulty \u00b7 medium fees.',
'**Peer-to-peer (Bisq, RoboSats):** non-custodial \u00b7 high privacy \u00b7 harder \u00b7 variable fees.',
'**Tumblers:** custodial \u00b7 unpredictable privacy \u00b7 easy \u00b7 high risk. **Avoid.**',
],
},
{
id: 'donors-can-be-seen',
heading: 'Your donation history is visible to future supporters',
paragraphs: [
'Anyone considering supporting you can look up your address and see the full donation history. Keep in mind how that history reads to a new donor.',
],
},
];
/** Substitute placeholders in a single guide section. */
function substituteGuideSection(section: GuideSection, appName: string): GuideSection {
return {
...section,
heading: substitute(section.heading, appName),
paragraphs: section.paragraphs.map((p) => substitute(p, appName)),
pros: section.pros?.map((p) => substitute(p, appName)),
cons: section.cons?.map((c) => substitute(c, appName)),
};
}
/** Donor guide sections with `{appName}` resolved. */
export function getDonorGuideSections(appName: string): GuideSection[] {
return DONOR_GUIDE_TEMPLATE.map((s) => substituteGuideSection(s, appName));
}
/** Activist guide sections with `{appName}` resolved. */
export function getActivistGuideSections(appName: string): GuideSection[] {
return ACTIVIST_GUIDE_TEMPLATE.map((s) => substituteGuideSection(s, appName));
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Shared inline-markup renderer used by Help/FAQ surfaces and the
* Donor / Activist guide pages.
*
* Supports a deliberately tiny syntax so authors can write content in plain
* strings without pulling in a full markdown parser:
*
* **bold** → <strong>bold</strong>
* [link text](url) → <a href="url" target="_blank" …>link text</a>
*
* The renderer returns an array of React nodes suitable for splatting into a
* paragraph or span. It is intentionally non-recursive: bold inside a link or
* vice versa is not supported (and not needed by current content).
*/
export function renderInlineMarkup(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
// Match **bold** or [text](url)
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Push text before this match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
// **bold**
nodes.push(
<strong key={match.index} className="font-semibold text-foreground">
{match[1]}
</strong>,
);
} else if (match[2] !== undefined && match[3] !== undefined) {
// [text](url)
nodes.push(
<a
key={match.index}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
>
{match[2]}
</a>,
);
}
lastIndex = match.index + match[0].length;
}
// Trailing text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
+71
View File
@@ -0,0 +1,71 @@
import { useSeoMeta } from '@unhead/react';
import { AlertTriangle, Megaphone } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { GuideSectionCard } from '@/components/GuideSectionCard';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { getActivistGuideSections } from '@/lib/helpContent';
/**
* Activist Guide — long-form companion to the Help page.
*
* Explains how receiving donations works on Agora, why incoming donations
* are public, and the main paths for cashing out privately. Linked from
* `/help` as one of the two large guide buttons.
*/
export function ActivistGuidePage() {
const { config } = useAppContext();
useLayoutOptions({});
useSeoMeta({
title: `Activist Guide | ${config.appName}`,
description: `How to receive donations on ${config.appName} and cash out privately.`,
});
const sections = getActivistGuideSections(config.appName);
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader
title="Activist Guide"
icon={<Megaphone className="size-5" />}
backTo="/help"
/>
<div className="px-4 pt-2 pb-4 space-y-4">
{/* Above-ground recommendation alert */}
<Alert className="border-amber-500/50 [&>svg]:text-amber-500">
<AlertTriangle className="size-4" />
<AlertTitle className="text-amber-700 dark:text-amber-400">
Recommended for above-ground activism
</AlertTitle>
<AlertDescription className="text-foreground/80">
<p>
{config.appName} is recommended only for above-ground activism. Every donation you
receive is recorded publicly on the Bitcoin blockchain and on Nostr. If you or your
donors require extreme privacy &mdash; including protection from state actors
&mdash; additional steps are needed to protect yourself and the people supporting you.
Read the sections below before accepting donations.
</p>
</AlertDescription>
</Alert>
{/* Short intro */}
<p className="text-sm text-muted-foreground">
Receiving support on {config.appName} means donors send Bitcoin directly to an address
derived from your Nostr key. Here&apos;s how it works, and how to move funds privately if
you need to.
</p>
{/* Sections */}
{sections.map((section) => (
<GuideSectionCard key={section.id} section={section} />
))}
</div>
</main>
);
}
export default ActivistGuidePage;
+69
View File
@@ -0,0 +1,69 @@
import { useSeoMeta } from '@unhead/react';
import { AlertTriangle, HandHeart } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { GuideSectionCard } from '@/components/GuideSectionCard';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { getDonorGuideSections } from '@/lib/helpContent';
/**
* Donor Guide — long-form companion to the Help page.
*
* Explains how on-chain donations on Agora work, why they are publicly
* visible, and what a donor can do if they need privacy. Linked from
* `/help` as one of the two large guide buttons.
*/
export function DonorGuidePage() {
const { config } = useAppContext();
useLayoutOptions({});
useSeoMeta({
title: `Donor Guide | ${config.appName}`,
description: `How donating works on ${config.appName} and how to protect your privacy.`,
});
const sections = getDonorGuideSections(config.appName);
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader
title="Donor Guide"
icon={<HandHeart className="size-5" />}
backTo="/help"
/>
<div className="px-4 pt-2 pb-4 space-y-4">
{/* Above-ground recommendation alert */}
<Alert className="border-amber-500/50 [&>svg]:text-amber-500">
<AlertTriangle className="size-4" />
<AlertTitle className="text-amber-700 dark:text-amber-400">
Recommended for above-ground activism
</AlertTitle>
<AlertDescription className="text-foreground/80">
<p>
{config.appName} is recommended only for supporting above-ground activism. Your
donation is public on the Bitcoin blockchain and on Nostr. If you need extreme
privacy &mdash; including protection from state actors &mdash; additional steps are
required before donating. Read the sections below first.
</p>
</AlertDescription>
</Alert>
{/* Short intro */}
<p className="text-sm text-muted-foreground">
Supporting an activist on {config.appName} means sending real Bitcoin on-chain. Here&apos;s
how it works, and how to do it privately if you need to.
</p>
{/* Sections */}
{sections.map((section) => (
<GuideSectionCard key={section.id} section={section} />
))}
</div>
</main>
);
}
export default DonorGuidePage;
+63 -4
View File
@@ -1,7 +1,8 @@
import { useSeoMeta } from '@unhead/react';
import { HelpCircle, Shield } from 'lucide-react';
import { AlertTriangle, ChevronRight, HandHeart, HelpCircle, Megaphone, Shield } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { PageHeader } from '@/components/PageHeader';
@@ -14,21 +15,54 @@ export function HelpPage() {
useSeoMeta({
title: `Help | ${config.appName}`,
description: `Get help with ${config.appName}Nostr 101, FAQs, and support`,
description: `Get help with ${config.appName}donor and activist guides, FAQs, and support`,
});
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Help" icon={<HelpCircle className="size-5" />} />
{/* Top-of-page disclaimer: first thing visitors see */}
<div className="px-4 pt-2">
<Alert className="border-amber-500/50 [&>svg]:text-amber-500">
<AlertTriangle className="size-4" />
<AlertTitle className="text-amber-700 dark:text-amber-400">Read this first</AlertTitle>
<AlertDescription className="text-foreground/80">
<p>
{config.appName} is recommended only for above-ground activism. Every donation
&mdash; given or received &mdash; is public on the Bitcoin blockchain and on Nostr. If
you or your donors require extreme privacy, including from state actors, additional
steps are required to protect yourself. Read the <strong>Donor Guide</strong> and{' '}
<strong>Activist Guide</strong> below before participating.
</p>
</AlertDescription>
</Alert>
</div>
{/* Two large guide buttons */}
<div className="px-4 pt-4 grid gap-3 sm:grid-cols-2">
<GuideButton
to="/help/donors"
icon={<HandHeart className="size-6" />}
title="Donor Guide"
description="How to support activists privately and safely."
/>
<GuideButton
to="/help/activists"
icon={<Megaphone className="size-6" />}
title="Activist Guide"
description="Receiving donations and cashing out privately."
/>
</div>
{/* Team Soapbox follow pack */}
<TeamSoapboxCard className="px-4 pt-2 pb-4" />
<TeamSoapboxCard className="px-4 pt-4 pb-4" />
{/* FAQ heading */}
<div className="px-4 pt-4 pb-1">
<h2 className="text-lg font-bold">Frequently Asked Questions</h2>
<p className="text-sm text-muted-foreground mt-1">
Everything you need to know about Nostr, {config.appName}, and how it all works.
Everything else you need to know about Nostr, {config.appName}, and how it all works.
</p>
</div>
@@ -48,3 +82,28 @@ export function HelpPage() {
</main>
);
}
interface GuideButtonProps {
to: string;
icon: React.ReactNode;
title: string;
description: string;
}
function GuideButton({ to, icon, title, description }: GuideButtonProps) {
return (
<Link
to={to}
className="group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
{icon}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold leading-snug">{title}</p>
<p className="text-sm text-muted-foreground leading-snug">{description}</p>
</div>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
);
}