5020 Commits

Author SHA1 Message Date
Chad Curtis 3d825aef04 campaigns: hardcode moderators, gate lists on a single curator
The home page used to serialize two single-relay round-trips before any
campaign card could render: useCampaignModerators fetched the Team Soapbox
follow pack (kind 39089), and useCampaignLists waited on it to apply an
authors: gate. Each could stall up to an 8s EOSE timeout against the app
relay.

Both lookups are now eliminated from the critical path:

- CAMPAIGN_MODERATORS in agoraDefaults.ts is a hardcoded snapshot of the
  pack's p tags. useCampaignModerators serves it synchronously (no
  queryFn network call), keeping its useQuery return shape so all ~15
  consumers work unchanged. The roster changes rarely; update the array
  and re-cut a release when it does.

- Lists are an editorial surface curated by one identity (MK Fain / Team
  Soapbox), not the whole moderator pack. useCampaignLists now pins
  authors: to LIST_CURATOR_PUBKEY and no longer depends on the moderator
  query at all. The multi-author allowlist remains for labels only
  (approve/hide), where any pack member is trusted.

Regression-of: be1fadfc
2026-06-01 15:36:08 -05:00
Chad Curtis 575603554b home: decouple funding-bar skeleton from card, parallelize list queries
CampaignCard now paints immediately and shows a dedicated skeleton for
the funding/progress bar while useCampaignDonations resolves, instead of
flashing a misleading "0 raised" before the on-chain balance lands.

useCampaignLists no longer serializes behind useCampaignModerators: the
list relay query fires immediately on the hashtag filter and the
moderator allowlist is applied client-side in foldCampaignLists. The two
single-relay round-trips (each up to an 8s EOSE timeout) now run in
parallel on cold sessions. The trust gate is unchanged — a list authored
by a non-moderator is dropped before it reaches the UI.
2026-06-01 15:36:08 -05:00
Alex Gleason dfb0a52603 Upgrade Nostrify 2026-06-01 22:20:43 +02:00
mkfain 545e6cf4be home: rewrite whyDifferent lede as a concrete manifesto strapline
Replace the generic "Three things that make us not like the
others." with copy that names the actual mechanism and three
specific threat models the section addresses:

  "Direct Bitcoin from donor to activist. No platform in the
   way, no custodian holding the bag, no permission required."

Three short clauses, one per block:

  • "No platform in the way" sets up block 1 (vs GoFundMe / Stripe /
    Visa platform censorship).
  • "No custodian holding the bag" sets up block 2 (vs other
    "Bitcoin" platforms with Lightning custodians / LSPs).
  • "No permission required" sets up block 3 (the public/private
    receiving choice — your threat model, your call).

