Compare commits

...

9 Commits

Author SHA1 Message Date
mkfain 00509f979a Activist Guide: drop redundant 'state-level threats' and 'Private Mode tradeoffs' callouts
Both callouts repeated information already covered by the mode
comparison table directly above them (table rows 'Best for' and
'Watch out for' on the Private side carry the same content).
2026-05-21 14:27:39 -05:00
mkfain a7550f3e49 Activist Guide: move 'donation history' prose to directly below the mode table
The donor-history visibility note is most useful as immediate context
on what the table just compared, so it now sits right after the
modeComparison block instead of being the page's closing section.
2026-05-21 14:26:54 -05:00
mkfain 65e9bd72a1 Donor Guide: clarify Fast vs Private privacy risk in 'Donating privately'
- The warning callout above the OptionGrid now spells out the
  difference: Fast Mode publishes every donation to one persistent
  public address, while Private Mode hides the campaign's receiving
  side entirely. The donor's sending side still looks the same on
  chain in both modes, but Private Mode breaks the most direct link
  between donor and cause.

- The OptionGrid intro now opens with 'these steps matter most when
  donating to Fast Mode campaigns,' notes that the casual-observer
  risk is lower for Private Mode (no public donor list to enumerate),
  and reminds the reader that targeted analysis of their sending
  wallet is still possible in both modes, so high-sender-risk donors
  should still stack these privacy steps.
2026-05-21 14:25:03 -05:00
mkfain a0c3e34e14 Remove em dashes from FAQ and guide copy
Replaces every em dash inside user-facing FAQ and Donor / Activist guide
content with a period, comma, colon, or parenthesis depending on what
fits the sentence. Affects:

- src/lib/helpContent.ts: all FAQ answers (active and hidden legacy
  items) and both guide block templates (TLDR ledes, comparison
  footnotes, callout bodies, step bodies, OptionGrid item copy).
- src/components/guide/ModeComparisonTable.tsx: the donor and activist
  row content strings.

JSDoc comments inside source files are left alone (developer-facing,
never rendered).
2026-05-21 14:23:52 -05:00
mkfain f3b95157dc Redesign Donor and Activist guides as a typed block kit
Replaces 7 (donor) and 12 (activist) sequential prose Cards with a
small typed block kit that the page dispatches on. Each guide drops
roughly 40-50% of body text and trades walls of paragraphs for
scannable visual blocks.

helpContent.ts:
- Replace GuideSection with a GuideBlock discriminated union (tldr,
  steps, modeComparison, callout, optionGrid, prose).
- Rewrite the Donor Guide as 6 blocks: TLDR, 3-step flow, Fast vs.
  Private comparison, on-chain visibility warning, donate-privately
  OptionGrid, consumer-apps callout.
- Rewrite the Activist Guide as 8 blocks: TLDR, mode comparison
  (centerpiece), state-actor warning, Private Mode tradeoffs callout,
  2-step move-funds flow, cash-out OptionGrid (now includes Bitrefill
  for direct gift-card spending), avoid-tumblers callout, brief prose
  on donation history visibility.
- Voice shifts to direct second-person across both guides.

New components in src/components/guide/:
- InlineModeBadge - pill that visually distinguishes Fast (primary
  tint) and Private (indigo tint).
- GuideTLDR - hero-adjacent two-column summary with a lede and 2-3
  checkmark next-actions.
- GuideSteps - numbered vertical step list.
- CalloutCard - tinted (info/warning/danger/success) icon callouts.
- ModeComparisonTable - 3-column grid on desktop; collapses to two
  stacked tinted cards on mobile (no sideways scroll). Audience flag
  switches between donor and activist row copy.
- OptionGrid + inline OptionCard - 2-up grid of compact tiles with
  chips and optional external link.
- GuideProse - small escape hatch.
- index.ts barrel.

Pages:
- DonorGuidePage.tsx and ActivistGuidePage.tsx now just fetch their
  block array and dispatch each block to the right component via a
  small switch.
- Body width tightened from max-w-3xl to max-w-2xl for line length;
  block spacing increased from space-y-4 to space-y-6.

Deleted:
- src/components/GuideSectionCard.tsx (no remaining consumers).

renderInlineMarkup, GuideHero, and the FAQ data shape are unchanged.
2026-05-21 14:16:24 -05:00
mkfain bd8e0b5c5c FAQ copy edits: trim wording, drop sign-in question, expand Lightning answer
- Remove the 'Why is my sign-in so different and long?' question entirely.
- send-bitcoin-onchain: drop the 'Agora doesn't hold or forward the
  money' clause; the campaign-address line stands on its own.
- connect-wallet: drop 'not a personal wallet shared across Agora'.
- what-are-silent-payments: drop 'non-custodial' from the bridge
  description.
- silent-payments-supported: cut the second paragraph about non-custody
  / one-shot addresses / Agora not holding spendable funds.
- private-mode-reliability: drop the stale-address / maintenance-window
  examples; soften 'can't guarantee' to 'can't 100% guarantee' and cut
  the 'honest cost of privacy' line.
- choose-mode-as-activist: drop 'in plain English.'
- why-not-lightning: replace the Cash-App/Coinbase paragraph with a
  framing about every Bitcoin holder + add a paragraph on cash-out
  reliability (on-chain reliably converts to spendable money; Lightning
  liquidity is patchy and custodial Lightning wallets can freeze
  withdrawals).
2026-05-21 13:59:34 -05:00
mkfain 4d827e01f4 Style Donor/Activist Guide buttons as campaign-style cover cards
The two Help page guide buttons now mirror CampaignCard's structure: a
16:9 cover image on top with a soft hover scale, a rounded icon badge
floating in the top-left corner, and title + description in the body.

Donor Guide reuses the World Liberty Congress hero image already used
on the Donor Guide page; Activist Guide reuses the 'Raised Fists' image
that opens the Activist Guide's hero gallery, so each button visually
previews its destination.
2026-05-21 13:49:55 -05:00
mkfain 4c32b93f5e Remove 'Read this first' alert from Help page
The amber state-actor warning at the top of /help duplicated copy that
now lives in the Fast Mode section of the Activist Guide and would read
as overly conservative once Private Mode campaigns exist. The two large
guide buttons below it already point readers at the long-form content.
2026-05-21 13:42:18 -05:00
mkfain 45dae078ac Update FAQs and guides for Fast Mode / Private Mode donations
Introduces per-campaign privacy-mode framing across all donor- and
activist-facing help content, ahead of the campaign-creation UI that
will actually expose the choice.

- FAQ (src/lib/helpContent.ts):
  - Rewrite send-bitcoin-onchain, connect-wallet, and
    donations-are-public-general to reflect per-campaign addresses
    (not npub-derived) and the two-mode model.
  - Replace why-not-silent-payments with silent-payments-supported
    (yes, via a non-custodial bridge that lets donors keep using any
    Bitcoin wallet).
  - Replace why-not-rotating-addresses with fast-vs-private-mode and
    add what-are-silent-payments, private-mode-reliability, and
    choose-mode-as-activist.
  - Soften censorship-resistance and why-onchain to acknowledge the
    bridge dependency in Private Mode without overclaiming.

- Donor Guide (src/lib/helpContent.ts):
  - Fold the silent-payments donor-side note into how-donating-works;
    flag the fresh-address-per-visit behaviour so donors don't read it
    as a phishing attempt.
  - Update why-public to clarify that donor-side advice still applies
    on Private Mode campaigns.

- Activist Guide (src/lib/helpContent.ts):
  - Rewrite how-receiving-works to introduce the per-campaign choice.
  - Add choose-receive-mode (with pros/cons), when-to-use-which,
    fast-mode-warning (absorbs the deleted page-level alert), and
    private-mode-failure-modes (concrete reliability disclosures).
  - Qualify why-public, dont-keep-funds, cashout-overview, and
    donors-can-be-seen with mode-specific framing. Cash-out sections
    themselves are unchanged.

