Merge branch 'main' of gitlab.com:soapbox-pub/agora
This commit is contained in:
@@ -56,6 +56,8 @@ const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").the
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
|
||||
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
|
||||
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
@@ -299,6 +301,8 @@ export function AppRouter() {
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/help/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/help/activists" element={<ActivistGuidePage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -97,10 +97,10 @@ function SiteFooter() {
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
<a href="/help" className="hover:text-foreground motion-safe:transition-colors">Help</a>
|
||||
<a href="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</a>
|
||||
<a href="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</a>
|
||||
<a href="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</a>
|
||||
<Link to="/help" className="hover:text-foreground motion-safe:transition-colors">Help</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
|
||||
|
||||
interface GuideHeroProps {
|
||||
/** Large hero headline. */
|
||||
title: string;
|
||||
/** Short subtitle under the headline. */
|
||||
subtitle: string;
|
||||
/** Rotating banner images. Pass at least one. */
|
||||
images: readonly string[];
|
||||
/**
|
||||
* Color palette to cycle through for the atmospheric tint. Defaults
|
||||
* to {@link HOPE_PALETTE} (warm). Pass {@link COOL_PALETTE} for the
|
||||
* blue/green Organize-style vibe.
|
||||
*/
|
||||
palette?: readonly HopeHue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact photo hero shared by the Donor Guide and Activist Guide pages.
|
||||
*
|
||||
* Same structural recipe as the Organize / Actions homepage heroes
|
||||
* ({@link HeroBanner} + {@link HeroAtmosphere} + scrims + overlay copy),
|
||||
* but tuned smaller because these are sub-pages, not primary destinations.
|
||||
* Also embeds a "Back to Help" link in the top-left as the page's
|
||||
* primary navigation out — so a separate sticky bar isn't needed.
|
||||
*/
|
||||
export function GuideHero({
|
||||
title,
|
||||
subtitle,
|
||||
images,
|
||||
palette = HOPE_PALETTE,
|
||||
}: GuideHeroProps) {
|
||||
// Cycle through the palette on a slow cadence so the photo never
|
||||
// feels static even when a single banner image is on screen.
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (palette.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % palette.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [palette]);
|
||||
|
||||
const activeHue = palette[hueIndex];
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
|
||||
<HeroBanner images={images} />
|
||||
<HeroAtmosphere hue={activeHue} />
|
||||
|
||||
{/* Top + bottom scrims so the overlay text stays legible across
|
||||
every photo in the rotation. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-48 sm:h-56 pointer-events-none bg-gradient-to-b from-black/75 via-black/45 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-32 sm:h-40 pointer-events-none bg-gradient-to-t from-black/60 via-black/25 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8 min-h-[240px] sm:min-h-[280px] flex flex-col">
|
||||
{/* Back-to-Help action sits on its own row at the top so it
|
||||
doubles as both the navigation out and the breadcrumb. */}
|
||||
<div>
|
||||
<Link
|
||||
to="/help"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-black/30 hover:bg-black/45 backdrop-blur-sm border border-white/20 px-3 py-1.5 text-xs sm:text-sm font-medium text-white drop-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back to Help
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Headline + subtitle anchored to the bottom of the hero so the
|
||||
photo gets room to breathe up top. */}
|
||||
<div className="flex-1 min-h-[40px]" aria-hidden="true" />
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-white/85 drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { type GuideSection } from '@/lib/helpContent';
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
|
||||
/**
|
||||
* Renders a single {@link GuideSection} as a Card. Used by the Donor Guide
|
||||
* and Activist Guide pages.
|
||||
*
|
||||
* Paragraphs accept the same inline markup as FAQ answers (**bold** and
|
||||
* [link](url)). Optional `pros` / `cons` arrays render as colored bullet
|
||||
* lists beneath the paragraphs.
|
||||
*/
|
||||
export function GuideSectionCard({ section }: { section: GuideSection }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{section.heading}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm leading-relaxed text-foreground/80">
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={i}>{renderInlineMarkup(p)}</p>
|
||||
))}
|
||||
|
||||
{section.pros && section.pros.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-1">
|
||||
Pros
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{section.pros.map((p, i) => (
|
||||
<li key={i}>{renderInlineMarkup(p)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.cons && section.cons.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 mb-1">
|
||||
Cons
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{section.cons.map((c, i) => (
|
||||
<li key={i}>{renderInlineMarkup(c)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,54 +8,7 @@ import {
|
||||
} from '@/components/ui/accordion';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
|
||||
|
||||
// ── Inline markup renderer ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Very lightweight inline markup: **bold** and [text](url).
|
||||
* Returns an array of React nodes.
|
||||
*/
|
||||
function renderInlineMarkup(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
// Match **bold** or [text](url)
|
||||
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Push text before this match
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
// **bold**
|
||||
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
|
||||
} else if (match[2] !== undefined && match[3] !== undefined) {
|
||||
// [text](url)
|
||||
nodes.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Trailing text
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -93,6 +46,13 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H
|
||||
const filteredCategories = useMemo(() => {
|
||||
let cats: FAQCategory[] = getFAQCategories(config.appName);
|
||||
|
||||
// Drop hidden categories from the default render. They still exist in
|
||||
// the underlying template so `HelpTip` can look up individual items by
|
||||
// ID, but they don't show up in the FAQ accordion.
|
||||
if (!categories && !items) {
|
||||
cats = cats.filter((c) => !c.hidden);
|
||||
}
|
||||
|
||||
// Filter to specific categories
|
||||
if (categories) {
|
||||
cats = cats.filter((c) => categories.includes(c.id));
|
||||
|
||||
@@ -4,42 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getFAQItem } from '@/lib/helpContent';
|
||||
|
||||
/**
|
||||
* Renders **bold** and [text](url) markup in FAQ answer strings.
|
||||
*/
|
||||
function renderInlineMarkup(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
|
||||
} else if (match[2] !== undefined && match[3] !== undefined) {
|
||||
nodes.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -520,11 +520,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
<span className="text-muted-foreground shrink-0">·</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 max-h-[4.5em] overflow-hidden">
|
||||
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 overflow-wrap-anywhere">
|
||||
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
|
||||
<span className="italic">Encrypted content</span>
|
||||
) : (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
|
||||
<NoteContent event={event} className="text-sm leading-snug whitespace-normal" disableEmbeds disableNoteEmbeds as="span" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChartLegendContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TimeSeriesBucket } from './types';
|
||||
|
||||
interface ActivityChartProps {
|
||||
@@ -27,45 +28,49 @@ const chartConfig: ChartConfig = {
|
||||
|
||||
export function ActivityChart({ data }: ActivityChartProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Publishing Activity (5-min intervals)</h3>
|
||||
<ChartContainer config={chartConfig} className="aspect-[2.5/1] w-full">
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="posts"
|
||||
stroke="var(--color-posts)"
|
||||
fill="var(--color-posts)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="posters"
|
||||
stroke="var(--color-posters)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Publishing Activity (5-min intervals)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="aspect-[2.5/1] w-full">
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="posts"
|
||||
stroke="var(--color-posts)"
|
||||
fill="var(--color-posts)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="posters"
|
||||
stroke="var(--color-posters)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs skeleton */}
|
||||
<Skeleton className="h-9 w-52 rounded-md" />
|
||||
|
||||
{/* KPI grid skeleton */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -14,19 +18,27 @@ export function DashboardSkeleton() {
|
||||
</div>
|
||||
|
||||
{/* Chart skeleton */}
|
||||
<div className="rounded-2xl border border-border p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bar chart skeleton */}
|
||||
<div className="rounded-2xl border border-border p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-[140px] w-full rounded-lg" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[140px] w-full rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="rounded-lg border bg-card shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { DistributionSlice } from './types';
|
||||
|
||||
interface DistributionDonutProps {
|
||||
@@ -19,46 +20,50 @@ export function DistributionDonut({ data }: DistributionDonutProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Post Distribution</h3>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
<div className="w-full max-w-[200px]">
|
||||
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="55%"
|
||||
outerRadius="85%"
|
||||
strokeWidth={2}
|
||||
stroke="hsl(var(--background))"
|
||||
>
|
||||
{data.map((slice, i) => (
|
||||
<Cell key={i} fill={slice.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Post Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
<div className="w-full max-w-[200px]">
|
||||
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="55%"
|
||||
outerRadius="85%"
|
||||
strokeWidth={2}
|
||||
stroke="hsl(var(--background))"
|
||||
>
|
||||
{data.map((slice, i) => (
|
||||
<Cell key={i} fill={slice.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
<div className="flex-1 w-full space-y-2">
|
||||
{data.map((slice) => {
|
||||
const pct = total > 0 ? Math.round((slice.value / total) * 100) : 0;
|
||||
return (
|
||||
<div key={slice.name} className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="size-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: slice.fill }}
|
||||
/>
|
||||
<span className="flex-1 truncate font-medium">{slice.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{pct}%</span>
|
||||
<span className="font-semibold tabular-nums w-10 text-right">{slice.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 w-full space-y-2">
|
||||
{data.map((slice) => {
|
||||
const pct = total > 0 ? Math.round((slice.value / total) * 100) : 0;
|
||||
return (
|
||||
<div key={slice.name} className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="size-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: slice.fill }}
|
||||
/>
|
||||
<span className="flex-1 truncate font-medium">{slice.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{pct}%</span>
|
||||
<span className="font-semibold tabular-nums w-10 text-right">{slice.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ParticipantsList({ data, territorialLevel }: ParticipantsListPro
|
||||
const columnLabel = territorialLevel === 'states' ? 'State' : 'Municipality';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function RecentActivityList({ data }: RecentActivityListProps) {
|
||||
const paged = data.slice(page * ITEMS_PER_PAGE, (page + 1) * ITEMS_PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Recent Activity</h3>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { LeaderboardEntry, TerritorialLevel } from './types';
|
||||
|
||||
interface TopRegionsChartProps {
|
||||
@@ -33,38 +34,42 @@ export function TopRegionsChart({ data, territorialLevel }: TopRegionsChartProps
|
||||
const title = territorialLevel === 'states' ? 'Top 5 States' : 'Top 5 Municipalities';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 4, right: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<YAxis
|
||||
dataKey="label"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={100}
|
||||
tickMargin={4}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]} barSize={24}>
|
||||
{data.map((_, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={MEDAL_COLORS[i] ?? 'hsl(var(--primary) / 0.7)'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 4, right: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<YAxis
|
||||
dataKey="label"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={100}
|
||||
tickMargin={4}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]} barSize={24}>
|
||||
{data.map((_, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={MEDAL_COLORS[i] ?? 'hsl(var(--primary) / 0.7)'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,8 +243,8 @@ export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboa
|
||||
return [...muniFeeds, ...customFeeds];
|
||||
}, [regionFeeds, regionById, territorialLevel]);
|
||||
|
||||
// Helper: apply relay COUNT as a stable floor for state-level counts.
|
||||
// Used only in leaderboard/distribution, NOT in participants.
|
||||
// Applies the relay COUNT floor to displayed state-level counts.
|
||||
// No-op for municipalities.
|
||||
const getStableCount = useCallback((feed: AggregatedFeed): number =>
|
||||
territorialLevel === 'states'
|
||||
? Math.max(feed.count, stateCounts?.get(feed.code) ?? 0)
|
||||
@@ -326,21 +326,23 @@ export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboa
|
||||
}, [displayFeeds, getStableCount]);
|
||||
|
||||
// Participants (full sorted list).
|
||||
// Intentionally uses raw event-based counts (no COUNT floor) because each
|
||||
// row derives live/activity state from the loaded events themselves.
|
||||
// Counts use the same stable COUNT floor as leaderboard/distribution so
|
||||
// that per-state numbers are consistent across all dashboard sections.
|
||||
// Live/activity state still derives from the loaded events themselves.
|
||||
const participants = useMemo<ParticipantRow[]>(() => {
|
||||
const thirtySecondsAgo = now - 30;
|
||||
return [...displayFeeds]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((feed) => ({ ...feed, stableCount: getStableCount(feed) }))
|
||||
.sort((a, b) => b.stableCount - a.stableCount)
|
||||
.map((feed, i) => ({
|
||||
rank: i + 1,
|
||||
regionId: feed.regionId,
|
||||
label: feed.label,
|
||||
hashtag: feed.code,
|
||||
count: feed.count,
|
||||
count: feed.stableCount,
|
||||
isActive: feed.posts.length > 0 && feed.posts[0].created_at > thirtySecondsAgo,
|
||||
}));
|
||||
}, [displayFeeds, now]);
|
||||
}, [displayFeeds, getStableCount, now]);
|
||||
|
||||
// Activity items (from globalPosts with label resolution)
|
||||
const activity = useMemo<ActivityItem[]>(() => {
|
||||
|
||||
+310
-203
@@ -30,6 +30,13 @@ export interface FAQCategory {
|
||||
label: string;
|
||||
description?: string;
|
||||
items: FAQItem[];
|
||||
/**
|
||||
* If true, this category is excluded from the default `HelpFAQSection`
|
||||
* render. Used for legacy items kept around so existing `HelpTip` call
|
||||
* sites on other pages don't break, without exposing them in the public
|
||||
* FAQ accordion.
|
||||
*/
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||
@@ -40,307 +47,217 @@ export interface FAQCategory {
|
||||
* and friends.
|
||||
*/
|
||||
const FAQ_TEMPLATE: FAQCategory[] = [
|
||||
// ── Getting Started ─────────────────────────────────────────────────────
|
||||
// ── About Agora ─────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'getting-started',
|
||||
label: 'Getting Started',
|
||||
label: 'About Agora',
|
||||
items: [
|
||||
{
|
||||
id: 'what-is-ditto',
|
||||
question: 'What is {appName}?',
|
||||
answer: [
|
||||
'{appName} is a social media platform built on Nostr \u2014 a new kind of open, decentralized network. Think of {appName} as the app you\'re using right now to connect with people, post, and discover content.',
|
||||
'Because {appName} is built on Nostr, your account isn\'t locked to this site. You own your identity and can take it to any other Nostr app.',
|
||||
'{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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-is-nostr',
|
||||
question: 'What is Nostr?',
|
||||
answer: [
|
||||
'Nostr is a new kind of social network where **you** own your account, not a company. Think of it like email \u2014 you can use different apps, but your identity stays the same. Nobody can ban you from the entire network.',
|
||||
'Everything you post, every person you follow, and your entire identity is portable. You can take it with you anywhere. To learn more, check out [Nostr 101](https://soapbox.pub/blog/nostr101).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'login-other-apps',
|
||||
question: 'Can I log into other Nostr apps with my {appName} account?',
|
||||
answer: [
|
||||
'Yes! Your {appName} account **is** a Nostr account. You can use the same keys to log into any Nostr app \u2014 Primal, Damus, Amethyst, Coracle, and many more. Your posts, followers, and profile carry over everywhere.',
|
||||
'Explore the full range of Nostr apps at [nostrapps.com](https://nostrapps.com/).',
|
||||
'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 \u2014 like a really secure digital ID.',
|
||||
'Your "public key" (starts with **npub**) is your username that everyone can see. Your "secret key" (starts with **nsec**) is your password. The long string of characters is what makes it virtually impossible to hack.',
|
||||
'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',
|
||||
question: 'What happens if I lose my secret key?',
|
||||
answer: [
|
||||
'**There is no "forgot password" button.** No company stores your key or can reset it for you. If you lose it, your account is gone forever.',
|
||||
'This is the tradeoff for true ownership \u2014 nobody can take your account away, but nobody can recover it either. **Save your secret key somewhere safe right now.** For tips on keeping your key safe, read [Managing Your Nostr Keys](https://soapbox.pub/blog/managing-nostr-keys).',
|
||||
'**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.',
|
||||
'**Save your secret key somewhere safe right now.** For tips, read [Managing Your Nostr Keys](https://soapbox.pub/blog/managing-nostr-keys).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'manage-secret-key',
|
||||
question: 'Can I save my secret key in my phone\'s password manager?',
|
||||
answer: [
|
||||
'Yes! You can save it in your device\'s password manager (like iCloud Keychain, 1Password, or Bitwarden). On iPhone, if you save it correctly in Passwords, you can even use Face ID or Touch ID to log in.',
|
||||
'For a full guide on the best ways to store and manage your keys, check out [Managing Your Nostr Keys](https://soapbox.pub/blog/managing-nostr-keys).',
|
||||
'Yes. You can save it in your device\'s password manager (iCloud Keychain, 1Password, Bitwarden, etc.). On iPhone, saving it in Passwords lets you use Face ID or Touch ID to log in.',
|
||||
'For a full guide, see [Managing Your Nostr Keys](https://soapbox.pub/blog/managing-nostr-keys).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cost-to-use',
|
||||
question: 'Does {appName} cost anything?',
|
||||
answer: [
|
||||
'**Nope!** {appName} is completely free to use. Zaps (tips) are optional and just for fun. There are no premium tiers, no paywalls, no hidden fees.',
|
||||
'**No.** {appName} takes no platform fee. When you donate, you pay only the Bitcoin network fee that goes to miners \u2014 not to us.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'beginner-guide',
|
||||
question: 'Is there a step-by-step guide for getting started?',
|
||||
id: 'who-made-this',
|
||||
question: 'Who made {appName}?',
|
||||
answer: [
|
||||
'You\'re looking at it! This Help section covers everything you need. Start by saving your secret key, then explore your feed, follow some people, and try posting.',
|
||||
'Don\'t worry about getting everything perfect \u2014 you can always come back here.',
|
||||
'{appName} is built by [Soapbox](https://soapbox.pub), an open-source team building tools for the Nostr ecosystem.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Apps & Access ───────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'apps-access',
|
||||
label: 'Apps & Access',
|
||||
items: [
|
||||
{
|
||||
id: 'download-app',
|
||||
question: 'Can I download this on the App Store or Google Play?',
|
||||
answer: [
|
||||
'This site works as a web app right from your browser \u2014 no download needed! You can also "Add to Home Screen" on your phone to get an app-like experience.',
|
||||
'On Android, you can download {appName} from [Zap Store](https://zapstore.dev/apps/spot.agora.app), a community-driven app store for the Nostr ecosystem. iOS support is planned for the future \u2014 stay tuned!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'one-account-many-apps',
|
||||
question: 'Can I use my account on other apps?',
|
||||
answer: [
|
||||
'Yes! That\'s one of the best things about Nostr. Your account isn\'t locked to any single app.',
|
||||
'You can take your keys to Primal, Damus, Amethyst, Coracle, or any other Nostr app and everything carries over \u2014 your posts, your followers, all of it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nostr-app-store',
|
||||
question: 'Is there a Nostr-specific app store?',
|
||||
answer: [
|
||||
'Yes! [Zap Store](https://zapstore.dev/) is a community-driven app store built specifically for the Nostr ecosystem. You can discover and download Nostr apps, and the apps are verified by the community rather than a corporation. {appName} is listed there \u2014 [get it on Zap Store](https://zapstore.dev/apps/spot.agora.app).',
|
||||
'You can also browse a directory of Nostr apps at [nostrapps.com](https://nostrapps.com/).',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Payments & Zaps ─────────────────────────────────────────────────────
|
||||
// ── Bitcoin Donations on Agora ──────────────────────────────────────────
|
||||
// Merged section combining the practical "how it works" Q&A with the
|
||||
// "why we designed it this way" rationale. On-chain only — Lightning and
|
||||
// zap content is intentionally absent.
|
||||
{
|
||||
id: 'payments',
|
||||
label: 'Payments & Zaps',
|
||||
label: 'Bitcoin Donations on Agora',
|
||||
items: [
|
||||
{
|
||||
id: 'what-are-zaps',
|
||||
question: 'What are zaps?',
|
||||
answer: [
|
||||
'Zaps are tips! They let you send tiny amounts of Bitcoin to someone as a way of saying "great post" or "thanks."',
|
||||
'Think of it like a super-powered Like button that actually sends real money. They use the Lightning Network, which makes them instant and nearly free. To learn more, check out [Understanding Zaps](https://nostr.how/en/zaps).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send-bitcoin-onchain',
|
||||
question: 'How does sending Bitcoin work?',
|
||||
answer: [
|
||||
'This sends real Bitcoin on-chain, using your Nostr key as your wallet \u2014 no separate account, no top-up.',
|
||||
'Your send pays a small network fee to miners so the transaction gets confirmed. Faster confirmation costs a bit more; {appName} picks a sensible default.',
|
||||
'Once broadcast, it\'s public and irreversible. The creator\'s post gets tagged so they know the Bitcoin came from you.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send-bitcoin-lightning',
|
||||
question: 'How does sending Bitcoin over Lightning work?',
|
||||
answer: [
|
||||
'Lightning is a faster, cheaper layer built on top of Bitcoin. Payments settle in seconds and fees are usually fractions of a cent.',
|
||||
'You\'ll pay from your connected Lightning wallet. The creator receives the Bitcoin right away, and the payment is attached to their post as a zap so everyone can see the support.',
|
||||
'To learn more, check out [Understanding Zaps](https://nostr.how/en/zaps).',
|
||||
'You send real Bitcoin on-chain directly to the activist. Your Nostr key is your wallet \u2014 no separate account, no top-up.',
|
||||
'You pay a small network fee to miners so the transaction gets confirmed. Once broadcast, it\'s public and irreversible.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'connect-wallet',
|
||||
question: 'How do I connect a wallet?',
|
||||
question: 'What is the wallet on {appName}?',
|
||||
answer: [
|
||||
'To send or receive zaps, you need a Lightning wallet. Great options for beginners include [Alby](https://getalby.com/), [Zeus](https://zeusln.com/), and [Wallet of Satoshi](https://www.walletofsatoshi.com/).',
|
||||
'Once you have one, add your Lightning address to your profile settings, and you\'re ready to go.',
|
||||
'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**.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'only-bitcoin',
|
||||
question: 'Can I only use Bitcoin, or can I use regular money?',
|
||||
id: 'donations-are-public-general',
|
||||
question: 'Are donations on {appName} public?',
|
||||
answer: [
|
||||
'Zaps use Bitcoin\'s Lightning Network. If you don\'t have Bitcoin, you can skip zaps entirely \u2014 they\'re completely optional.',
|
||||
'If you\'re curious, most Lightning wallets let you buy small amounts of Bitcoin right inside the app.',
|
||||
'Yes. Every donation \u2014 given or received \u2014 is recorded on the public Bitcoin blockchain and on Nostr. Anyone can see the amounts, the timing, and the addresses involved.',
|
||||
'Read the **Donor Guide** and **Activist Guide** for what this means in practice and how to protect your privacy if you need to.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'why-onchain',
|
||||
question: 'Why on-chain Bitcoin?',
|
||||
answer: [
|
||||
'On-chain Bitcoin is the most widely supported and censorship-resistant payment rail in the world. Every Bitcoin wallet can send it.',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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-other-crypto',
|
||||
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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
'Generating a fresh address per donation would require {appName} to run a server that signs and serves addresses. That server becomes a single point of failure \u2014 someone could shut it down to silence activists.',
|
||||
'{appName} derives each user\'s donation address from their Nostr public key. No server is required, and the platform itself can\'t be turned off to censor anyone.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Content & Safety ────────────────────────────────────────────────────
|
||||
// ── Hidden legacy items ─────────────────────────────────────────────────
|
||||
// Kept so existing HelpTip call sites on other pages don't break, but
|
||||
// excluded from the visible FAQ. {appName} is on-chain only; Lightning,
|
||||
// zaps, and the network/safety topics aren't part of the public help
|
||||
// content right now.
|
||||
{
|
||||
id: 'content-safety',
|
||||
label: 'Content & Safety',
|
||||
id: 'legacy',
|
||||
label: 'Legacy',
|
||||
hidden: true,
|
||||
items: [
|
||||
{
|
||||
id: 'fyp',
|
||||
question: 'Will I have a "For You" page? How do I make my feed relevant?',
|
||||
id: 'send-bitcoin-lightning',
|
||||
question: 'How does sending Bitcoin over Lightning work?',
|
||||
answer: [
|
||||
'Your feed shows posts from people you follow \u2014 there\'s no algorithm deciding what you see. The more people you follow, the better your feed gets.',
|
||||
'Use the "Trends" page to discover popular content, and check out Follow Packs (curated groups of people) to quickly fill your feed with interesting voices.',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-are-zaps',
|
||||
question: 'What are zaps?',
|
||||
answer: [
|
||||
'Zaps are small Lightning tips on Nostr, separate from {appName}\'s on-chain donation flow.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fyp',
|
||||
question: 'How does the feed work?',
|
||||
answer: [
|
||||
'Your feed shows campaigns and posts from people you follow. There\'s no algorithm deciding what you see.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-are-relays',
|
||||
question: 'What are relays?',
|
||||
answer: [
|
||||
'Relays are the servers that store and deliver your posts. Think of them like different mail carriers \u2014 your messages get sent through them to reach other people.',
|
||||
'You don\'t need to think about relays to use Nostr; the defaults work great. But if you\'re curious, you can add or remove relays in Settings > Network.',
|
||||
'Using multiple relays means your content is backed up in more places, making it harder for anyone to silence you. To dive deeper, read [Understanding Nostr Relays](https://nostr.how/en/relays).',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-are-blossom',
|
||||
question: 'What are Blossom servers?',
|
||||
answer: [
|
||||
'Blossom servers are where your media files (photos, videos, audio) get stored when you upload them. Think of them like cloud storage for your files.',
|
||||
'Different Blossom servers are run by different people in different places. You can manage which servers you use in Settings > Network. To learn more about how Blossom works, read [The Blossom Protocol](https://onnostr.substack.com/p/the-blossom-protocol-supercharging).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'media-content',
|
||||
question: 'What happens to media I upload? Can it be removed?',
|
||||
answer: [
|
||||
'When you upload media to Nostr, it gets stored on a Blossom server. That server has the right to remove any content for any reason, including based on the laws of their region.',
|
||||
'This is why it\'s important to use multiple Blossom servers, manage your server connections, and make informed choices about where you store your data.',
|
||||
'Blossom servers store media files (campaign images, profile pictures) when you upload them. You can manage which servers you use in Settings > Network.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'report-content',
|
||||
question: 'How do I report harmful content?',
|
||||
answer: [
|
||||
'To report a post, tap the three-dot menu (**...**) on any post and select "Report." You can also mute or block individual users from the same menu.',
|
||||
'Because Nostr is decentralized, there\'s no single company reviewing reports \u2014 but relay operators can choose to remove content from their servers, and your mute list keeps your feed clean for you.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'terms-of-service',
|
||||
question: 'Are there terms of service I need to agree to?',
|
||||
answer: [
|
||||
'Nostr itself is a protocol (like email or the web) \u2014 it doesn\'t have terms of service. Individual relays and apps may have their own rules.',
|
||||
'Since no single entity controls the network, the community largely self-moderates. Think of it less like a walled garden and more like the open internet.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Profile & Identity ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'profile-identity',
|
||||
label: 'Profile & Identity',
|
||||
items: [
|
||||
{
|
||||
id: 'profile-fields',
|
||||
question: 'What are profile fields?',
|
||||
answer: [
|
||||
'Profile fields let you add extra info to your profile sidebar — like links, wallet addresses, music, photos, videos, and more. They\'re a way to express yourself and share what matters to you.',
|
||||
'You can add fields from the profile settings page. Each field has a **label** (what it\'s called) and a **value** (the content). For media fields, you can upload files directly and they\'ll render as players or embeds on your profile.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'profile-fields-music',
|
||||
question: 'What audio formats can I upload for music fields?',
|
||||
answer: [
|
||||
'You can upload audio files in these formats: **MP3**, **OGG**, **WAV**, **FLAC**, **AAC**, **M4A**, and **Opus**. They\'ll appear as a mini audio player on your profile sidebar.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'profile-fields-media',
|
||||
question: 'What image and video formats are supported?',
|
||||
answer: [
|
||||
'For images: **JPG**, **PNG**, **GIF**, **WebP**, **SVG**, and **AVIF**. For video: **MP4**, **WebM**, and **MOV**.',
|
||||
'Images will display as linked thumbnails, and videos will be embedded inline on your profile.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Why is this different from Big Tech? ────────────────────────────────
|
||||
{
|
||||
id: 'big-tech',
|
||||
label: 'Why Is This Different from Big Tech?',
|
||||
items: [
|
||||
{
|
||||
id: 'why-different',
|
||||
question: 'How is this different from Instagram, X, or Facebook?',
|
||||
answer: [
|
||||
'On traditional social media, a company owns your account, controls what you see, and can delete your profile at any time.',
|
||||
'On Nostr, **you** own your identity. No company can lock you out, shadowban you, or shut down your account. Your followers, your posts, and your identity belong to you \u2014 not a corporation. We take this seriously \u2014 read our [ethics pledge](https://soapbox.pub/ethics) to see what we stand for.',
|
||||
'Tap the three-dot menu (**...**) on any post and select "Report." You can mute or block users from the same menu.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vs-mastodon-bluesky',
|
||||
question: 'How is this different from Mastodon or Bluesky?',
|
||||
question: 'How is Nostr different from Mastodon or Bluesky?',
|
||||
answer: [
|
||||
'Mastodon and Bluesky are also alternatives to Big Tech, but they work very differently from Nostr. On Mastodon, your account is tied to a specific server \u2014 if that server shuts down or bans you, you lose your account and have to start over. On Bluesky, the network is technically decentralized but in practice almost everyone depends on a single company (bsky.social), which can block entire servers.',
|
||||
'Nostr is different because your identity is a cryptographic key that **you** control. It\'s not tied to any server, company, or app. No one can delete your account, and you can switch between apps freely while keeping your followers and posts.',
|
||||
'The good news is you don\'t have to choose just one \u2014 bridges like Mostr let you follow people across all three networks. For a deeper comparison, check out [Nostr vs. Fediverse vs. Bluesky](https://soapbox.pub/blog/comparing-protocols).',
|
||||
'On Mastodon, your account lives on a specific server. On Bluesky, most accounts depend on one company. On Nostr, your identity is a key you control, and your donation address goes with you to any Nostr app.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-is-decentralization',
|
||||
question: 'What does "decentralized" actually mean?',
|
||||
id: 'profile-fields',
|
||||
question: 'What are profile fields?',
|
||||
answer: [
|
||||
'It means there\'s no single company or server running everything. Nostr is a network of independent relays and apps, all speaking the same language.',
|
||||
'If one relay goes down or kicks you off, your account still works everywhere else. It\'s like the difference between one company owning all the roads vs. having thousands of independent roads anyone can build and use. For more on why this matters, read [The Future Is Decentralized](https://soapbox.pub/blog/future-is-decentralized).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'censorship-resistance',
|
||||
question: 'What does "censorship-resistant" mean?',
|
||||
answer: [
|
||||
'It means no single person, company, or government can stop you from posting.',
|
||||
'On traditional platforms, one decision by a content moderation team can erase your entire online presence. On Nostr, as long as there\'s at least one relay willing to host your content, you can keep posting. You may lose reach on some relays, but you can never be fully silenced.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'open-source',
|
||||
question: 'What does "open source" mean, and why does it matter?',
|
||||
answer: [
|
||||
'Open source means the code that powers this app is publicly available for anyone to read, verify, and improve. There are no hidden algorithms, no secret data collection, and no backdoors.',
|
||||
'Anyone can check exactly what the software does. It\'s the digital equivalent of a restaurant with a glass kitchen \u2014 nothing to hide. You can browse the [{appName} source code](https://gitlab.com/soapbox-pub/agora-3) yourself.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'self-host',
|
||||
question: 'Can I self-host {appName}?',
|
||||
answer: [
|
||||
'Yes! Because {appName} is open source, anyone can run their own instance. You get full control over your server, your data, and your community.',
|
||||
'If you\'re interested, check out the project README for self-hosting and deployment steps.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'who-made-this',
|
||||
question: 'Who made this?',
|
||||
answer: [
|
||||
'This platform is built by [Soapbox](https://soapbox.pub), a team of developers who believe social media should be owned by its users, not corporations.',
|
||||
'Soapbox builds open-source tools for the Nostr ecosystem. You can learn more about the team and their mission at [soapbox.pub](https://soapbox.pub).',
|
||||
'Profile fields let you add extra info to your profile \u2014 links, wallet addresses, music, photos, videos.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -404,3 +321,193 @@ export function getFAQItem(appName: string, itemId: string): FAQItem | undefined
|
||||
* canonical constant directly.
|
||||
*/
|
||||
export { TEAM_SOAPBOX as TEAM_SOAPBOX_PACK } from '@/lib/agoraDefaults';
|
||||
|
||||
// ── Donor / Activist guide content ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single section inside a long-form guide page (Donor Guide / Activist
|
||||
* Guide). Each section renders as a Card on the guide page.
|
||||
*
|
||||
* `paragraphs` accept the same inline markup as FAQ answers (**bold** and
|
||||
* [link](url)), rendered by `renderInlineMarkup` from `@/lib/helpMarkup`.
|
||||
*
|
||||
* `pros` / `cons` are optional and render as a bullet pair underneath the
|
||||
* paragraphs. They are used for tradeoff-heavy topics like cash-out methods.
|
||||
*/
|
||||
export interface GuideSection {
|
||||
/** Stable key, used for React keys and potential deep-linking. */
|
||||
id: string;
|
||||
/** Section heading. */
|
||||
heading: string;
|
||||
/** Body paragraphs, in order. */
|
||||
paragraphs: string[];
|
||||
/** Optional positives, rendered as a green-flavored bullet list. */
|
||||
pros?: string[];
|
||||
/** Optional negatives / caveats, rendered as an amber-flavored bullet list. */
|
||||
cons?: string[];
|
||||
}
|
||||
|
||||
const DONOR_GUIDE_TEMPLATE: GuideSection[] = [
|
||||
{
|
||||
id: 'how-donating-works',
|
||||
heading: 'How donating works',
|
||||
paragraphs: [
|
||||
'You send real Bitcoin on-chain directly to the activist. {appName} doesn\'t hold or route the money \u2014 the address you\'re paying is derived from the activist\'s Nostr key, so there\'s no middleman in between.',
|
||||
'You pay a small network fee to Bitcoin miners. Once the transaction is broadcast, it\'s public and irreversible.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'why-public',
|
||||
heading: 'Why your donation is public',
|
||||
paragraphs: [
|
||||
'Bitcoin is a public ledger. Anyone can look up an activist\'s address and see every donation \u2014 the amount, the time, and the address it came from.',
|
||||
'Your sending address can usually be traced back to wherever you bought the Bitcoin \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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'privacy-non-kyc',
|
||||
heading: 'For privacy: use non-KYC Bitcoin',
|
||||
paragraphs: [
|
||||
'Buy Bitcoin peer-to-peer so it isn\'t linked to your government ID. Marketplaces like [Bisq](https://bisq.network), [RoboSats](https://learn.robosats.com), and [HodlHodl](https://hodlhodl.com) let you trade directly with another person.',
|
||||
],
|
||||
pros: ['No exchange knows who you are.', 'Strongest privacy starting point.'],
|
||||
cons: ['Slower and harder than a consumer app.', 'Requires finding a counterparty.'],
|
||||
},
|
||||
{
|
||||
id: 'privacy-coinjoin',
|
||||
heading: 'For privacy: coinjoin before donating',
|
||||
paragraphs: [
|
||||
'A coinjoin mixes your Bitcoin with other people\'s coins so the output can\'t be linked back to the input. Wallets like [Wasabi](https://wasabiwallet.io), [Sparrow](https://sparrowwallet.com), and [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver) support this.',
|
||||
],
|
||||
pros: ['Breaks the on-chain trail from your KYC purchase.', 'Non-custodial \u2014 you keep your keys.'],
|
||||
cons: ['Costs fees and takes time.', 'Fewer maintained tools after the Samourai shutdown.'],
|
||||
},
|
||||
{
|
||||
id: 'fresh-wallet',
|
||||
heading: 'Use a fresh wallet',
|
||||
paragraphs: [
|
||||
'Donate from a wallet that has never touched a KYC exchange or your main identity. Even one shared transaction input can link the wallet back to you.',
|
||||
'Free options include [Sparrow](https://sparrowwallet.com) on desktop and [BlueWallet](https://bluewallet.io) on mobile.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vary-amounts',
|
||||
heading: 'Vary amounts and timing',
|
||||
paragraphs: [
|
||||
'Round numbers ($50, $100) and recurring donations create a pattern that\'s easy to fingerprint. Send unusual amounts at irregular times if you want to be harder to track.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'what-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[] = [
|
||||
{
|
||||
id: 'how-receiving-works',
|
||||
heading: 'How receiving works',
|
||||
paragraphs: [
|
||||
'Your {appName} donation address is derived from your Nostr public key. Donors send on-chain Bitcoin directly to it. No one stands between you and the funds, and no server can be shut down to stop the donations.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'why-public',
|
||||
heading: 'Why incoming donations are public',
|
||||
paragraphs: [
|
||||
'Bitcoin is a public ledger. Anyone can look up your address and see every donation \u2014 the amount, the time, and the sending address. Your supporters\' addresses are visible too.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dont-keep-funds',
|
||||
heading: 'Don\'t keep funds at your {appName} address',
|
||||
paragraphs: [
|
||||
'Move funds to a wallet you control as soon as practical. Treat your {appName} address like a mailbox, not a savings account.',
|
||||
'Good self-custody wallets to move funds into: [Sparrow](https://sparrowwallet.com), [BlueWallet](https://bluewallet.io), or [Phoenix](https://phoenix.acinq.co) (Lightning).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cashout-overview',
|
||||
heading: 'Cashing out privately \u2014 overview',
|
||||
paragraphs: [
|
||||
'To spend donations without revealing who you are, you have to break the on-chain trail before converting to cash. The next sections cover the main paths. Each has tradeoffs in custody, privacy, difficulty, and fees.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cashout-lightning-swap',
|
||||
heading: 'Lightning swap (Boltz, Bolt.exchange)',
|
||||
paragraphs: [
|
||||
'Services like [Boltz](https://boltz.exchange) atomic-swap your on-chain Bitcoin into Lightning. Lightning payments are private by default \u2014 they don\'t appear on the public blockchain.',
|
||||
],
|
||||
pros: ['Instant and non-custodial.', 'Lightning payments aren\'t publicly traceable.'],
|
||||
cons: ['Per-swap limits and swap fees.', 'Depends on the swap service being online.'],
|
||||
},
|
||||
{
|
||||
id: 'cashout-coinjoin',
|
||||
heading: 'Coinjoin',
|
||||
paragraphs: [
|
||||
'A coinjoin mixes your Bitcoin with other users\' coins so the output can\'t be linked to the input. [Wasabi](https://wasabiwallet.io) and [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver) are the main maintained options after the Samourai shutdown.',
|
||||
],
|
||||
pros: ['Strong on-chain unlinkability.', 'Non-custodial.'],
|
||||
cons: ['Fees and wait time.', 'Steeper learning curve than a swap.'],
|
||||
},
|
||||
{
|
||||
id: 'cashout-p2p',
|
||||
heading: 'Peer-to-peer exchange',
|
||||
paragraphs: [
|
||||
'Trade Bitcoin for fiat directly with another person on [Bisq](https://bisq.network), [RoboSats](https://learn.robosats.com), or [HodlHodl](https://hodlhodl.com). No exchange records your identity.',
|
||||
],
|
||||
pros: ['Cash in hand without KYC.', 'No central exchange knows you.'],
|
||||
cons: ['Slower than an exchange.', 'Requires a willing counterparty.', 'Some learning curve.'],
|
||||
},
|
||||
{
|
||||
id: 'cashout-tumblers',
|
||||
heading: 'Tumblers and centralized mixers',
|
||||
paragraphs: [
|
||||
'**Generally not recommended.** Centralized tumblers are custodial \u2014 you have to trust the operator not to steal your coins or log who sent what. Many are scams or law-enforcement honeypots.',
|
||||
'Coinjoin is the non-custodial alternative and is almost always the better choice.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cashout-comparison',
|
||||
heading: 'Quick comparison',
|
||||
paragraphs: [
|
||||
'**Lightning swap (Boltz):** non-custodial \u00b7 medium privacy \u00b7 easy \u00b7 low fees.',
|
||||
'**Coinjoin (Wasabi, JoinMarket):** non-custodial \u00b7 high privacy \u00b7 medium difficulty \u00b7 medium fees.',
|
||||
'**Peer-to-peer (Bisq, RoboSats):** non-custodial \u00b7 high privacy \u00b7 harder \u00b7 variable fees.',
|
||||
'**Tumblers:** custodial \u00b7 unpredictable privacy \u00b7 easy \u00b7 high risk. **Avoid.**',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'donors-can-be-seen',
|
||||
heading: 'Your donation history is visible to future supporters',
|
||||
paragraphs: [
|
||||
'Anyone considering supporting you can look up your address and see the full donation history. Keep in mind how that history reads to a new donor.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Substitute placeholders in a single guide section. */
|
||||
function substituteGuideSection(section: GuideSection, appName: string): GuideSection {
|
||||
return {
|
||||
...section,
|
||||
heading: substitute(section.heading, appName),
|
||||
paragraphs: section.paragraphs.map((p) => substitute(p, appName)),
|
||||
pros: section.pros?.map((p) => substitute(p, appName)),
|
||||
cons: section.cons?.map((c) => substitute(c, appName)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Donor guide sections with `{appName}` resolved. */
|
||||
export function getDonorGuideSections(appName: string): GuideSection[] {
|
||||
return DONOR_GUIDE_TEMPLATE.map((s) => substituteGuideSection(s, appName));
|
||||
}
|
||||
|
||||
/** Activist guide sections with `{appName}` resolved. */
|
||||
export function getActivistGuideSections(appName: string): GuideSection[] {
|
||||
return ACTIVIST_GUIDE_TEMPLATE.map((s) => substituteGuideSection(s, appName));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared inline-markup renderer used by Help/FAQ surfaces and the
|
||||
* Donor / Activist guide pages.
|
||||
*
|
||||
* Supports a deliberately tiny syntax so authors can write content in plain
|
||||
* strings without pulling in a full markdown parser:
|
||||
*
|
||||
* **bold** → <strong>bold</strong>
|
||||
* [link text](url) → <a href="url" target="_blank" …>link text</a>
|
||||
*
|
||||
* The renderer returns an array of React nodes suitable for splatting into a
|
||||
* paragraph or span. It is intentionally non-recursive: bold inside a link or
|
||||
* vice versa is not supported (and not needed by current content).
|
||||
*/
|
||||
|
||||
export function renderInlineMarkup(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
// Match **bold** or [text](url)
|
||||
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Push text before this match
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
// **bold**
|
||||
nodes.push(
|
||||
<strong key={match.index} className="font-semibold text-foreground">
|
||||
{match[1]}
|
||||
</strong>,
|
||||
);
|
||||
} else if (match[2] !== undefined && match[3] !== undefined) {
|
||||
// [text](url)
|
||||
nodes.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Trailing text
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
|
||||
import { getActivistGuideSections } from '@/lib/helpContent';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
|
||||
/**
|
||||
* Activist Guide — long-form companion to the Help page.
|
||||
*
|
||||
* Explains how receiving donations works on Agora, why incoming donations
|
||||
* are public, and the main paths for cashing out privately. Linked from
|
||||
* `/help` as one of the two large guide buttons.
|
||||
*/
|
||||
export function ActivistGuidePage() {
|
||||
const { config } = useAppContext();
|
||||
useLayoutOptions({});
|
||||
|
||||
useSeoMeta({
|
||||
title: `Activist Guide | ${config.appName}`,
|
||||
description: `How to receive donations on ${config.appName} and cash out privately.`,
|
||||
});
|
||||
|
||||
const sections = getActivistGuideSections(config.appName);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<GuideHero
|
||||
title="Activist Guide"
|
||||
subtitle="How to receive donations on Agora and move funds privately when you need to."
|
||||
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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* not just generic "users."
|
||||
*/
|
||||
const ACTIVIST_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map(
|
||||
(c) => c.url,
|
||||
);
|
||||
|
||||
export default ActivistGuidePage;
|
||||
@@ -205,6 +205,7 @@ export function CreateCampaignPage() {
|
||||
const [coverUploading, setCoverUploading] = useState(false);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [goalUsd, setGoalUsd] = useState('');
|
||||
const [goalTouched, setGoalTouched] = useState(false);
|
||||
const [deadline, setDeadline] = useState('');
|
||||
const [countryQuery, setCountryQuery] = useState('');
|
||||
const [countryCode, setCountryCode] = useState('');
|
||||
@@ -377,8 +378,11 @@ export function CreateCampaignPage() {
|
||||
}
|
||||
|
||||
// Goal / deadline.
|
||||
// In edit mode, preserve the exact stored sats unless the user changed the field.
|
||||
let goalNum: number | undefined;
|
||||
if (goalUsd.trim()) {
|
||||
if (isEditMode && !goalTouched) {
|
||||
goalNum = editCampaign?.goalSats;
|
||||
} else if (goalUsd.trim()) {
|
||||
const n = Number(goalUsd.replace(/[, $]/g, ''));
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
throw new Error('Goal must be a positive USD amount.');
|
||||
@@ -599,12 +603,12 @@ export function CreateCampaignPage() {
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL preview:{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
/{activeIdentifier || 'your-campaign-title'}
|
||||
<p className="text-xs text-muted-foreground flex items-baseline gap-1 min-w-0">
|
||||
<span className="shrink-0">URL preview:</span>
|
||||
<span className="font-mono text-foreground truncate min-w-0">
|
||||
/{activeIdentifier || 'your-campaign-title'}{!isEditMode && derivedIdentifier.length >= 64 && '...'}
|
||||
</span>
|
||||
{isEditMode && ' (kept from original)'}
|
||||
{isEditMode && <span className="shrink-0">(kept from original)</span>}
|
||||
</p>
|
||||
</FormSection>
|
||||
|
||||
@@ -736,16 +740,30 @@ export function CreateCampaignPage() {
|
||||
inputMode="decimal"
|
||||
placeholder="100,000"
|
||||
value={goalUsd}
|
||||
onChange={(e) => setGoalUsd(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setGoalUsd(e.target.value);
|
||||
setGoalTouched(true);
|
||||
}}
|
||||
className="pl-7 pr-14"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
{goalSatsPreview > 0 && btcPrice && (
|
||||
{isEditMode && editCampaign?.goalSats && !goalTouched && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSats(goalSatsPreview)} sats ({satsToUSDWhole(goalSatsPreview, btcPrice)}).
|
||||
Current saved goal: {formatSats(editCampaign.goalSats)} sats
|
||||
{btcPrice
|
||||
? <> — about {satsToUSDWhole(editCampaign.goalSats, btcPrice)} today</>
|
||||
: null
|
||||
}. Only edit this field if you want to change the goal.
|
||||
</p>
|
||||
)}
|
||||
{(!isEditMode || goalTouched) && goalSatsPreview > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your goal will be saved as {formatSats(goalSatsPreview)} sats — about{' '}
|
||||
{satsToUSDWhole(goalSatsPreview, btcPrice)} today.
|
||||
The dollar estimate may change with Bitcoin's price.
|
||||
</p>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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 { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { getDonorGuideSections } from '@/lib/helpContent';
|
||||
import { COOL_PALETTE } from '@/lib/hopePalette';
|
||||
|
||||
/**
|
||||
* Donor Guide — long-form companion to the Help page.
|
||||
*
|
||||
* Explains how on-chain donations on Agora work, why they are publicly
|
||||
* visible, and what a donor can do if they need privacy. Linked from
|
||||
* `/help` as one of the two large guide buttons.
|
||||
*/
|
||||
export function DonorGuidePage() {
|
||||
const { config } = useAppContext();
|
||||
useLayoutOptions({});
|
||||
|
||||
useSeoMeta({
|
||||
title: `Donor Guide | ${config.appName}`,
|
||||
description: `How donating works on ${config.appName} and how to protect your privacy.`,
|
||||
});
|
||||
|
||||
const sections = getDonorGuideSections(config.appName);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<GuideHero
|
||||
title="Donor Guide"
|
||||
subtitle="Real Bitcoin, sent directly. Here's how it works and how to do it privately."
|
||||
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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero images for the Donor Guide. Reuses the World Liberty Congress
|
||||
* event photos already in `/public/hero/` — 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.
|
||||
*/
|
||||
const DONOR_HERO_IMAGES: readonly string[] = [
|
||||
'/hero/wlc-1.webp',
|
||||
'/hero/wlc-2.webp',
|
||||
'/hero/wlc-3.webp',
|
||||
];
|
||||
|
||||
export default DonorGuidePage;
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Activity, PanelLeft, Radio, Settings, Trash2 } from 'lucide-react';
|
||||
import { Activity, Radio, Settings } from 'lucide-react';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
import { useEventDashboard } from '@/hooks/useEventDashboard';
|
||||
import { KpiGrid } from '@/components/event-dashboard/KpiGrid';
|
||||
@@ -26,20 +24,6 @@ import type { TerritorialLevel } from '@/components/event-dashboard/types';
|
||||
export function EventDashboardPage() {
|
||||
const [territorialLevel, setTerritorialLevel] = useState<TerritorialLevel>('municipalities');
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
|
||||
const { toast } = useToast();
|
||||
const isInSidebar = orderedItems.includes('dashboard');
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (isInSidebar) {
|
||||
removeFromSidebar('dashboard');
|
||||
toast({ title: 'Removed from sidebar' });
|
||||
return;
|
||||
}
|
||||
|
||||
addToSidebar('dashboard');
|
||||
toast({ title: 'Added to sidebar' });
|
||||
};
|
||||
|
||||
// Use wider layout — removes 600px cap but keeps sidebar shell
|
||||
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
|
||||
@@ -49,33 +33,6 @@ export function EventDashboardPage() {
|
||||
status, isLoading, error,
|
||||
} = useEventDashboard({ enabled: true, territorialLevel });
|
||||
|
||||
// Error state
|
||||
if (error && kpis.totalPosts === 0) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Dashboard" icon={<Activity className="size-5" />}>
|
||||
<Badge variant="destructive" className="gap-1.5 text-xs font-medium">
|
||||
<span className="size-2 rounded-full bg-red-100" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
</PageHeader>
|
||||
<div className="px-4 py-6 max-w-2xl mx-auto">
|
||||
<Card className="border-destructive/30">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-4">
|
||||
<Radio className="h-10 w-10 text-destructive mx-auto" />
|
||||
<p className="text-lg font-semibold">Unable to connect</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not reach the relay. Check your connection or try again shortly.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge
|
||||
const statusBadge = (
|
||||
<Badge variant={status === 'disconnected' ? 'destructive' : 'outline'} className="gap-1.5 text-xs font-medium">
|
||||
@@ -87,28 +44,49 @@ export function EventDashboardPage() {
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Dashboard" icon={<Activity className="size-5" />}>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={handleToggleSidebar}
|
||||
title={isInSidebar ? 'Remove from sidebar' : 'Add to sidebar'}
|
||||
>
|
||||
{isInSidebar ? <Trash2 className="size-4" /> : <PanelLeft className="size-4" />}
|
||||
<span className="hidden sm:inline">{isInSidebar ? 'Remove' : 'Add to sidebar'}</span>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-8" onClick={() => setConfigOpen(true)}>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusBadge}
|
||||
<Button size="icon" variant="ghost" className="size-8" onClick={() => setConfigOpen(true)} aria-label="Dashboard settings">
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const headerClassName = 'max-w-5xl mx-auto sm:px-6';
|
||||
|
||||
// Error state
|
||||
if (error && kpis.totalPosts === 0) {
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Dashboard" icon={<Activity className="size-5" />} className={headerClassName}>
|
||||
{headerActions}
|
||||
</PageHeader>
|
||||
<div className="px-4 sm:px-6 py-6 max-w-5xl mx-auto">
|
||||
<Card className="border-destructive/30">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-4">
|
||||
<Radio className="h-10 w-10 text-destructive mx-auto" />
|
||||
<p className="text-lg font-semibold">Unable to connect</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not reach the relay. Check your connection or try again shortly.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<ConfigDrawer open={configOpen} onOpenChange={setConfigOpen} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Dashboard" icon={<Activity className="size-5" />} className={headerClassName}>
|
||||
{headerActions}
|
||||
</PageHeader>
|
||||
|
||||
<div className="px-4 pb-24 max-w-4xl mx-auto space-y-6">
|
||||
<div className="px-4 sm:px-6 pb-8 max-w-5xl mx-auto space-y-6">
|
||||
{isLoading ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
@@ -118,7 +96,7 @@ export function EventDashboardPage() {
|
||||
value={territorialLevel}
|
||||
onValueChange={(v) => setTerritorialLevel(v as TerritorialLevel)}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsList className="bg-muted/50">
|
||||
<TabsTrigger value="states">States</TabsTrigger>
|
||||
<TabsTrigger value="municipalities">Municipalities</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
+66
-7
@@ -1,7 +1,8 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { HelpCircle, Shield } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronRight, HandHeart, HelpCircle, Megaphone, Shield } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
@@ -14,26 +15,59 @@ export function HelpPage() {
|
||||
|
||||
useSeoMeta({
|
||||
title: `Help | ${config.appName}`,
|
||||
description: `Get help with ${config.appName} — Nostr 101, FAQs, and support`,
|
||||
description: `Get help with ${config.appName} — donor and activist guides, FAQs, and support`,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Help" icon={<HelpCircle className="size-5" />} />
|
||||
|
||||
{/* Team Soapbox follow pack */}
|
||||
<TeamSoapboxCard className="px-4 pt-2 pb-4" />
|
||||
{/* Top-of-page disclaimer: first thing visitors see */}
|
||||
<div className="px-4 pt-2">
|
||||
<Alert className="border-amber-500/50 [&>svg]:text-amber-500">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle className="text-amber-700 dark:text-amber-400">Read this first</AlertTitle>
|
||||
<AlertDescription className="text-foreground/80">
|
||||
<p>
|
||||
{config.appName} is recommended only for above-ground activism. Every donation
|
||||
— given or received — is public on the Bitcoin blockchain and on Nostr. If
|
||||
you or your donors require extreme privacy, including from state actors, additional
|
||||
steps are required to protect yourself. Read the <strong>Donor Guide</strong> and{' '}
|
||||
<strong>Activist Guide</strong> below before participating.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Two large guide buttons */}
|
||||
<div className="px-4 pt-4 grid gap-3 sm:grid-cols-2">
|
||||
<GuideButton
|
||||
to="/help/donors"
|
||||
icon={<HandHeart className="size-6" />}
|
||||
title="Donor Guide"
|
||||
description="How to support activists privately and safely."
|
||||
/>
|
||||
<GuideButton
|
||||
to="/help/activists"
|
||||
icon={<Megaphone className="size-6" />}
|
||||
title="Activist Guide"
|
||||
description="Receiving donations and cashing out privately."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FAQ heading */}
|
||||
<div className="px-4 pt-4 pb-1">
|
||||
<div className="px-4 pt-6 pb-1">
|
||||
<h2 className="text-lg font-bold">Frequently Asked Questions</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Everything you need to know about Nostr, {config.appName}, and how it all works.
|
||||
Everything else you need to know about Nostr, {config.appName}, and how it all works.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* FAQ accordion sections */}
|
||||
<HelpFAQSection className="px-4 pb-8" />
|
||||
<HelpFAQSection className="px-4 pb-4" />
|
||||
|
||||
{/* Team Soapbox follow pack — at the end, after the FAQ */}
|
||||
<TeamSoapboxCard className="px-4 pt-2 pb-4" />
|
||||
|
||||
{/* Privacy policy link */}
|
||||
<div className="px-4 pb-8">
|
||||
@@ -48,3 +82,28 @@ export function HelpPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface GuideButtonProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function GuideButton({ to, icon, title, description }: GuideButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold leading-snug">{title}</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug">{description}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,13 +68,12 @@ export function WorldPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
// h-dvh inside the column fills the full viewport on both mobile (where
|
||||
// the column's negative margin pulls content under the translucent top
|
||||
// bar) and desktop (where there's no top/bottom chrome). The floating
|
||||
// discovery button is absolutely positioned inside this wrapper so it
|
||||
// stays scoped to the column and doesn't overlap the docked desktop
|
||||
// discovery panel.
|
||||
<div className="relative w-full h-dvh overflow-hidden bg-muted/20">
|
||||
// The height must account for the sticky TopNav (h-16 = 4rem) so the
|
||||
// map fills exactly the remaining viewport. Using `h-dvh` (100dvh)
|
||||
// would make the page scrollable and let the sticky header overlap the
|
||||
// top of the map — hiding the Leaflet zoom controls. The calc keeps
|
||||
// the page at exactly one viewport with no scroll.
|
||||
<div className="relative w-full h-[calc(100dvh-4rem)] overflow-hidden bg-muted/20">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="absolute inset-0">
|
||||
|
||||
Reference in New Issue
Block a user