Updated across all 11 locales. The headline ("Built different.")
stays in place; the lede now carries the weight that the giant
Bebas Neue display headline needs as support.
2026-06-01 22:09:54 +02:00
mkfain eb978d651c home: trim block1 heading to just "Unlike GoFundMe"
Drop the trailing "and similar sites" qualifier across all 11
locales. Matches the shorter form already used in the
\`about.twoWays.noCustody.gofundme.heading\` key, so the two
surfaces ("home / why different" and "/about / no custody")
now read consistently.
2026-06-01 22:03:08 +02:00
mkfain 7a52631eb2 home: redesign whyDifferent as a manifesto-style editorial section
Drop the brand-orange band entirely. The section now sits on the
canonical \`bg-background\` so it reads as a continuation of the
home page, not a separate marketing slab. No more navy/slate
surfaces.

New visual structure:

  • Decorative spine — a soft vertical brand-orange gradient line
    runs down the left margin (md+), evoking an editorial /
    manifesto feel without changing the page surface.

  • Eyebrow framed with brand-orange leader lines + tracking-wider
    "WHY ÁGORA" wordmark — reads like a chapter marker.

  • Giant Bebas Neue italic display headline at scale (text-5xl
    → text-7xl), uppercase, stroke-painted. The headline now
    earns the visual weight that a colored background was doing
    before. Matches the page hero typography exactly.

  • Three numbered chapters (01 / 02 / 03), each anchored by a
    massive italic Bebas Neue numeral in brand orange paired
    with a thin orange seam line. No card chrome — chapters sit
    directly on the page background so the section reads as
    continuous editorial copy, not three boxed tiles. Each
    chapter has heading + mission paragraph + brand-orange
    ✓ checklist for blocks 1-2.

  • Block 3 uses a split-card public/private cell pair with
    brand-orange (public) and muted (private) tints, framed in
    a single rounded border — a tiny diagram of the "your
    choice" framing rather than a generic bullet list.

  • Soft brand-orange halo behind the headline (CSS only, blur-3xl)
    for depth.

  • Closing CTA is now a small text-link with an underlined wordmark
    and a chevron that nudges on hover — quieter than a button,
    consistent with the editorial idiom.

Drops the indigo accent that the v1 design used and standardizes
on brand-orange + neutral foreground/muted-foreground tokens,
which means dark mode inherits the canonical dark surface and
typography automatically.

No new translation keys; reuses the existing
\`campaigns.home.whyDifferent.*\` strings as-is. Dropped unused
\`Bitcoin\` and \`ShieldOff\` icon imports.
2026-06-01 21:49:46 +02:00
mkfain d48094ff68 home: recolor whyDifferent section to brand orange band
Background was cream-on-light / dark-navy-on-dark. Swap to a
brand-orange band (`bg-primary`) with dark slate type on top:

- Section heading: `text-slate-900` for ~9:1 contrast on orange
- Eyebrow: `text-white/90` (label-on-orange feel, AA on hsl(24 100% 50%))
- Lede: `text-slate-800/90`
- Cards: solid white in light mode (was `bg-white`-on-cream, now
  reads as crisp surfaces lifted off the orange) and dark slate
  in dark mode; copy is slate-700/-600
- Card shadows bumped to `shadow-md` so cards sit proud of the
  saturated orange instead of disappearing into it
- Block 3 accent changed from indigo to neutral slate so the
  third card doesn't compete chromatically with the orange band
- Read-the-full-breakdown CTA is now a solid dark-slate pill
  with white text (instead of an outline button that disappeared
  on the new background)
2026-06-01 21:49:46 +02:00
mkfain 247fbefa9b home: add "Why Ágora is different" info section at the bottom
Three-block info band beneath the WLC hero row and topic-list
strip explaining what makes Ágora different:

  1. Unlike GoFundMe and similar sites — no platform freeze, no
     payment-processor middleman, zero platform fees.
  2. Unlike other "Bitcoin" platforms — no central Lightning
     node, custodian, or LSP; settles on-chain to a wallet you
     control.
  3. Public or private — receiving-option contrast (Bitcoin
     on-chain vs BIP-352 silent payments) with a CTA to the
     long-form breakdown at /about#how-it-works.

Visual idiom matches the AboutPage sections (cream / dark-navy
band, brand-orange eyebrow, Inter Bold heading, RailCard-style
cards with icon chip + checklist) so the home page reads as a
shorter front-door version of /about. Always visible — the
value prop is part of the home page identity, not gated on
auth state.

Strings live under `campaigns.home.whyDifferent.*` with full
translations in all ten canonical locales (ar, es, fa, fr, km,
ps, pt, ru, sn, zh). Technical tokens (GoFundMe, Stripe, Visa,
Bitcoin, Lightning, LSP, BIP-352, QR) and the {{appName}}
placeholder are preserved verbatim across locales.
2026-06-01 21:49:46 +02:00
Chad Curtis 4a3c5df519 Don't let an empty persisted translateWorkerUrl hide the Translate button
A blank `translateWorkerUrl` saved to localStorage was shadowing the
build-time default in the config merge, so the Translate button's
"no worker configured" guard hid it even when VITE_TRANSLATE_WORKER_URL
was set. Coalesce an empty persisted value back to the default, and stop
the Advanced Settings field from persisting an empty string on blur.
2026-06-01 14:32:09 -05:00
mkfain 74478ee8ac Remove the 'View the full list' link under the WLC hero row
The link encouraged users to navigate away from the home page to
see members beyond the visible cap. The home page is the
editorial surface; if a campaign isn't in the visible cap, that's
the curator's call. Cleanup drops the link, the campaigns.home.viewFullList
key across all 16 locales, and nothing else.
2026-06-01 21:18:56 +02:00
mkfain da94609855 Replace the Featured campaign concept with the World Liberty Congress list
The home page's hero row was driven by kind-1985 'featured' /
'unfeatured' moderation labels (the campaign-specific Featured
axis). Now that curated lists exist, the WLC-published list with
d='world-liberty-congress' is a strictly better mechanism: same
trust model (moderator-published), explicit ordering (positional
'a' tags instead of a separate rank stream), and the membership is
edited through the same Add-to-list flow that powers every other
list.

Changes:

- CampaignsPage: replace the Featured row with a hero row backed by
  useCampaignList('world-liberty-congress'). Capped at 6 entries
  with a 'View the full list' link to the list's detail page when
  there's overflow. The WLC avatar/name/check still anchor the
  heading. The empty state covers both 'no list yet' and 'list
  exists but empty'.
- CampaignCard: drop the verifiedBy prop and the WLC verified-by
  chip. Nothing else passed verifiedBy.
- CampaignCard: stop opting into the 'featured' axis on the
  moderator kebab. Only 'hide' remains for campaigns.
- ModerationMenu / ModerationOverlay: strip the reorder prop chain
  (only the deleted Featured row consumed it). Pledge / group
  surfaces keep their 'featured' axis since their featured shelves
  are unchanged.
- Delete useReorderCampaign, ReorderableCampaignGrid,
  ReorderProvider, reorderContext — the campaign-rank reordering
  infrastructure they served is gone.
- Update i18n: drop campaigns.home.featured, featuredDesc,
  verifiedByAria across all 16 locales. Add wlcDesc and
  viewFullList. Translations dispatched in parallel.

The featuredCoords / featuredOrder fields in the shared moderation
fold (agoraModeration.ts) stay — they're still consumed by
useFeaturedOrganizations (groups) and usePledgeModeration (pledges).
Existing kind-1985 'featured' labels referencing campaign coords
become inert: nothing reads them, but the label namespace is
shared so we don't garbage-collect them.
2026-06-01 21:18:56 +02:00
mkfain 8b90ef90f7 Wrap campaign list pills instead of horizontal scroll
The strip used overflow-x-auto with a thin scrollbar, which cut off
pills past the viewport edge on smaller screens. Switch to
flex-wrap so the pills flow onto multiple rows and stay fully
visible without any scroll affordance.
2026-06-01 21:18:07 +02:00
mkfain 49f0ec2765 Remove the moderator-only Hidden section from the home page
The home page is meant to be tightly curated — Featured row + topic
strip + browse-all CTA. Even keeping the Hidden collapsible closed-
by-default for moderators meant the home page was carrying a
review surface that belongs on /campaigns, where the Show-hidden
toggle is already available to everyone and the structured Hidden
collapsible already exists.

Drops the Hidden section's rendering and all of its supporting
state: the recent-stream useCampaigns call, the targeted hidden-
coord useCampaigns call, hiddenCoordList, hiddenCampaigns, isMod,
plus the imports they kept alive (EyeOff, ModeratorCollapsibleSection,
CampaignGridSkeleton).
2026-06-01 21:18:07 +02:00
mkfain 72c2170139 Replace the home page 'All campaigns' grid with the topic-lists strip
The chronological 'All campaigns' grid on the home page duplicated
what /campaigns already does better (search, sort, country filters,
unbounded scroll). Swap it for the curated topic-list strip
(CampaignListsStrip) followed by a single 'Browse all campaigns'
CTA that links to /campaigns. The Verified hero row above and the
moderator-only Hidden section below are unchanged.

Removed the no-longer-needed allCampaignsChronological derived
state, featuredCoordSet O(1) lookup, useReorderCampaign /
useToast / onFeaturedMoveToTop/Up/Down callbacks, and the
ConditionalReorderProvider helper that wrapped the chronological
grid for moderators.
2026-06-01 21:17:44 +02:00
mkfain a0082cbbcd Refetch list membership when the Add-to-List dialog opens
useCampaignLists caches its query for 30 seconds, so a moderator who
added a campaign to a list from one surface (e.g. the list detail
page) and then opened the per-campaign membership dialog for the
same campaign from another card would see stale 'Add' buttons for
those lists until the cache expired.

Invalidate the campaign-lists query whenever the dialog opens so the
membership state always reflects the latest published revisions
without requiring a page refresh.
2026-06-01 21:17:01 +02:00
mkfain a8561f46f9 Fix list-membership dialog navigation + hide hidden campaigns from add-to-list search
Two fixes for the curated lists feature:

1. Clicking an Add/Added toggle in the per-campaign membership dialog
   was navigating to the campaign's detail page. Although Radix Dialog
   portals content to document.body, React's synthetic events still
   bubble through the React tree — past the Link that wraps the
   CampaignCard the moderator opened the kebab from. Stop propagation
   on the toggle's click handler and at the DialogContent root.
   Applied the same stopPropagation to ListFormDialog and IconPicker
   since both can mount inside the membership flow.

2. The campaign-search dialog opened from a list detail page was
   surfacing campaigns hidden by moderators. Filter the search
   results through useCampaignModeration.hiddenCoords so suppressed
   campaigns don't get encouraged into curated lists. Existing list
   members that later get hidden remain visible in the dialog so a
   moderator can still remove them.
2026-06-01 21:17:01 +02:00
mkfain 2c248f8269 Add 'Add to list…' row to the campaign moderator kebab
Adds a new row at the top of the moderator dropdown on campaign cards
(both / and /campaigns) that opens a per-campaign list-membership
modal. Each known curated list renders as a row with the campaign's
current membership state — toggling immediately publishes a new
revision of the list event through useCampaignListActions, so a
moderator can multi-tag a campaign without leaving the dialog. The
modal also exposes a '+ New list' shortcut that runs the standard
create flow and auto-adds the campaign to the just-created list.

The membership dialog's state is owned by ModerationMenu (the kebab
trigger), not by the dropdown content. Radix unmounts content on
close, so a sibling dialog rendered inside DropdownMenuContent would
be torn down on the same tick the user clicks the item. Lifting the
state to the trigger lets the dialog survive the menu closing.
2026-06-01 21:17:01 +02:00
mkfain b8749f7064 Add moderator-curated campaign lists to /campaigns
Lists are NIP-51 kind 30003 Bookmark Sets authored by Team Soapbox
moderators (the same allowlist gating Featured / Hidden), carrying
the 'agora.campaign-list' hashtag plus a custom 'icon' tag holding a
Lucide icon name. Membership order is encoded in the order of the
'a' tags on the event; the order of the topic strip itself is held
in a sentinel kind 30003 with d='agora.campaign-lists.index'.

Replaces the 'Your campaigns' shelf on /campaigns with a horizontal
strip of pill buttons (one per list). Each pill links to a new
/campaigns/lists/:slug detail page rendering the list members in
moderator-defined order. Moderators see a trailing '+' pill to
create a list, a per-pill kebab for edit/delete/move, and drag-and-
drop to reorder the strip on desktop. Inside each list, moderators
can search and add campaigns, remove members, and reorder via the
same native-HTML5 DnD pattern.

The icon picker is searchable over every named Lucide icon. The
registry is dynamically imported through a single shared module so
the full library lives in its own Vite chunk and the main bundle
isn't penalized; LucideIcon renders a 'List' fallback while the
chunk resolves.
2026-06-01 21:17:01 +02:00
Chad Curtis f800d55451 Stop clobbering VITE_* CI vars with literal placeholders
The deploy-web job re-declared project-level CI/CD variables as `KEY: $KEY`.
When a source variable is out of scope for the job (e.g. a Protected variable
on an unprotected ref), GitLab leaves the reference unexpanded, so the literal
string "$VITE_TRANSLATE_WORKER_URL" got inlined into the build and surfaced in
the UI. Project-level variables are already in the job environment, so the
re-declaration is removed entirely.
2026-06-01 14:15:38 -05:00
Chad Curtis ee8414f694 Make translation worker URL user-configurable via AppConfig
The DeepL translate worker endpoint is now a configurable AppConfig field
(translateWorkerUrl), defaulting to the build-time VITE_TRANSLATE_WORKER_URL
env value with no hardcoded fallback. Users can override or clear it in
Advanced Settings (System section), and the setting syncs across devices via
encrypted NIP-78 settings. The Translate button hides itself when no worker
is configured.
2026-06-01 13:53:52 -05:00
lemon 23ac55af6b Merge campaign profile setup into wizard 2026-06-01 11:28:54 -07:00
lemon 2ef0642f6d Keep wizard chrome above body content 2026-06-01 11:15:18 -07:00
lemon 18aacad290 Require campaign creator profiles 2026-06-01 11:09:47 -07:00
mkfain e82f0146d2 Allow reordering over-cap featured campaigns from All Campaigns
The 6-card cap on the WLC Verified row meant moderators couldn't
reach featured campaigns at positions 7..N to reorder them — the
drag handles and kebab move rows only existed on cards in the
visible hero row.

Wrap the All Campaigns section in a ReorderProvider seeded with the
*full* featuredCoords list (not just what's visible) so every
WLC-chipped card in the chronological grid gets the same Move up /
Move down / Move to top rows in its kebab. The provider only mounts
for moderators; non-mods see no behavior change.

Non-featured cards aren't in the provider's byCoord lookup, so
their kebab simply doesn't show reorder rows — the moderation menu
already gates the section behind canMoveUp || canMoveDown.

No optimistic local reorder here: the chronological grid is sorted
by createdAt, not by featured rank, so a successful 'Move to top'
on a position-12 card lifts it into the Verified hero row above
(and out of the chronological feed, via the existing heroSet
dedupe) once the moderation pack invalidates and refetches.
Failures surface as a toast, matching ReorderableCampaignGrid.

A small ConditionalReorderProvider helper keeps the JSX clean and
spares non-mods the provider work.
2026-06-01 19:51:51 +02:00
mkfain 973defcd28 All campaigns: deduplicate vs hero row, sort oldest-first
Two corrections to the new 'All campaigns' section on the home page:

1. Deduplicate against the Verified hero row. Campaigns rendered in
   the row above are now excluded from the chronological feed below
   (matched by aTag against orderedFeatured). Over-cap featured
   campaigns — the ones a moderator featured beyond the 6-card cap —
   still appear here, and still pick up the WLC chip via
   featuredCoordSet. The user sees each campaign at most once on the
   home page.

2. Sort by createdAt ascending (oldest first), not descending. The
   spec was 'chronological order from when they were created,' not
   reverse-chronological. The allCampaignsDesc copy is also updated
   in all 16 locales to drop the 'newest first' language.
2026-06-01 19:21:05 +02:00
mkfain 5bbd86ea90 Cap WLC Verified row at 6; add chronological 'All campaigns' section
The WLC Verified hero row now shows at most 6 campaigns (the two
large hero cards on top plus a single 4-up row), regardless of how
many a moderator has featured. Anything beyond the cap is still
featured for moderation purposes — it just doesn't earn a hero slot.

Below the Verified row, a new 'All campaigns' section displays every
campaign in the home page's recent stream (the existing 200-event
window from useCampaigns(limit: 200)) minus anything currently
hidden, sorted newest-first. Featured campaigns are intentionally
NOT removed from this chronological feed; instead, each card whose
coord is in featuredCoordSet still picks up the WLC chip via
verifiedBy. So a verified campaign appears twice on the home page —
once as a hero, once in chronological order — and both placements
make the WLC endorsement visible.

The 'Browse all campaigns →' link moves from the Verified section
footer to the All Campaigns section footer, where it makes more
sense as a gateway to /campaigns' search and sort surface for the
full censorship-resistant set beyond the 200-event window.

Five locale strings touched in all 16 locales: featured,
featuredDesc, verifiedByAria (existing), allCampaigns,
allCampaignsDesc (new).
2026-06-01 19:07:19 +02:00
mkfain 65481d1280 Featured: two large hero cards on top, rest in 4-up rows
Restructure the home Featured row into a two-tier layout when there
are 3+ featured campaigns:

  - Top two: large 'hero' placement (full width on mobile/sm, half
    width side-by-side on lg+).
  - Rest: standard compact cards in rows of four on lg+, two on sm,
    one on mobile.

Implementation is a single CSS grid with conditional col-spans on
the first two children rather than two separate grids — that keeps
the existing ReorderableCampaignGrid intact, including drag-and-drop
between hero cards and tail cards, optimistic reorder, and the
moderator kebab actions.

ReorderableCampaignGrid gains an itemClassName(index) prop so the
caller can paint per-position wrapper classes onto each card slot
without the grid component knowing about hero layouts.

The renderCard signature now also receives the display index — not
used yet, but kept aligned with itemClassName for future use cases.

≤2 featured campaigns keep the original adaptive layout (no spans
needed), and the single-campaign hero variant is unchanged.
2026-06-01 18:56:21 +02:00
mkfain 3d4b40188e Show 'WLC' on the featured-card verifier chip
The chip on featured campaign cards previously rendered the full
'World Liberty Congress' name, which truncated awkwardly at our 140px
max-width on smaller covers. Add an optional shortLabel to the
verifiedBy prop so the chip can display a compact 'WLC' while the
aria-label and avatar fallback continue to use the full name.
2026-06-01 18:52:34 +02:00
mkfain a7f28e3963 Move check icon after 'Verified' and add WLC chip to featured cards
Two follow-up tweaks to the World Liberty Congress Verified row:

1. Heading reads '<avatar> World Liberty Congress  Verified <check>'
   instead of leading with the check icon, so the action verb sits
   next to the brand and the check is the visual punctuation.

2. CampaignCard gains an optional verifiedBy={{ pubkey, npub,
   defaultName }} prop. When set, it renders a translucent chip on
   the top-left of the cover art with the verifier's avatar (pulled
   from kind 0 metadata via useAuthor), name, and a BadgeCheck. The
   chip is itself a Link to the verifier's profile and stops click
   propagation so it doesn't trigger the outer campaign link.

   CampaignsPage threads { WLC_PUBKEY, WLC_NPUB } through FeaturedRow
   so every featured card on the home page picks up the WLC chip.

A new verifiedByAria locale key is added in all 16 locales for the
chip's aria-label.
2026-06-01 18:49:58 +02:00
mkfain d2b6785ca7 Rebrand home Featured row as 'World Liberty Congress Verified'
The home page Featured row is curated in partnership with the World
Liberty Congress, so the heading should make the source of trust
explicit. Replace the plain 'Featured' heading with:

  [avatar] World Liberty Congress [BadgeCheck] Verified

The brand name and avatar link to WLC's profile
(npub126e6hwd6a5std2upv9a22xwgvd8fyrhsx5wjjchv99g6nv3n4vhs5fr9g3),
the avatar is pulled live from kind 0 metadata via useAuthor, and a
lucide BadgeCheck conveys verification at a glance.

The localized 'featured' key now reads 'Verified' (and is translated
across all 16 locales); the brand name itself is intentionally not
translated. featuredDesc is rewritten to describe WLC verification
in every locale.
2026-06-01 18:42:48 +02:00
mkfain 2bab7ebe6e Drop featured pinning from /campaigns and append new features to the bottom of the row
Two related curation tweaks:

- /campaigns now renders every non-hidden campaign in pure
  reverse-chronological order. Featured-pinning on this shelf was
  redundant with the home page's dedicated Featured row and obscured
  newly-published campaigns. The active (search / sort / country)
  branch was already unpinned; this only changes the idle landing.

- Featuring a campaign as a moderator now publishes an explicit
  rank one below the smallest existing featured rank, so the new
  card lands at the **bottom** of the Featured row instead of
  jumping the queue. The display sort stays descending by rank, so
  the existing Move-to-top / Move-up controls still work for
  moderators who want to promote a fresh feature.

The pledge and organization moderation surfaces keep the legacy
created_at-fallback behavior — the bottom-append rank is computed in
CampaignItemsInner and threaded through the shared shell only for the
campaign branch.
2026-05-31 15:38:31 +02:00
Chad Curtis e6fc7931b6 onboarding: campaign-framed profile heading for new campaign creators 2026-05-30 21:12:19 -05:00
Chad Curtis c845f7286b onboarding: hide bio behind Advanced toggle; show profile step first for new campaign creators 2026-05-30 20:14:56 -05:00
Alex Gleason f5cdbb6f3a Upgrade Nostrify (this fixes everything) 2026-05-31 02:52:50 +02:00
mkfain b0759402cf Retire the approval axis; Featured becomes the sole positive-curation mechanism
Now that moderators can directly order the Featured row, the second
"Community Campaigns" bucket (approved + not-featured + not-hidden)
is redundant. This commit removes the approval axis end-to-end and
collapses the home page to a single curated section.

Protocol (NIP.md):

- `ModerationLabel` shrinks from six values to four — `hidden`,
  `unhidden`, `featured`, `unfeatured`. The legacy `approved` /
  `unapproved` labels are now ignored on read and MUST NOT be
  published.
- `ModerationAxis` shrinks from three to two: `hide` and `featured`,
  both supported by all three surfaces (campaigns, organizations,
  pledges).
- The rank tag now only applies to `featured` labels.
- A migration note in NIP.md explains the retirement and tells
  clients to ignore lingering approval-axis labels in relay
  archives.

UI:

- CampaignsPage drops the Community Campaigns and Pending sections.
  Home is now Featured (with the empty state in place when nothing
  is featured) → Browse-all link → moderator-only Hidden section.
  The labeled-coord targeted fetch shrinks to hidden coords only.
- ModerationMenu loses the Approve / Unapprove rows and the
  `hasApproval` / `isApproved` plumbing.
- `CampaignCard`'s `axes` prop drops `'approval'`.
- `ReorderAxis` collapses to a single axis — the type and the
  parameter are removed from the reorder hook, provider, context,
  and grid component since every reorder targets the featured axis.
- Pledge and organization moderation hooks lose their defensive
  `'approved' | 'unapproved'` rejection branches now that those
  values are off the `ModerationLabel` union.

i18n (16 locales):

- Five moderation.menu keys removed: `approve`, `unapprove`,
  `approvedState`, `toastApproved`, `toastUnapproved`.
- Five campaigns.home keys removed: `community`, `communityDesc`,
  `pending`, `pendingDesc`, `pendingEmpty`.
- `campaigns.home.yourCampaignsDesc` rewritten across every locale
  to drop the "appears on the homepage once a moderator approves"
  copy; new copy points authors at /campaigns for discovery and
  notes that the team curates a featured selection on the home page.

Test suite green: tsc, eslint, vitest, vite build all pass.
2026-05-30 22:48:21 +02:00
mkfain ef9c2eff89 Fix campaign reorders silently reverting when moving downward
The first reorder implementation encoded list position directly in
the moderation label's `created_at` and republished the same axis
label with a chosen timestamp. That fights the fold's
newest-event-per-(coord,axis) rule the moment a moderator tries to
lower a campaign's position: the new label has an older
`created_at` than the existing one and the fold rejects it. The
relay accepts the publish, but every subsequent read folds back to
the higher-`created_at` predecessor and the move appears to revert.

Move-up worked (its new `created_at` was strictly newer); move-down,
drag-down, and any drag-to-midpoint that landed below an existing
neighbor silently no-op'd. Anything dragged into the middle of an
already-old list also picked a past timestamp that some relays
reject for being too far behind "now".

The fix decouples sort key from event recency:

- Reorder publishes always use `created_at = now`, so the fold's
  newest-wins rule always picks them up.
- The chosen position is encoded as a `["rank", "<integer>"]`
  tag on the label.
- `foldModerationLabels` extracts the rank with a `created_at`
  fallback, so labels published before this change (and any normal
  approve / hide / feature actions that don't carry a rank) still
  sort by `created_at` exactly as they used to.

Ranks are sourced from `Date.now() * 1000` (microseconds since
epoch), so:

- Fresh "feature" / "approve" publishes always sit above legacy
  labels whose effective rank is a seconds-since-epoch value.
- Midpoint inserts have ~1000x headroom per second of inter-rank
  gap, comfortably enough for thousands of reorders before any
  renumbering would matter.
- Headroom against `Number.MAX_SAFE_INTEGER` is ~150 years.

Callers downstream (CampaignsPage, CampaignsDiscoverySection,
PledgesDiscoverySection, useFeaturedOrganizations) still consume
`featuredOrder` / `approvedOrder` as `Map<coord, number>` sorted
descending — the map names and shapes are unchanged, only the
value computation is now "rank ?? created_at" instead of
"created_at".

NIP.md updated to document the rank tag, the fallback semantics,
and the reorder operations in terms of ranks.
2026-05-30 22:22:09 +02:00
mkfain 2cde8fe1f8 Remove the 12-campaign cap on the home-page Featured row
Moderators can now feature any number of campaigns and the row
expands to fit. The cap was hardcoded as `MAX_FEATURED = 12` and
applied at three points (the sort+slice on coords, the
`useCampaigns` limit, and the sort+slice on the ordered list);
all three are gone.

`useCampaigns` already ignores its `limit` when `coordinates` is
set (it fans out into one per-author `#d` filter), so the relay
request was never actually capped — only the rendered slice was.
Dropping the slice is sufficient.