- Page chrome (src/pages/DonorGuidePage.tsx, ActivistGuidePage.tsx):
  - Remove the amber 'Recommended for above-ground activism' alert from
    both pages. The state-actor warning now lives in
    fast-mode-warning, where it's contextually accurate (Private Mode
    is the actual mitigation, not 'read the sections below').
  - Drop the now-unused Alert/AlertTriangle imports.

No product code or campaign-creation flow is touched in this commit -
this is content-only, ahead of the UI work.
2026-05-21 13:40:30 -05:00
13 changed files with 913 additions and 337 deletions
-52
View File
@@ -1,52 +0,0 @@
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>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { AlertTriangle, CheckCircle2, Info, ShieldAlert } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { renderInlineMarkup } from '@/lib/helpMarkup';
import type { GuideCalloutBlock } from '@/lib/helpContent';
const VARIANT_STYLES: Record<
GuideCalloutBlock['variant'],
{ container: string; icon: string; title: string; Icon: LucideIcon }
> = {
info: {
container: 'border-sky-500/40 bg-sky-500/5',
icon: 'text-sky-600 dark:text-sky-400',
title: 'text-sky-700 dark:text-sky-300',
Icon: Info,
},
warning: {
container: 'border-amber-500/40 bg-amber-500/5',
icon: 'text-amber-600 dark:text-amber-400',
title: 'text-amber-700 dark:text-amber-300',
Icon: AlertTriangle,
},
danger: {
container: 'border-red-500/40 bg-red-500/5',
icon: 'text-red-600 dark:text-red-400',
title: 'text-red-700 dark:text-red-300',
Icon: ShieldAlert,
},
success: {
container: 'border-emerald-500/40 bg-emerald-500/5',
icon: 'text-emerald-600 dark:text-emerald-400',
title: 'text-emerald-700 dark:text-emerald-300',
Icon: CheckCircle2,
},
};
/**
* Tinted callout card with an icon, short title, and one-paragraph body.
* The four variants map to common semantic intents (info, warning, danger,
* success) and share the same layout so the page reads as a consistent
* rhythm of blocks rather than a parade of different shapes.
*/
export function CalloutCard({ block }: { block: GuideCalloutBlock }) {
const styles = VARIANT_STYLES[block.variant];
const { Icon } = styles;
return (
<div
className={cn(
'rounded-xl border p-5 sm:p-6 flex gap-4',
styles.container,
)}
>
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full bg-background/70',
styles.icon,
)}
>
<Icon className="size-5" />
</div>
<div className="flex-1 min-w-0">
<p className={cn('font-semibold leading-snug mb-1', styles.title)}>
{renderInlineMarkup(block.title)}
</p>
<p className="text-sm text-foreground/85 leading-relaxed">
{renderInlineMarkup(block.body)}
</p>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { renderInlineMarkup } from '@/lib/helpMarkup';
import type { GuideProseBlock } from '@/lib/helpContent';
/**
* Plain prose escape hatch \u2014 used sparingly when nothing in the visual
* kit fits. Renders an optional heading and short paragraphs.
*/
export function GuideProse({ block }: { block: GuideProseBlock }) {
return (
<section>
{block.heading && (
<h2 className="text-lg font-bold tracking-tight mb-3">{block.heading}</h2>
)}
<div className="space-y-3 text-sm leading-relaxed text-foreground/85">
{block.paragraphs.map((p, i) => (
<p key={i}>{renderInlineMarkup(p)}</p>
))}
</div>
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { renderInlineMarkup } from '@/lib/helpMarkup';
import type { GuideStepsBlock } from '@/lib/helpContent';
/**
* Numbered vertical flow of short steps. Each step gets a primary-tinted
* circle on the left with its index, then title + body to the right.
*
* Visual goal: replace 3\u20134 paragraphs of "first X, then Y" prose with a
* scannable list that can be read in seconds.
*/
export function GuideSteps({ block }: { block: GuideStepsBlock }) {
return (
<section>
<h2 className="text-lg font-bold tracking-tight mb-4">{block.heading}</h2>
<ol className="space-y-4">
{block.steps.map((step, i) => (
<li key={i} className="flex gap-4">
<span
aria-hidden="true"
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary font-semibold text-sm"
>
{i + 1}
</span>
<div className="flex-1 min-w-0 pt-0.5">
<p className="font-semibold text-foreground leading-snug">
{renderInlineMarkup(step.title)}
</p>
<p className="text-sm text-muted-foreground leading-relaxed mt-1">
{renderInlineMarkup(step.body)}
</p>
</div>
</li>
))}
</ol>
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Check } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { renderInlineMarkup } from '@/lib/helpMarkup';
import type { GuideTldrBlock } from '@/lib/helpContent';
/**
* Top-of-page summary card. Renders the lede on the left and a checklist of
* 2\u20133 next actions on the right (stacked on mobile). Sets the page's
* promise in a single screen.
*/
export function GuideTLDR({ block }: { block: GuideTldrBlock }) {
return (
<Card className="overflow-hidden border-primary/20 bg-gradient-to-br from-primary/5 via-card to-card">
<div className="grid gap-5 p-5 sm:p-6 sm:grid-cols-[1fr_auto] sm:gap-8 sm:items-center">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary/80 mb-2">
The short version
</p>
<p className="text-lg sm:text-xl font-medium leading-snug text-foreground">
{renderInlineMarkup(block.lede)}
</p>
</div>
<ul className="space-y-2 sm:min-w-[220px]">
{block.nextActions.map((action, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-foreground/85"
>
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
<Check className="size-3" strokeWidth={3} />
</span>
<span className="leading-snug">{renderInlineMarkup(action)}</span>
</li>
))}
</ul>
</div>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils';
import type { CampaignMode } from '@/lib/helpContent';
interface InlineModeBadgeProps {
mode: CampaignMode;
className?: string;
}
/**
* Small inline pill that visually distinguishes Fast Mode and Private Mode
* wherever they're mentioned in guide copy or table headers.
*
* Fast Mode uses the project's primary accent (orange-ish). Private Mode uses
* an indigo/violet tint so the two read as visually different at a glance
* without either looking like a warning state.
*/
export function InlineModeBadge({ mode, className }: InlineModeBadgeProps) {
const label = mode === 'fast' ? 'Fast Mode' : 'Private Mode';
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold leading-none',
mode === 'fast'
? 'bg-primary/15 text-primary border border-primary/30'
: 'bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/30',
className,
)}
>
{label}
</span>
);
}
@@ -0,0 +1,172 @@
import { Eye, EyeOff, Gauge, ShieldCheck, Sparkles, Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { renderInlineMarkup } from '@/lib/helpMarkup';
import { InlineModeBadge } from './InlineModeBadge';
import type { GuideModeComparisonBlock } from '@/lib/helpContent';
interface Row {
label: string;
Icon: LucideIcon;
fast: string;
private: string;
}
const DONOR_ROWS: Row[] = [
{
label: 'What you see',
Icon: Eye,
fast: 'Same Bitcoin address every visit.',
private: 'A fresh Bitcoin address each visit.',
},
{
label: 'Confirmation time',
Icon: Gauge,
fast: '~10\u201330 min, normal Bitcoin behavior.',
private: '2\u20133\u00d7 slower than Fast Mode.',
},
{
label: 'Activist privacy',
Icon: ShieldCheck,
fast: 'None. Every donation is publicly tied to the campaign.',
private: 'Strong. Receiving side is unlinkable on-chain.',
},
{
label: 'Your job',
Icon: Sparkles,
fast: 'Pay the address from any wallet.',
private: 'Pay the address from any wallet. Same as Fast Mode.',
},
];
const ACTIVIST_ROWS: Row[] = [
{
label: 'How it works',
Icon: Sparkles,
fast: 'One static Bitcoin address per campaign.',
private: 'Silent payments. Each donor sees a fresh `bc1\u2026` address.',
},
{
label: 'Speed & reliability',
Icon: Gauge,
fast: 'Fast confirmations. Every wallet works.',
private: '2\u20133\u00d7 slower. Depends on the bridge being online.',
},
{
label: 'Donor list visible?',
Icon: Users,
fast: 'Yes. Amounts and sending addresses are public forever.',
private: 'No. No single address shows the campaign\u2019s history.',
},
{
label: 'Best for',
Icon: ShieldCheck,
fast: 'Above-ground fundraisers where being seen is the point.',
private: 'Campaigns where donor or activist privacy is a real risk.',
},
{
label: 'Watch out for',
Icon: EyeOff,
fast: 'Permanent public record of every donor.',
private: 'More failure modes. No 100% delivery guarantee.',
},
];
/**
* Side-by-side comparison of Fast Mode vs. Private Mode.
*
* - Desktop (`sm:` and up): two-column grid with row labels on the left,
* Fast tinted in primary, Private tinted in indigo.
* - Mobile: collapses to two stacked tinted cards (one per mode) with the
* same row labels inside each card. No sideways scrolling.
*
* Row content is driven by the `audience` flag so donors and activists get
* row copy tuned to what they care about.
*/
export function ModeComparisonTable({ block }: { block: GuideModeComparisonBlock }) {
const rows = block.audience === 'donor' ? DONOR_ROWS : ACTIVIST_ROWS;
return (
<section>
{/* ── Desktop: aligned 3-column grid ──────────────────────────── */}
<div className="hidden sm:block">
<div className="rounded-xl border overflow-hidden">
{/* Header row */}
<div className="grid grid-cols-[1.1fr_1fr_1fr] bg-secondary/40 border-b">
<div className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{block.audience === 'donor' ? 'When you donate' : 'When you create'}
</div>
<div className="px-4 py-3 border-l">
<InlineModeBadge mode="fast" />
</div>
<div className="px-4 py-3 border-l">
<InlineModeBadge mode="private" />
</div>
</div>
{/* Body rows */}
{rows.map((row, i) => (
<div
key={row.label}
className={cn(
'grid grid-cols-[1.1fr_1fr_1fr]',
i < rows.length - 1 && 'border-b',
)}
>
<div className="px-4 py-3 flex items-center gap-2 text-sm font-medium text-foreground">
<row.Icon className="size-4 text-muted-foreground shrink-0" />
{row.label}
</div>
<div className="px-4 py-3 border-l text-sm text-foreground/85 leading-snug">
{renderInlineMarkup(row.fast)}
</div>
<div className="px-4 py-3 border-l text-sm text-foreground/85 leading-snug">
{renderInlineMarkup(row.private)}
</div>
</div>
))}
</div>
</div>
{/* ── Mobile: two stacked tinted cards ───────────────────────── */}
<div className="grid gap-3 sm:hidden">
<ModeStack mode="fast" rows={rows} />
<ModeStack mode="private" rows={rows} />
</div>
{block.footnote && (
<p className="text-xs text-muted-foreground mt-3 leading-relaxed">
{renderInlineMarkup(block.footnote)}
</p>
)}
</section>
);
}
function ModeStack({ mode, rows }: { mode: 'fast' | 'private'; rows: Row[] }) {
return (
<div
className={cn(
'rounded-xl border overflow-hidden',
mode === 'fast' ? 'border-primary/30 bg-primary/[0.04]' : 'border-indigo-500/30 bg-indigo-500/[0.04]',
)}
>
<div className="px-4 py-3 border-b border-inherit">
<InlineModeBadge mode={mode} />
</div>
<dl className="divide-y divide-border/60">
{rows.map((row) => (
<div key={row.label} className="px-4 py-3">
<dt className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<row.Icon className="size-3.5 shrink-0" />
{row.label}
</dt>
<dd className="mt-1 text-sm text-foreground/85 leading-snug">
{renderInlineMarkup(mode === 'fast' ? row.fast : row.private)}
</dd>
</div>
))}
</dl>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { ExternalLink } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { renderInlineMarkup } from '@/lib/helpMarkup';
import type { GuideOptionGridBlock, GuideOptionItem } from '@/lib/helpContent';
/**
* Two-column grid of compact OptionCards (single column on mobile). Used for
* the "donate privately" and "cash out" sections \u2014 condenses what used to
* be 4\u20136 individual long-form GuideSectionCards into a scannable tile grid.
*/
export function OptionGrid({ block }: { block: GuideOptionGridBlock }) {
return (
<section>
<h2 className="text-lg font-bold tracking-tight">{block.heading}</h2>
{block.intro && (
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{renderInlineMarkup(block.intro)}
</p>
)}
<div className="grid gap-3 sm:grid-cols-2 mt-4">
{block.options.map((option) => (
<OptionCard key={option.name} option={option} />
))}
</div>
</section>
);
}
function OptionCard({ option }: { option: GuideOptionItem }) {
const isLink = Boolean(option.href);
const inner = (
<Card
className={cn(
'h-full p-4 flex flex-col gap-3 border-border/70 motion-safe:transition-shadow motion-safe:duration-200',
isLink && 'group-hover:shadow-md group-hover:border-primary/40 motion-safe:transition-colors',
)}
>
<div className="flex items-start justify-between gap-2">
<p className="font-semibold text-foreground leading-snug">{option.name}</p>
{isLink && (
<ExternalLink
className="size-3.5 mt-1 shrink-0 text-muted-foreground motion-safe:transition-colors group-hover:text-primary"
aria-hidden="true"
/>
)}
</div>
<p className="text-sm text-muted-foreground leading-relaxed flex-1">
{renderInlineMarkup(option.purpose)}
</p>
{option.chips.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{option.chips.map((chip) => (
<li
key={chip}
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-secondary-foreground/80"
>
{chip}
</li>
))}
</ul>
)}
</Card>
);
if (isLink) {
return (
<a
href={option.href}
target="_blank"
rel="noopener noreferrer"
className="group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:hover:-translate-y-0.5"
>
{inner}
</a>
);
}
return inner;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Block primitives for the Donor Guide and Activist Guide pages. Each
* component takes the matching {@link GuideBlock} variant and renders it
* \u2014 the page just dispatches on `block.kind`.
*/
export { CalloutCard } from './CalloutCard';
export { GuideProse } from './GuideProse';
export { GuideSteps } from './GuideSteps';
export { GuideTLDR } from './GuideTLDR';
export { InlineModeBadge } from './InlineModeBadge';
export { ModeComparisonTable } from './ModeComparisonTable';
export { OptionGrid } from './OptionGrid';
+312 -185
View File
@@ -57,7 +57,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
question: 'What is {appName}?', question: 'What is {appName}?',
answer: [ answer: [
'{appName} is a platform for sending on-chain Bitcoin donations directly to activists. There\'s no middleman, no payment processor, and no account that can be frozen.', '{appName} is a platform for sending on-chain Bitcoin donations directly to activists. There\'s no middleman, no payment processor, and no account that can be frozen.',
'{appName} is built on Nostr, so your identity isn\'t locked to this site \u2014 you own it.', '{appName} is built on Nostr, so your identity isn\'t locked to this site, you own it.',
], ],
}, },
{ {
@@ -65,22 +65,14 @@ const FAQ_TEMPLATE: FAQCategory[] = [
question: 'What is Nostr?', question: 'What is Nostr?',
answer: [ answer: [
'Nostr is an open network where **you** own your account, not a company. Your identity is a cryptographic key you control, not a username on someone else\'s server.', 'Nostr is an open network where **you** own your account, not a company. Your identity is a cryptographic key you control, not a username on someone else\'s server.',
'On {appName}, that same key is also what your donation address is derived from \u2014 which is why you can receive Bitcoin without signing up with anyone.', 'On {appName}, that same key is also what your donation address is derived from, which is why you can receive Bitcoin without signing up with anyone.',
],
},
{
id: 'why-login-different',
question: 'Why is my sign-in so different and long?',
answer: [
'Instead of a username and password controlled by a company, Nostr uses a pair of cryptographic keys.',
'Your "public key" (starts with **npub**) is your username. Your "secret key" (starts with **nsec**) is your password. The long string is what makes it virtually impossible to guess.',
], ],
}, },
{ {
id: 'lose-secret-key', id: 'lose-secret-key',
question: 'What happens if I lose my secret key?', question: 'What happens if I lose my secret key?',
answer: [ answer: [
'**There is no "forgot password" button.** Nobody can reset it for you. If you lose it, your account \u2014 and any Bitcoin sitting at your donation address \u2014 is gone forever.', '**There is no "forgot password" button.** Nobody can reset it for you. If you lose it, your account, and any Bitcoin sitting at your donation address, is gone forever.',
'**Save your secret key somewhere safe right now.** For tips, read [Managing Your Nostr Keys](https://soapbox.pub/blog/managing-nostr-keys).', '**Save your secret key somewhere safe right now.** For tips, read [Managing Your Nostr Keys](https://soapbox.pub/blog/managing-nostr-keys).',
], ],
}, },
@@ -96,7 +88,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
id: 'cost-to-use', id: 'cost-to-use',
question: 'Does {appName} cost anything?', question: 'Does {appName} cost anything?',
answer: [ answer: [
'**No.** {appName} takes no platform fee. When you donate, you pay only the Bitcoin network fee that goes to miners \u2014 not to us.', '**No.** {appName} takes no platform fee. When you donate, you pay only the Bitcoin network fee that goes to miners, not to us.',
], ],
}, },
{ {
@@ -121,32 +113,76 @@ const FAQ_TEMPLATE: FAQCategory[] = [
id: 'send-bitcoin-onchain', id: 'send-bitcoin-onchain',
question: 'How does sending Bitcoin work?', question: 'How does sending Bitcoin work?',
answer: [ answer: [
'You send real Bitcoin on-chain directly to the activist. Your Nostr key is your wallet \u2014 no separate account, no top-up.', 'You send real Bitcoin on-chain directly to the campaign you\'re supporting. Your payment goes to the campaign\'s own Bitcoin address.',
'Some campaigns use **Fast Mode** (one public address that every donation lands on). Others use **Private Mode**, which shows you a fresh address each time you visit, bridged behind the scenes to the activist\'s silent-payment wallet. Either way, you just pay the address you see from any Bitcoin wallet.',
'You pay a small network fee to miners so the transaction gets confirmed. Once broadcast, it\'s public and irreversible.', 'You pay a small network fee to miners so the transaction gets confirmed. Once broadcast, it\'s public and irreversible.',
], ],
}, },
{ {
id: 'connect-wallet', id: 'connect-wallet',
question: 'What is the wallet on {appName}?', question: 'What address receives my donations?',
answer: [ answer: [
'Your {appName} wallet is an on-chain Bitcoin address derived from your Nostr key. There\'s nothing to sign up for \u2014 it exists the moment you have an account.', 'Donations arrive at an on-chain Bitcoin address belonging to the campaign. The activist picks one of two modes when they create the campaign:',
'Donations sent to you arrive at that address. To spend them, see the **Activist Guide**.', '**Fast Mode** uses a single static Bitcoin address for the whole campaign. Donations are reliable and arrive quickly, but the full donation history is publicly tied to that one address.',
'**Private Mode** uses silent payments: every donor sees a fresh, one-shot Bitcoin address that funnels into the activist\'s silent-payment wallet via {appName}\'s non-custodial bridge. Donations are slower and depend on the bridge, but the receiving side is unlinkable on-chain.',
'See the **Activist Guide** for how to choose, and the **Donor Guide** for what to expect when paying either kind of campaign.',
], ],
}, },
{ {
id: 'donations-are-public-general', id: 'donations-are-public-general',
question: 'Are donations on {appName} public?', question: 'Are donations on {appName} public?',
answer: [ 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.', 'It depends on the campaign\'s mode.',
'**Fast Mode:** fully public. Every donation, amount, time, and sending address, is recorded on the Bitcoin blockchain and on Nostr, and anyone can read the full history forever.',
'**Private Mode:** the activist\'s receiving side is hidden by silent payments. There\'s no single address an observer can watch to see who donated. The donor\'s sending side, however, still looks the same on-chain as any other Bitcoin transaction.',
'Read the **Donor Guide** and **Activist Guide** for what this means in practice and how to protect your privacy if you need to.', 'Read the **Donor Guide** and **Activist Guide** for what this means in practice and how to protect your privacy if you need to.',
], ],
}, },
{
id: 'fast-vs-private-mode',
question: 'What\'s the difference between Fast Mode and Private Mode?',
answer: [
'**Fast Mode** is a single static Bitcoin address per campaign. It\'s the fastest, most reliable option. Every Bitcoin wallet supports it, confirmations are normal, and there are no extra moving parts. The tradeoff is privacy: every donation is publicly tied to that one address forever.',
'**Private Mode** uses silent payments. Each donor sees a fresh, one-shot Bitcoin address that bridges into the activist\'s silent-payment wallet. The receiving side is unlinkable on-chain. The tradeoff is reliability: confirmations are 2\u20133\u00d7 slower, the bridge can fail or be slow, and there\'s no hard guarantee every donation arrives.',
'Donors don\'t need to know or care which mode a campaign uses. They just pay the Bitcoin address they\'re shown, from any wallet.',
],
},
{
id: 'what-are-silent-payments',
question: 'What are silent payments?',
answer: [
'Silent payments are a Bitcoin standard that lets a recipient publish **one re-usable identifier** while every actual donation lands on a **fresh, unlinkable address** on the blockchain. An outside observer can\'t scan the chain for "all donations to this campaign" the way they can with a regular Bitcoin address.',
'Normally, the donor\'s wallet has to support silent payments for this to work, and most consumer apps (Cash App, Coinbase, Strike, Venmo, PayPal) don\'t. {appName} solves that with a bridge: the donor sees a normal `bc1\u2026` address, pays it from any Bitcoin wallet, and the funds sweep through to the activist\'s silent-payment wallet automatically.',
],
},
{
id: 'silent-payments-supported',
question: 'Does {appName} support silent payments?',
answer: [
'Yes. Campaigns set to **Private Mode** use silent payments for receiving. Donors don\'t need a silent-payments-capable wallet because {appName}\'s bridge converts a normal Bitcoin address into a sweep to the activist\'s silent-payment wallet behind the scenes.',
],
},
{
id: 'private-mode-reliability',
question: 'How reliable is Private Mode?',
answer: [
'Private Mode is meaningfully more fragile than Fast Mode. Confirmations are typically **2\u20133\u00d7 slower**, the bridge service has to be online when the donation lands, and there are more moving parts that can fail.',
'We can\'t 100% guarantee that every Private Mode donation will arrive. For campaigns where speed and reliability matter most, **Fast Mode** is the right choice. For campaigns where donor and activist privacy matter most, **Private Mode** is.',
],
},
{
id: 'choose-mode-as-activist',
question: 'How do I choose between Fast Mode and Private Mode?',
answer: [
'You pick when you create a campaign. The choice is per-campaign, not per-account, and there\'s no default. The **Activist Guide** walks through who each mode is for and the tradeoffs.',
],
},
{ {
id: 'censorship-resistance', id: 'censorship-resistance',
question: 'What does "censorship-resistant" mean here?', question: 'What does "censorship-resistant" mean here?',
answer: [ answer: [
'No company sits between a donor and an activist. {appName} doesn\'t hold the funds and can\'t freeze the address.', 'No company sits between a donor and an activist. {appName} doesn\'t hold the funds and can\'t freeze a campaign\'s address.',
'As long as the Bitcoin network is running, donations can be sent and received. {appName} itself going offline wouldn\'t stop them.', 'In **Fast Mode**, the campaign\'s address lives entirely on the Bitcoin network, so {appName} going offline wouldn\'t stop donations to it. **Private Mode** depends on {appName}\'s bridge service to convert addresses, so prolonged bridge downtime can delay donations until the bridge is back. In both cases, no third party can reverse, seize, or freeze funds once they\'re on-chain.',
], ],
}, },
{ {
@@ -154,33 +190,17 @@ const FAQ_TEMPLATE: FAQCategory[] = [
question: 'Why on-chain Bitcoin?', question: 'Why on-chain Bitcoin?',
answer: [ answer: [
'On-chain Bitcoin is the most widely supported and censorship-resistant payment rail in the world. Every Bitcoin wallet can send it.', 'On-chain Bitcoin is the most widely supported and censorship-resistant payment rail in the world. Every Bitcoin wallet can send it.',
'It requires **zero extra setup** for activists once they have a Nostr account, and **zero extra setup** for donors who already hold Bitcoin. That accessibility is what makes {appName} actually viable for normal people to use every day.', 'It requires **zero extra setup** for donors who already hold Bitcoin, regardless of which mode a campaign uses. That accessibility is what makes {appName} actually viable for normal people to use every day.',
'The tradeoff is that on-chain transactions are public and pay a miner fee. The Donor and Activist guides explain how to handle both.', 'The tradeoff is that on-chain transactions are public (mitigated by Private Mode on the receiving side) and pay a miner fee. The Donor and Activist guides explain how to handle both.',
], ],
}, },
{ {
id: 'why-not-lightning', id: 'why-not-lightning',
question: 'Why doesn\'t {appName} use Lightning?', question: 'Why doesn\'t {appName} use Lightning?',
answer: [ answer: [
'Lightning requires a Lightning wallet. The easiest ones (Wallet of Satoshi, Strike, Breez) are **custodial** \u2014 a company holds the funds and can be shut down, geo-blocked, or pressured into freezing accounts. Non-custodial Lightning is technically demanding and unreliable for newcomers.', 'Lightning requires a Lightning wallet. The easiest ones (Wallet of Satoshi, Strike, Breez) are **custodial**: a company holds the funds and can be shut down, geo-blocked, or pressured into freezing accounts. Non-custodial Lightning is technically demanding and unreliable for newcomers.',
'We want {appName} to work for someone whose only Bitcoin experience is a regular consumer app like Cash App, Coinbase, Strike, Venmo, or PayPal. On-chain Bitcoin works with every wallet on the planet.', 'We want {appName} to work for every person who currently holds Bitcoin, or is willing to get it. Lightning provides too much of a barrier for most.',
], 'Cashing out matters too. Activists need to actually convert donations into money they can spend. On-chain Bitcoin can be reliably sold or swapped almost anywhere in the world; Lightning liquidity, in contrast, is patchy. Routes fail, channels run dry, and custodial Lightning wallets can freeze your funds or refuse to let you withdraw. We won\'t build a donation pipeline that activists can\'t reliably get out of.',
},
{
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 consumer apps \u2014 Cash App, Coinbase, Strike, Venmo, PayPal, 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: [
'Doing this would require {appName} to act as a money-exchanging middleman \u2014 taking custody of the Bitcoin first and then forwarding it on to the activist.',
'That would make us a money transmitter, subject to the regulations that come with that, and a single point of failure: shut down {appName}\'s server and you\'ve shut down every donation flowing through it.',
'Instead, each user\'s donation address is derived from their Nostr public key. Donors send directly to the activist, {appName} never touches the funds, and the platform itself can\'t be turned off to censor anyone.',
], ],
}, },
{ {
@@ -188,7 +208,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
question: 'Why not Monero or another cryptocurrency?', question: 'Why not Monero or another cryptocurrency?',
answer: [ answer: [
'Bitcoin is by far the most widely adopted cryptocurrency. That means it\'s the easiest for donors to buy and send, and the easiest for activists to receive, hold, and spend.', 'Bitcoin is by far the most widely adopted cryptocurrency. That means it\'s the easiest for donors to buy and send, and the easiest for activists to receive, hold, and spend.',
'Privacy-focused coins like Monero solve some problems on-chain Bitcoin doesn\'t, but they\'re unsupported by most consumer apps and harder to convert back to local currency. Asking either side of a donation to first acquire a niche cryptocurrency is a barrier {appName} won\'t put in the way.', 'Privacy-focused coins like Monero solve some problems on-chain Bitcoin doesn\'t, but they\'re unsupported by most consumer apps and harder to convert back to local currency. Asking either side of a donation to first acquire a niche cryptocurrency is a barrier {appName} won\'t put in the way. **Private Mode** with silent payments gives activists meaningful receiving-side privacy without forcing donors onto a niche chain.',
], ],
}, },
], ],
@@ -209,7 +229,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
question: 'How does sending Bitcoin over Lightning work?', question: 'How does sending Bitcoin over Lightning work?',
answer: [ answer: [
'If a recipient has a Lightning address on their profile, you can send to that. Lightning settles in seconds and fees are tiny.', 'If a recipient has a Lightning address on their profile, you can send to that. Lightning settles in seconds and fees are tiny.',
'Lightning sends don\'t use {appName}\'s donation address \u2014 they go straight to whatever Lightning wallet the recipient set up themselves. {appName}\'s own donation flow is on-chain only.', 'Lightning sends don\'t use {appName}\'s donation address. They go straight to whatever Lightning wallet the recipient set up themselves. {appName}\'s own donation flow is on-chain only.',
], ],
}, },
{ {
@@ -230,7 +250,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
id: 'what-are-relays', id: 'what-are-relays',
question: 'What are relays?', question: 'What are relays?',
answer: [ answer: [
'Relays are the servers that store and deliver Nostr events \u2014 posts, donation receipts, profile info. The defaults work out of the box; you can add or remove relays in Settings > Network.', 'Relays are the servers that store and deliver Nostr events: posts, donation receipts, profile info. The defaults work out of the box; you can add or remove relays in Settings > Network.',
], ],
}, },
{ {
@@ -258,7 +278,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
id: 'profile-fields', id: 'profile-fields',
question: 'What are profile fields?', question: 'What are profile fields?',
answer: [ answer: [
'Profile fields let you add extra info to your profile \u2014 links, wallet addresses, music, photos, videos.', 'Profile fields let you add extra info to your profile: links, wallet addresses, music, photos, videos.',
], ],
}, },
], ],
@@ -326,189 +346,296 @@ export { TEAM_SOAPBOX as TEAM_SOAPBOX_PACK } from '@/lib/agoraDefaults';
// ── Donor / Activist guide content ──────────────────────────────────────────── // ── Donor / Activist guide content ────────────────────────────────────────────
/** /**
* A single section inside a long-form guide page (Donor Guide / Activist * The Donor Guide and Activist Guide pages are composed from a typed sequence
* Guide). Each section renders as a Card on the guide page. * of {@link GuideBlock}s. Each block kind is rendered by a dedicated component
* from `@/components/guide/` \u2014 the page just dispatches on `block.kind`.
* *
* `paragraphs` accept the same inline markup as FAQ answers (**bold** and * String fields may contain the same inline markup as FAQ answers
* [link](url)), rendered by `renderInlineMarkup` from `@/lib/helpMarkup`. * (`**bold**` and `[link](url)`), and the `{appName}` placeholder, both
* * resolved at read-time by the `getDonorGuideBlocks` / `getActivistGuideBlocks`
* `pros` / `cons` are optional and render as a bullet pair underneath the * helpers below.
* 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. */ /** "Fast Mode" or "Private Mode" \u2014 used by table headers and inline badges. */
id: string; export type CampaignMode = 'fast' | 'private';
/** Section heading. */
/**
* Top-of-page summary card. One-sentence lede, plus 2\u20133 chip-style
* next-actions that orient the reader without making them scroll.
*/
export interface GuideTldrBlock {
kind: 'tldr';
lede: string;
nextActions: string[];
}
/** Numbered vertical flow of 2\u20134 short steps. */
export interface GuideStepsBlock {
kind: 'steps';
heading: string; heading: string;
/** Body paragraphs, in order. */ steps: { title: string; body: string }[];
}
/**
* The Fast Mode vs. Private Mode comparison table. Rendered as a real two-column
* table on desktop and as two stacked tinted cards on mobile (no sideways
* scroll). Audience controls row copy: donors see "what to expect when paying,"
* activists see "what to choose."
*/
export interface GuideModeComparisonBlock {
kind: 'modeComparison';
audience: 'donor' | 'activist';
/** Optional one-line footnote rendered under the table. */
footnote?: string;
}
/** Single-line callout block with a tinted background and an icon. */
export interface GuideCalloutBlock {
kind: 'callout';
variant: 'info' | 'warning' | 'danger' | 'success';
title: string;
body: string;
}
/** A short prose paragraph block (escape hatch for the rare "needs words"). */
export interface GuideProseBlock {
kind: 'prose';
heading?: string;
paragraphs: string[]; 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[] = [ /** A single tile inside a {@link GuideOptionGridBlock}. */
export interface GuideOptionItem {
/** Tile heading. */
name: string;
/** One-sentence purpose / payoff. */
purpose: string;
/** Short tag chips (e.g. `non-custodial`, `low fees`). */
chips: string[];
/** Optional external URL the tile links to. */
href?: string;
}
/** Grid of compact OptionCard tiles \u2014 used for cash-out and privacy options. */
export interface GuideOptionGridBlock {
kind: 'optionGrid';
heading: string;
intro?: string;
options: GuideOptionItem[];
}
export type GuideBlock =
| GuideTldrBlock
| GuideStepsBlock
| GuideModeComparisonBlock
| GuideCalloutBlock
| GuideProseBlock
| GuideOptionGridBlock;
const DONOR_GUIDE_TEMPLATE: GuideBlock[] = [
{ {
id: 'how-donating-works', kind: 'tldr',
heading: 'How donating works', lede: 'Pay the Bitcoin address you see on the campaign page from any wallet. That\u2019s it.',
paragraphs: [ nextActions: [
'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.', 'Pay from any Bitcoin wallet',
'You pay a small network fee to Bitcoin miners. Once the transaction is broadcast, it\'s public and irreversible.', 'Expect 10\u201330 min confirmation',
'Want privacy? Read below',
], ],
}, },
{ {
id: 'why-public', kind: 'steps',
heading: 'Why your donation is public', heading: 'How a donation flows',
paragraphs: [ steps: [
'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 \u2014 a consumer app like Cash App, Coinbase, Strike, Venmo, PayPal, Kraken, or Binance. That link is what ties a donation to your real identity.', title: 'Open the campaign',
body: 'You\u2019ll see a Bitcoin address and a QR code.',
},
{
title: 'Pay it from any wallet',
body: 'Cash App, Coinbase, Strike, a hardware wallet, anything. Pay the amount plus the network fee.',
},
{
title: 'It arrives directly',
body: 'The funds land in the campaign\u2019s wallet. No middleman, no holding period.',
},
], ],
}, },
{ {
id: 'privacy-non-kyc', kind: 'modeComparison',
heading: 'For privacy: use non-KYC Bitcoin', audience: 'donor',
paragraphs: [ footnote: 'A fresh address on a Private Mode campaign isn\u2019t a phishing attempt. It\u2019s how the activist stays private.',
'Buy Bitcoin peer-to-peer so it isn\'t linked to your government ID. [Bisq](https://bisq.network) and [HodlHodl](https://hodlhodl.com) let you trade on-chain Bitcoin directly with another person. [RoboSats](https://learn.robosats.com) is Lightning-only, so you\'d swap to Lightning with [Boltz](https://boltz.exchange) and then receive on RoboSats.',
],
pros: ['No exchange knows who you are.', 'Strongest privacy starting point.'],
cons: ['Slower and harder than a consumer app.', 'Requires finding a counterparty.'],
}, },
{ {
id: 'privacy-coinjoin', kind: 'callout',
heading: 'For privacy: coinjoin before donating', variant: 'warning',
paragraphs: [ title: 'Your donation is visible on-chain',
'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.', body: 'In **Fast Mode**, every donation lands at the same public campaign address, so anyone watching can link your sending address to that campaign forever. In **Private Mode**, the campaign\u2019s receiving side is hidden by silent payments, so observers can\u2019t enumerate who donated to it. Your sending side still looks like a normal Bitcoin transaction in both cases, but Private Mode breaks the most direct link between you and the cause.',
],
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', kind: 'optionGrid',
heading: 'Use a fresh wallet', heading: 'Donating privately',
paragraphs: [ intro: 'These steps matter most when donating to **Fast Mode** campaigns, where every donation is permanently tied to a single public address. For **Private Mode** campaigns the risk is lower, because no public address shows the campaign\u2019s donor list, so casual observers can\u2019t see that you donated at all. Targeted analysis of your sending wallet is still possible in both cases, so if your sender risk is high, these steps are still worth taking. Pick one, or stack them.',
'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.', options: [
'Free options include [Sparrow](https://sparrowwallet.com) on desktop and [BlueWallet](https://bluewallet.io) on mobile.', {
name: 'Buy non-KYC Bitcoin',
purpose: 'Buy Bitcoin peer-to-peer so it isn\u2019t linked to your ID in the first place.',
chips: ['peer-to-peer', 'no ID', 'strongest start'],
href: 'https://bisq.network',
},
{
name: 'Coinjoin before donating',
purpose: 'Mix your Bitcoin with other people\u2019s coins so the output can\u2019t be traced to your KYC purchase.',
chips: ['non-custodial', 'mixes coins'],
href: 'https://wasabiwallet.io',
},
{
name: 'Use a fresh wallet',
purpose: 'Donate from a wallet that has never touched your main identity or a KYC exchange.',
chips: ['free', 'non-custodial', 'easiest'],
href: 'https://sparrowwallet.com',
},
], ],
}, },
{ {
id: 'vary-amounts', kind: 'callout',
heading: 'Vary amounts and timing', variant: 'danger',
paragraphs: [ title: 'Consumer apps can\u2019t make you anonymous',
'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.', body: 'Cash App, Coinbase, Strike, Venmo, and PayPal all verify your ID. No matter how you send the donation, every transaction stays tied to your real identity. Use a non-custodial wallet you control.',
],
},
{
id: 'what-consumer-apps-cant-do',
heading: 'What consumer apps can\'t do',
paragraphs: [
'Consumer apps like Cash App, Coinbase, Strike, Venmo, and PayPal are convenient, but they require ID verification and tie every transaction 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[] = [ const ACTIVIST_GUIDE_TEMPLATE: GuideBlock[] = [
{ {
id: 'how-receiving-works', kind: 'tldr',
heading: 'How receiving works', lede: 'Pick Fast Mode or Private Mode when you create your campaign. Both are non-custodial. {appName} never holds your funds.',
paragraphs: [ nextActions: [
'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.', 'Compare the two modes',
'Plan how you\u2019ll cash out',
'Sweep funds promptly',
], ],
}, },
{ {
id: 'why-public', kind: 'modeComparison',
heading: 'Why incoming donations are public', audience: 'activist',
footnote: 'You can\u2019t switch a campaign\u2019s mode after it\u2019s created. If you change your mind, make a new campaign.',
},
{
kind: 'prose',
heading: 'Your donation history and future supporters',
paragraphs: [ 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.', 'In **Fast Mode**, anyone considering supporting your campaign can look up the address and see the full donation history. A thin or uneven record affects how new donors decide to give.',
'In **Private Mode**, that history isn\u2019t directly visible. New donors only see whatever you publish about the campaign\u2019s progress.',
], ],
}, },
{ {
id: 'dont-keep-funds', kind: 'steps',
heading: 'Don\'t keep funds at your {appName} address', heading: 'Move donations promptly',
paragraphs: [ steps: [
'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).', title: 'Sweep to a wallet you control',
body: 'Good self-custody options: [Sparrow](https://sparrowwallet.com), [BlueWallet](https://bluewallet.io), or [Phoenix](https://phoenix.acinq.co) (Lightning).',
},
{
title: 'Don\u2019t sit on funds at the campaign address',
body: 'Treat it like a mailbox, not a savings account, in both Fast Mode and Private Mode.',
},
], ],
}, },
{ {
id: 'cashout-overview', kind: 'optionGrid',
heading: 'Cashing out privately \u2014 overview', heading: 'Cashing out privately',
paragraphs: [ intro: 'Private Mode hides who paid you, but spending on-chain still creates a trail unless you break it first. Pick a path that fits your situation.',
'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.', options: [
{
name: 'Lightning swap',
purpose: 'Atomic-swap on-chain Bitcoin to Lightning. Lightning payments don\u2019t hit the public blockchain.',
chips: ['non-custodial', 'easy', 'low fees'],
href: 'https://boltz.exchange',
},
{
name: 'Coinjoin',
purpose: 'Mix your Bitcoin with other users\u2019 coins so the output can\u2019t be linked back to the input.',
chips: ['non-custodial', 'high privacy'],
href: 'https://wasabiwallet.io',
},
{
name: 'Peer-to-peer',
purpose: 'Trade Bitcoin for fiat directly with another person on Bisq, HodlHodl, or RoboSats.',
chips: ['cash', 'no KYC'],
href: 'https://bisq.network',
},
{
name: 'Spend it directly',
purpose: 'Buy gift cards (Amazon, Uber, groceries, travel) straight from Bitcoin without converting to cash first.',
chips: ['skip cash-out', 'instant'],
href: 'https://www.bitrefill.com/us/en/',
},
], ],
}, },
{ {
id: 'cashout-lightning-swap', kind: 'callout',
heading: 'Lightning swap (Boltz, Bolt.exchange)', variant: 'danger',
paragraphs: [ title: 'Avoid centralized tumblers',
'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.', body: 'Custodial mixers can steal your coins, log who sent what, or turn out to be law-enforcement honeypots. Use coinjoin (non-custodial) instead.',
],
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. [Bisq](https://bisq.network) and [HodlHodl](https://hodlhodl.com) trade on-chain Bitcoin. [RoboSats](https://learn.robosats.com) is Lightning-only \u2014 swap your on-chain Bitcoin to Lightning first with [Boltz](https://boltz.exchange), then sell on RoboSats. No exchange records your identity either way.',
],
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, HodlHodl, RoboSats via Boltz):** 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. */ /** Substitute placeholders in a single guide block. */
function substituteGuideSection(section: GuideSection, appName: string): GuideSection { function substituteGuideBlock(block: GuideBlock, appName: string): GuideBlock {
return { switch (block.kind) {
...section, case 'tldr':
heading: substitute(section.heading, appName), return {
paragraphs: section.paragraphs.map((p) => substitute(p, appName)), ...block,
pros: section.pros?.map((p) => substitute(p, appName)), lede: substitute(block.lede, appName),
cons: section.cons?.map((c) => substitute(c, appName)), nextActions: block.nextActions.map((a) => substitute(a, appName)),
}; };
case 'steps':
return {
...block,
heading: substitute(block.heading, appName),
steps: block.steps.map((s) => ({
title: substitute(s.title, appName),
body: substitute(s.body, appName),
})),
};
case 'modeComparison':
return {
...block,
footnote: block.footnote ? substitute(block.footnote, appName) : undefined,
};
case 'callout':
return {
...block,
title: substitute(block.title, appName),
body: substitute(block.body, appName),
};
case 'prose':
return {
...block,
heading: block.heading ? substitute(block.heading, appName) : undefined,
paragraphs: block.paragraphs.map((p) => substitute(p, appName)),
};
case 'optionGrid':
return {
...block,
heading: substitute(block.heading, appName),
intro: block.intro ? substitute(block.intro, appName) : undefined,
options: block.options.map((o) => ({
...o,
name: substitute(o.name, appName),
purpose: substitute(o.purpose, appName),
chips: o.chips.map((c) => substitute(c, appName)),
})),
};
}
} }
/** Donor guide sections with `{appName}` resolved. */ /** Donor guide blocks with `{appName}` resolved. */
export function getDonorGuideSections(appName: string): GuideSection[] { export function getDonorGuideBlocks(appName: string): GuideBlock[] {
return DONOR_GUIDE_TEMPLATE.map((s) => substituteGuideSection(s, appName)); return DONOR_GUIDE_TEMPLATE.map((b) => substituteGuideBlock(b, appName));
} }
/** Activist guide sections with `{appName}` resolved. */ /** Activist guide blocks with `{appName}` resolved. */
export function getActivistGuideSections(appName: string): GuideSection[] { export function getActivistGuideBlocks(appName: string): GuideBlock[] {
return ACTIVIST_GUIDE_TEMPLATE.map((s) => substituteGuideSection(s, appName)); return ACTIVIST_GUIDE_TEMPLATE.map((b) => substituteGuideBlock(b, appName));
} }
+38 -33
View File
@@ -1,21 +1,26 @@
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { GuideHero } from '@/components/GuideHero'; import { GuideHero } from '@/components/GuideHero';
import { GuideSectionCard } from '@/components/GuideSectionCard'; import {
CalloutCard,
GuideProse,
GuideSteps,
GuideTLDR,
ModeComparisonTable,
OptionGrid,
} from '@/components/guide';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useLayoutOptions } from '@/contexts/LayoutContext';
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers'; import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
import { getActivistGuideSections } from '@/lib/helpContent'; import { getActivistGuideBlocks, type GuideBlock } from '@/lib/helpContent';
import { HOPE_PALETTE } from '@/lib/hopePalette'; import { HOPE_PALETTE } from '@/lib/hopePalette';
/** /**
* Activist Guide long-form companion to the Help page. * Activist Guide \u2014 the long-form companion to the Help page.
* *
* Explains how receiving donations works on Agora, why incoming donations * The page body is composed from a typed sequence of `GuideBlock`s defined
* are public, and the main paths for cashing out privately. Linked from * in `src/lib/helpContent.ts`. Each block kind has a dedicated component;
* `/help` as one of the two large guide buttons. * this page just hands each block to the right one.
*/ */
export function ActivistGuidePage() { export function ActivistGuidePage() {
const { config } = useAppContext(); const { config } = useAppContext();
@@ -26,48 +31,48 @@ export function ActivistGuidePage() {
description: `How to receive donations on ${config.appName} and cash out privately.`, description: `How to receive donations on ${config.appName} and cash out privately.`,
}); });
const sections = getActivistGuideSections(config.appName); const blocks = getActivistGuideBlocks(config.appName);
return ( return (
<main className="min-h-screen pb-16 sidebar:pb-0"> <main className="min-h-screen pb-16 sidebar:pb-0">
<GuideHero <GuideHero
title="Activist Guide" title="Activist Guide"
subtitle="How to receive donations on Agora and move funds privately when you need to." subtitle="Pick a mode, receive donations, cash out. Plain English, no walls of text."
images={ACTIVIST_HERO_IMAGES} images={ACTIVIST_HERO_IMAGES}
palette={HOPE_PALETTE} palette={HOPE_PALETTE}
/> />
<div className="px-4 pt-4 pb-4 space-y-4 max-w-3xl mx-auto"> <div className="px-4 pt-6 pb-4 space-y-6 max-w-2xl mx-auto">
{/* Above-ground recommendation alert */} {blocks.map((block, i) => (
<Alert className="border-amber-500/50 [&>svg]:text-amber-500"> <GuideBlockRenderer key={i} block={block} />
<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>
{/* Sections */}
{sections.map((section) => (
<GuideSectionCard key={section.id} section={section} />
))} ))}
</div> </div>
</main> </main>
); );
} }
/** Dispatches a single block to the correct visual component. */
function GuideBlockRenderer({ block }: { block: GuideBlock }) {
switch (block.kind) {
case 'tldr':
return <GuideTLDR block={block} />;
case 'steps':
return <GuideSteps block={block} />;
case 'modeComparison':
return <ModeComparisonTable block={block} />;
case 'callout':
return <CalloutCard block={block} />;
case 'optionGrid':
return <OptionGrid block={block} />;
case 'prose':
return <GuideProse block={block} />;
}
}
/** /**
* Hero images for the Activist Guide. Reuses the protest / action cover * Hero images for the Activist Guide. Reuses the protest / action cover
* gallery already used by the Actions page hero raised fists, people * gallery already used by the Actions page hero \u2014 raised fists, people
* power, freedom imagery so the page reads as belonging to activists, * power, freedom imagery \u2014 so the page reads as belonging to activists,
* not just generic "users." * not just generic "users."
*/ */
const ACTIVIST_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map( const ACTIVIST_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map(
+37 -31
View File
@@ -1,20 +1,25 @@
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { GuideHero } from '@/components/GuideHero'; import { GuideHero } from '@/components/GuideHero';
import { GuideSectionCard } from '@/components/GuideSectionCard'; import {
CalloutCard,
GuideProse,
GuideSteps,
GuideTLDR,
ModeComparisonTable,
OptionGrid,
} from '@/components/guide';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useLayoutOptions } from '@/contexts/LayoutContext';
import { getDonorGuideSections } from '@/lib/helpContent'; import { getDonorGuideBlocks, type GuideBlock } from '@/lib/helpContent';
import { COOL_PALETTE } from '@/lib/hopePalette'; import { COOL_PALETTE } from '@/lib/hopePalette';
/** /**
* Donor Guide long-form companion to the Help page. * Donor Guide \u2014 the long-form companion to the Help page.
* *
* Explains how on-chain donations on Agora work, why they are publicly * The page body is composed from a typed sequence of `GuideBlock`s defined
* visible, and what a donor can do if they need privacy. Linked from * in `src/lib/helpContent.ts`. Each block kind has a dedicated component;
* `/help` as one of the two large guide buttons. * this page just hands each block to the right one.
*/ */
export function DonorGuidePage() { export function DonorGuidePage() {
const { config } = useAppContext(); const { config } = useAppContext();
@@ -25,46 +30,47 @@ export function DonorGuidePage() {
description: `How donating works on ${config.appName} and how to protect your privacy.`, description: `How donating works on ${config.appName} and how to protect your privacy.`,
}); });
const sections = getDonorGuideSections(config.appName); const blocks = getDonorGuideBlocks(config.appName);
return ( return (
<main className="min-h-screen pb-16 sidebar:pb-0"> <main className="min-h-screen pb-16 sidebar:pb-0">
<GuideHero <GuideHero
title="Donor Guide" title="Donor Guide"
subtitle="Real Bitcoin, sent directly. Here's how it works and how to do it privately." subtitle="Real Bitcoin, sent directly. The whole flow in one short page."
images={DONOR_HERO_IMAGES} images={DONOR_HERO_IMAGES}
palette={COOL_PALETTE} palette={COOL_PALETTE}
/> />
<div className="px-4 pt-4 pb-4 space-y-4 max-w-3xl mx-auto"> <div className="px-4 pt-6 pb-4 space-y-6 max-w-2xl mx-auto">
{/* Above-ground recommendation alert */} {blocks.map((block, i) => (
<Alert className="border-amber-500/50 [&>svg]:text-amber-500"> <GuideBlockRenderer key={i} block={block} />
<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>
{/* Sections */}
{sections.map((section) => (
<GuideSectionCard key={section.id} section={section} />
))} ))}
</div> </div>
</main> </main>
); );
} }
/** Dispatches a single block to the correct visual component. */
function GuideBlockRenderer({ block }: { block: GuideBlock }) {
switch (block.kind) {
case 'tldr':
return <GuideTLDR block={block} />;
case 'steps':
return <GuideSteps block={block} />;
case 'modeComparison':
return <ModeComparisonTable block={block} />;
case 'callout':
return <CalloutCard block={block} />;
case 'optionGrid':
return <OptionGrid block={block} />;
case 'prose':
return <GuideProse block={block} />;
}
}
/** /**
* Hero images for the Donor Guide. Reuses the World Liberty Congress * Hero images for the Donor Guide. Reuses the World Liberty Congress
* event photos already in `/public/hero/` they read as "community of * event photos already in `/public/hero/` \u2014 they read as "community of
* supporters," which fits a donor-facing page. Same assets used by the * supporters," which fits a donor-facing page. Same assets used by the
* Organize and Communities homepage heroes, so we get free preload * Organize and Communities homepage heroes, so we get free preload
* caching across the app. * caching across the app.
+56 -36
View File
@@ -1,13 +1,25 @@
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { AlertTriangle, ChevronRight, HandHeart, HelpCircle, Megaphone, Shield } from 'lucide-react'; import { HandHeart, HelpCircle, Megaphone, Shield } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Card } from '@/components/ui/card';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useLayoutOptions } from '@/contexts/LayoutContext';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { TeamSoapboxCard } from '@/components/TeamSoapboxCard'; import { TeamSoapboxCard } from '@/components/TeamSoapboxCard';
import { HelpFAQSection } from '@/components/HelpFAQSection'; import { HelpFAQSection } from '@/components/HelpFAQSection';
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
import { cn } from '@/lib/utils';
/** Cover image used on the Donor Guide button. Mirrors the Donor Guide hero. */
const DONOR_GUIDE_COVER = '/hero/wlc-1.webp';
/**
* Cover image used on the Activist Guide button. Picks the same "Raised
* Fists" image that opens the Activist Guide's hero gallery, so the button
* visually previews the destination.
*/
const ACTIVIST_GUIDE_COVER = DEFAULT_ACTION_COVERS[0].url;
export function HelpPage() { export function HelpPage() {
const { config } = useAppContext(); const { config } = useAppContext();
@@ -22,36 +34,21 @@ export function HelpPage() {
<main className="min-h-screen pb-16 sidebar:pb-0"> <main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Help" icon={<HelpCircle className="size-5" />} /> <PageHeader title="Help" icon={<HelpCircle className="size-5" />} />
{/* Top-of-page disclaimer: first thing visitors see */} {/* Two large guide cards */}
<div className="px-4 pt-2"> <div className="px-4 pt-4 grid gap-4 sm:grid-cols-2">
<Alert className="border-amber-500/50 [&>svg]:text-amber-500"> <GuideCard
<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" to="/help/donors"
icon={<HandHeart className="size-6" />} icon={<HandHeart className="size-5" />}
title="Donor Guide" title="Donor Guide"
description="How to support activists privately and safely." description="How to support activists privately and safely."
cover={DONOR_GUIDE_COVER}
/> />
<GuideButton <GuideCard
to="/help/activists" to="/help/activists"
icon={<Megaphone className="size-6" />} icon={<Megaphone className="size-5" />}
title="Activist Guide" title="Activist Guide"
description="Receiving donations and cashing out privately." description="Receiving donations and cashing out privately."
cover={ACTIVIST_GUIDE_COVER}
/> />
</div> </div>
@@ -83,27 +80,50 @@ export function HelpPage() {
); );
} }
interface GuideButtonProps { interface GuideCardProps {
to: string; to: string;
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
description: string; description: string;
cover: string;
className?: string;
} }
function GuideButton({ to, icon, title, description }: GuideButtonProps) { /**
* Visually echoes `CampaignCard` so the Help page's two long-form guides
* read like content cards rather than utility buttons.
*/
function GuideCard({ to, icon, title, description, cover, className }: GuideCardProps) {
return ( return (
<Link <Link
to={to} 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" className={cn(
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
className,
)}
> >
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"> <Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
{icon} {/* Cover image */}
</div> <div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<div className="flex-1 min-w-0"> <img
<p className="font-semibold leading-snug">{title}</p> src={cover}
<p className="text-sm text-muted-foreground leading-snug">{description}</p> alt=""
</div> loading="lazy"
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /> className="absolute inset-0 size-full object-cover motion-safe:transition-transform motion-safe:duration-300 group-hover:scale-[1.02]"
/>
{/* Bottom gradient for legibility of the icon badge */}
<div className="absolute inset-0 bg-gradient-to-t from-background/40 via-transparent to-transparent pointer-events-none" />
<div className="absolute top-3 left-3 flex size-10 items-center justify-center rounded-full bg-background/90 backdrop-blur text-primary shadow-sm">
{icon}
</div>
</div>
{/* Body */}
<div className="flex flex-col gap-1.5 p-5 flex-1">
<h3 className="font-bold leading-tight tracking-tight text-lg">{title}</h3>
<p className="text-sm text-muted-foreground leading-snug">{description}</p>
</div>
</Card>
</Link> </Link>
); );
} }