Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00509f979a | |||
| a7550f3e49 | |||
| 65e9bd72a1 | |||
| a0c3e34e14 | |||
| f3b95157dc | |||
| bd8e0b5c5c | |||
| 4d827e01f4 | |||
| 4c32b93f5e | |||
| 45dae078ac |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
+313
-186
@@ -57,7 +57,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
|
||||
question: 'What is {appName}?',
|
||||
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 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?',
|
||||
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.',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
'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: 'lose-secret-key',
|
||||
question: 'What happens if I lose my secret key?',
|
||||
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).',
|
||||
],
|
||||
},
|
||||
@@ -96,7 +88,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
|
||||
id: 'cost-to-use',
|
||||
question: 'Does {appName} cost anything?',
|
||||
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',
|
||||
question: 'How does sending Bitcoin work?',
|
||||
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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'connect-wallet',
|
||||
question: 'What is the wallet on {appName}?',
|
||||
question: 'What address receives my donations?',
|
||||
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 sent to you arrive at that address. To spend them, see the **Activist Guide**.',
|
||||
'Donations arrive at an on-chain Bitcoin address belonging to the campaign. The activist picks one of two modes when they create the campaign:',
|
||||
'**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',
|
||||
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.',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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',
|
||||
question: 'What does "censorship-resistant" mean here?',
|
||||
answer: [
|
||||
'No company sits between a donor and an activist. {appName} doesn\'t hold the funds and can\'t freeze the address.',
|
||||
'As long as the Bitcoin network is running, donations can be sent and received. {appName} itself going offline wouldn\'t stop them.',
|
||||
'No company sits between a donor and an activist. {appName} doesn\'t hold the funds and can\'t freeze a campaign\'s address.',
|
||||
'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?',
|
||||
answer: [
|
||||
'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.',
|
||||
'The tradeoff is that on-chain transactions are public and pay a miner fee. The Donor and Activist guides explain how to handle both.',
|
||||
'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 (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',
|
||||
question: 'Why doesn\'t {appName} use Lightning?',
|
||||
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.',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
'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 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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,7 +208,7 @@ const FAQ_TEMPLATE: FAQCategory[] = [
|
||||
question: 'Why not Monero or another cryptocurrency?',
|
||||
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.',
|
||||
'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?',
|
||||
answer: [
|
||||
'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',
|
||||
question: 'What are relays?',
|
||||
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',
|
||||
question: 'What are profile fields?',
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single section inside a long-form guide page (Donor Guide / Activist
|
||||
* Guide). Each section renders as a Card on the guide page.
|
||||
* The Donor Guide and Activist Guide pages are composed from a typed sequence
|
||||
* 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
|
||||
* [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.
|
||||
* String fields may contain the same inline markup as FAQ answers
|
||||
* (`**bold**` and `[link](url)`), and the `{appName}` placeholder, both
|
||||
* resolved at read-time by the `getDonorGuideBlocks` / `getActivistGuideBlocks`
|
||||
* helpers below.
|
||||
*/
|
||||
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[];
|
||||
|
||||
/** "Fast Mode" or "Private Mode" \u2014 used by table headers and inline badges. */
|
||||
export type CampaignMode = 'fast' | 'private';
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
const DONOR_GUIDE_TEMPLATE: GuideSection[] = [
|
||||
/** Numbered vertical flow of 2\u20134 short steps. */
|
||||
export interface GuideStepsBlock {
|
||||
kind: 'steps';
|
||||
heading: string;
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 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',
|
||||
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.',
|
||||
kind: 'tldr',
|
||||
lede: 'Pay the Bitcoin address you see on the campaign page from any wallet. That\u2019s it.',
|
||||
nextActions: [
|
||||
'Pay from any Bitcoin wallet',
|
||||
'Expect 10\u201330 min confirmation',
|
||||
'Want privacy? Read below',
|
||||
],
|
||||
},
|
||||
{
|
||||
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 \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.',
|
||||
kind: 'steps',
|
||||
heading: 'How a donation flows',
|
||||
steps: [
|
||||
{
|
||||
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',
|
||||
heading: 'For privacy: use non-KYC Bitcoin',
|
||||
paragraphs: [
|
||||
'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.'],
|
||||
kind: 'modeComparison',
|
||||
audience: 'donor',
|
||||
footnote: 'A fresh address on a Private Mode campaign isn\u2019t a phishing attempt. It\u2019s how the activist stays private.',
|
||||
},
|
||||
{
|
||||
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.'],
|
||||
kind: 'callout',
|
||||
variant: 'warning',
|
||||
title: 'Your donation is visible on-chain',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
kind: 'optionGrid',
|
||||
heading: 'Donating privately',
|
||||
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.',
|
||||
options: [
|
||||
{
|
||||
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',
|
||||
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-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.',
|
||||
],
|
||||
kind: 'callout',
|
||||
variant: 'danger',
|
||||
title: 'Consumer apps can\u2019t make you anonymous',
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVIST_GUIDE_TEMPLATE: GuideSection[] = [
|
||||
const ACTIVIST_GUIDE_TEMPLATE: GuideBlock[] = [
|
||||
{
|
||||
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.',
|
||||
kind: 'tldr',
|
||||
lede: 'Pick Fast Mode or Private Mode when you create your campaign. Both are non-custodial. {appName} never holds your funds.',
|
||||
nextActions: [
|
||||
'Compare the two modes',
|
||||
'Plan how you\u2019ll cash out',
|
||||
'Sweep funds promptly',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'why-public',
|
||||
heading: 'Why incoming donations are public',
|
||||
kind: 'modeComparison',
|
||||
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: [
|
||||
'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',
|
||||
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).',
|
||||
kind: 'steps',
|
||||
heading: 'Move donations promptly',
|
||||
steps: [
|
||||
{
|
||||
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',
|
||||
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.',
|
||||
kind: 'optionGrid',
|
||||
heading: 'Cashing out privately',
|
||||
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.',
|
||||
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',
|
||||
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. [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.',
|
||||
],
|
||||
kind: 'callout',
|
||||
variant: 'danger',
|
||||
title: 'Avoid centralized tumblers',
|
||||
body: 'Custodial mixers can steal your coins, log who sent what, or turn out to be law-enforcement honeypots. Use coinjoin (non-custodial) instead.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Substitute placeholders in a single guide section. */
|
||||
function substituteGuideSection(section: GuideSection, appName: string): GuideSection {
|
||||
/** Substitute placeholders in a single guide block. */
|
||||
function substituteGuideBlock(block: GuideBlock, appName: string): GuideBlock {
|
||||
switch (block.kind) {
|
||||
case 'tldr':
|
||||
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)),
|
||||
...block,
|
||||
lede: substitute(block.lede, 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. */
|
||||
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));
|
||||
/** Donor guide blocks with `{appName}` resolved. */
|
||||
export function getDonorGuideBlocks(appName: string): GuideBlock[] {
|
||||
return DONOR_GUIDE_TEMPLATE.map((b) => substituteGuideBlock(b, appName));
|
||||
}
|
||||
|
||||
/** Activist guide blocks with `{appName}` resolved. */
|
||||
export function getActivistGuideBlocks(appName: string): GuideBlock[] {
|
||||
return ACTIVIST_GUIDE_TEMPLATE.map((b) => substituteGuideBlock(b, appName));
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
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 { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* are public, and the main paths for cashing out privately. Linked from
|
||||
* `/help` as one of the two large guide buttons.
|
||||
* The page body is composed from a typed sequence of `GuideBlock`s defined
|
||||
* in `src/lib/helpContent.ts`. Each block kind has a dedicated component;
|
||||
* this page just hands each block to the right one.
|
||||
*/
|
||||
export function ActivistGuidePage() {
|
||||
const { config } = useAppContext();
|
||||
@@ -26,48 +31,48 @@ export function ActivistGuidePage() {
|
||||
description: `How to receive donations on ${config.appName} and cash out privately.`,
|
||||
});
|
||||
|
||||
const sections = getActivistGuideSections(config.appName);
|
||||
const blocks = getActivistGuideBlocks(config.appName);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<GuideHero
|
||||
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}
|
||||
palette={HOPE_PALETTE}
|
||||
/>
|
||||
|
||||
<div className="px-4 pt-4 pb-4 space-y-4 max-w-3xl mx-auto">
|
||||
{/* 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>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((section) => (
|
||||
<GuideSectionCard key={section.id} section={section} />
|
||||
<div className="px-4 pt-6 pb-4 space-y-6 max-w-2xl mx-auto">
|
||||
{blocks.map((block, i) => (
|
||||
<GuideBlockRenderer key={i} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</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
|
||||
* gallery already used by the Actions page hero — raised fists, people
|
||||
* power, freedom imagery — so the page reads as belonging to activists,
|
||||
* gallery already used by the Actions page hero \u2014 raised fists, people
|
||||
* power, freedom imagery \u2014 so the page reads as belonging to activists,
|
||||
* not just generic "users."
|
||||
*/
|
||||
const ACTIVIST_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map(
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
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 { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { getDonorGuideSections } from '@/lib/helpContent';
|
||||
import { getDonorGuideBlocks, type GuideBlock } from '@/lib/helpContent';
|
||||
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
|
||||
* visible, and what a donor can do if they need privacy. Linked from
|
||||
* `/help` as one of the two large guide buttons.
|
||||
* The page body is composed from a typed sequence of `GuideBlock`s defined
|
||||
* in `src/lib/helpContent.ts`. Each block kind has a dedicated component;
|
||||
* this page just hands each block to the right one.
|
||||
*/
|
||||
export function DonorGuidePage() {
|
||||
const { config } = useAppContext();
|
||||
@@ -25,46 +30,47 @@ export function DonorGuidePage() {
|
||||
description: `How donating works on ${config.appName} and how to protect your privacy.`,
|
||||
});
|
||||
|
||||
const sections = getDonorGuideSections(config.appName);
|
||||
const blocks = getDonorGuideBlocks(config.appName);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<GuideHero
|
||||
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}
|
||||
palette={COOL_PALETTE}
|
||||
/>
|
||||
|
||||
<div className="px-4 pt-4 pb-4 space-y-4 max-w-3xl mx-auto">
|
||||
{/* 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>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((section) => (
|
||||
<GuideSectionCard key={section.id} section={section} />
|
||||
<div className="px-4 pt-6 pb-4 space-y-6 max-w-2xl mx-auto">
|
||||
{blocks.map((block, i) => (
|
||||
<GuideBlockRenderer key={i} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</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
|
||||
* 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
|
||||
* Organize and Communities homepage heroes, so we get free preload
|
||||
* caching across the app.
|
||||
|
||||
+52
-32
@@ -1,13 +1,25 @@
|
||||
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { TeamSoapboxCard } from '@/components/TeamSoapboxCard';
|
||||
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() {
|
||||
const { config } = useAppContext();
|
||||
@@ -22,36 +34,21 @@ export function HelpPage() {
|
||||
<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
|
||||
{/* Two large guide cards */}
|
||||
<div className="px-4 pt-4 grid gap-4 sm:grid-cols-2">
|
||||
<GuideCard
|
||||
to="/help/donors"
|
||||
icon={<HandHeart className="size-6" />}
|
||||
icon={<HandHeart className="size-5" />}
|
||||
title="Donor Guide"
|
||||
description="How to support activists privately and safely."
|
||||
cover={DONOR_GUIDE_COVER}
|
||||
/>
|
||||
<GuideButton
|
||||
<GuideCard
|
||||
to="/help/activists"
|
||||
icon={<Megaphone className="size-6" />}
|
||||
icon={<Megaphone className="size-5" />}
|
||||
title="Activist Guide"
|
||||
description="Receiving donations and cashing out privately."
|
||||
cover={ACTIVIST_GUIDE_COVER}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -83,27 +80,50 @@ export function HelpPage() {
|
||||
);
|
||||
}
|
||||
|
||||
interface GuideButtonProps {
|
||||
interface GuideCardProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
title: 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 (
|
||||
<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"
|
||||
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">
|
||||
{/* Cover image */}
|
||||
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
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 className="flex-1 min-w-0">
|
||||
<p className="font-semibold leading-snug">{title}</p>
|
||||
</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>
|
||||
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user