The skeleton placeholder still bounds itself at 8 cards so a
moderator who's featured 50+ campaigns doesn't get a screenful of
grey rectangles before the real cards land. The bound is renamed
`FEATURED_SKELETON_CAP` to make the intent obvious.
2026-05-30 22:13:46 +02:00
mkfain 9e26bb8209 Let moderators reorder Featured and Community campaign lists
The Featured row already sorted by the moderator's `featured` label
`created_at`, but reordering required clicking Unfeature then Feature
again — clumsy, and the Community grid sorted only by campaign
`created_at` with no moderator input at all.

This commit promotes the existing axis-label `created_at` into a
first-class sort key on both lists and adds drag-and-drop + kebab-row
UI for moderators.

Protocol (no schema change):

- The Featured row sorts by the `featured` label's `created_at`,
  newest first (existing behavior).
- The Community grid now sorts by the `approved` label's
  `created_at`, newest first (mirroring the Featured row).
- Reordering = republishing the same axis label for the moved
  campaign with a chosen `created_at`. Move-to-top stamps `now`;
  move-up stamps `neighborAbove.t + 1`; move-down stamps
  `neighborBelow.t - 1`. Drag-to-position picks a value between the
  two new neighbors.
- No new tags, no new kinds, no new authority — readers that already
  understand the moderation namespace pick up the order for free.
