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:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 — including protection from state actors
|
||||
— 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'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;
|
||||
@@ -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 — including protection from state actors — 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'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
@@ -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
|
||||
— given or received — 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user