- Conflict model unchanged: newest label per (coord, axis) wins.

Implementation:

- `foldModerationLabels` now populates `approvedOrder` alongside
  `featuredOrder`.
- `useCampaignModeration().moderate` accepts an optional explicit
  `created_at` for the label event (omitted for normal
  approve/hide/feature; passed by the reorder hook).
- New `useReorderCampaign` hook with `moveToTop`, `moveUp`,
  `moveDown`, and a general `moveTo(toIndex)` used by drag-and-drop.
- New `ReorderableCampaignGrid` wraps a list of `CampaignCard`s:
    - non-mods get a plain grid, zero overhead;
    - mods on desktop get HTML5 drag-and-drop with a six-dot handle
      on hover (the handle is the only `draggable` element so card
      clicks still navigate the underlying `<Link>`);
    - mods on mobile get Move up / Move down / Move to top rows
      injected into the existing moderator kebab via a context
      provider (`ReorderProvider` / `useReorderControlsFor`).
- An optimistic local order smooths the gap between publish and
  refetch so the card snaps into the new position immediately; it
  rolls back automatically on publish failure.
- Translations added in all 15 non-English locales.
- NIP.md documents the ordering convention in a new
  "Moderator-driven Ordering" section under the campaign-moderation
  surfacing rules.
2026-05-30 22:07:53 +02:00
mkfain 7c14115119 Drop the campaign title slug preview hint
The d-tag never appears in a user-visible URL. Campaign links are
naddr1… bech32 strings (see CampaignDetailPage.tsx:263 and
CreateCampaignPage.tsx:573 / navigate(`/${encodeCampaignNaddr(...)}`)),
which bundle the d-tag inside the encoded payload. Showing a
transliterated 'anqthwa-ahmd-wwaldh-…' string under the title input
and calling it 'your campaign URL' was actively misleading — the
user sees no such URL anywhere.

Rip out TitleSlugHint, the previewSlug memo, and the slugPreview /
slugFallbackNotice locale keys from all 16 locales. The
transliteration + random-fallback slug derivation in buildCampaignSlug
stays — that's still the right fix for the underlying bug — but it's
internal Nostr plumbing the user shouldn't have to see.

Regression-of: 12bc7219
2026-05-30 21:40:09 +02:00
mkfain 12bc721952 Let campaign titles in any language produce a valid URL slug
Arabic, Persian, CJK, and other non-Latin titles were collapsing to an
empty d-tag because slugifyCampaignIdentifier only kept [a-z0-9] after
NFKD. NFKD doesn't transliterate Arabic to Latin, so a title like
حملة لمساعدة الأطفال slugified to "" and the user hit the cryptic
errorTitleInvalid message at submit time — after walking through the
entire wizard.

Route the title through the slugify package's charMap first (covers
Arabic, Persian, Cyrillic, Greek, Georgian, Armenian, Vietnamese, common
Latin diacritics, currency symbols, smart quotes). For inputs that still
produce no ASCII characters — emoji-only titles, CJK, Thai, Tamil —
buildCampaignSlug returns a random campaign-XXXXXX identifier so the
user can still publish; the human-readable title lives in the title tag,
not the URL.

Also strip Unicode bidi controls and zero-width characters
(RLM/LRM/FSI/PDI/ZWNJ/BOM) before they reach the title tag. RTL
keyboards routinely insert these invisibly, and preserving them in
display strings is a homograph/phishing vector.

Surface validation under the title input itself rather than at submit:
when the title transliterates cleanly, show the slug preview in a
muted-tone code block; when it doesn't, show an amber notice explaining
that a random URL identifier will be generated and that the title is
preserved verbatim. Hidden in edit mode where the d-tag is locked.
2026-05-30 21:32:26 +02:00
mkfain 6c5205cc75 Make insufficient-fee broadcast failures actionable
When Bitcoin transactions were rejected by the network for fee-related
reasons (min relay fee not met, mempool full, RBF replacement
underpriced), both the HD wallet Send dialog and the campaign Donate
dialog surfaced a destructive toast titled "Transaction failed" /
"Donation failed" with the raw bitcoind RPC error as the description.
The dialog stayed open with state preserved, but the donor:

- Saw an opaque, technical error string they couldn't act on.
- Got no affordance to recover. Re-tapping Send re-fired the same
  rejected transaction. In HDSendBitcoinDialog the existing
  two-tap arm wasn't reset on broadcast failure, so a second tap
  immediately re-broadcasted with the same (rejected) parameters.
- In DonateDialog had no path to bump the fee without manually
  backing out to the form step and re-picking a tier.

Three pieces, plus a small adjacent fix:

1. Classifier in src/lib/bitcoinBroadcastError.ts maps the verbatim
   bitcoind / Blockbook-WebSocket / Esplora error strings onto a small
   enum (feeTooLow, rbfReplacementFeeTooLow, mempoolFull,
   mempoolConflict, tooLongChain, absurdlyHighFee, badInputs, network,
   unknown). For the canonical 'min relay fee not met, A < B' form,
   the actual and minimum sat/vB values are parsed out so the UI can
   show "minimum right now is N sat/vB" and seed a custom fee. 17
   unit tests covering real-world fixtures from mempool.space,
   Blockstream Esplora, Blockbook framing, and bare bitcoind output.

2. Shared BroadcastErrorAlert in src/components/BroadcastErrorAlert.tsx
   renders inline above the Send button. Replaces the toast for
   classified errors (toast is retained only as a fallback for the
   `unknown` bucket so something always surfaces). Fee-recoverable
   kinds get a "Use a higher fee" action; `network` gets "Try again";
   everything else has no action and waits for the donor to adjust
   amount / recipient via the auto-dismiss effect. A `presetTiersOnly`
   prop hides the bump button once a preset-only consumer (DonateDialog)
   is on the fastest tier, surfacing "You're already on the fastest
   tier" instead.

3. HDSendBitcoinDialog wiring — broadcast errors set a classified
   state, the alert renders above Send, and a new bumpFeeForRetry
   helper jumps to the next-faster preset OR, if already at the
   top of the deduped preset list, switches to a custom rate seeded
   from the strongest available hint (parsed minRelayFee + 1, or
   1.5x the rejected rate, or current+1 as a last resort). Refetches
   fee rates, opens the fee popover so the donor can see and tweak
   the new rate, marks the picker as user-touched so the 40%-of-amount
   auto-tune doesn't fight the bump, and resets the two-tap arm
   unconditionally on every failure.

4. DonateDialog wiring — same alert in the confirm step. The dialog
   has no custom-rate input by design (it's the simple donate flow),
   so the bump action walks the preset chain economy -> hour ->
   halfHour -> fastest. At the fastest tier the alert hides the
   button and tells the donor to use an external wallet via the QR
   panel on the campaign detail page.

5. i18n — 22 new keys under walletSend.broadcastError, translated
   into all 15 non-English locales in parallel with placeholder and
   technical-token preservation.

The auto-dismiss effects in both dialogs clear the alert as soon as
the donor adjusts a field that could plausibly resolve the failure
(recipient, amount, fee speed, custom rate) so the alert doesn't
linger once the donor has engaged with the fix.
2026-05-30 20:48:11 +02:00
Alex Gleason 737b197aa8 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-30 20:13:03 +02:00
Alex Gleason 2cf3db0a51 Convert single-candidate pastes straight into a recipient chip
Pasting a bare bc1…/sp1… address or a single-endpoint bitcoin: URI now
resolves directly to the recipient chip instead of dropping into the input
behind a one-row dropdown the donor still had to click. Pastes carrying
both an on-chain address and an sp1 code still fall through to the dropdown
so the donor picks privacy vs. compatibility.

Extracted the candidate-resolution logic into a shared resolveCandidates()
helper so the live input memo and the paste handler agree on what counts as
a valid destination; the paste handler resolves from the clipboard text
directly (query state hasn't updated yet inside the event) and
preventDefault()s the single-match case so the raw text never flickers in.
2026-05-30 20:12:33 +02:00
mkfain c54008cd3d Surface a hint when the recipient picker is closed with no selection
The campaign donate flow opens HDSendBitcoinDialog with a prefilled
bitcoin:bc1q…?sp=sp1… URI. BitcoinRecipientInput auto-opens its
dropdown so the donor picks between the on-chain address and the
silent-payment code — privacy vs. compatibility, the explicit choice
the picker was designed around (92608f14).

In practice the donor's eye lands on the amount presets first. Tapping
$100 counts as an outside click, dismisses the popover, and leaves
`recipient` null. The Send button is disabled (correctly — no
destination resolved), the input still shows the prefilled URI, and
nothing on screen tells the donor what's missing. They eventually
discover that re-tapping the recipient input reopens the dropdown.

Add a small amber hint with a warning icon directly beneath the
recipient input whenever the input has parseable candidates but no
selection AND the popover is closed. The whole hint is a button that
reopens the popover and refocuses the input on tap, so the recovery
takes one click instead of a guessing game.

Gate the hint on a new `hasOpenedForQuery` flag that flips true the
first time the popover opens for the current query and resets when the
query clears. That keeps the hint from flashing for one paint frame
between mount and the auto-open effect on prefilled inputs.

Regression-of: 92608f14
2026-05-30 20:04:27 +02:00
Alex Gleason 7f16678acc Order the Bitcoin address row above silent payment in the recipient dropdown
Swap the dropdown row order so "Send to Bitcoin address" renders above
"Send to silent payment address" — the broadly-compatible on-chain option
leads, with the privacy option following.
2026-05-30 20:01:12 +02:00
Alex Gleason e77876ed16 Keep the recipient dropdown open when the input loses focus
The candidate dropdown is a persistent choice list, but Radix's Popover
dismissed it whenever the input blurred or the user tapped elsewhere,
making the rows vanish even though a valid destination was still in the
field. Block the auto-dismiss-on-outside-interaction handlers so the
dropdown stays open as long as the input holds a candidate; it now closes
only on selection, on a cleared input, or via Escape.
2026-05-30 19:59:02 +02:00
Alex Gleason 03b68c3a24 Clear the recipient input when the chip is X'd out
Previously, clearing a selected chip in a prefilled flow (campaign "Pay
with Agora") restored the prefilled bitcoin: URI / address back into the
input. Removed that restore effect so X-ing out the chip now returns to an
empty field, letting the donor type or scan a fresh destination without
first deleting the prefill.
2026-05-30 19:53:31 +02:00
Alex Gleason 2ab45a27d5 Preselect the recipient chip for single-endpoint Pay with Agora prefills
When the Send Bitcoin picker mounts pre-filled with a single valid
endpoint — e.g. a campaign with only a bc1 address (or only an sp1 code) —
it now auto-selects that candidate into the recipient chip instead of
leaving the bare value in the input behind a one-item dropdown the donor
still had to click.

Prefills carrying both an on-chain address and an sp1 code are left in the
input so the dropdown can surface both rows; picking privacy vs.
compatibility is a real choice the donor should make. Guarded by a
mount-once ref so it never overrides a manual selection or a clear-chip
restore.
2026-05-30 19:42:38 +02:00
Alex Gleason e40f32a54f Always copy a bitcoin: URI on campaign donate panels
The donate panel's copyable row only used a BIP-21 URI when a campaign
exposed both an on-chain address and a silent-payment code; single-
endpoint campaigns copied the bare bc1.../sp1... value instead. The QR
already encoded a bitcoin: URI in every case, so the copy row now mirrors
it — donors always get a wallet-parseable URI regardless of which
endpoints a campaign declares.
2026-05-30 19:36:41 +02:00
mkfain c53e476dee Move the all-campaigns directory from /campaigns/all to /campaigns
/campaigns was a redirect to / (the curated home), and the actual
all-campaigns directory lived at /campaigns/all. Flip the routing
so /campaigns IS the directory, the home page stays at /, and
/campaigns/all becomes a redirect to /campaigns for any external
links and bookmarks that still point there.

Rewrite every internal link/navigate target accordingly (TopNav,
the Browse-all CTA on the home page, the OnboardingGate donor
redirect, NoteCard's kind-33863 nounRoute) and refresh the
doc/comment references in NIP.md and the discovery hooks.
2026-05-30 13:19:29 +02:00