Compare commits

...

113 Commits

Author SHA1 Message Date
Alex Gleason 71ca2778fd home: stop fetching kind 1985 moderation labels
The home page (CampaignsPage) called useCampaignModeration() solely to
drop hidden campaigns from the WLC hero row, which fired a kind 1985
label query (limit 2000) on every initial load just to check ≤6
curated coords. Remove the dependency: the hero row now only reorders
to the moderator-curated list order. Hidden-campaign moderation already
lives entirely on /campaigns, so the home page no longer needs it.
2026-06-01 23:07:57 +02:00
mkfain 545f288aee campaigns: drop duplicate arrow from browseAll button label
The 'Browse all campaigns' Link on the home page renders an <ArrowRight>
lucide icon next to t('campaigns.home.browseAll'), but the translated
string itself ended in '→' (or '←' for RTL locales), so the button
displayed two arrows. Strip the literal arrow from all 16 locale files
and let the icon do the visual work — it already handles RTL via
rtl:rotate-180 in CampaignsPage.tsx.
2026-06-01 23:07:57 +02:00
Chad Curtis 0895b763c6 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 23:07:57 +02:00
Chad Curtis 670ef9a3e9 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 23:07:57 +02:00
filemon be3c6fd3eb campaign: warm the wallet card to match the Save-your-key onboarding
Bring the wallet destination card into the same warm, protective visual
language as the onboarding SecureStep card so the wallet decision feels
just as carefully guided.

- Swap the neutral muted surface for the onboarding card's primary-token
  wash (bg-primary/10, border-2 border-primary/30, ring-primary/10).
- Retint the Bitcoin chip with brand tokens (bg-primary/15, text-primary,
  ring-primary/20).
- Add an inset 'destination preview' panel (bg-background/60, dark:bg-
  black/20, border-primary/20) echoing the masked-key area, with a
  'Donations will land here' headline and the destination note.
- Drop the neutral hairline; the inset panel now provides separation.
- Restyle the accept pills for the tinted card: inset surface when
  unselected, solid primary fill when selected (a faint tint vanished on
  the wash). Behavior, labels, disabled states unchanged.
- Update walletStepSubtitle and walletDestinationNote; add
  walletDestinationLanding. All locales updated.

No wizard or wallet logic changes.
2026-06-01 23:07:57 +02:00
filemon 1996d960b8 campaign: consolidate wallet step into one calm card
Make the wizard's wallet step read as a single guided decision in the
cadence of the onboarding "Save your key" screen instead of a stack of
form sections.

- Render the wallet picker bare in the wizard step (drop the FormSection
  "Bitcoin wallet / Required" header, which competed with the centered
  step title). Edit mode keeps the FormSection wrapper for single-page
  layout consistency.
- Collapse the 'mine' branch into one Card: identity row, theme-aware
  hairline, accept-mode picker, a reassuring destination note, and the
  soft "Use another wallet instead" action.
- Reword the step title/subtitle and add walletDestinationNote; update
  all locales.

No wallet logic or wizard behavior changes.
2026-06-01 23:07:56 +02:00
Alex Gleason 5c9d332d21 Upgrade Nostrify 2026-06-01 23:07:56 +02:00
filemon 86d132ed73 campaign: polish donation destination step with premium wallet card
Restyle the Agora wallet row in the campaign wizard's wallet step as a
theme-aware muted Card with a subtle orange ring/glow, larger avatar,
stronger spacing, and a Bitcoin icon on the right so it reads as the
chosen donation destination. Give the Accept All / Public Only /
Private Only toggles a clearer orange active state. No layout or wallet
logic changes.

Reword the step copy (title, subtitle, custom-wallet link) and update
all locales to match.
2026-06-01 22:18:35 +02:00
filemon c1ace8422b Disable Since dropdown while From block override is active
The Since dropdown showed 'Last month' on page load even though the
pre-filled From block override (block 951430) was the actual scan
source. This was visually misleading.

- Disable the Select and hide the Custom hours input while
  fromOverride is non-empty, so the UI makes it obvious that the
  manual block height is in control.
- Show a short hint below the disabled Select: 'Using the From block
  override below. Clear it to use a time window instead.'
- Clearing the From block re-enables the dropdown for preset scans.
2026-06-01 22:18:35 +02:00
filemon e02a008069 Pre-fill recovery-era block height and fix audit findings
Restore the original recovery guarantee: the Advanced 'From block'
input is pre-populated with recovery.defaultFromHeight (block 951430)
and the Advanced section starts open, so the first scan covers the
full known affected window without depending on mempool.space. Users
can clear the override and use the Since preset dropdown for narrower
re-scans.

Additional fixes from the pre-merge audit:

- Add isManualUpToDate guard: disable Start and show a hint when the
  manual override exceeds the chain tip (consistent with
  HDSilentPaymentScanDialog).
- Disable Select, custom hours input, and From block input while
  isResolvingSince is true to prevent mid-flight input changes.
- Reset sweptSats alongside step/error/txid when starting a new scan.
- Add recoveryWindowHint and upToDate locale strings.
2026-06-01 22:18:35 +02:00
filemon a4d8bf50e3 Show dates instead of block heights in double-tweak recovery UI
Replace the raw block-height input and progress display with
human-readable dates so non-technical users can navigate the recovery
scanner intuitively.

- Add estimateDateFromHeight/estimateHeightFromDate utilities anchored
  to block 840 000 (4th halving) with Bitcoin's 10-min average interval.
- Swap the numeric height <Input> for a <input type="date"> that
  converts the selected date to an estimated height internally.
- Render scan progress and chain-tip hint as localized date strings
  (e.g. "May 15, 2026") instead of block numbers.
- Update en.json labels: fromHeightLabel → fromDateLabel, tipHint and
  progress now interpolate date strings, noFunds nudges an earlier date
  instead of an earlier height.
2026-06-01 22:18:35 +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
mkfain 3a06dcd4cb Translate the new HRF/WLC category set and the refreshed campaigns tagline
Two pieces of stale i18n caught up:

1. The 16 new campaignsCreate.categories.* keys (human-rights,
   democracy, press-freedom, political-prisoners, humanitarian-aid,
   civil-resistance, digital-rights, anti-corruption, women-girls,
   refugees, legal-aid, emergency-relief, animal-rights, education,
   medical, community) translated into all 15 non-English locales.

2. campaigns.all.sectionTagline rewritten across all 16 locales to
   match the discovery-section fix that now lists featured campaigns
   first and the rest of the network underneath, instead of
   featured-only-with-fallback. Old copy ('Highlighted by moderators.
   Search or sort to browse the full network.') implied search was
   required to see non-featured campaigns, which is no longer true.
2026-05-30 13:12:54 +02:00
mkfain d7144200fb Replace generic campaign categories with HRF/WLC-aligned set
Swap the picker's preset list from the generic GoFundMe-style
catalog (adoption, animals, church, family, memorial, mission,
non-profit, event, first-responders, political) to a set that
reflects Agora's editorial focus on the kinds of activism HRF and
the World Liberty Congress champion: human rights, democracy,
press freedom, political prisoners, civil resistance, digital
rights, anti-corruption, women & girls, refugees & exiles, legal
aid. Plus humanitarian aid (per request), animal rights, emergency
relief, education, medical, and community to round out the
breadth.

16 entries total, ordered by editorial prominence (freedom /
democracy themes first, everyday humanitarian needs after). The
picker UI is unchanged \u2014 it iterates the array, so swapping
contents is enough.

Existing campaigns that selected one of the dropped slugs keep
their on-chain `t` tag intact \u2014 only the editor stops lighting up
a pill for them. No migration; we're pre-launch.

Strip the now-orphaned campaignsCreate.categories.* keys from the
15 non-English locales; the new English keys are in en.json only,
non-English locales will fall back to English at runtime until
proper translations land in a follow-up.
2026-05-30 13:07:49 +02:00
mkfain b5cb884004 Swap Campaigns and Activity order in the main nav
Campaigns is the primary surface of Agora; lead with it.
2026-05-30 12:49:42 +02:00
mkfain 0800b854ae Restore four-section home page and stop dropping approved campaigns
The home page community grid was missing approved campaigns whenever
the approval was older than the most recent 200 events on the
network. The grid was fed by a single `useCampaigns({ limit: 200 })`
call, so an approved campaign with a low `created_at` would silently
fall off the end of the chronological window and disappear from the
public surface even though its approval label was still active.

Two fixes here:

1. Add a second `useCampaigns` call keyed on every approved + hidden
   coord, alongside the existing recent-stream query. Merge both
   result sets, de-dupe by aTag, keep whichever revision is newer.
   Approved coverage no longer depends on recency.

2. Restore the four-section layout the home page was supposed to
   have: Featured, Community (approved only), Pending (mods-only),
   Hidden (mods-only, collapsed by default). The single
   chronological-all-with-toggle grid this commit replaces was the
   wrong target \u2014 censorship-resistant viewing belongs on
   /campaigns/all, the home page should be the moderator-curated
   front door.

Extend ModeratorCollapsibleSection with an explicit `defaultOpen`
prop so the Hidden section can be forced closed independent of the
existing 'auto-open when short' heuristic.
2026-05-30 12:47:10 +02:00
Alex Gleason 3a98e38f7b Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-30 12:36:31 +02:00
mkfain e198e8d572 Bump home-page Featured cap from 4 to 12 2026-05-30 12:35:51 +02:00
Alex Gleason cf6364a84b Fail over on 404 from always-present Esplora paths
mempool.space serves a 404 (instead of 429) to rate-limited clients,
which is common on carrier-NAT'd mobile connections where many users
share an egress IP. esploraFetch treated 404 as a legitimate "not
found", marked the endpoint healthy, and returned it WITHOUT failing
over — so getFeeRates threw 'Failed to fetch fee estimates', the query
swallowed it, and the on-chain Zap/donation dialogs showed no fee rates.
This is why fees loaded on WiFi but not LTE.

Add a per-call retryStatuses option to esploraFetch that extends the
retryable set (failover + cool-down) for that call, and apply [404] to
the paths that always exist on a healthy backend: /fee-estimates,
/address, /address/txs, /address/utxo, and the /tx broadcast. The
/tx/{txid} lookup keeps 404 meaningful (genuinely-unknown tx).
2026-05-30 12:35:48 +02:00
mkfain 34cae4c9ad Stop hiding approved-not-featured campaigns on /campaigns/all
The idle (no search / no sort / no country) view of the campaigns
discovery section was a featured-only shelf with a fallback to
chronological only when nothing was featured at all. As soon as a
moderator featured one campaign, every approved-but-not-featured
campaign vanished until the viewer typed a search, picked a sort,
or filtered by country.

Switch idle mode to a true featured-first list: pin featured at the
top of the grid, then append every other non-hidden campaign in
chronological order, deduped against the featured set. Approved-
not-featured now shows up where viewers expect it.

Active mode is unchanged \u2014 it already rendered the full result set.
The section tagline still reads 'Highlighted by moderators. Search
or sort to browse the full network.' which is now slightly stale;
leaving the translation update for after we confirm the new
behavior in the wild.
2026-05-30 12:32:40 +02:00
mkfain 0c686a2091 Let anyone unhide hidden campaigns on the Campaigns page
The Show-hidden toggle in CampaignsDiscoverySection was gated to
moderators. Drop that gate so every viewer of /campaigns/all sees
the toggle and can unhide what mods have suppressed.

Rationale: moderation labels live on public relays regardless, so
hiding the toggle was security-by-obscurity. The Campaigns page is
the censorship-resistant browseable index; the only honest UX is
transparent moderation. The home page (/) keeps its curated
behavior \u2014 only mods see hidden campaigns there \u2014 and the Hidden
collapsible *below* the discovery section on /campaigns/all stays
mod-only because it's a review workflow with one-click hide/unhide
affordances, not a discovery surface.

The toggle's default is unchanged: off. Viewers see only non-hidden
campaigns until they opt in.
2026-05-30 12:26:58 +02:00
Alex Gleason d07bc64032 Add custom fee rate to wallet Send; stop showing empty fee tiers
The HD-wallet Send dialog's fee popover relied on getUniqueBitcoinFeeSpeeds
falling back to all four preset tiers when rates hadn't loaded — rendering
clickable tiers with no sat/vB value (and no way to send at all when the
Blockbook estimate API was down).

- Show loading/error status (with a Retry) in the fee popover instead of
  bare tiers when rates haven't loaded.
- Add a "Custom" fee tier with an inline sat/vB input so users can always
  specify a rate, including when the estimate API is unavailable.
- Disable Send when the resolved rate is < 1 and surface an inline error.
- Add resolveBitcoinFeeRate + a PresetBitcoinFeeSpeed type so 'custom' is
  handled distinctly from the preset tiers.
2026-05-30 12:15:45 +02:00
mkfain e8acf45656 Hide Groups and Pledges from main nav for launch
Comment out the two NAV_ITEMS entries (desktop nav and mobile drawer
share this array, so one edit covers both). Routes and feature code
stay intact \u2014 visiting /groups or /pledges still works, in-page CTAs
still link, only the persistent nav chrome stops promoting them.

Re-enable by uncommenting the two lines and re-adding the Users and
Megaphone icon imports.
2026-05-30 11:34:42 +02:00
mkfain 3c28e2b789 Show every campaign on the home page, with a hidden toggle
The community grid stops gating on moderator approval and now lists
every kind-33863 campaign on the network, newest-first. A Switch in
the section header reveals the moderator-hidden bucket on demand (off
by default, count badge when something's there).

The moderator-only Pending and Hidden collapsibles disappear with
this change — Pending is now part of the main grid, and Hidden is one
toggle flip away. The non-mod 'Your campaigns' pending shelf goes
away for the same reason: a creator's not-yet-approved campaign
already shows up in the main grid.

Featured row, hero, and 'Browse all campaigns' link are untouched.
2026-05-30 10:46:54 +02:00
Alex Gleason dc43f723fb Trim the eager countries chunk from 244 KB to 47 KB
src/lib/countries.ts imported the full iso-3166 package solely to build
a Set of valid ISO 3166-2 subdivision codes for validation. That dataset
(~5000 objects with names, parents, and tree structure) landed in the
eagerly-preloaded countries chunk because NoteContent, ComposeBox, and
campaign.ts all import from countries.ts on the critical path.

Ship only the subdivision code strings instead, generated at build time
into src/lib/subdivisionCodes.ts via scripts/gen-subdivision-codes.mjs.
iso-3166 moves to devDependencies since only the generator script needs
it now. The strict-validation contract (rejecting US-ZZ etc.) is
preserved.
2026-05-30 02:20:01 +02:00
Alex Gleason c7ed31305d Lazy-load locale bundles to shrink the initial bundle
i18n.ts statically imported all 16 locale JSON files (~2.4 MB),
collapsing them into a single eager chunk that every user downloaded
on startup regardless of their language. Bundle the English fallback
only and fetch the other 15 locales on demand via dynamic import(),
so each language becomes its own lazily-loaded chunk.

This removes the 2.1 MB i18n chunk from the initial load; the eager
i18n chunk is now ~109 KB (runtime + English).
2026-05-30 02:10:35 +02:00
Alex Gleason 441eea160f Restore the full campaigns content area on the home page
The previous commit left the home page with a single
CampaignsDiscoverySection (search/sort/country toolbar over one grid).
The original layout was richer and read better: a dedicated Featured
row, the Community Campaigns grid with a "Browse all" link, the
moderator-only Pending / Hidden collapsibles, and a per-viewer "Your
campaigns" shelf.

Rebuild that content area from moderation labels (useCampaignModeration
+ useCampaignModerators + useCampaigns), keeping the current hero and
leaving campaigns as the home page's sole focus. The shared discovery
components and the dedicated /campaigns/all, /groups, and /pledges pages
that consume them are untouched.

Regression-of: 7ccff2fb
2026-05-29 16:41:12 -05:00
Alex Gleason 4f056dfac0 Show only campaigns on the home page, not groups and pledges
The home page is the primary browse surface for campaigns and reads best
when it stays focused on them. Groups and Pledges each have their own
dedicated browse pages (/groups, /pledges), so surfacing all three on /
duplicated those experiences and diluted the page.

Drop the GroupsDiscoverySection and PledgesDiscoverySection from the home
page, leaving only CampaignsDiscoverySection. The shared discovery
components and the dedicated pages that consume them are untouched.

Regression-of: 7ccff2fb
2026-05-29 16:36:56 -05:00
lemon f16d5ea334 Use wallet price source in dashboard balance card 2026-05-29 14:16:34 -07:00
lemon ef8e6f9564 Use wallet price source in header balance 2026-05-29 14:12:22 -07:00
Lemon 40f3179a63 Merge branch 'style/campaign-wizard' into 'main'
Campaign Wizard

See merge request soapbox-pub/agora!38
2026-05-29 13:52:20 -07:00
lemon 3b35b084fd Translate the wallet step's accept-mode pills, hints, and custom-wallet toggle into every shipping locale 2026-05-29 13:50:06 -07:00
lemon 0ade19c51e Translate the pledge and group wizard strings, plus the campaign categories, into every shipping locale 2026-05-29 13:50:06 -07:00
lemon 81f3c9e755 Show the Skip and Launch shortcut on the pledge wizard's Set Your Pledge step
The shortcut previously appeared only from step 3 onward. Once the
user fills the pledge amount on step 2 they're fully submittable —
title and description (the step 1 gate) plus a positive pledge
amount cover every required field. Forcing one extra Next click to
reach the shortcut on step 3 just to skip the rest was friction
for no benefit.

Moving the shortcut to step 2 reuses the same canAdvanceFromStep
gate the Next button does, so the button is visibly grayed out
until the pledge amount resolves to a positive sats value. Once
the amount is filled, both Next (continue to Cover) and Skip and
Launch (publish now) light up together and the user picks the
path. A minimal pledge is now two Next clicks plus a Skip and
Launch tap.
2026-05-29 13:50:06 -07:00
lemon 657c0e43e3 Align the pledge wizard tags step with campaigns and drop the dedicated deadline step
Two coordinated tweaks to the pledge create flow:

1. The free-form tag input is replaced with the same pill-style
   CategoryPicker that campaigns and groups already use, drawing
   from the curated 15-entry CAMPAIGN_CATEGORIES vocabulary. The
   tag list emitted on publish is now ordered canonically (the
   CAMPAIGN_CATEGORIES order) rather than insertion-order from
   the comma-separated input — same posture campaigns and groups
   adopted when they swapped pickers. Side effects:

     - parseContentTagInput is no longer imported by this file
       (still used by CreateEventPage and CreateCommunityEventDialog).
     - pledges.create.tagsPlaceholder is dropped from en.json and
       all fifteen non-en locales, since the picker has no
       free-text input to placeholder.
     - The step subtitle stays "Help the right people find your
       pledge"; the title is renamed "Country and categories" to
       match the picker (groups uses the same string).

2. The dedicated Deadline step is folded into the Pledge-amount
   step. The two questions answer the same beat — "how much, and
   by when?" — and a step that often gets skipped felt like
   padding next to the amount field it conceptually belongs
   with. The timezone subsection still reveals only once a date
   is chosen, the date is still required to be present-or-future,
   and the deadline tag still publishes only when a date is set.

Step count drops from 5 to 4: Title+Description, Pledge+Deadline,
Cover, Country+Categories. The Skip Next and Launch shortcut keeps
its from-step-3 placement (both required gates still clear by the
end of step 2), so a minimal pledge takes two Next clicks plus
one Skip and Launch tap.

i18n: deletes pledges.create.wizard.deadlineStepTitle and
deadlineStepSubtitle from en.json (they exist only in en so no
locale cleanup is needed). Updates pledgeStepSubtitle to mention
the optional deadline. Renames tagsStepTitle to "Country and
categories" to match the picker.
2026-05-29 13:50:06 -07:00
lemon 5a72cf1fd0 Convert the pledge create flow into the wizard layout
Pledges followed the original single-page stacked form for every
create. With campaigns and groups both running through the captive
wizard overlay, pledges were the odd one out — the FAB / hero CTA
landed on a long scrolling form while every other create flow
opened a focused step-by-step layout. This brings them inline.

Five steps:

  1. Title + Description (both required; step 1 gates on both)
  2. Pledge amount (required; gates on a positive sats preview so
     the BTC/USD price has resolved before publish)
  3. Cover image (optional)
  4. Deadline + timezone (optional; the timezone subsection still
     reveals only when a date is chosen)
  5. Country + Tags (optional, terminal)

Skip Next and Launch appears from step 3 onward. Steps 1 and 2 hide
the shortcut because publishing without their fields would trip a
server-side validation error; once both required gates are
cleared, the remaining three steps are explicitly optional and a
single-tap launch is the desired escape hatch. Matches the same
posture the campaign wizard uses for its required title + wallet
gates.

Side cleanups while in the file:

  - The local CountrySelect is replaced with the shared one. The
    pledges.create.{countryClearAria, flagOfAria, countryHint}
    locale keys were already absent from non-en locales (cleaned
    out during the earlier campaign/groups extraction), so this
    just removes the now-orphan en.json entries.
  - pledges.create.{publishing, uploadingCover} were dead since
    the page was already reading forms.publishing /
    forms.uploadingCover; deleted from all sixteen locales.
  - OrganizationContextChip now rides along inside step 1 as a
    step1Lead, same treatment the campaign wizard gives it. The
    captive overlay swallows the page header chrome, so the
    "publishing under <org>" affordance has to live inside the
    step body to stay visible.

No edit-mode path: pledges aren't editable today, and the file
mirrors that — there's a single create branch and that's it. If
edit support is ever added, the campaign / groups pattern (the
single-page form lives behind an isEditMode branch above the
wizard return) is the template.

i18n: adds pledges.create.wizard.{titleStepTitle, titleStepSubtitle,
pledgeStepTitle, pledgeStepSubtitle, coverStepTitle,
coverStepSubtitle, deadlineStepTitle, deadlineStepSubtitle,
tagsStepTitle, tagsStepSubtitle, launchNow} to en.json. Other
locales fall back to English until translated.
2026-05-29 13:50:06 -07:00
lemon b3163ea2c9 Show the Skip and Launch shortcut on step 1 of the group wizard
Groups require only a name to publish. The shortcut used to appear
from step 2 onward, which still forced one mandatory Next click
before the user could opt out of the remaining optional steps.
Moving it to step 1 lets a minimal group publish in a single
action: type a name, tap Skip and Launch.

The shortcut shares its disabled state with the Next button via
canAdvanceFromStep, so on step 1 it only becomes clickable once
the name field is non-empty.

Also tightens Wizard's canSubmit calculation: the mid-wizard
shortcut now respects the same canAdvance gate the Next button
does. Previously a launch button placed on a gated step would
remain clickable even when the gate was unmet, then trip a
server-side validation error. The terminal step's own submit
button keeps its old behavior because by definition every gated
step has already been cleared by then.
2026-05-29 13:50:06 -07:00
lemon 1f545e7361 Add a Skip Next and Launch shortcut to the group create wizard, restore the single-page form for edits
Two changes that go together:

1. The group create wizard now exposes a Skip Next and Launch ghost
   shortcut from step 2 onward. Name is the only required field
   (it is the gate on step 1 and the slug source); once a user
   clears that step, everything else is opt-in and they should
   not have to click Next, Next, Next through three optional
   screens just to publish a minimal group. Matches the same
   shortcut the campaign wizard offers from step 3 onward.

2. Edit mode now renders the original single-page stacked form
   instead of the wizard, mirroring the create-vs-edit split the
   campaign flow uses. Editing a populated entity benefits from
   seeing all fields at once: every wizard step would already be
   pre-filled, and walking through them adds friction without
   adding clarity. The edit form reuses the exact same section
   bundles the wizard does (nameDescriptionSection, coverSection,
   moderatorsSection, countryCategoriesSection) so create and
   edit stay byte-identical in their field rendering. Ordering
   matches the pre-wizard page: name, description, country,
   categories, cover, moderators.

i18n: adds groups.create.wizard.launchNow ("Skip Next and Launch")
to en.json. Other locales fall back to English until translated.
2026-05-29 13:50:06 -07:00
lemon 1b21edef19 Convert the group create / edit flow into the wizard layout
Groups used to render every field on a single long form. Now they
share the same captive overlay the campaign flow uses — sticky
progress bar across the top, one focused decision per step, top-left
back chrome and top-right escape, big rounded primary CTA. Four
steps:

  1. Name + Description (gated; name is required to advance)
  2. Cover image
  3. Moderators
  4. Country + Categories

The free-form 'mutual-aid, local-news, digital-rights' tag input is
replaced with the same pill-style CategoryPicker the campaign flow
uses, drawing from the same 15-entry CAMPAIGN_CATEGORIES vocabulary
so the two creation surfaces feel like the same product. Country
input uses the shared CountrySelect.

Edit mode behaviors:

  - The d-tag stays immutable (kept as editCommunity.community.dTag).
  - The pre-fill loop only pre-selects existing  tags that exist
    in the curated category set. Arbitrary  tags an older
    free-form entry may have published (e.g. 'mutual-aid') are
    intentionally dropped from the picker — the user has no way to
    re-select them, and silently re-publishing tags they can't see
    would be a stealth foot-gun. Same posture campaigns adopted when
    their tag input was swapped.
  - The preserved-tag list in the edit branch already strips every
    ; nothing else changes there.

No 'Skip Next & Launch' shortcut here. Groups are only four steps
and three of them are optional, so a mid-wizard submit shortcut
would clutter the footer without saving real effort.

i18n: adds groups.create.wizard.{name,cover,moderators,tags}Step{Title,Subtitle}
to en.json. The non-en locales fall back to English for these new
strings until they're translated.
2026-05-29 13:50:06 -07:00
lemon 2ba19fc135 Extract Wizard, CategoryPicker into reusable components
The wizard scaffolding (progress bar, captive overlay, step-aware
header chrome, Enter-to-advance keyboard handling) had been living
inline at the bottom of CreateCampaignPage.tsx as CampaignWizard and
WizardStep, and the category-pill picker was inline as well. Both
need to drop into the group-creation flow next, so they get lifted
into src/components/Wizard.tsx and src/components/CategoryPicker.tsx
with no behavioral change for the campaign page.

The campaign-specific bits — the org chip and the 'Skip Next &
Launch' shortcut — survive the move as optional props (step1Lead,
launchNowLabel) so the group flow can omit them without dragging
along irrelevant chrome. The wizard's Back / Next / close labels now
read from common.back / common.next / common.goBack, which both
flows can reuse.

CountrySelect had already been pulled out into its own component for
the calendar-event flow; it now gains the same localization the
inline campaign copy had (countryClearAria, flagOfAria, countryHint
move to the shared forms.* namespace, replacing the hardcoded
English strings the calendar-event flow shipped with) plus an
optional id prop so callers that own their own <label htmlFor> can
keep wiring it explicitly.

The three localized strings used to live duplicated under
campaignsCreate.* and groups.create.*; both copies are removed from
en.json and from every non-en locale so the locales test passes.
2026-05-29 13:50:06 -07:00
lemon 934495a7d3 Reflow the category picker into auto-wrapping pills
The grid layout forced every chip to the width of the widest label,
which left half the pills with awkward whitespace and the rest with
truncation pressure. Switching to a flex-wrap row lets each pill
size to its own text — short labels (Family, Legal) take less room,
long labels (First Responders) take more, and the row breaks
whenever the next pill wouldn't fit. Some rows naturally fit three
pills, others fit four, depending on which labels neighbor each
other on a given line.

Also drops Current Events from the curated set (it overlaps heavily
with the Event category and was usually mis-selected as a synonym)
and bumps the chip font from text-xs back to text-sm now that the
text is no longer constrained by a narrow grid cell.
2026-05-29 13:50:06 -07:00
lemon a2a4c8b2a7 Trim the campaign category picker down to a 16-tile, 3-col grid
Drops Competitive, Creative, Evangelism, and Business — those four
were the weakest fit for the kinds of fundraisers that actually run
on Agora (memorial drives, medical emergencies, mission trips,
mutual-aid efforts), and including them in the curated set diluted
the signal of the other 16. Also renames Animals / Pets → Animals,
which reads cleaner in the chip and avoids the awkward slash.

Locks the picker to a three-column grid (was 2/3/4 responsive) so
the full label is always visible — at the wizard's narrow max-w-md
column the previous two-column layout left half the chips with
truncated labels, and the four-column layout never had room for
multi-word categories like 'First Responders' or 'Current Events'.
Three columns gives every short label its own line and lets the two
long ones wrap to two; a min-h-[3rem] keeps the grid uniform.
2026-05-29 13:50:06 -07:00
lemon c560bd8acd Replace the wizard's tag input with a curated category picker
The free-form 'beach-cleanup, mutual-aid, …' input asked donors to
invent and spell their own taxonomy on the spot, which produced
sparse and inconsistent tag data (no two campaigns used the same
slug for 'medical', the picker on the discover page never had a
stable set to filter against, etc). Replaces it with a fixed
20-category multi-select grid — Adoption, Animals/Pets, Business,
Church, Community, Creative, Current Events, Education, Emergency,
Evangelism, Event, Family, First Responders, Legal, Medical,
Memorial, Mission, Non-Profit, Political, Competitive — each chip
rendered with its Lucide icon.

Selected categories are persisted as ordinary lowercase 't' tags,
identical at the protocol level to anything the old input would
have produced, so existing readers (relays, the discover feed,
cross-client viewers) need no changes. Edit mode intersects the
event's existing 't' tags with the curated slug set so a campaign
created under this picker round-trips cleanly.

Also restores the previously-merged 'goal & deadline' and 'country &
tags' wizard steps as separate screens — collapsing them into one
turned out to push the category picker too far down the page on
mobile to be the first thing the user sees on the final step.
2026-05-29 13:50:06 -07:00
lemon 21907014e0 Translate the campaign wizard step titles into every shipping locale
The wizard's step titles, subtitles, and footer button labels lived
only in en.json, so every non-English user saw the captive create
flow in English — even after the locale fell back gracefully for the
rest of the page. Adds the wizard subobject to all 15 other locales
with idiomatic translations matching each file's established voice.
2026-05-29 13:50:05 -07:00
lemon 0b77980fc7 Merge goal, deadline, country, and tags into one final wizard step
The wizard's last two screens were each only ~one field of work:
goal+deadline (a USD input and a date) and country+tags (a country
combobox and a comma-list). Asking users to advance twice through
near-empty steps was busywork — both screens fit comfortably on the
same step without breaking the captive flow's vertical rhythm.

Collapses them into a single 'Goal, deadline, and tags' step, which
becomes the new terminal step where the Launch button lives. The
shortcut still appears from the banner step onward, so the wizard
remains a five-step flow with the same opt-in tail.
2026-05-29 13:50:05 -07:00
lemon 3bab0ef3e0 Don't let Enter on a non-terminal step silently publish the campaign
A <form> with a single text input treats Enter as submit. The wizard
sets the form's onSubmit to the publish handler, so hitting Enter on
step 1 (title) would call submitMutation.mutate() — and for a logged-in
nsec user the wallet picker already defaults to a valid HD-wallet
'mine' / 'all' configuration, so the publish actually went through and
the campaign launched after a single Enter on the title field. There
was no opportunity to fill in anything else.

Intercept Enter on the form's onKeyDown:
* If we're on the terminal step, do nothing — Enter should submit.
* If the focused element is a <textarea>, do nothing — Enter is a
  legitimate newline inside the field.
* If we're mid-IME composition, do nothing — let the IME finish.
* Otherwise preventDefault and call the same "advance" logic the Next
  button uses, gated by submitting + canAdvance so the gate behaves
  identically.

Also wrap each child of the custom-wallet header in a block <div> so
the "← Use my Agora wallet" link stacks beneath the "Custom wallet"
title instead of sitting on the same line. Both children were
inline-flex; the parent's space-y-1 only adds margin between block
children, so on wide enough viewports the two pieces ended up
side-by-side.
2026-05-29 13:39:18 -07:00
lemon cb52920259 Quiet down the wallet step's identity row and accept-mode pills
Four small refinements after first review:

* Drop the card chrome (border + bg) around the identity row and
  remove the pencil. The row is now a plain avatar + name + balance
  display sitting on the wizard's transparent background — visual
  confirmation of the destination, not a button. The "Use a custom
  wallet instead" sub-link beneath becomes the only affordance for
  the swap.
* Stack the "← Use my Agora wallet" link beneath the "Custom wallet"
  heading instead of placing it on the same row. Two pieces of
  hierarchy fighting for the same line was too much; the swap link
  reads more clearly on its own line.
* Drop the icons (sparkles / bitcoin / radar) from the accept-mode
  toggle pills. Each pill now carries just its label. The icons
  were trying to compress meaning into one glyph each and the
  captions already say the same thing.
* Expand the toggle labels to "Accept All" / "Public Only" /
  "Private Only" — full enough to read as commands rather than tags.

Cleans up the lucide imports (Pencil, Sparkles, Bitcoin, Radar) and
locale key (walletEditAria) the previous version introduced and the
new version no longer needs.
2026-05-29 13:39:18 -07:00
lemon 6e4eff602a Redesign the wizard's wallet step around the user's wallet card
The wallet step previously stacked two generic dropdowns (source +
accept) on top of two custom-address inputs that the user had to expand
explicitly. Every donation flow starts the same way: pick "my wallet"
and accept everything. The redesign treats that path as the default
view, not one of two dropdown options.

What changed:

* The Source dropdown becomes an inline identity card — avatar +
  display name on the left, live USD/BTC balance on the right
  (modeled on the wallet-page treatment), pencil affordance on the
  far right. Tapping anywhere on the card swaps the view into the
  custom-wallet inputs; a quieter "Use a custom wallet instead"
  sub-link beneath it offers the same swap. From custom mode a small
  "← Use my Agora wallet" mirror-link snaps back.

* The Accept dropdown becomes a three-pill segmented ToggleGroup —
  All / Public / Private — with icons (sparkles / bitcoin / radar)
  and a one-line caption beneath that explains the current
  selection. The All and Private buttons disable when silent
  payments aren't supported by the current login. Default stays
  'all' (HD wallet with SP); empty toggle deselects are coerced
  back to the previous value since the field is required.

* Balance comes from the parent's existing useHdWallet hook (passed
  in via new `totalBalance` + `balanceLoading` props) plus an
  in-component useBtcPrice call. Loading state shows a small
  Skeleton in place of the price line; missing price falls back to
  BTC-only.

* When no HD wallet is available (extension / bunker logins) the
  picker collapses to just the two custom inputs with the existing
  intro copy — no card, no toggle.

Existing locale keys are reused where the strings still fit; new
ones cover the toggle short labels, the captions, and the swap
affordances. The wider "Custom" label widens to "Custom wallet" so
the segmented header reads cleanly. Other locales fall back to
English on the new keys until the copy settles.
2026-05-29 13:39:18 -07:00
lemon 337d18951a Split the campaign wizard into six single-purpose steps
The previous four-step layout bundled title with wallet and banner with
story. Each pairing forced the user to mentally context-switch inside a
single screen. Splitting them out makes every step ask exactly one
question:

    1. title
    2. wallet
    3. banner
    4. story
    5. goal + deadline
    6. country + tags (terminal)

The 'Skip Next & Launch' shortcut now appears from step 3 onward — once
both required steps (title @ 1, wallet @ 2) are cleared. Earlier steps
hide the shortcut entirely so the user can't try to publish before the
wallet picker has been shown.

The wizard signature changes from positional step1..step4 props to a
single `steps` array plus a `canAdvanceFromStep` predicate and a
`launchAvailableFromStep` cursor, so future step inserts / removals
don't ripple through the type. Step state moves from a `1|2|3|4`
literal union to `number`, validated against `steps.length` at runtime.

Step copy is rewritten to be concise — one question, one line of
context. Other locales already fell back to English; the wizard keys
they don't yet have stay untranslated until the copy settles.
2026-05-29 13:39:18 -07:00
lemon 31154f382d Polish the campaign wizard's header and shortcut affordance
Four small refinements on the captive overlay:

* Drop the 'Step N of 4' eyebrow above each title — the sticky
  progress fill at the top of the overlay already carries that signal,
  and removing the duplicate keeps the focus on the step heading.
* Rename the launch shortcut on steps 1-3 from 'Launch campaign' to
  'Skip Next & Launch' so its relationship to the primary Next button
  is unambiguous. Step 4's terminal button keeps the 'Launch campaign'
  label (it isn't a shortcut, it's the only forward action).
* Move the per-step Back affordance from a text link under the launch
  button up to a round icon button mirroring the close X in the
  top-left corner. The two header buttons now bracket the dialog
  symmetrically and the footer stays focused on forward motion.
* Reverse the order of the banner and story fields inside step 2 so
  the banner upload sits on top — it's the first thing a donor sees on
  the campaign card and feels like the natural first decision when
  telling the story.

Campaign launches still navigate to the campaign details page on
success via encodeCampaignNaddr in submitMutation.onSuccess; no change
needed there.
2026-05-29 13:39:18 -07:00
lemon 236e6aa211 Render the campaign wizard as a fullscreen captive overlay
Mounts the create-mode wizard as a 'fixed inset-0 z-50' dialog so it
sits above the persistent TopNav, matching the captive OnboardingGate
signup flow. Creating a campaign is now a focused, distraction-free
task without the app's regular chrome competing for attention.

The page-level back arrow + heading are replaced by an unobtrusive
top-right X (same affordance as the onboarding overlay). The
OrganizationContextChip — previously sat under the page heading —
moves inline into step 1 so the 'publishing under <org>' context
isn't lost.

Edit mode is unaffected — it still renders inside the normal
FundraiserLayout with the page header intact.
2026-05-29 13:39:18 -07:00
lemon 8c684aeef2 Restyle the campaign wizard after the captive onboarding flow
Swaps the segmented-pill progress indicator and boxed step body for the
visual language Chad established in OnboardingGate: a sticky single-bar
progress fill across the top, a centered narrow column per step, a
centered eyebrow / heading / subtitle block, a big rounded-full primary
CTA, and a subtle text 'Back' link. Steps now fade-and-slide in on
transition so the swap reads as navigation rather than a re-render.

Step boundaries are unchanged. Step 1 still holds the required fields
(title + wallet) and gates 'Next' on a non-empty title; every step from
1 onward surfaces a ghost 'Launch campaign' shortcut so the rest of the
wizard stays opt-in. Step 4 is terminal — its only forward action is
the primary 'Launch campaign' button.

Edit mode is unaffected — it keeps the single-page form.
2026-05-29 13:39:18 -07:00
lemon ab59960233 Break the new-campaign form into a four-step wizard
New campaigns now use a multi-step flow modeled on AuthDialog's signup:
required fields (title + wallet) on step 1, story + banner on step 2,
goal + deadline on step 3, country + tags on step 4. A 'Launch campaign'
button sits next to 'Next' on every step from step 1 onward, so once the
required fields are filled the user can publish immediately and skip the
rest. Step 1's 'Next' is disabled until the title is non-empty.

Edit mode (?edit=naddr) keeps the original single-page layout — all
pre-populated fields stay visible and editable in one place, which the
linear wizard isn't optimized for.
2026-05-29 13:39:18 -07:00
lemon 3565ebf098 Hoist three duplicated discovery helpers to shared modules
Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.

- getPledgeCoord → src/lib/pledges.ts. Was defined three times
  (PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
  the same '36639:<pubkey>:<d>' template. Lifted into the existing
  pledges lib and typed structurally on { pubkey, id } so the lib
  layer doesn't take a hook dep on Action.

- parseSort + toQuerySort → exported from useDiscoveryFilters and
  useAllCampaigns respectively. AllCampaignsPage was carrying its own
  copy of both with an apologetic comment ('mirroring the one in
  useDiscoveryFilters'); CampaignsDiscoverySection had its own
  toQuerySort. One source of truth each now, with the pages and the
  section importing from the same module as the hook that consumes
  the result.

- PledgeCardSkeleton → exported from PledgeCard. Replaces two
  byte-identical ActionSkeleton components in PledgesDiscoverySection
  and ActionsPage. Naming matches the existing CampaignCardSkeleton /
  CommunityMiniCardSkeleton convention of placing the skeleton next
  to its card.
2026-05-29 13:39:18 -07:00
lemon 0dcc2f2b93 Skip wasted discovery-section queries
Two related gates on the unified discovery sections:

- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
  query only feeds the idle render branch (via idlePledges), but it
  was firing in active-search mode too. Active mode renders searchHits
  from useNip50Search, which never reads rawActions. On every keystroke
  that activates search we were burning a 300-event relay round-trip
  whose results went nowhere. Gate the query on !isSearching so the
  fetch happens only when the idle branch can actually consume it.

- CampaignsDiscoverySection: align the featured-coords useCampaigns
  query's enabled flag with the pledges section's pattern. useCampaigns
  already short-circuits on an empty coordinates array, so this is
  purely about not creating an empty cache entry when moderators have
  curated nothing — but it removes a small asymmetry that would have
  made the next reviewer second-guess which pattern is intentional.
2026-05-29 13:39:18 -07:00
lemon 4cbc9f64c1 Extract three reusable discovery sections shared by home and dedicated pages
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:

  CampaignsDiscoverySection  src/components/discovery/
  GroupsDiscoverySection
  PledgesDiscoverySection

Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:

  filterPersistence='url'   - flat ?q=&sort=&country= params. Used by
                              the dedicated pages so search results
                              are shareable and survive refresh.
  filterPersistence='local' - local-only state. Used by / where three
                              sections coexist and can't all own ?q=.
                              Refreshing the home lands on the curated
                              idle view, which matches what we want.

The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.

Side effects:

  - ActionShareMenu moves from inside ActionsPage to its own file
    so PledgesDiscoverySection can render it on every card without
    re-importing the page module.

  - useDiscoverCommunities is unchanged but only the dedicated
    /groups page calls it now (for the Hidden collapsible /
    hidden-count badge). The home page never triggers it.

  - browseAllGroups and browseAllPledges locale keys drop from all
    16 locales since the launchpad layout that needed them no
    longer exists.
2026-05-29 13:39:18 -07:00
lemon 7ccff2fbad Turn the home page into a featured-only launchpad for all three surfaces
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.

Rebuild / as a three-section launchpad:

  Hero  -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
  Featured campaigns  -> capped at 4, links to /campaigns/all.
  Featured groups     -> capped at 8, links to /groups.
  Featured pledges    -> capped at 8, links to /pledges.

Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.

Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.

Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.

Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
2026-05-29 13:39:18 -07:00
lemon 83554c726d Keep skeleton up while moderation labels are still resolving
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.

Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
2026-05-29 13:39:18 -07:00
lemon 3adaf9709f Render featured groups directly without intermediate event flash
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.

Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.

useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
2026-05-29 13:39:18 -07:00
lemon eebb6bf424 Merge Featured and All sections on discovery pages
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:

- Idle (no query, no sort, no country) shows the moderator-featured
  grid. If nothing is featured yet, falls back to the chronological
  all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
  the full result set, ranked or chronological per the toolbar.

The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.

Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
2026-05-29 13:39:18 -07:00
lemon 51d3acd076 Shorten All groups tagline 2026-05-29 13:39:18 -07:00
lemon 58bcd56787 Hide group shelves until content is known
Regression-of: 5607f5fa
2026-05-29 13:39:18 -07:00
lemon 2b00cf9d7b Hide Featured headers when no events are surfaced
Regression-of: 9663b05e
2026-05-29 13:39:18 -07:00
lemon 2bfe712e2c Hide My pledges header when the user has no pledges 2026-05-29 13:39:18 -07:00
lemon 30258d8ad1 Hide My campaigns header when the user has no campaigns 2026-05-29 13:39:18 -07:00
lemon f8b9fdf8b9 Hide My groups header when the user has no groups 2026-05-29 13:39:18 -07:00
Chad Curtis 7761d01c79 Batch single-#a query filters in NostrBatcher
The /feed page was firing one REQ per visible CampaignCard for
`{ kinds: [8333], '#a': [aTag], limit: 500 }` (kind 8333 donations
keyed to that card's campaign coordinate). With 25 Agora entities
per page that was ~25 parallel single-aTag REQs hitting the relay
in the same render tick — enough for relay.ditto.pub to drop the
socket with a 1005 close.

Mirror the existing single-#e pattern: detect single-#a filters,
collect them per (kinds, limit) shape over a microtask, then issue
one combined REQ with the merged `#a` array and group the result
events back to their callers by which addressable coordinate they
reference. Same approach `useAllCampaigns` already uses by hand for
its campaigns grid, now applied transparently to every per-card
hook (useCampaignDonations, useEventRSVPs, useMyRSVP, the badge and
livestream-chat single-coord lookups).
2026-05-29 14:01:41 -05:00
97 changed files with 12681 additions and 3996 deletions
+6 -6
View File
@@ -37,12 +37,12 @@ deploy-web:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
variables:
# Vite inlines VITE_* env vars at build time. Sourced from GitLab CI/CD
# variables so values can be rotated without a code change.
VITE_TRANSLATE_WORKER_URL: $VITE_TRANSLATE_WORKER_URL
VITE_PLAUSIBLE_DOMAIN: $VITE_PLAUSIBLE_DOMAIN
VITE_PLAUSIBLE_ENDPOINT: $VITE_PLAUSIBLE_ENDPOINT
# Vite inlines VITE_* env vars at build time. These are sourced directly from
# project-level CI/CD variables, which are already present in the job
# environment — do NOT re-declare them here as `KEY: $KEY`. That self-reference
# overwrites the real value with the literal string "$KEY" whenever the source
# variable is out of scope (e.g. a Protected variable on an unprotected ref),
# which is how "$VITE_TRANSLATE_WORKER_URL" leaked into the built app.
script:
# Build the web app
- npm ci
+59 -19
View File
@@ -22,7 +22,7 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
### Agora Content Marker
@@ -70,7 +70,7 @@ Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags a
#### Backward compatibility
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns/all`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
### Community Chat
@@ -498,7 +498,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
### Agora Moderation Labels
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
@@ -521,27 +521,26 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
Two independent axes are defined; the newest moderator-signed label per axis per coordinate wins. All three surfaces (campaigns, organizations, pledges) use the same two axes — every Agora-tagged entity is publicly visible by default, and moderation reduces to suppressing unwanted entries (`hide`) and lifting curated ones into a featured row (`featured`).
| Axis | Values | Surfaces | Meaning |
|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
> **Legacy `approved` / `unapproved` labels.** A previous revision of this spec defined a third axis ("approval") used only by campaigns to gate which campaigns appeared on the home page. The axis was retired once `featured` became the single positive-curation mechanism on the home page. Clients MUST ignore `approved` / `unapproved` labels and SHOULD NOT publish new ones. Existing labels in relay archives are dead data.
Surfacing rules (hide always wins):
**Campaigns**
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
- **Discover shelf** — iff approved AND not hidden.
- **Moderator-only "Pending"** — iff neither approved nor hidden.
- **Moderator-only "Hidden"** — iff hidden.
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank (see Moderator-driven Ordering), descending.
- **Discover shelf on `/campaigns`** — iff the latest hide label is not `hidden`. Every non-hidden campaign on the network is enumerable here; the home page's Featured row is a curated subset, not a gate.
- **Moderator-only "Hidden"** — iff hidden. Surfaces the suppressed set so moderators can unhide.
**Organizations**
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank, descending (see Moderator-driven Ordering).
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
- **Moderator-only "Hidden"** — iff hidden.
@@ -554,6 +553,28 @@ Surfacing rules (hide always wins):
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
#### Moderator-driven Ordering
The Featured row is sorted by the **effective rank** of the moderator's latest `featured` label per campaign coordinate, descending.
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature actions naturally float to the top.
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
A moderator MAY reorder the row by republishing the `featured` label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
- **Move down by one** — publish with `rank = neighborBelow.rank - 1`. Only the moved campaign's label is republished; the neighbor below is untouched.
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any integer rank strictly between their ranks. When the gap is too tight (`prev.rank - next.rank < 2`), clients SHOULD pick `next.rank + 1` and accept that the rendered list may briefly be off by one until the next reorder leaves a wider gap. Using a sub-second-resolution `freshRank` keeps inter-rank gaps wide enough for many midpoint inserts before any renumbering is needed.
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same hide / feature state they always have.
The featured row is the only Agora surface that uses moderator-driven ordering today. The same mechanism MAY be applied to the organization or pledge featured shelves if those grow a curation UI; until then, those shelves sort by `created_at` (the legacy behavior, identical to using a missing rank tag).
#### Event Structure
```json
@@ -562,9 +583,9 @@ Surfacing rules (hide always wins):
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "approved", "agora.moderation"],
["l", "featured", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["alt", "Campaign moderation: approved"]
["alt", "Campaign moderation: featured"]
]
}
```
@@ -606,6 +627,26 @@ Required tags:
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
Optional tags:
- `rank` — single string element parsed as an integer. Used on `featured` labels to position the target within the moderator-curated Featured row; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
A label with a rank tag looks like:
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "featured", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["rank", "1700000000123000"],
["alt", "Campaign moderation: featured"]
]
}
```
#### Trust Model
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
@@ -618,8 +659,8 @@ d-tag: k4p5w0n22suf
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
- Self-approval is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
- Self-promotion is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive on the Featured row only by their labels fall off the row until another moderator features them.
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
@@ -652,10 +693,9 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
#### Client Behavior
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
- Clients SHOULD render hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations/pledges when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Authors' own campaigns, organizations, and pledges are visible at their NIP-19 routes regardless of moderation state. The campaign URL remains live and donatable even when the campaign is not on the home page's Featured row.
---
+102 -37
View File
@@ -52,8 +52,8 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -102,7 +102,6 @@
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"iso-3166": "^4.4.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qr-scanner": "^1.4.2",
@@ -149,6 +148,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"iso-3166": "^4.4.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
@@ -196,6 +196,44 @@
"lru-cache": "^10.4.3"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/util": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.222.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz",
"integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2468,9 +2506,9 @@
}
},
"node_modules/@nostrify/nostrify": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.2.tgz",
"integrity": "sha512-X4pteBW9p2sVhBX9Dxt7Wf+beJYI7ophfEopcNmaTipNdj/u1LeS5ufze2fKozTvje53s4MoK7+DkMpRtFSKDg==",
"dependencies": {
"@nostrify/types": "0.37.0",
"@scure/base": "^2.0.0",
@@ -2485,18 +2523,18 @@
}
},
"node_modules/@nostrify/nostrify/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostrify/nostrify/node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -2509,11 +2547,11 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.2.tgz",
"integrity": "sha512-D7SXjhEQ74Gd3aEjlG4FOzrDZ/uPMb3LgWwGmZg48F8noRWKAUjDBS9i7d3J6lShPBydw/BLg7Yhue2GValAhg==",
"dependencies": {
"@nostrify/nostrify": "0.52.0",
"@nostrify/nostrify": "0.52.2",
"@nostrify/types": "0.37.0"
},
"peerDependencies": {
@@ -5832,10 +5870,36 @@
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@smithy/core": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz",
"integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
"integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5845,13 +5909,12 @@
}
},
"node_modules/@smithy/util-base64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
"integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.6.tgz",
"integrity": "sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"@smithy/core": "^3.24.6",
"tslib": "^2.6.2"
},
"engines": {
@@ -5859,24 +5922,25 @@
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.2",
"@smithy/is-array-buffer": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
"node": ">=14.0.0"
}
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
"integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.3.6.tgz",
"integrity": "sha512-ooo5MQdstAtIlgS0bchoMkVsQ3x1wLLPtFilpeIV8wVtpwZYY8PoSdlvR79+yw0aJU9hjd8stKsmzIxrmAQ6fw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.6",
"tslib": "^2.6.2"
},
"engines": {
@@ -5884,16 +5948,16 @@
}
},
"node_modules/@smithy/util-utf8": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-buffer-from": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
"node": ">=14.0.0"
}
},
"node_modules/@standard-schema/utils": {
@@ -9333,6 +9397,7 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/iso-3166/-/iso-3166-4.4.0.tgz",
"integrity": "sha512-I6ylkNQgxVh7cYADMUJpqBUdremGvyGZkDRSk9Cdic/ITBUemsllQnUeRpz7yDKyfgAXI9oPa5A9dia+7IXLqw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
+3 -3
View File
@@ -59,8 +59,8 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -109,7 +109,6 @@
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"iso-3166": "^4.4.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qr-scanner": "^1.4.2",
@@ -156,6 +155,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"iso-3166": "^4.4.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
+39
View File
@@ -0,0 +1,39 @@
// Generate src/lib/subdivisionCodes.ts — the authoritative list of ISO 3166-2
// subdivision codes, extracted from the `iso-3166` package.
//
// We ship only the code strings (~42 KB) instead of importing the full
// `iso-3166` dataset (~244 KB of objects with names, parents, and tree
// structure) into the critical-path bundle. The only thing the runtime needs
// these for is validating that a `CC-XX` code is a real subdivision
// (see src/lib/countries.ts `isValidSubdivisionCode`).
//
// Run with: node scripts/gen-subdivision-codes.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { iso31662 } from 'iso-3166';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const OUTPUT = path.join(REPO_ROOT, 'src/lib/subdivisionCodes.ts');
const codes = [...new Set(iso31662.map((s) => s.code))].sort();
const header = `// AUTO-GENERATED — do not edit by hand.
//
// The authoritative list of ISO 3166-2 subdivision codes, extracted from the
// \`iso-3166\` package at build time. We ship only the code strings (~42 KB)
// instead of importing the full \`iso-3166\` dataset (~244 KB of objects with
// names, parents, and tree structure) into the critical-path bundle, since
// the only thing the runtime needs these for is validating that a \`CC-XX\`
// code is a real subdivision.
//
// Regenerate with: node scripts/gen-subdivision-codes.mjs
`;
const body = `export const SUBDIVISION_CODES: readonly string[] = ${JSON.stringify(codes)};\n`;
fs.writeFileSync(OUTPUT, header + body);
console.log(`Wrote ${path.relative(REPO_ROOT, OUTPUT)} (${codes.length} codes)`);
+1
View File
@@ -162,6 +162,7 @@ const hardcodedConfig: AppConfig = {
aiApiKey: '',
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
};
/**
+7 -2
View File
@@ -21,6 +21,7 @@ import NotFound from "./pages/NotFound";
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
const CampaignListDetailPage = lazy(() => import("./pages/CampaignListDetailPage").then(m => ({ default: m.CampaignListDetailPage })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
@@ -187,9 +188,13 @@ export function AppRouter() {
constraints. */}
<Route element={<FundraiserLayout narrow={false} />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns" element={<AllCampaignsPage />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
<Route path="/campaigns/lists/:slug" element={<CampaignListDetailPage />} />
{/* Legacy URL: the all-campaigns directory lived at
`/campaigns/all` for a while. Keep it as a redirect so
external links and bookmarks still resolve. */}
<Route path="/campaigns/all" element={<Navigate to="/campaigns" replace />} />
<Route path="/groups" element={<CommunitiesPage />} />
<Route path="/groups/new" element={<CreateCommunityPage />} />
<Route path="/events/new" element={<CreateEventPage />} />
+193
View File
@@ -0,0 +1,193 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import {
Check,
Link as LinkIcon,
Loader2,
MoreHorizontal,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ModerationMenuItems } from '@/components/moderation';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { getPledgeCoord } from '@/lib/pledges';
import type { Action } from '@/hooks/useActions';
/**
* Per-card kebab menu for pledges. Surfaces:
* • Delete (owner only) — NIP-09 with both `e` and `a` tags so
* relays that ignore a-tag-only deletions still drop the event.
* • Copy link — naddr1 URL on the current share origin.
* • Moderation actions (mods only) — hide / feature, under a
* separator that only renders when the viewer is a moderator.
*
* Lives outside `ActionsPage` so both the page and the reusable
* `PledgesDiscoverySection` can pin it to the card's `topRight` slot
* without duplicating the logic.
*/
export function ActionShareMenu({
action,
displayTitle,
}: {
action: Action;
displayTitle: string;
}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { mutateAsync: createEvent } = useNostrPublish();
const { toast } = useToast();
const shareOrigin = useShareOrigin();
const queryClient = useQueryClient();
const [copied, setCopied] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isOwner = user?.pubkey === action.pubkey;
// Moderator gate is identical to the one in `ModerationMenuItems`,
// duplicated here so we can decide whether to render the trailing
// separator that introduces the moderator section.
// `ModerationMenuItems` returns `null` for non-mods, so without
// this check we'd render an orphaned separator at the bottom of
// the dropdown.
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const actionUrl = `${shareOrigin}/${naddr}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(actionUrl);
setCopied(true);
toast({ title: t('pledges.card.linkCopied') });
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy link:', error);
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user || !isOwner) return;
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
if (!confirmed) return;
setIsDeleting(true);
try {
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
// honour a-tag-only deletions for addressable events.
await createEvent({
kind: 5,
content: t('pledges.card.deletedContent'),
tags: [
['e', action.event.id],
['a', getPledgeCoord(action)],
],
});
// Extract any organization `A` tag the pledge was associated with so
// the org's activity shelf and community feeds refresh too.
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
...(orgATag
? [
queryClient.invalidateQueries({
queryKey: ['organization-activity', orgATag],
}),
queryClient.invalidateQueries({
queryKey: ['community-actions', orgATag],
}),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return (
root === 'community-activity-feed' &&
typeof aTagsKey === 'string' &&
aTagsKey.split(',').includes(orgATag)
);
},
}),
]
: []),
]);
toast({ title: t('pledges.card.deleted') });
} catch (error) {
console.error('Failed to delete pledge:', error);
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
} finally {
setIsDeleting(false);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t('pledges.card.actionsAriaLabel')}
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{isOwner && (
<>
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t('pledges.card.deletePledge')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleCopyLink}>
{copied ? (
<Check className="h-4 w-4 mr-2 text-primary" />
) : (
<LinkIcon className="h-4 w-4 mr-2" />
)}
{t('pledges.card.copyLink')}
</DropdownMenuItem>
{/* Moderator actions appear under a separator when the viewer
is a Team Soapbox moderator. `ModerationMenuItems` returns
null for non-mods, so we gate the trailing separator on
the same `isMod` check to avoid an orphan separator at
the bottom of non-mod dropdowns. */}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems
coord={getPledgeCoord(action)}
entityTitle={displayTitle}
surface="pledge"
axes={['hide', 'featured']}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
+38
View File
@@ -17,6 +17,9 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
/** Build-time default translation worker URL from the environment variable. */
const DEFAULT_TRANSLATE_WORKER_URL = import.meta.env.VITE_TRANSLATE_WORKER_URL || '';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -34,6 +37,7 @@ export function AdvancedSettings() {
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [translateWorkerUrl, setTranslateWorkerUrl] = useState(config.translateWorkerUrl);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
@@ -399,6 +403,40 @@ export function AdvancedSettings() {
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
</div>
</div>
{/* Translation Worker URL */}
<div>
<Label htmlFor="translate-worker-url" className="text-sm font-medium">
Translation Worker URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
DeepL-backed worker endpoint used by the "Translate" button on notes. Receives a POST with the text and target language.
</p>
<Input
id="translate-worker-url"
type="url"
value={translateWorkerUrl}
onChange={(e) => setTranslateWorkerUrl(e.target.value)}
onBlur={async () => {
const trimmed = translateWorkerUrl.trim();
if (trimmed && trimmed !== config.translateWorkerUrl) {
updateConfig(() => ({ translateWorkerUrl: trimmed }));
if (user) await updateSettings.mutateAsync({ translateWorkerUrl: trimmed });
toast({ title: 'Translation worker URL updated' });
}
}}
placeholder={DEFAULT_TRANSLATE_WORKER_URL || 'https://example.workers.dev'}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
{DEFAULT_TRANSLATE_WORKER_URL && (
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">{DEFAULT_TRANSLATE_WORKER_URL}</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
+6
View File
@@ -72,6 +72,12 @@ export function AppProvider(props: AppProviderProps) {
const config = {
...defaultConfig,
...rawConfig,
// An empty persisted translateWorkerUrl must not shadow the build-time
// default — fall back to the default so the Translate button stays
// available. (Earlier builds could persist "" by merely opening Settings.)
translateWorkerUrl: rawConfig.translateWorkerUrl?.trim()
? rawConfig.translateWorkerUrl
: defaultConfig.translateWorkerUrl,
// Deep-merge feedSettings so new keys added to the default are visible
// even for existing users who have an older feedSettings in localStorage.
feedSettings: { ...defaultConfig.feedSettings, ...rawConfig.feedSettings },
+172 -45
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ClipboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Bitcoin, EyeOff, QrCode, X } from 'lucide-react';
import { AlertTriangle, Bitcoin, EyeOff, QrCode, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
@@ -45,6 +46,45 @@ export interface ResolvedRecipient {
raw: string;
}
// ---------------------------------------------------------------------------
// Candidate extraction
// ---------------------------------------------------------------------------
/**
* Resolve a piece of recipient text into the valid on-chain and/or
* silent-payment candidates it carries.
*
* Handles bare `bc1…` / `sp1…` addresses and `bitcoin:` BIP-21 URIs (which
* may carry an on-chain path, an `sp=` parameter, or both). Returns empty
* strings for whichever kind isn't present/valid. Shared by the live
* input memo and the paste handler so both agree on what counts.
*/
function resolveCandidates(text: string): { btc: string; sp: string } {
const trimmed = text.trim();
if (!trimmed) return { btc: '', sp: '' };
const bip21 = parseBitcoinUri(trimmed);
// On-chain: the URI path (when present) or the raw input. SP addresses
// live in the `sp` field; don't double-count them as on-chain.
const btcRaw = bip21 ? bip21.address : trimmed;
const btc =
btcRaw && !isSilentPaymentAddress(btcRaw) && validateBitcoinAddress(btcRaw)
? btcRaw
: '';
// Silent payment: prefer the URI `sp=` parameter; otherwise the path may
// itself be an sp1 address (rare but legal — `bitcoin:sp1…` is a URI
// without an on-chain fallback), or the raw input is a bare sp1.
const spRaw = bip21 ? (bip21.sp ?? bip21.address) : trimmed;
const sp =
spRaw && isSilentPaymentAddress(spRaw) && validateSilentPaymentAddress(spRaw)
? spRaw
: '';
return { btc, sp };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -62,9 +102,8 @@ interface BitcoinRecipientInputProps {
* pre-fill a `bitcoin:…` URI or bare address so the donor only needs to
* pick from the dropdown.
*
* Re-applied each time the value transitions from non-null → null while
* `initialInput` is set, so a "clear chip" inside a prefilled flow
* restores the prefilled text instead of leaving the field empty.
* Applied on mount only. Clearing a selected chip (value → null) returns
* to an empty input rather than restoring the prefill.
*/
initialInput?: string;
}
@@ -89,9 +128,10 @@ interface BitcoinRecipientInputProps {
* returns to the input view.
*
* Anything else (npub, nprofile, free text) is silently ignored — there is
* no account search here, by design. Refocusing or clicking the input while
* it still contains a BIP-21 URI reopens the dropdown so the donor can swap
* between the available options without retyping.
* no account search here, by design. The dropdown stays open as long as the
* input holds at least one valid candidate; it doesn't dismiss when the
* input loses focus or the user taps elsewhere. It closes only on selection,
* when the input is cleared, or on Escape.
*/
export function BitcoinRecipientInput({
value,
@@ -105,25 +145,19 @@ export function BitcoinRecipientInput({
// Local input state. Independent of `value` so the user can keep typing
// after dismissing the dropdown without losing their query, and so the
// chip-cleared view starts blank instead of repopulating the previous
// selection.
// selection. `initialInput` only seeds the field on first mount —
// clearing the chip (value → null) returns to an empty input, not the
// prefill.
const [query, setQuery] = useState<string>(initialInput ?? '');
const [open, setOpen] = useState(false);
// Tracks whether the popover has been opened at least once for the
// current query. The "choose a payment method" hint suppresses on the
// very first render so callers prefilling the input don't see the hint
// flash for one frame before the auto-open effect runs.
const [hasOpenedForQuery, setHasOpenedForQuery] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Re-apply `initialInput` whenever the picker returns to the input view
// (value !== null → null) AND there is no active query the user might be
// editing. This restores the prefilled URI after a "clear chip" in flows
// like the campaign donate page, without clobbering a fresh edit.
const prevValueRef = useRef<ResolvedRecipient | null>(value);
useEffect(() => {
const justCleared = prevValueRef.current !== null && value === null;
if (justCleared && initialInput && query.length === 0) {
setQuery(initialInput);
}
prevValueRef.current = value;
}, [value, initialInput, query]);
// ── Candidate extraction ──────────────────────────────────────────────
//
// BIP-21 `bitcoin:` URI handling. If the input is a URI, we route the
@@ -132,25 +166,10 @@ export function BitcoinRecipientInput({
// (on-chain). A raw bc1…/sp1… input falls through here unchanged: `bip21`
// is null and the candidate is just the trimmed query.
const trimmed = query.trim();
const bip21 = useMemo(() => parseBitcoinUri(trimmed), [trimmed]);
const btcCandidate = useMemo(() => {
const c = bip21 ? bip21.address : trimmed;
if (!c) return '';
// sp addresses live in spCandidate; don't double-count.
if (isSilentPaymentAddress(c)) return '';
return validateBitcoinAddress(c) ? c : '';
}, [bip21, trimmed]);
const spCandidate = useMemo(() => {
// From the URI: prefer `sp=` if valid; otherwise the path may itself be
// an sp1 address (rare but legal — `bitcoin:sp1…` is just a URI without
// an on-chain fallback).
const c = bip21 ? (bip21.sp ?? bip21.address) : trimmed;
if (!c) return '';
if (!isSilentPaymentAddress(c)) return '';
return validateSilentPaymentAddress(c) ? c : '';
}, [bip21, trimmed]);
const { btc: btcCandidate, sp: spCandidate } = useMemo(
() => resolveCandidates(trimmed),
[trimmed],
);
const hasBtc = !!btcCandidate;
const hasSp = !!spCandidate;
@@ -161,11 +180,20 @@ export function BitcoinRecipientInput({
useEffect(() => {
if (trimmed.length === 0) {
setOpen(false);
setHasOpenedForQuery(false);
return;
}
if (hasSp || hasBtc) setOpen(true);
}, [trimmed, hasSp, hasBtc]);
// Track the first time the popover opens for the current query, so the
// "choose a payment method" hint only appears after the donor has had a
// chance to see (and dismiss) the dropdown — not flash for one paint
// frame between mount and the auto-open effect above.
useEffect(() => {
if (open) setHasOpenedForQuery(true);
}, [open]);
// ── Selection callbacks ───────────────────────────────────────────────
const selectBtc = useCallback(
(address: string) => {
@@ -187,6 +215,67 @@ export function BitcoinRecipientInput({
[onChange, query],
);
// ── Mount-time auto-select for single-endpoint prefills ────────────────
//
// When the picker mounts pre-filled (e.g. the campaign "Pay with Agora"
// flow) and `initialInput` resolves to exactly one valid candidate, skip
// the dropdown and select it directly so it lands as a chip. When the
// prefill carries *both* an on-chain address and an sp1 code we leave it
// in the input and let the dropdown surface both rows — that's a genuine
// choice the donor must make (privacy vs. compatibility).
//
// Guarded by a ref so it fires once per mount and never overrides a
// selection the user has already made or a `clear chip → restore prefill`
// transition (the picker is keyed on each open in the dialog, so a fresh
// mount is the right granularity).
const autoSelectedRef = useRef(false);
useEffect(() => {
if (autoSelectedRef.current) return;
autoSelectedRef.current = true;
if (value || !initialInput) return;
if (totalItems !== 1) return;
if (hasSp) {
selectSp(spCandidate);
} else if (hasBtc) {
selectBtc(btcCandidate);
}
// Intentionally mount-only: candidates are derived from `initialInput`
// (via the initial `query`), so reading them here reflects the prefill.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Paste auto-select ──────────────────────────────────────────────────
//
// When the user pastes text that resolves to exactly one valid candidate
// (a bare `bc1…` / `sp1…` address or a single-endpoint `bitcoin:` URI),
// convert it straight into a chip instead of making them click the lone
// dropdown row. A paste carrying *both* an on-chain address and an sp1
// code falls through to the normal dropdown so the donor picks privacy
// vs. compatibility.
//
// We resolve from the pasted text directly because `query` state hasn't
// updated yet inside the paste event. Returning early on a single match
// lets us `preventDefault()` so the input never flickers the raw text.
const handlePaste = useCallback(
(e: ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData('text');
if (!pasted) return;
const { btc, sp } = resolveCandidates(pasted);
const count = (btc ? 1 : 0) + (sp ? 1 : 0);
if (count !== 1) return; // 0 → let it land as text; 2 → use the dropdown.
e.preventDefault();
if (btc) {
onChange({ address: btc, kind: 'address', raw: pasted.trim() });
} else {
onChange({ address: sp, kind: 'sp', raw: pasted.trim() });
}
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange],
);
// ── QR scan handling ──────────────────────────────────────────────────
/**
* Interpret a freshly-scanned QR code.
@@ -276,6 +365,7 @@ export function BitcoinRecipientInput({
id="hd-recipient-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
onPaste={handlePaste}
// Reopen on focus so a user can recover the dropdown after an
// outside-click dismiss (the value is still in the field).
onFocus={() => {
@@ -315,22 +405,59 @@ export function BitcoinRecipientInput({
// the input and dismiss the mobile keyboard mid-type.
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
// The dropdown is a persistent choice list, not a transient
// hover-popover: it should stay open even when the input loses
// focus or the user taps elsewhere on the page, so blurring out
// doesn't make the candidate rows vanish. We block Radix's
// auto-dismiss-on-outside-interaction and instead close the
// dropdown explicitly — on selection, on a cleared input
// (the auto-open effect), or via Escape (still honored below).
onFocusOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
style={{ width: 'var(--radix-popover-trigger-width)' }}
className="p-0 w-[--radix-popover-trigger-width] max-h-none rounded-xl border border-border bg-popover shadow-lg overflow-hidden"
>
<div role="listbox" className="max-h-[280px] overflow-y-auto py-1">
{/* SP comes before BTC so the privacy-preserving option is
the user's first scan target when both are present. */}
{hasSp && (
<SpAddressRow address={spCandidate} onClick={selectSp} />
)}
{/* BTC comes before SP — the on-chain address is the
broadly-compatible default; the silent-payment option
follows for donors who want privacy. */}
{hasBtc && (
<BtcAddressRow address={btcCandidate} onClick={selectBtc} />
)}
{hasSp && (
<SpAddressRow address={spCandidate} onClick={selectSp} />
)}
</div>
</PopoverContent>
</Popover>
{/* Picker-closed reminder. When the input holds parseable candidates
but the donor hasn't actually picked one yet — typically because
they tapped an amount preset, which counts as an outside-click
and dismisses the popover — the Send button is disabled with no
visible reason. Surface an actionable hint that re-opens the
dropdown so the donor doesn't have to guess that they're meant
to tap the recipient input again.
Gated on `hasOpenedForQuery` so the hint doesn't flash for one
paint frame between mount and the auto-open effect on prefilled
inputs (campaign donate flow). */}
{hasOpenedForQuery && !popoverOpen && totalItems > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setOpen(true);
inputRef.current?.focus();
}}
className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400 motion-safe:transition-colors text-left"
>
<AlertTriangle className="size-3.5 shrink-0" />
<span>{t('walletSend.recipient.choosePaymentMethod')}</span>
</button>
)}
<QrScannerDialog
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
+197
View File
@@ -0,0 +1,197 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
getUniqueBitcoinFeeSpeeds,
type BitcoinFeeRates,
type BitcoinFeeSpeed,
} from '@/lib/bitcoinFeeSpeed';
import {
isFeeRecoverable,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
interface BroadcastErrorAlertProps {
/** Classifier output from {@link classifyBroadcastError}. */
error: BroadcastErrorKind;
/** Currently-resolved sat/vB rate, used to decide whether bump can do anything. */
currentFeeRate: number | undefined;
/** Currently-selected fee tier. */
feeSpeed: BitcoinFeeSpeed;
/** Loaded fee rates, used to compute the de-duped preset tier list. */
feeRates: BitcoinFeeRates | undefined;
/** Whether the underlying mutation is in flight (disables actions). */
isPending: boolean;
/** Bump-fee recovery action. */
onBumpFee: () => void;
/** Plain retry recovery action (used for `network` failures). */
onRetry: () => void;
/**
* When `true` the component knows there's no custom-rate input available
* in the consumer (e.g. {@link DonateDialog}), so we hide the bump button
* and surface a static "you're on the fastest tier" message once the
* user is already on the top preset.
*/
presetTiersOnly?: boolean;
}
/**
* Inline alert rendered above a Bitcoin transaction's Send button when a
* broadcast attempt is rejected. The classifier in
* {@link ../lib/bitcoinBroadcastError} maps the raw relay error onto a
* small enum; each kind gets specific copy and, where recovery is
* possible, an action button.
*
* Action button rules:
*
* - **Fee-recoverable kinds** (`feeTooLow`, `mempoolFull`,
* `rbfReplacementFeeTooLow`) get **Use a higher fee**, which calls
* `onBumpFee`. In `presetTiersOnly` consumers, the button is disabled
* when the user is already on the top preset and a separate hint
* suggests donating from an external wallet.
* - **`network`** gets **Try again**, which re-fires the mutation as-is.
* - **Everything else** gets no action button — the user has to adjust
* amount or recipient (which the consumer's auto-dismiss effect uses
* to clear the alert) before retrying.
*
* The toast surface is intentionally not used for classified failures.
* Toasts auto-dismiss and are visually disconnected from the fee picker;
* an inline alert directly above Send keeps the recovery in the donor's
* line of sight.
*/
export function BroadcastErrorAlert({
error,
currentFeeRate,
feeSpeed,
feeRates,
isPending,
onBumpFee,
onRetry,
presetTiersOnly,
}: BroadcastErrorAlertProps) {
const { t } = useTranslation();
const { title, body } = useMemo(() => {
switch (error.kind) {
case 'feeTooLow':
return {
title: t('walletSend.broadcastError.feeTooLowTitle'),
body: error.minRelayFeeRate
? t('walletSend.broadcastError.feeTooLowBodyWithMin', { min: error.minRelayFeeRate })
: t('walletSend.broadcastError.feeTooLowBody'),
};
case 'rbfReplacementFeeTooLow':
return {
title: t('walletSend.broadcastError.rbfTitle'),
body: t('walletSend.broadcastError.rbfBody'),
};
case 'mempoolFull':
return {
title: t('walletSend.broadcastError.mempoolFullTitle'),
body: t('walletSend.broadcastError.mempoolFullBody'),
};
case 'network':
return {
title: t('walletSend.broadcastError.networkTitle'),
body: t('walletSend.broadcastError.networkBody'),
};
case 'mempoolConflict':
return {
title: t('walletSend.broadcastError.mempoolConflictTitle'),
body: t('walletSend.broadcastError.mempoolConflictBody'),
};
case 'tooLongChain':
return {
title: t('walletSend.broadcastError.tooLongChainTitle'),
body: t('walletSend.broadcastError.tooLongChainBody'),
};
case 'badInputs':
return {
title: t('walletSend.broadcastError.badInputsTitle'),
body: t('walletSend.broadcastError.badInputsBody'),
};
case 'absurdlyHighFee':
return {
title: t('walletSend.broadcastError.absurdlyHighFeeTitle'),
body: t('walletSend.broadcastError.absurdlyHighFeeBody'),
};
case 'unknown':
default:
return {
title: t('walletSend.broadcastError.unknownTitle'),
// Fall back to the raw bitcoind / framing message so the donor
// (or a support thread) has something concrete to act on. Empty
// when the classifier had no message to preserve.
body: 'raw' in error && error.raw ? error.raw : '',
};
}
}, [error, t]);
// Decide whether the bump-fee CTA is actually useful here. For consumers
// that ship a custom-rate input (the HD wallet flow), the bump is always
// useful — we either jump to a faster preset or escalate to a custom
// rate seeded from the error. For preset-only consumers (the donate
// flow), the button only makes sense while a faster preset exists; once
// the user is on the top preset they need to switch to an external
// wallet.
const uniquePresets = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
const isCustom = feeSpeed === 'custom';
const isOnTopPreset =
!isCustom
&& uniquePresets.length > 0
// Cast through the preset union to avoid `.indexOf` narrowing
// `feeSpeed` for the rest of the function body.
&& uniquePresets.indexOf(feeSpeed as Exclude<BitcoinFeeSpeed, 'custom'>) === 0;
const haveFeeHint =
error.kind === 'feeTooLow'
&& !!(error.minRelayFeeRate || error.actualFeeRate);
const showBumpFee = isFeeRecoverable(error.kind) && !(presetTiersOnly && isOnTopPreset);
const showAtMaxHint = presetTiersOnly && isOnTopPreset && isFeeRecoverable(error.kind);
const canBumpUsefully =
!isOnTopPreset || haveFeeHint || isCustom || !!currentFeeRate;
const showRetry = error.kind === 'network';
return (
<Alert variant="destructive" className="py-2.5">
<AlertTriangle className="size-4" />
<AlertTitle className="text-sm">{title}</AlertTitle>
{body && <AlertDescription className="text-xs mt-1">{body}</AlertDescription>}
{showAtMaxHint && (
<AlertDescription className="text-xs mt-1 font-medium">
{t('walletSend.broadcastError.atMaxFeeTier')}
</AlertDescription>
)}
{(showBumpFee || showRetry) && (
<div className="mt-2 flex flex-wrap gap-2">
{showBumpFee && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onBumpFee}
disabled={isPending || !canBumpUsefully}
>
{t('walletSend.broadcastError.useHigherFee')}
</Button>
)}
{showRetry && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
disabled={isPending}
>
{t('walletSend.broadcastError.tryAgain')}
</Button>
)}
</div>
)}
</Alert>
);
}
+31 -3
View File
@@ -47,13 +47,33 @@ function CampaignProgress({
raisedSats,
goalUsd,
btcPrice,
isLoading,
className,
}: {
raisedSats: number;
goalUsd?: number;
btcPrice?: number;
/**
* True while the donation totals are still being fetched. The bar gets
* its own skeleton — independent of the card, which paints immediately —
* so we never flash a misleading "0 raised" before the on-chain balance
* lands. Footprint matches the loaded state (bar row + one text row).
*/
isLoading?: boolean;
className?: string;
}) {
if (isLoading) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-2 w-full" />
<div className="flex items-baseline justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
</div>
);
}
const hasGoal = !!goalUsd && goalUsd > 0;
const raisedUsd = satsToUsd(raisedSats, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
@@ -127,6 +147,9 @@ interface CampaignCardProps {
*
* - `compact` — default grid item.
* - `featured` — hero placement (wider, side-by-side on `sm+`).
* The token is purely visual — it names the layout, not a
* curation state — and stayed after the moderator-level
* "Featured" concept was retired in favor of curated lists.
* - `shelf` — fixed-width card for horizontal scroll rails (e.g. group
* official-activity). Caller no longer hand-rolls the size wrapper.
*/
@@ -148,7 +171,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
@@ -247,7 +270,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
coord={campaign.aTag}
entityTitle={campaign.title}
surface="campaign"
axes={['approval', 'hide', 'featured']}
axes={['hide']}
badgeSize="default"
className="absolute top-3 right-3 z-10 flex items-center gap-2"
/>
@@ -284,7 +307,12 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
<CampaignProgress
raisedSats={raisedSats}
goalUsd={campaign.goalUsd}
btcPrice={btcPrice}
isLoading={donationsLoading}
/>
)}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
+11 -17
View File
@@ -51,14 +51,12 @@ function buildQrPayload(wallets: CampaignWallets): string {
* Inline panel rendering the campaign's wallet endpoints as a scannable
* QR code, a copyable string, and an "Open in wallet" button.
*
* Behavior:
* Behavior — the QR and the copyable row always carry a `bitcoin:`
* BIP-21 URI, regardless of which endpoints the campaign exposes:
*
* - **on-chain only** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address
* and a copyable row for the raw address.
* - **silent payment only** (`sp1…`) — raw silent-payment code QR and a
* copyable row for the raw SP code.
* - **both** — combined BIP-21 URI in the QR and a single copyable row
* containing the same `bitcoin:<addr>?sp=<sp>` URI; BIP-352-aware
* - **on-chain only** (`bc1q…` / `bc1p…`) — `bitcoin:<bc1>`.
* - **silent payment only** (`sp1…`) — `bitcoin:?sp=<sp1>`.
* - **both** — combined `bitcoin:<bc1>?sp=<sp1>` URI; BIP-352-aware
* wallets pick the SP path automatically, legacy wallets fall back to
* the on-chain address.
*
@@ -72,17 +70,13 @@ export function CampaignWalletDonatePanel({
}: CampaignWalletDonatePanelProps) {
const { t } = useTranslation();
const qrPayload = buildQrPayload(wallets);
const { onchain, sp } = wallets;
// When both endpoints are present, donors copy the same BIP-21 URI
// that the QR encodes — modern wallets parse it in their recipient
// field. When only one endpoint exists, the raw value is friendlier.
const copyValue = onchain && sp ? qrPayload : (onchain?.value ?? sp?.value ?? '');
const copyLabel = onchain && sp
? 'Payment URI'
: sp
? 'Silent-payment code'
: 'Bitcoin address';
// Donors always copy the same BIP-21 URI that the QR encodes — modern
// wallets parse it in their recipient field, and a `bitcoin:` URI
// round-trips through any wallet whether the campaign exposes an
// on-chain address, a silent-payment code, or both.
const copyValue = qrPayload;
const copyLabel = 'Payment URI';
return (
<div className="space-y-5">
+69
View File
@@ -0,0 +1,69 @@
import { useTranslation } from 'react-i18next';
import { CAMPAIGN_CATEGORIES } from '@/lib/campaignCategories';
import { cn } from '@/lib/utils';
export interface CategoryPickerProps {
/** Set of currently-selected category slugs. */
selected: Set<string>;
/** Called with the slug whenever a pill is tapped. */
onToggle: (slug: string) => void;
}
/**
* Multi-select pill row of curated content categories — shared by
* Agora's campaign and group creation flows. Each chip renders a
* Lucide icon + a localized label, and toggling it adds or removes
* that category's slug from the parent's selection set.
*
* The picker has no protocol-level awareness — the parent serializes
* each selected slug as an ordinary `['t', slug]` tag, which keeps
* the published events fully readable by any Nostr client that
* already understands content tags.
*
* Layout is a free-flowing `flex flex-wrap` row: each pill sizes to
* its own text, and the row breaks whenever the next pill wouldn't
* fit. The category list is intentionally shared between campaigns
* and groups so the same Lucide vocabulary feels consistent across
* the two flows.
*/
export function CategoryPicker({ selected, onToggle }: CategoryPickerProps) {
const { t } = useTranslation();
return (
// Free-flowing pill row: each chip sizes to its own text, the row
// wraps to a new line whenever the next chip wouldn't fit. Some
// rows naturally land at three pills, others at four — driven by
// the labels' intrinsic widths rather than a fixed column count.
// Each pill is fully rounded with generous horizontal padding so
// it reads as a tag, not a grid cell.
<div className="flex flex-wrap gap-2">
{CAMPAIGN_CATEGORIES.map(({ slug, labelKey, Icon }) => {
const isSelected = selected.has(slug);
return (
<button
key={slug}
type="button"
onClick={() => onToggle(slug)}
aria-pressed={isSelected}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm whitespace-nowrap transition-colors motion-safe:transition-shadow',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected
? 'border-primary bg-primary/10 text-foreground shadow-sm'
: 'border-border bg-background hover:border-primary/40 hover:bg-primary/5 text-muted-foreground hover:text-foreground',
)}
>
<Icon
className={cn(
'size-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground',
)}
aria-hidden="true"
/>
<span>{t(labelKey)}</span>
</button>
);
})}
</div>
);
}
+3 -2
View File
@@ -31,6 +31,7 @@ import { PostActionBar } from '@/components/PostActionBar';
import { CommentsSection } from '@/components/CommentsSection';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import {
AlertDialog,
@@ -515,7 +516,7 @@ function GroupActionColumn({
</div>
<div className="relative grid grid-cols-2 gap-3">
<Link
<StartCampaignLink
to={`/campaigns/new${createQuery}`}
className="group col-span-2 overflow-hidden rounded-2xl border border-primary/20 bg-primary text-primary-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:hover:translate-y-0"
>
@@ -534,7 +535,7 @@ function GroupActionColumn({
</span>
</div>
</div>
</Link>
</StartCampaignLink>
<Link
to={`/pledges/new${createQuery}`}
+56 -14
View File
@@ -1,38 +1,76 @@
import { useMemo, useState } from 'react';
import { useId, useMemo, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { MapPin, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { CountryFlag } from '@/components/CountryFlag';
import { Input } from '@/components/ui/input';
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
import { cn } from '@/lib/utils';
interface CountrySelectProps {
id: string;
export interface CountrySelectProps {
/** Current free-text query in the input. */
query: string;
/** Currently-selected ISO 3166 code (e.g. "US"). Empty string when none. */
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
/**
* Explicit DOM `id` for the input. Optional — a stable `useId()`
* value is generated when not provided. Callers that already wire
* their own form labels (e.g. a `<label htmlFor={…}>` outside the
* picker) should pass a known id; the wizard flows leave it
* auto-generated.
*/
id?: string;
/** Override the localized "Search countries" placeholder. */
placeholder?: string;
/**
* Hide the i18n hint that explains the `i: iso3166:<code>` tag
* we publish. Default `false` — the creation flows show it; the
* event-detail dialog hides it because the surrounding card already
* documents the behavior.
*/
hideHint?: boolean;
}
/**
* Combobox-style country picker used across Agora's creation flows
* (campaigns, groups, calendar events, …). Shows a `MapPin` icon, a
* clear button when a value is present, and a dropdown of
* `searchCountries(query)` results with full keyboard support
* (ArrowUp/Down/Enter/Escape).
*
* The selection produces a country code (`onSelect(country.code)`)
* that the parent serializes as `['i', 'iso3166:<CC>']` + `['k',
* 'iso3166']` on its event.
*
* All i18n strings live under the shared `forms.*` namespace so the
* picker drops into any flow without per-page key duplication.
*/
export function CountrySelect({
id,
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
id,
placeholder,
hideHint = false,
}: CountrySelectProps) {
const { t } = useTranslation();
// `useId` gives us a stable, unique pair of ids for the
// combobox/listbox association without forcing the caller to pass
// a name — important when the wizard mounts the picker multiple
// times across step navigations.
const generatedId = useId();
const inputId = id ?? generatedId;
const listboxId = `${inputId}-results`;
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined;
const results = useMemo(() => searchCountries(query), [query]);
const showResults = open && results.length > 0;
const resultsId = `${id}-results`;
const selectCountry = (country: CountryEntry) => {
onSelect(country);
@@ -45,7 +83,7 @@ export function CountrySelect({
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={id}
id={inputId}
value={query}
onChange={(e) => {
onQueryChange(e.target.value);
@@ -74,14 +112,14 @@ export function CountrySelect({
autoComplete="off"
role="combobox"
aria-expanded={showResults}
aria-controls={resultsId}
aria-controls={listboxId}
/>
{(query || selectedCode) && (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
aria-label="Clear country"
aria-label={t('forms.countryClearAria')}
>
<X className="size-4" />
</button>
@@ -89,7 +127,7 @@ export function CountrySelect({
{showResults && (
<div
id={resultsId}
id={listboxId}
role="listbox"
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
>
@@ -110,7 +148,7 @@ export function CountrySelect({
<CountryFlag
code={country.code}
emoji={country.flag}
label={`Flag of ${country.name}`}
label={t('forms.flagOfAria', { name: country.name })}
className="text-lg"
/>
</span>
@@ -124,9 +162,13 @@ export function CountrySelect({
)}
</div>
{selectedCountry && (
{selectedCountry && !hideHint && (
<p className="text-xs text-muted-foreground">
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
<Trans
i18nKey="forms.countryHint"
values={{ code: selectedCode }}
components={{ 0: <span className="font-mono text-foreground" /> }}
/>
</p>
)}
</div>
@@ -133,7 +133,14 @@ export function DiscoverySearchToolbar({
key={value}
checked={sort === value}
onCheckedChange={(checked) => {
// `checked === false` means the user clicked the
// currently-active item — return to the curated
// `default` view (featured-first) rather than leaving
// them stuck on Top/New with no exit affordance now
// that `default` is no longer an exposed option in the
// dropdown.
if (checked) onSortChange(value);
else onSortChange('default');
}}
// The checkbox slot on the left is hidden in favour of an
// explicit `Check` on the right (matches the
+74 -7
View File
@@ -37,6 +37,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import AuthDialog from '@/components/auth/AuthDialog';
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -53,6 +54,10 @@ import {
usdToSats,
type FeeRates,
} from '@/lib/bitcoin';
import {
classifyBroadcastError,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
import {
type ParsedCampaign,
} from '@/lib/campaign';
@@ -130,15 +135,30 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
const [comment, setComment] = useState('');
const [feeSpeed, setFeeSpeed] = useState<DonationFeeSpeed>('fastest');
const [result, setResult] = useState<DonateCampaignResult | null>(null);
/**
* Classified failure from the most recent broadcast attempt. Renders as
* an inline {@link BroadcastErrorAlert} above the Send button in the
* confirm step. Cleared when the donor adjusts the fee speed or returns
* to the form step.
*/
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
// Reset when the dialog reopens for a fresh donation.
useEffect(() => {
if (open) {
setStep('form');
setResult(null);
setBroadcastError(null);
}
}, [open]);
// Clear the broadcast-error alert whenever the donor adjusts the fee
// speed — the explicit recovery action — so the alert disappears when
// they engage with the picker.
useEffect(() => {
setBroadcastError(null);
}, [feeSpeed]);
const effectiveUsd = customUsd.trim()
? parseUsdInput(customUsd)
: amountUsd;
@@ -171,12 +191,21 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
}
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Donation failed',
description: msg,
variant: 'destructive',
});
const classified = classifyBroadcastError(error);
setBroadcastError(classified);
// Inline `<BroadcastErrorAlert>` in the confirm step is the primary
// recovery surface for classified failures; a destructive toast on
// top would just be noise. Keep the toast as a fallback for the
// catch-all `unknown` bucket so the donor always sees *something*
// even when we can't recognise the reject reason.
if (classified.kind === 'unknown') {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Donation failed',
description: msg,
variant: 'destructive',
});
}
},
});
@@ -249,8 +278,21 @@ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateD
feeSpeed={feeSpeed}
btcPrice={btcPrice}
isPending={donateMutation.isPending}
onBack={() => setStep('form')}
broadcastError={broadcastError}
onBack={() => {
setBroadcastError(null);
setStep('form');
}}
onSubmit={() => donateMutation.mutate()}
onBumpFee={() => {
// Step toward the fastest preset. BITCOIN_FEE_SPEED_ORDER is
// declared fast → slow; index 0 is `fastest`, so "bump" means
// moving toward index 0.
const order: DonationFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
const idx = order.indexOf(feeSpeed);
if (idx > 0) setFeeSpeed(order[idx - 1]);
// `useEffect([feeSpeed])` clears the broadcastError alert.
}}
/>
)}
@@ -448,8 +490,12 @@ interface ConfirmViewProps {
feeSpeed: DonationFeeSpeed;
btcPrice: number | undefined;
isPending: boolean;
/** Classified failure from the most recent broadcast attempt, if any. */
broadcastError: BroadcastErrorKind | null;
onBack: () => void;
onSubmit: () => void;
/** Steps `feeSpeed` toward the fastest preset; no-op once at `fastest`. */
onBumpFee: () => void;
}
function ConfirmView({
@@ -460,8 +506,10 @@ function ConfirmView({
feeSpeed,
btcPrice,
isPending,
broadcastError,
onBack,
onSubmit,
onBumpFee,
}: ConfirmViewProps) {
const { user } = useCurrentUser();
const { config } = useAppContext();
@@ -552,6 +600,25 @@ function ConfirmView({
)}
</div>
{/* Classified broadcast failure with an actionable bump-fee recovery.
Sits between the donation rows and the Send button so the donor
sees the alert in the same visual region they're about to tap.
`presetTiersOnly` hides the bump button once they're on the
fastest preset — at that point the recommendation is to switch
to an external wallet via the panel on the campaign detail page. */}
{broadcastError && (
<BroadcastErrorAlert
error={broadcastError}
currentFeeRate={feeRatesQuery.data ? feeRateForSpeed(feeRatesQuery.data, feeSpeed) : undefined}
feeSpeed={feeSpeed}
feeRates={feeRatesQuery.data}
isPending={isPending}
onBumpFee={onBumpFee}
onRetry={onSubmit}
presetTiersOnly
/>
)}
<Button
size="lg"
className="w-full"
+217 -18
View File
@@ -22,12 +22,14 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import {
BitcoinRecipientInput,
type ResolvedRecipient,
} from '@/components/BitcoinRecipientInput';
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
import { HelpTip } from '@/components/HelpTip';
import { cn } from '@/lib/utils';
@@ -39,8 +41,13 @@ import { notificationSuccess } from '@/lib/haptics';
import {
getBitcoinFeeRate,
getUniqueBitcoinFeeSpeeds,
resolveBitcoinFeeRate,
type BitcoinFeeSpeed,
} from '@/lib/bitcoinFeeSpeed';
import {
classifyBroadcastError,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
import { isLargeAmount, satsToUSD } from '@/lib/bitcoin';
import {
broadcastBlockbookTx,
@@ -136,6 +143,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
halfHour: t('walletSend.feeSpeed.halfHour'),
hour: t('walletSend.feeSpeed.hour'),
economy: t('walletSend.feeSpeed.economy'),
custom: t('walletSend.feeSpeed.custom'),
}),
[t],
);
@@ -147,25 +155,40 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
const [recipient, setRecipient] = useState<ResolvedRecipient | null>(null);
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
/** Raw text for the custom sat/vB rate input (only used when feeSpeed === 'custom'). */
const [customFeeRate, setCustomFeeRate] = useState('');
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [success, setSuccess] = useState<SendResult | null>(null);
/**
* Classified failure from the most recent broadcast attempt. Renders as an
* inline {@link BroadcastErrorAlert} above the Send button with a recovery
* action (typically "Use a higher fee"). Cleared automatically whenever
* the user adjusts any field that could plausibly resolve the failure,
* and on every successful submit.
*/
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
const feeSpeedUserChanged = useRef(false);
// ── Fee rates ────────────────────────────────────────────────
const { data: feeRates } = useQuery({
const {
data: feeRates,
isLoading: feeRatesLoading,
isError: feeRatesError,
refetch: refetchFeeRates,
} = useQuery({
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal),
enabled: isOpen && isReady,
staleTime: 30_000,
});
const currentFeeRate = useMemo(() => {
if (!feeRates) return undefined;
return getBitcoinFeeRate(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
const currentFeeRate = useMemo(
() => resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate),
[feeSpeed, feeRates, customFeeRate],
);
// ── Owned UTXO set ───────────────────────────────────────────
//
@@ -245,7 +268,9 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
feeSpeedUserChanged.current = true;
setFeeSpeed(speed);
setFeePopoverOpen(false);
// Keep the popover open for 'custom' so the user can type a rate; close
// it for preset tiers since the choice is complete.
if (speed !== 'custom') setFeePopoverOpen(false);
}, []);
// ── Two-tap arm + raw-address disclaimer ─────────────────────
@@ -286,11 +311,12 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
}
if (!recipient) throw new Error(t('walletSend.errors.enterRecipient'));
if (!ownedInputs.length) throw new Error(t('walletSend.errors.noSpendable'));
if (!feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
if (feeSpeed !== 'custom' && !feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
const rate = getBitcoinFeeRate(feeRates, feeSpeed);
const rate = resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate);
if (!rate || rate < 1) throw new Error(t('walletSend.errors.feeRateTooLow'));
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
setProgress('building');
@@ -339,11 +365,100 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
void refetchWallet();
},
onError: (err) => {
toast({ title: t('walletSend.toast.failedTitle'), description: err.message, variant: 'destructive' });
const classified = classifyBroadcastError(err);
setBroadcastError(classified);
// Force a re-arm on every failure so the donor explicitly re-confirms
// after seeing the error — without this, a second tap of an
// already-armed Send would immediately re-broadcast with the same
// (rejected) parameters.
setConfirmArmed(false);
// The inline alert is the primary surface for classified errors;
// a toast on top would be noisy. Keep the toast only for the
// catch-all `unknown` bucket so something always surfaces even when
// we can't recognise the reject reason.
if (classified.kind === 'unknown') {
toast({
title: t('walletSend.toast.failedTitle'),
description: err.message,
variant: 'destructive',
});
}
},
onSettled: () => setProgress('idle'),
});
// Clear the broadcast-error alert as soon as the donor adjusts anything
// that could plausibly resolve the failure. Recipient / amount / fee rate
// changes are the obvious cases; we don't clear on a btcPrice tick alone
// because that's just a passive refresh.
useEffect(() => {
setBroadcastError(null);
}, [recipient?.address, amountSats, feeSpeed, customFeeRate]);
/**
* Recovery action for fee-related broadcast failures.
*
* Strategy:
* - If the user is on a preset and a faster preset exists in the
* *deduped* tier list, jump to it.
* - Otherwise (already on the fastest tier, or only one unique tier
* loaded), switch to a custom rate seeded from the strongest hint
* we have: the parsed minRelayFee from the error, the parsed actual
* rate * 1.5, or the current fastest preset + 1. Open the fee popover
* so the donor can see the new rate and tweak it further.
*
* Either way: refetch fee rates, mark the picker as user-touched (so the
* auto-tune effect doesn't override the bump on the next render), clear
* the broadcast-error alert, and reset `confirmArmed`.
*/
const bumpFeeForRetry = useCallback(() => {
feeSpeedUserChanged.current = true;
setConfirmArmed(false);
setBroadcastError(null);
void refetchFeeRates();
const uniqueSpeeds = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
const presetIndex = uniqueSpeeds.indexOf(feeSpeed as Exclude<FeeSpeed, 'custom'>);
if (feeSpeed !== 'custom' && presetIndex > 0) {
// A faster preset exists — jump to it.
setFeeSpeed(uniqueSpeeds[presetIndex - 1]);
return;
}
// Either at the fastest preset already, or on `custom`. Fall back to
// a custom rate using the strongest available hint.
const fastestPresetRate = feeRates?.fastestFee ?? 1;
const fromError =
broadcastError?.kind === 'feeTooLow'
? (broadcastError.minRelayFeeRate ?? broadcastError.actualFeeRate)
: undefined;
const seed = (() => {
if (broadcastError?.kind === 'feeTooLow' && broadcastError.minRelayFeeRate) {
// +1 sat/vB over the network minimum so we clear it comfortably.
return Math.max(broadcastError.minRelayFeeRate + 1, fastestPresetRate);
}
if (fromError) {
// No minimum surfaced but we know the rejected rate — 1.5× as a
// safe escalation step.
return Math.max(Math.ceil(fromError * 1.5), fastestPresetRate + 1);
}
// No usable hint — nudge above the current fastest tier.
const current = currentFeeRate ?? fastestPresetRate;
return Math.max(current + 1, fastestPresetRate + 1);
})();
setFeeSpeed('custom');
setCustomFeeRate(String(Math.max(1, Math.ceil(seed))));
setFeePopoverOpen(true);
}, [
broadcastError,
currentFeeRate,
feeRates,
feeSpeed,
refetchFeeRates,
]);
const handleSend = useCallback(() => {
setError('');
if (availability.status !== 'available') {
@@ -353,6 +468,14 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; }
if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; }
if (!currentFeeRate || currentFeeRate < 1) {
setError(
feeSpeed === 'custom'
? t('walletSend.errors.feeRateTooLow')
: t('walletSend.errors.feesNotLoadedYet'),
);
return;
}
if (insufficient) { setError(t('walletSend.errors.insufficient')); return; }
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
sendMutation.mutate();
@@ -363,6 +486,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
btcPrice,
amountSats,
ownedInputs.length,
currentFeeRate,
feeSpeed,
insufficient,
requiresArm,
confirmArmed,
@@ -378,8 +503,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
setRecipient(null);
setUsdAmount(5);
setError('');
setFeeSpeed('halfHour');
setCustomFeeRate('');
setConfirmArmed(false);
setSuccess(null);
setBroadcastError(null);
feeSpeedUserChanged.current = false;
}, 200);
}, [onClose, sendMutation.isPending]);
@@ -405,7 +533,9 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
!btcPrice ||
amountSats <= 0 ||
insufficient ||
!ownedInputs.length;
!ownedInputs.length ||
!currentFeeRate ||
currentFeeRate < 1;
// ── Render ───────────────────────────────────────────────────
return (
@@ -476,6 +606,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
</Alert>
)}
{/* Classified broadcast failure with an actionable recovery.
Replaces the older raw-toast UX so the donor can see why
the network rejected the tx (fee too low, mempool full,
RBF replacement underpriced, etc.) AND act on it without
guessing. Cleared automatically the moment they touch a
field that could resolve the failure. */}
{broadcastError && (
<BroadcastErrorAlert
error={broadcastError}
currentFeeRate={currentFeeRate}
feeSpeed={feeSpeed}
feeRates={feeRates}
isPending={sendMutation.isPending}
onBumpFee={bumpFeeForRetry}
onRetry={() => sendMutation.mutate()}
/>
)}
{/* Send button */}
<Button
type="button"
@@ -504,16 +652,41 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
<> {satsToUSD(estimatedFeeSats, btcPrice)}</>
) : currentFeeRate ? (
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
) : feeRatesLoading && feeSpeed !== 'custom' ? (
<>{t('walletSend.fee.loading')}</>
) : feeRatesError && feeSpeed !== 'custom' ? (
<>{t('walletSend.fee.unavailable')}</>
) : (
<></>
)}
<span className="opacity-60">·</span>
{feeSpeedLabels[feeSpeed]}
{feeSpeed === 'custom' && currentFeeRate
? t('walletSend.satPerVB', { rate: currentFeeRate })
: feeSpeedLabels[feeSpeed]}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="center">
<PopoverContent className="w-56 p-1" align="center">
<div className="grid gap-0.5">
{getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
{feeRatesError && (
<div className="px-3 py-1.5 text-xs text-muted-foreground">
<p className="text-destructive">{t('walletSend.fee.loadFailed')}</p>
<button
type="button"
onClick={() => refetchFeeRates()}
className="mt-1 underline hover:text-foreground transition-colors"
>
{t('walletSend.fee.retry')}
</button>
<p className="mt-1">{t('walletSend.fee.orCustom')}</p>
</div>
)}
{feeRatesLoading && !feeRatesError && (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t('walletSend.fee.loadingTiers')}
</div>
)}
{feeRates && getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
<button
key={speed}
type="button"
@@ -524,13 +697,39 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
)}
>
<span>{feeSpeedLabels[speed]}</span>
{feeRates && (
<span className="text-muted-foreground tabular-nums">
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
</span>
)}
<span className="text-muted-foreground tabular-nums">
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
</span>
</button>
))}
{/* Custom fee rate */}
<button
type="button"
onClick={() => handleFeeSpeedChange('custom')}
className={cn(
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
feeSpeed === 'custom' && 'bg-muted',
)}
>
<span>{feeSpeedLabels.custom}</span>
</button>
{feeSpeed === 'custom' && (
<div className="flex items-center gap-1.5 px-3 py-1.5">
<Input
type="number"
inputMode="decimal"
min={1}
step={1}
autoFocus
value={customFeeRate}
onChange={(e) => setCustomFeeRate(e.target.value)}
placeholder={t('walletSend.fee.customPlaceholder')}
className="h-7 text-xs"
aria-label={t('walletSend.fee.customAriaLabel')}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">sat/vB</span>
</div>
)}
</div>
</PopoverContent>
</Popover>
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { getAllLucideIcons } from '@/lib/lucideIconRegistry';
import { cn } from '@/lib/utils';
interface IconPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Currently-selected icon name, used to highlight the active cell. */
value?: string;
/** Called with the chosen icon's PascalCase name when the user picks one. */
onSelect: (name: string) => void;
}
type IconEntry = {
name: string;
Component: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>;
};
/**
* Searchable picker over every named Lucide icon.
*
* The icon set is loaded on demand via {@link getAllLucideIcons}, which
* dynamically imports `lucide-react` once per session and emits the whole
* library as a separate Vite chunk. Until the chunk resolves the dialog
* shows a spinner; subsequent opens read from the cached promise and are
* effectively instant.
*
* **Search semantics.** Case-insensitive substring match against the
* icon's PascalCase name with the camel-case word boundaries flattened
* into spaces — so `arrow up` matches `ArrowUp`. Empty query shows the
* full registry.
*
* **Rendering.** A windowed grid via plain CSS — we render the filtered
* results up to a soft cap (`MAX_VISIBLE`) so the DOM stays manageable
* for unfiltered queries. With ~1500 icons total this caps the picker
* at a few hundred initial cells; typing narrows the set quickly.
*/
const MAX_VISIBLE = 600;
export function IconPicker({ open, onOpenChange, value, onSelect }: IconPickerProps) {
const { t } = useTranslation();
const [icons, setIcons] = useState<IconEntry[] | null>(null);
const [query, setQuery] = useState('');
useEffect(() => {
if (!open) return;
if (icons) return;
let cancelled = false;
getAllLucideIcons()
.then((all) => {
if (cancelled) return;
setIcons(all);
})
.catch(() => {
if (cancelled) return;
setIcons([]);
});
return () => {
cancelled = true;
};
}, [open, icons]);
const filtered = useMemo<IconEntry[]>(() => {
if (!icons) return [];
const q = query.trim().toLowerCase().replace(/\s+/g, '');
if (!q) return icons.slice(0, MAX_VISIBLE);
const out: IconEntry[] = [];
for (const entry of icons) {
// Flatten name to lowercase for substring match.
if (entry.name.toLowerCase().includes(q)) {
out.push(entry);
if (out.length >= MAX_VISIBLE) break;
}
}
return out;
}, [icons, query]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-2xl max-h-[80dvh] rounded-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<DialogTitle>{t('campaigns.lists.iconPicker.title')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('campaigns.lists.iconPicker.description')}
</DialogDescription>
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
aria-hidden
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('campaigns.lists.iconPicker.search')}
aria-label={t('campaigns.lists.iconPicker.search')}
className="pl-9"
autoFocus
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
{icons === null ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{t('campaigns.lists.iconPicker.empty')}
</div>
) : (
<div
className="grid gap-1.5 py-2"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))',
}}
>
{filtered.map(({ name, Component }) => {
const isSelected = name === value;
return (
<button
key={name}
type="button"
onClick={() => {
onSelect(name);
onOpenChange(false);
}}
title={name}
aria-pressed={isSelected}
className={cn(
'flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 motion-safe:transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-transparent hover:border-border hover:bg-accent text-foreground',
)}
>
<Component className="size-5" aria-hidden />
<span className="text-[10px] leading-tight text-muted-foreground truncate max-w-full">
{name}
</span>
</button>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { List as ListFallback } from 'lucide-react';
import { getLucideIcon } from '@/lib/lucideIconRegistry';
import { cn } from '@/lib/utils';
interface LucideIconProps {
/** PascalCase Lucide icon name (e.g. `"Heart"`). */
name: string;
/** Optional className passed through to the rendered icon. */
className?: string;
/** Optional aria-label; defaults to hidden from assistive tech. */
ariaLabel?: string;
}
/**
* Renders a Lucide icon resolved by name at runtime. The icon registry is
* loaded via a single shared dynamic import (see `lucideIconRegistry.ts`),
* so the whole icon set lives in a separate Vite chunk and only pays its
* bundle cost once per session.
*
* **Fallback.** While the registry resolves, and for any name that fails
* to resolve (event published with an icon we don't recognize), the
* generic `List` icon is rendered — already statically imported by other
* parts of the app, so the fallback never causes a layout shift waiting
* for a network round-trip.
*/
export function LucideIcon({ name, className, ariaLabel }: LucideIconProps) {
// `Component` starts at `null` so the first paint always uses the
// fallback. Once the dynamic import resolves, the matching component
// takes over. We deliberately don't suspend on the import — the
// fallback is a perfectly serviceable icon and suspending would force
// every list pill to wait for the same chunk before anything renders.
const [Component, setComponent] = useState<React.ComponentType<{
className?: string;
'aria-hidden'?: boolean;
'aria-label'?: string;
}> | null>(null);
useEffect(() => {
let cancelled = false;
setComponent(null);
getLucideIcon(name)
.then((c) => {
if (cancelled) return;
setComponent(() => c);
})
.catch(() => {
// Network error loading the chunk — keep fallback.
});
return () => {
cancelled = true;
};
}, [name]);
const Icon = Component ?? ListFallback;
return (
<Icon
className={cn(className)}
aria-hidden={!ariaLabel}
aria-label={ariaLabel}
/>
);
}
+8
View File
@@ -367,6 +367,14 @@ export function NostrSync() {
changed = true;
}
if (
encryptedSettings.translateWorkerUrl &&
encryptedSettings.translateWorkerUrl !== current.translateWorkerUrl
) {
updates.translateWorkerUrl = encryptedSettings.translateWorkerUrl;
changed = true;
}
// Return the same reference if nothing changed to prevent re-render
return changed ? updates : current;
});
+1 -1
View File
@@ -1867,7 +1867,7 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: HandHeart,
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.campaignLaunched", updated: "noteCard.kindHeader.campaignUpdated", fallback: "noteCard.kindHeader.campaignFallback" }),
noun: "noteCard.kindHeader.campaignNoun",
nounRoute: "/campaigns/all",
nounRoute: "/campaigns",
},
8: {
icon: Award,
+43 -234
View File
@@ -1,9 +1,7 @@
import {
useCallback,
useMemo,
useRef,
useState,
type ChangeEvent,
type ReactNode,
} from 'react';
import { useNavigate } from 'react-router-dom';
@@ -19,7 +17,6 @@ import {
Link2,
Loader2,
Megaphone,
Upload,
User,
X,
} from 'lucide-react';
@@ -28,13 +25,10 @@ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useOnboarding, type OnboardingRole } from '@/contexts/onboardingContextDef';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { downloadTextFile } from '@/lib/downloadFile';
import { cn } from '@/lib/utils';
@@ -42,9 +36,9 @@ import { cn } from '@/lib/utils';
* Step state machine for the captive signup flow.
*
* Order:
* keygen → secure → profile → role
* keygen → secure → role
*
* Four screens total. The old flow had a separate "wallet-coupling explainer"
* Three screens total. The old flow had a separate "wallet-coupling explainer"
* step and a separate "outro" celebration screen; both were folded in. The
* coupling explainer was redundant with `secure` (both screens are about the
* key), so the secure step now carries the "this key is your account AND
@@ -57,9 +51,9 @@ import { cn } from '@/lib/utils';
* AuthDialog's "Create a new Nostr account" button), so the user has
* already picked "signup" by the time we mount.
*/
type Step = 'keygen' | 'secure' | 'profile' | 'role';
type Step = 'keygen' | 'secure' | 'role';
const STEPS: Step[] = ['keygen', 'secure', 'profile', 'role'];
const SIGNUP_STEPS: Step[] = ['keygen', 'secure', 'role'];
/**
* The captive onboarding gate. Render this as a sibling of `<AppRouter />`;
@@ -69,8 +63,7 @@ const STEPS: Step[] = ['keygen', 'secure', 'profile', 'role'];
* The flow guides a brand-new user through:
* 1. Key generation
* 2. Save the nsec (with inline wallet-coupling framing)
* 3. Optional profile metadata (kind 0)
* 4. Role pick — primary CTA navigates by intent: creator → /campaigns/new,
* 3. Role pick — primary CTA navigates by intent: creator → /campaigns/new,
* donor → / (campaign grid)
*
* The overlay sits above all app chrome and cannot be dismissed by clicking
@@ -97,12 +90,9 @@ function CaptiveOverlay() {
const { toast } = useToast();
const login = useLoginActions();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishingProfile } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploadingAvatar } = useUploadFile();
// Decide the entry step. Already-authenticated users (e.g. a CTA called
// startSignup() on a logged-in surface to walk them to the role picker)
// skip keygen / secure / profile and land on `role` directly.
// Decide the entry step.
// - Already-authenticated users normally land on `role` directly.
const initialStep: Step = useMemo(() => {
if (user) return 'role';
return 'keygen';
@@ -114,25 +104,35 @@ function CaptiveOverlay() {
const [nsec, setNsec] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [showKey, setShowKey] = useState(false);
const [profileData, setProfileData] = useState({ name: '', about: '', picture: '' });
const avatarInputRef = useRef<HTMLInputElement>(null);
// Linear progress bar position. Every step in the machine counts toward
// the bar — there's no longer a non-funnel "welcome" step to skip.
const currentProgressIndex = STEPS.indexOf(step);
// the bar.
const currentProgressIndex = SIGNUP_STEPS.indexOf(step);
const progress = currentProgressIndex < 0
? 0
: ((currentProgressIndex + 1) / STEPS.length) * 100;
: ((currentProgressIndex + 1) / SIGNUP_STEPS.length) * 100;
// Navigation helpers ------------------------------------------------------
const goTo = useCallback((target: Step) => {
setStep(target);
}, []);
const showBackButton = !(step === 'keygen' && isGenerating);
const handleBack = useCallback(() => {
if (step === 'keygen') {
cancel();
} else if (step === 'secure') {
goTo('keygen');
} else {
if (user) cancel();
else goTo('secure');
}
}, [step, user, cancel, goTo]);
// Role pick is the final step. Picking a role both records the choice
// (used by the role-pick CTA labels) and navigates to the matching
// surface: creator → campaign-creation form, donor → full campaign grid
// (`/campaigns/all`, not `/`, so they land on the browse-everything view
// (`/campaigns`, not `/`, so they land on the browse-everything view
// rather than the curated home with its own marketing hero). No separate
// outro / celebration screen.
const handleRolePick = useCallback(
@@ -142,7 +142,7 @@ function CaptiveOverlay() {
if (next === 'creator') {
navigate('/campaigns/new');
} else {
navigate('/campaigns/all');
navigate('/campaigns');
}
},
[setContextRole, cancel, navigate],
@@ -172,7 +172,7 @@ function CaptiveOverlay() {
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
await downloadTextFile(filename, nsec);
login.nsec(nsec);
goTo('profile');
goTo('role');
} catch {
toast({
title: t('onboarding.secure.downloadFailedTitle'),
@@ -182,57 +182,6 @@ function CaptiveOverlay() {
}
}, [nsec, login, goTo, toast, t]);
// Avatar upload (profile step) -------------------------------------------
const handleAvatarUpload = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
if (!file.type.startsWith('image/')) {
toast({ title: t('onboarding.profile.imageOnly'), variant: 'destructive' });
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({ title: t('onboarding.profile.imageTooLarge'), variant: 'destructive' });
return;
}
try {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) setProfileData((prev) => ({ ...prev, picture: url }));
} catch {
toast({ title: t('onboarding.profile.uploadFailed'), variant: 'destructive' });
}
},
[uploadFile, toast, t],
);
// Profile publish ---------------------------------------------------------
const finishProfile = useCallback(
async (skip: boolean) => {
try {
if (!skip && (profileData.name || profileData.about || profileData.picture)) {
const metadata: Record<string, string> = {};
if (profileData.name) metadata.name = profileData.name;
if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture;
await publishEvent({ kind: 0, content: JSON.stringify(metadata) });
}
} catch {
toast({
title: t('onboarding.profile.publishFailedTitle'),
description: t('onboarding.profile.publishFailedDescription'),
variant: 'destructive',
});
} finally {
goTo('role');
}
},
[profileData, publishEvent, toast, t, goTo],
);
// Step renderer -----------------------------------------------------------
const stepBody = (() => {
switch (step) {
@@ -243,7 +192,6 @@ function CaptiveOverlay() {
<KeygenStep
isGenerating={isGenerating}
onGenerate={handleGenerateKey}
onBack={cancel}
/>
);
case 'secure':
@@ -253,33 +201,17 @@ function CaptiveOverlay() {
showKey={showKey}
onToggleShow={() => setShowKey((v) => !v)}
onContinue={handleDownloadAndContinue}
onBack={() => goTo('keygen')}
/>
);
case 'profile':
return (
<ProfileStep
data={profileData}
isPublishing={isPublishingProfile}
isUploading={isUploadingAvatar}
onChange={(patch) => setProfileData((prev) => ({ ...prev, ...patch }))}
onUploadClick={() => avatarInputRef.current?.click()}
avatarInputRef={avatarInputRef}
onAvatarChange={handleAvatarUpload}
onFinish={() => finishProfile(false)}
onSkip={() => finishProfile(true)}
/>
);
case 'role':
// Final step. Picking a role navigates to the matching surface
// (creator → /campaigns/new, donor → /); Back goes to profile if
// the user signed up through the full flow, or cancels the overlay
// if they were already-authenticated and landed here directly.
// (creator → /campaigns/new, donor → /); Back goes to secure if the
// user signed up through the full flow, or cancels the overlay if
// they were already-authenticated and landed here directly.
return (
<RoleStep
role={contextRole}
onPick={handleRolePick}
onBack={user ? cancel : () => goTo('profile')}
/>
);
}
@@ -300,6 +232,17 @@ function CaptiveOverlay() {
/>
</div>
{showBackButton && (
<button
type="button"
onClick={handleBack}
aria-label={t('common.back')}
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
</button>
)}
{/* Top-right close. Lets users escape if they truly don't want to
continue — but it's deliberately unobtrusive vs. a backdrop click
so casual taps don't drop them out of the flow. */}
@@ -307,7 +250,7 @@ function CaptiveOverlay() {
type="button"
onClick={cancel}
aria-label={t('onboarding.close')}
className="absolute right-4 top-4 sm:right-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
@@ -331,7 +274,6 @@ function CaptiveOverlay() {
interface RoleStepProps {
role: OnboardingRole;
onPick: (role: 'creator' | 'donor') => void;
onBack: () => void;
}
/**
@@ -341,7 +283,7 @@ interface RoleStepProps {
* structure that makes the choice feel like a role rather than a feature
* menu.
*/
function RoleStep({ role, onPick, onBack }: RoleStepProps) {
function RoleStep({ role, onPick }: RoleStepProps) {
const { t } = useTranslation();
return (
@@ -370,7 +312,6 @@ function RoleStep({ role, onPick, onBack }: RoleStepProps) {
/>
</div>
<BackButton onClick={onBack} />
</div>
);
}
@@ -416,12 +357,11 @@ function RoleCard({ icon, title, description, finderNote, selected, onClick }: R
interface KeygenStepProps {
isGenerating: boolean;
onGenerate: () => void;
onBack: () => void;
}
/** Key generation step — a single CTA that fires off `generateSecretKey()`
* with a brief visible spinner for tactile feedback. */
function KeygenStep({ isGenerating, onGenerate, onBack }: KeygenStepProps) {
function KeygenStep({ isGenerating, onGenerate }: KeygenStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6 text-center">
@@ -449,7 +389,6 @@ function KeygenStep({ isGenerating, onGenerate, onBack }: KeygenStepProps) {
{t('onboarding.keygen.button')}
</Button>
)}
{!isGenerating && <BackButton onClick={onBack} />}
</div>
);
}
@@ -459,7 +398,6 @@ interface SecureStepProps {
showKey: boolean;
onToggleShow: () => void;
onContinue: () => void;
onBack: () => void;
}
/**
@@ -478,7 +416,7 @@ interface SecureStepProps {
* permanence to brand-new users, so it has to carry weight without scaring
* them.
*/
function SecureStep({ nsec, showKey, onToggleShow, onContinue, onBack }: SecureStepProps) {
function SecureStep({ nsec, showKey, onToggleShow, onContinue }: SecureStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
@@ -545,135 +483,6 @@ function SecureStep({ nsec, showKey, onToggleShow, onContinue, onBack }: SecureS
<Download className="w-4 h-4 mr-2" />
{t('onboarding.secure.button')}
</Button>
<BackButton onClick={onBack} />
</div>
);
}
interface ProfileStepProps {
data: { name: string; about: string; picture: string };
isPublishing: boolean;
isUploading: boolean;
onChange: (patch: Partial<{ name: string; about: string; picture: string }>) => void;
onUploadClick: () => void;
avatarInputRef: React.RefObject<HTMLInputElement | null>;
onAvatarChange: (e: ChangeEvent<HTMLInputElement>) => void;
onFinish: () => void;
onSkip: () => void;
}
/** Optional kind-0 metadata — same fields as the legacy AuthDialog profile
* step. Publishes only if at least one field is non-empty and the user
* doesn't choose to skip. */
function ProfileStep({
data,
isPublishing,
isUploading,
onChange,
onUploadClick,
avatarInputRef,
onAvatarChange,
onFinish,
onSkip,
}: ProfileStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.profile.title')}</h2>
<p className="text-sm text-muted-foreground">{t('onboarding.profile.subtitle')}</p>
</div>
<div className={cn('space-y-4', isPublishing && 'opacity-50 pointer-events-none')}>
<div className="space-y-1.5">
<label htmlFor="onb-profile-name" className="text-sm font-medium">
{t('onboarding.profile.nameLabel')}
</label>
<Input
id="onb-profile-name"
value={data.name}
onChange={(e) => onChange({ name: e.target.value })}
placeholder={t('onboarding.profile.namePlaceholder')}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="onb-profile-about" className="text-sm font-medium">
{t('onboarding.profile.aboutLabel')}
</label>
<Textarea
id="onb-profile-about"
value={data.about}
onChange={(e) => onChange({ about: e.target.value })}
placeholder={t('onboarding.profile.aboutPlaceholder')}
className="resize-none"
rows={3}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="onb-profile-picture" className="text-sm font-medium">
{t('onboarding.profile.avatarLabel')}
</label>
<div className="flex gap-2">
<Input
id="onb-profile-picture"
value={data.picture}
onChange={(e) => onChange({ picture: e.target.value })}
placeholder="https://…"
className="flex-1"
/>
<input
type="file"
accept="image/*"
className="hidden"
ref={avatarInputRef}
onChange={onAvatarChange}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={onUploadClick}
disabled={isUploading}
title={t('onboarding.profile.uploadAvatar')}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<Button onClick={onFinish} disabled={isPublishing} className="w-full h-12 rounded-full">
{isPublishing ? t('onboarding.profile.saving') : t('onboarding.profile.finish')}
</Button>
<Button variant="ghost" onClick={onSkip} disabled={isPublishing} className="w-full">
{t('onboarding.profile.skip')}
</Button>
</div>
</div>
);
}
// =============================================================================
// Shared bits
// =============================================================================
function BackButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClick}
className="w-full text-sm text-muted-foreground hover:text-foreground inline-flex items-center justify-center gap-1.5 py-2"
>
<ArrowLeft className="h-3.5 w-3.5 rtl:rotate-180" />
{t('onboarding.back')}
</button>
);
}
+28
View File
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
import { AuthorByline } from '@/components/AuthorByline';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { parseAction, type Action } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
@@ -172,3 +173,30 @@ export function PledgeCard({
</Link>
);
}
/**
* Loading placeholder that matches `PledgeCard`'s grid-variant shape:
* 16:9 cover, then title, two lines of body, a progress bar row, and
* a footer line. Sized to slot into the same `<DiscoveryGrid>` / 4-col
* grids as the real card so the skeleton row doesn't reflow when data
* arrives.
*
* Lives next to `PledgeCard` for parity with `CampaignCardSkeleton`
* and `CommunityMiniCardSkeleton`, which sit next to their cards too.
* Was duplicated as `ActionSkeleton` in `PledgesDiscoverySection` and
* `ActionsPage` before this consolidation.
*/
export function PledgeCardSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { forwardRef, useState, type ButtonHTMLAttributes, type MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import AuthDialog from '@/components/auth/AuthDialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
type StartCampaignLinkProps = ButtonHTMLAttributes<HTMLButtonElement> & {
to?: string;
};
export const StartCampaignLink = forwardRef<HTMLButtonElement, StartCampaignLinkProps>(function StartCampaignLink(
{ onClick, to = '/campaigns/new', type = 'button', ...props },
ref,
) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [authOpen, setAuthOpen] = useState(false);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (user) {
navigate(to);
} else {
setAuthOpen(true);
}
};
return (
<>
<button ref={ref} type={type} onClick={handleClick} {...props} />
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
</>
);
});
+12 -8
View File
@@ -7,12 +7,10 @@ import {
HandHeart,
Info,
LayoutDashboard,
Megaphone,
Menu,
Search,
Settings,
User,
Users,
Wallet,
X,
} from 'lucide-react';
@@ -22,9 +20,9 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useAppContext } from '@/hooks/useAppContext';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
import { useHdWallet } from '@/hooks/useHdWallet';
import { satsToUSD } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
@@ -39,10 +37,16 @@ interface NavItem {
}
const NAV_ITEMS: NavItem[] = [
{ labelKey: 'nav.campaigns', to: '/campaigns', icon: HandHeart },
{ labelKey: 'nav.activity', to: '/feed', icon: Activity },
{ labelKey: 'nav.campaigns', to: '/campaigns/all', icon: HandHeart },
{ labelKey: 'nav.groups', to: '/groups', icon: Users },
{ labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
// Groups and Pledges are intentionally hidden from the main nav for
// launch — keep the routes and feature code intact so we can re-add
// them later by uncommenting these two lines (and re-importing the
// `Users` and `Megaphone` icons from `lucide-react` at the top of
// this file). Both pages still work when visited directly and are
// still linked from in-page CTAs and user-authored content.
// { labelKey: 'nav.groups', to: '/groups', icon: Users },
// { labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
];
interface MobileLinkItem extends NavItem {
@@ -184,7 +188,7 @@ export function TopNav() {
/**
* Compact USD balance pill in the top-nav right cluster, replacing the
* previous search icon. Reads the HD-wallet sats balance via {@link useHdWallet}
* and converts to USD via {@link useBtcPrice}. Renders nothing when the wallet
* and converts to USD via {@link useHdBtcPrice}. Renders nothing when the wallet
* isn't available (logged out, extension/bunker login, still loading, or no
* price yet) so the chrome stays quiet rather than flashing placeholder text.
*/
@@ -201,7 +205,7 @@ function DeferredWalletBalancePill() {
function WalletBalancePill() {
const { t } = useTranslation();
const { availability, totalBalance, isLoading, error } = useHdWallet();
const { data: btcPrice } = useBtcPrice();
const { data: btcPrice } = useHdBtcPrice();
if (availability.status !== 'available') return null;
if (isLoading || error || !btcPrice) return null;
+8 -3
View File
@@ -3,11 +3,10 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useAppContext } from "@/hooks/useAppContext";
import { prepareForTranslation, restoreTokens } from "@/lib/prepareTranslation";
import { cn } from "@/lib/utils";
const DEFAULT_TRANSLATE_WORKER_URL = "https://agora-translate.mk-cc1.workers.dev";
const LANG_MAP: Record<string, string> = {
en: "EN-US",
es: "ES",
@@ -54,6 +53,7 @@ export function TranslateButton({
className,
}: TranslateButtonProps) {
const { t, i18n } = useTranslation();
const { config } = useAppContext();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
@@ -66,6 +66,9 @@ export function TranslateButton({
if (!text.trim()) return;
const translateUrl = config.translateWorkerUrl.trim();
if (!translateUrl) return;
setLoading(true);
setError(false);
@@ -73,7 +76,6 @@ export function TranslateButton({
const languagePrefix = i18n.language.split("-")[0].toLowerCase();
const targetLang = LANG_MAP[languagePrefix] ?? "EN-US";
const prepared = (texts && texts.length > 0 ? texts : [text]).map(prepareForTranslation);
const translateUrl = import.meta.env.VITE_TRANSLATE_WORKER_URL ?? DEFAULT_TRANSLATE_WORKER_URL;
const response = await fetch(translateUrl, {
method: "POST",
@@ -100,6 +102,9 @@ export function TranslateButton({
}
};
// No translation worker configured — hide the button entirely.
if (!config.translateWorkerUrl.trim()) return null;
return (
<Button
type="button"
+280
View File
@@ -0,0 +1,280 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface WizardStep {
/** Centered heading at the top of the step. Concise — one short phrase. */
title: string;
/** Muted single-line subtitle beneath the heading. Optional. */
subtitle?: string;
/** The form fields for this step. */
body: ReactNode;
}
export interface WizardProps {
/**
* Accessibility label for the wizard's dialog role. Should describe
* what the user is creating — e.g. "Create a campaign", "Create a
* group". Used as the `aria-label` on the outer `role="dialog"`.
*/
headingAriaLabel: string;
/** 1-indexed list of steps. Length determines the total. */
steps: WizardStep[];
/**
* Optional lead content rendered above the first step's body. The
* campaign wizard uses this for the "publishing under <org>" chip so
* the publishing-context is the very first thing the user sees on
* step 1. Hidden on every other step.
*/
step1Lead?: ReactNode;
/** Error alert rendered beneath each step's body. Pass null when no error. */
errorAlert?: ReactNode;
/**
* Content rendered inside the terminal step's submit button — typically
* "Launch campaign" / "Create group" with a leading icon, and a spinner +
* "Publishing…" copy while submitting.
*/
submitButtonContent: ReactNode;
/** True while the parent mutation is in flight; disables all forward actions. */
submitting: boolean;
/**
* Predicate gating forward progress from a given (1-indexed) step.
* Return `false` to disable Next on that step. Steps not gated by
* this fn are always allowed to advance.
*/
canAdvanceFromStep: (step: number) => boolean;
/** Optional async guard called before moving from a non-terminal step. */
onBeforeAdvance?: (step: number) => boolean | Promise<boolean>;
/**
* 1-indexed step from which the "Skip Next & Launch" shortcut may
* appear. The shortcut is *only* rendered when `launchNowLabel` is
* also provided — pass `Infinity` (or omit `launchNowLabel`) to
* disable the shortcut entirely. Earlier steps render only the Next
* button.
*/
launchAvailableFromStep?: number;
/**
* Label for the optional ghost shortcut that submits the form
* mid-wizard without finishing the remaining (optional) steps. Pass
* `undefined` to hide the shortcut.
*/
launchNowLabel?: string;
onSubmit: (e: FormEvent) => void;
onClose: () => void;
}
/**
* Multi-step layout used by Agora's creation flows (campaigns,
* groups, …).
*
* Rendered as a **fullscreen captive overlay** (`fixed inset-0 z-50`)
* so it sits above the persistent TopNav — the same treatment Chad's
* onboarding flow uses for signup. From the user's perspective each
* creation flow is a focused, distraction-free task, not "another
* page in the app."
*
* Visually: a sticky single-bar progress fill across the top, a
* top-right X to escape, a top-left back arrow from step 2 onward, a
* centered narrow column for each step, and a big rounded-full
* primary CTA at the bottom.
*
* Earlier required steps are gated by {@link WizardProps.canAdvanceFromStep};
* an optional "Skip Next & Launch" ghost shortcut appears from
* {@link WizardProps.launchAvailableFromStep} onward when a
* {@link WizardProps.launchNowLabel} is provided. The last step is
* terminal — its only forward action is the primary submit button.
*
* The `<form>` lives inside this wrapper (not the parent) so the
* submit button — wherever it ends up in the wizard — submits the
* same form and reuses the parent's `onSubmit`.
*/
export function Wizard({
headingAriaLabel,
steps,
step1Lead,
errorAlert,
submitButtonContent,
submitting,
canAdvanceFromStep,
onBeforeAdvance,
launchAvailableFromStep = Infinity,
launchNowLabel,
onSubmit,
onClose,
}: WizardProps) {
const { t } = useTranslation();
const [step, setStep] = useState(1);
const [isAdvancing, setIsAdvancing] = useState(false);
const totalSteps = steps.length;
const current = steps[step - 1];
const isTerminal = step === totalSteps;
const progress = (step / totalSteps) * 100;
const launchVisible = !!launchNowLabel && step >= launchAvailableFromStep;
const canAdvance = canAdvanceFromStep(step);
// The terminal step's own submit honors only `submitting` — its
// required fields have already been cleared by the gates on
// previous steps. The mid-wizard shortcut, on the other hand,
// sits *on* a potentially-gated step, so it must respect the
// same `canAdvance` check the Next button does — otherwise a
// user could click "Skip Next & Launch" with a still-empty
// required field and trip a server-side validation error.
const canSubmit = isTerminal
? !submitting && !isAdvancing
: launchVisible && canAdvance && !submitting && !isAdvancing;
const handleAdvance = async () => {
if (submitting || isAdvancing || !canAdvance) return;
setIsAdvancing(true);
try {
const shouldAdvance = await onBeforeAdvance?.(step);
if (shouldAdvance === false) return;
setStep((s) => Math.min(s + 1, totalSteps));
} catch {
// The parent owns user-visible errors for async step validation.
} finally {
setIsAdvancing(false);
}
};
return (
<div
className="fixed inset-0 z-50 bg-background overflow-y-auto flex flex-col"
role="dialog"
aria-modal="true"
aria-label={headingAriaLabel}
>
{/* Sticky single-bar progress indicator, mirroring the captive
onboarding flow. */}
<div className="sticky top-0 z-10 h-1 bg-muted">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{/* Top-right close. Lets users escape if they truly don't want to
continue — deliberately unobtrusive so casual taps don't drop
them out of the flow. */}
<button
type="button"
onClick={onClose}
aria-label={t('common.goBack')}
className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
{/* Top-left back. Mirrors the close button so the user can step
back through the wizard without scrolling to the footer. Only
rendered from step 2 onward — step 1's escape route is the X. */}
{step > 1 && (
<button
type="button"
onClick={() => setStep((s) => Math.max(s - 1, 1))}
disabled={submitting || isAdvancing}
aria-label={t('common.back')}
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
>
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
</button>
)}
<form
className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12"
onSubmit={onSubmit}
// Hitting Enter inside an <input> normally triggers the
// form's default submit — and on a non-terminal wizard step
// that would silently publish the entity. Intercept Enter on
// non-terminal steps and treat it as "advance" instead, so
// keyboard users get the same flow as clicking Next.
//
// Textarea Enter is left alone — that's a legitimate newline
// character inside the field.
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if (isTerminal) return;
const target = e.target as HTMLElement;
if (target.tagName === 'TEXTAREA') return;
// IME composition still in progress — don't hijack.
if (e.nativeEvent.isComposing) return;
e.preventDefault();
if (submitting || isAdvancing || !canAdvance) return;
void handleAdvance();
}}
>
<div
key={step}
className="w-full max-w-md mx-auto space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
{/* Centered title block — captive-onboarding cadence: large
heading + muted subtitle, no progress eyebrow (the
top-of-page bar carries that signal). */}
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{current.title}</h2>
{current.subtitle && (
<p className="text-sm text-muted-foreground">{current.subtitle}</p>
)}
</div>
{/* Step body. Step 1's optional lead (e.g. the campaign
wizard's org chip) rides along here so the
"publishing-as" context is the first thing the user
sees. No card chrome — onboarding keeps the content
area visually quiet so the focus stays on the fields. */}
<div className="space-y-3">
{step === 1 && step1Lead}
{current.body}
</div>
{errorAlert}
{/* Footer.
- Non-terminal steps: primary "Next" advances the wizard.
When `launchNowLabel` is provided and the user has
cleared `launchAvailableFromStep`, a ghost shortcut sits
beneath Next so the remaining steps are opt-in.
- Terminal step: the primary submit button is the only
forward action.
- Back navigation lives in the top-left header chrome,
not here. */}
<div className="space-y-3 pt-1">
{isTerminal ? (
<Button
type="submit"
disabled={!canSubmit}
className="w-full h-12 text-base rounded-full"
>
{submitButtonContent}
</Button>
) : (
<>
<Button
type="button"
onClick={() => void handleAdvance()}
disabled={submitting || isAdvancing || !canAdvance}
className="w-full h-12 text-base rounded-full"
>
{t('common.next')}
</Button>
{launchVisible && (
<Button
type="submit"
variant="ghost"
disabled={!canSubmit}
className="w-full"
>
{submitting ? submitButtonContent : launchNowLabel}
</Button>
)}
</>
)}
</div>
</div>
</form>
</div>
);
}
@@ -0,0 +1,208 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, Loader2, Search } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAllCampaigns } from '@/hooks/useAllCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useDebounce } from '@/hooks/useDebounce';
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import type { ParsedCampaign } from '@/lib/campaign';
interface AddCampaignToListDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Slug of the list the chosen campaign will be added to. */
slug: string;
/** Coords already in the list, used to mark existing members and avoid duplicates. */
existingCoords: readonly string[];
}
/**
* Modal that lets a moderator search the network of published campaigns
* and pick one to add to a given list. Multi-pick within a single open
* session (each click immediately publishes a new revision), since the
* RMW path is cheap and being able to add several campaigns in a row
* without reopening the dialog matches the curation workflow.
*
* The search query is debounced and runs through {@link useAllCampaigns}.
* Already-in-list campaigns are shown with a check mark and an
* "already added" affordance instead of an Add button.
*
* Campaigns hidden via {@link useCampaignModeration} are filtered out
* entirely — a moderator shouldn't be encouraged to surface suppressed
* content into a curated list. (If a coord is already on the list and
* later gets hidden, it stays on the list but renders as the
* `member`-state row so a moderator can still see + remove it.)
*/
export function AddCampaignToListDialog({
open,
onOpenChange,
slug,
existingCoords,
}: AddCampaignToListDialogProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 250);
const { data: campaigns = [], isLoading } = useAllCampaigns({
sort: 'none',
search: debounced,
limit: 50,
enabled: open,
});
const { data: moderation } = useCampaignModeration();
// Filter out hidden campaigns. Existing list members that are hidden
// remain in the dialog so the moderator can spot them and unwind the
// membership — but freshly-searched hidden campaigns are dropped.
const visibleCampaigns = useMemo<ParsedCampaign[]>(() => {
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
if (hiddenCoords.size === 0) return campaigns;
const existingSet = new Set(existingCoords);
return campaigns.filter(
(c) => !hiddenCoords.has(c.aTag) || existingSet.has(c.aTag),
);
}, [campaigns, moderation, existingCoords]);
const { addCampaignToList } = useCampaignListActions();
const [pendingCoord, setPendingCoord] = useState<string | null>(null);
// Track coords added within this session so they switch from "Add" to
// the "already added" affordance immediately, before the moderation
// query refetches.
const [justAdded, setJustAdded] = useState<Set<string>>(new Set());
const existingSet = useMemo(
() => new Set([...existingCoords, ...justAdded]),
[existingCoords, justAdded],
);
const handleAdd = async (campaign: ParsedCampaign) => {
const coord = campaign.aTag;
setPendingCoord(coord);
try {
await addCampaignToList(slug, coord);
setJustAdded((s) => {
const next = new Set(s);
next.add(coord);
return next;
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('campaigns.lists.addFailed'),
description: msg,
variant: 'destructive',
});
} finally {
setPendingCoord(null);
}
};
// Reset the just-added state on close so reopening starts clean.
const handleOpenChange = (next: boolean) => {
if (!next) {
setJustAdded(new Set());
setSearch('');
}
onOpenChange(next);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg max-h-[80dvh] rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>{t('campaigns.lists.addCampaign')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('campaigns.lists.addCampaignDesc')}
</DialogDescription>
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none"
aria-hidden
/>
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('campaigns.lists.searchPlaceholder')}
aria-label={t('campaigns.lists.searchPlaceholder')}
className="pl-9"
autoFocus
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6 py-2">
{isLoading && visibleCampaigns.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : visibleCampaigns.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t('campaigns.lists.searchEmpty')}
</div>
) : (
<ul className="divide-y divide-border">
{visibleCampaigns.map((campaign) => {
const isMember = existingSet.has(campaign.aTag);
const isPending = pendingCoord === campaign.aTag;
return (
<li
key={campaign.aTag}
className="flex items-center gap-3 py-2.5"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{campaign.title}
</div>
{campaign.summary && (
<div className="text-xs text-muted-foreground truncate">
{campaign.summary}
</div>
)}
</div>
<Button
type="button"
variant={isMember ? 'ghost' : 'outline'}
size="sm"
disabled={isMember || isPending}
onClick={() => handleAdd(campaign)}
className={cn(isMember && 'text-muted-foreground')}
>
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : isMember ? (
<>
<Check className="size-4 mr-1" />
{t('campaigns.lists.alreadyAdded')}
</>
) : (
t('campaigns.lists.addToList')
)}
</Button>
</li>
);
})}
</ul>
)}
</div>
<div className="flex justify-end pt-2">
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
{t('common.close')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,258 @@
import { useEffect, useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Check, Loader2, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { LucideIcon } from '@/components/LucideIcon';
import { ListFormDialog } from './ListFormDialog';
import { useCampaignLists } from '@/hooks/useCampaignLists';
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface CampaignListMembershipDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The campaign's addressable coordinate (`33863:<pubkey>:<d>`). */
campaignCoord: string;
/** Visible title for the campaign, used in dialog copy. */
campaignTitle: string;
}
/**
* Multi-toggle modal for managing which curated topic lists a single
* campaign belongs to. Opened from the moderator kebab's "Add to list…"
* row on `CampaignCard`.
*
* Each list renders as a row with the list's icon + title and a single
* action button — "Add" when the campaign isn't in that list, "Added"
* when it is. Toggling immediately publishes a new revision of the list
* event (read-modify-write through `useCampaignListActions`), so a
* moderator can multi-tag a campaign in one open session without a
* "save" step.
*
* The dialog also exposes a "+ New list" pill that opens the standard
* `ListFormDialog` create flow — convenient when the moderator wants to
* coin a list specifically for this campaign.
*
* The membership state shown is the union of (a) lists currently
* containing this campaign per the cached `useCampaignLists` data and
* (b) any toggles made within this session ("optimistic" set), so the
* UI reflects the latest write immediately rather than waiting for the
* relay refetch.
*/
export function CampaignListMembershipDialog({
open,
onOpenChange,
campaignCoord,
campaignTitle,
}: CampaignListMembershipDialogProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data, isLoading } = useCampaignLists();
const actions = useCampaignListActions();
const [pendingSlug, setPendingSlug] = useState<string | null>(null);
// Per-slug optimistic membership overrides. `true` = this campaign is
// now in the list, `false` = not in the list. Wins over the
// authoritative cache until the relay query refetches.
const [optimistic, setOptimistic] = useState<Map<string, boolean>>(new Map());
const [createOpen, setCreateOpen] = useState(false);
// Force a refetch of the campaign-lists query every time the dialog
// opens. The query's default staleTime is 30s, which means a
// moderator who edits a list elsewhere (e.g. by adding this campaign
// from the list detail page) and then opens this membership dialog
// for the same campaign would otherwise see stale "Add" buttons for
// 30s. Invalidating on open guarantees the membership state shown
// reflects the latest published revisions.
useEffect(() => {
if (open) {
void queryClient.invalidateQueries({ queryKey: ['campaign-lists'] });
}
}, [open, queryClient]);
const lists = data?.lists ?? [];
const isMember = useMemo(() => {
return (slug: string, coords: readonly string[]): boolean => {
const override = optimistic.get(slug);
if (override !== undefined) return override;
return coords.includes(campaignCoord);
};
}, [optimistic, campaignCoord]);
const handleToggle = async (
slug: string,
currentlyMember: boolean,
event: React.MouseEvent,
) => {
// The dialog is portaled but React's synthetic events still bubble
// through the React tree — without this, clicking a row inside the
// dialog would propagate to whichever <Link> wraps the card the
// moderator opened the kebab from and trigger a navigation.
event.preventDefault();
event.stopPropagation();
setPendingSlug(slug);
try {
if (currentlyMember) {
await actions.removeCampaignFromList(slug, campaignCoord);
} else {
await actions.addCampaignToList(slug, campaignCoord);
}
setOptimistic((m) => {
const next = new Map(m);
next.set(slug, !currentlyMember);
return next;
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: currentlyMember
? t('campaigns.lists.removeFailed')
: t('campaigns.lists.addFailed'),
description: msg,
variant: 'destructive',
});
} finally {
setPendingSlug(null);
}
};
const handleCreate = async (values: {
title: string;
description?: string;
icon: string;
}) => {
// Create the list, then immediately add this campaign to it.
const { slug } = await actions.createList(values);
try {
await actions.addCampaignToList(slug, campaignCoord);
setOptimistic((m) => {
const next = new Map(m);
next.set(slug, true);
return next;
});
} catch (err) {
// Surface but keep the list around — the moderator can retry the
// toggle from the row that will appear once the refetch lands.
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('campaigns.lists.addFailed'),
description: msg,
variant: 'destructive',
});
}
};
const handleOpenChange = (next: boolean) => {
if (!next) setOptimistic(new Map());
onOpenChange(next);
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-md max-h-[80dvh] rounded-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<DialogTitle>{t('campaigns.lists.membershipTitle')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('campaigns.lists.membershipDesc', { title: campaignTitle })}
</DialogDescription>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6 py-2">
{isLoading && lists.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : lists.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t('campaigns.lists.membershipEmpty')}
</div>
) : (
<ul className="divide-y divide-border">
{lists.map((list) => {
const member = isMember(list.slug, list.coords);
const pending = pendingSlug === list.slug;
return (
<li
key={list.aTag}
className="flex items-center gap-3 py-2.5"
>
<span className="inline-flex size-9 items-center justify-center rounded-md bg-primary/10 text-primary shrink-0">
<LucideIcon name={list.icon} className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{list.title}
</div>
{list.description && (
<div className="text-xs text-muted-foreground truncate">
{list.description}
</div>
)}
</div>
<Button
type="button"
variant={member ? 'secondary' : 'outline'}
size="sm"
disabled={pending}
onClick={(e) => handleToggle(list.slug, member, e)}
className={cn('min-w-[88px] justify-center')}
>
{pending ? (
<Loader2 className="size-4 animate-spin" />
) : member ? (
<>
<Check className="size-4 mr-1" />
{t('campaigns.lists.added')}
</>
) : (
t('campaigns.lists.addToList')
)}
</Button>
</li>
);
})}
</ul>
)}
</div>
<div className="flex justify-between items-center gap-2 pt-2 border-t -mx-6 px-6">
<Button
variant="ghost"
size="sm"
onClick={() => setCreateOpen(true)}
>
<Plus className="size-4 mr-1.5" />
{t('campaigns.lists.create')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenChange(false)}
>
{t('common.close')}
</Button>
</div>
</DialogContent>
</Dialog>
<ListFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
mode="create"
onSubmit={handleCreate}
/>
</>
);
}
@@ -0,0 +1,451 @@
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowUpToLine,
MoreVertical,
Pencil,
Plus,
Trash2,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { LucideIcon } from '@/components/LucideIcon';
import { ListFormDialog } from './ListFormDialog';
import { useCampaignLists } from '@/hooks/useCampaignLists';
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
import { useIsMobile } from '@/hooks/useIsMobile';
import { toast } from '@/hooks/useToast';
import type { ParsedCampaignList } from '@/lib/campaignLists';
import { cn } from '@/lib/utils';
const DRAG_MIME = 'text/x-agora-campaign-list-coord';
/**
* Horizontal scrollable strip of moderator-curated campaign list pills.
*
* **Layout.** A `flex` row that overflows horizontally — pills size to
* their own content (icon + label) and the row scrolls sideways on
* narrow viewports. Moderators get a trailing "+" pill that opens the
* Create List dialog, and a kebab on every pill exposing Edit, Delete,
* Move up, Move down, Move to start.
*
* **Moderator DnD.** Pills are draggable on desktop via the same
* native-HTML5 / non-library pattern used by `ReorderableCampaignGrid`.
* A drop on another pill calls `reorderLists` with the new full-strip
* order. Optimistic local ordering smooths the gap between the publish
* and the moderation refetch.
*
* **Mobile.** Drag is disabled (touch DnD without a library is
* unreliable). Reorder happens via the kebab actions instead. Same
* publish path, different trigger — matching the existing Featured row
* precedent.
*/
export function CampaignListsStrip() {
const { t } = useTranslation();
const { data, isLoading } = useCampaignLists();
const actions = useCampaignListActions();
const isMobile = useIsMobile();
const [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ParsedCampaignList | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ParsedCampaignList | null>(null);
const [optimisticOrder, setOptimisticOrder] = useState<readonly string[] | null>(null);
const lists = useMemo(() => data?.lists ?? [], [data]);
const authoritativeCoords = useMemo(() => lists.map((l) => l.aTag), [lists]);
// Optimistic order overrides authoritative until the latter catches
// up — same pattern as `ReorderableCampaignGrid`.
const displayed = useMemo<ParsedCampaignList[]>(() => {
if (!optimisticOrder) return lists;
const byCoord = new Map(lists.map((l) => [l.aTag, l]));
const out: ParsedCampaignList[] = [];
for (const coord of optimisticOrder) {
const found = byCoord.get(coord);
if (found) out.push(found);
}
if (out.length !== optimisticOrder.length) return lists;
return out;
}, [optimisticOrder, lists]);
if (
optimisticOrder &&
authoritativeCoords.length === optimisticOrder.length &&
authoritativeCoords.every((c, i) => c === optimisticOrder[i])
) {
queueMicrotask(() => setOptimisticOrder(null));
}
const handleReorder = useCallback(
async (newOrder: string[]) => {
const prev = optimisticOrder;
setOptimisticOrder(newOrder);
try {
await actions.reorderLists(newOrder);
} catch (err) {
setOptimisticOrder(prev);
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('moderation.menu.failedReorder'),
description: msg,
variant: 'destructive',
});
}
},
[optimisticOrder, actions, t],
);
const moveTo = useCallback(
(coord: string, toIndex: number) => {
const current = displayed.map((l) => l.aTag);
const fromIndex = current.indexOf(coord);
if (fromIndex < 0 || fromIndex === toIndex) return;
const next = [...current];
next.splice(fromIndex, 1);
next.splice(toIndex, 0, coord);
void handleReorder(next);
},
[displayed, handleReorder],
);
const handleCreate = useCallback(
async (values: { title: string; description?: string; icon: string }) => {
await actions.createList(values);
},
[actions],
);
const handleEditSubmit = useCallback(
async (values: { title: string; description?: string; icon: string }) => {
if (!editTarget) return;
await actions.updateListMeta({
slug: editTarget.slug,
title: values.title,
description: values.description,
icon: values.icon,
});
},
[actions, editTarget],
);
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await actions.deleteList(deleteTarget.slug);
setDeleteTarget(null);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('campaigns.lists.deleteFailed'),
description: msg,
variant: 'destructive',
});
}
};
// Loading skeleton: a few placeholder pills so the strip doesn't pop in.
if (isLoading && lists.length === 0) {
return (
<section className="space-y-3" aria-label={t('campaigns.lists.stripAria')}>
<div className="flex flex-wrap gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-32 rounded-full" />
))}
</div>
</section>
);
}
// No lists and non-mod viewer: render nothing rather than an empty row.
if (!isLoading && lists.length === 0 && !actions.isMod) {
return null;
}
return (
<>
<section
className="space-y-3"
aria-label={t('campaigns.lists.stripAria')}
>
<div className="flex flex-wrap gap-2">
{displayed.map((list, idx) => (
<ListPill
key={list.aTag}
list={list}
index={idx}
isMod={actions.isMod}
isMobile={isMobile}
onDropAt={(coord) => moveTo(coord, idx)}
onEdit={() => setEditTarget(list)}
onDelete={() => setDeleteTarget(list)}
onMoveUp={() => moveTo(list.aTag, Math.max(0, idx - 1))}
onMoveDown={() => moveTo(list.aTag, idx + 1)}
onMoveToStart={() => moveTo(list.aTag, 0)}
canMoveUp={idx > 0}
canMoveDown={idx < displayed.length - 1}
/>
))}
{actions.isMod && (
<button
type="button"
onClick={() => setCreateOpen(true)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-dashed px-3.5 py-2 text-sm whitespace-nowrap shrink-0',
'border-border bg-background hover:border-primary/60 hover:bg-primary/5 text-muted-foreground hover:text-foreground',
'motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)}
>
<Plus className="size-4 shrink-0" aria-hidden />
<span>{t('campaigns.lists.create')}</span>
</button>
)}
</div>
</section>
<ListFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
mode="create"
onSubmit={handleCreate}
/>
{editTarget && (
<ListFormDialog
open={!!editTarget}
onOpenChange={(o) => !o && setEditTarget(null)}
mode="edit"
initial={{
title: editTarget.title,
description: editTarget.description,
icon: editTarget.icon,
}}
onSubmit={handleEditSubmit}
/>
)}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('campaigns.lists.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('campaigns.lists.deleteConfirmDesc', {
title: deleteTarget?.title ?? '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void handleDeleteConfirm();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface ListPillProps {
list: ParsedCampaignList;
index: number;
isMod: boolean;
isMobile: boolean;
onDropAt: (coord: string) => void;
onEdit: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onMoveToStart: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}
function ListPill({
list,
index,
isMod,
isMobile,
onDropAt,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
onMoveToStart,
canMoveUp,
canMoveDown,
}: ListPillProps) {
const { t } = useTranslation();
const [isOver, setIsOver] = useState(false);
// Visible label + icon — same shape for mods and non-mods.
const content: ReactNode = (
<>
<LucideIcon name={list.icon} className="size-4 shrink-0 text-primary" />
<span className="whitespace-nowrap">{list.title}</span>
</>
);
// Non-moderators: just a link pill.
if (!isMod) {
return (
<Link
to={`/campaigns/lists/${list.slug}`}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-border px-3.5 py-2 text-sm shrink-0',
'bg-background hover:border-primary/40 hover:bg-primary/5 text-foreground',
'motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)}
>
{content}
</Link>
);
}
// Moderator pill: drop target on desktop, kebab menu on both.
const dropHandlers = isMobile
? {}
: {
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(DRAG_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!isOver) setIsOver(true);
},
onDragLeave: () => setIsOver(false),
onDrop: (e: React.DragEvent) => {
const sourceCoord = e.dataTransfer.getData(DRAG_MIME);
setIsOver(false);
if (!sourceCoord || sourceCoord === list.aTag) return;
e.preventDefault();
onDropAt(sourceCoord);
},
};
return (
<div
className={cn(
'relative inline-flex items-stretch shrink-0 rounded-full motion-safe:transition-shadow',
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background shadow-md',
)}
{...dropHandlers}
>
{!isMobile && (
<span
role="button"
tabIndex={-1}
draggable
aria-label={t('moderation.menu.dragHandle', { index: index + 1 })}
title={t('moderation.menu.dragHandle', { index: index + 1 })}
onDragStart={(e) => {
e.dataTransfer.setData(DRAG_MIME, list.aTag);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="inline-flex items-center pl-2 pr-1 rounded-l-full bg-background border border-r-0 border-border text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing"
>
<DragHandleIcon />
</span>
)}
<Link
to={`/campaigns/lists/${list.slug}`}
className={cn(
'inline-flex items-center gap-1.5 border border-border px-3.5 py-2 text-sm',
'bg-background hover:border-primary/40 hover:bg-primary/5 text-foreground',
'motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isMobile ? 'rounded-l-full' : '',
)}
>
{content}
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('campaigns.lists.menuAria', { title: list.title })}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center px-2 rounded-r-full bg-background border border-l-0 border-border text-muted-foreground hover:text-foreground hover:bg-primary/5 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<MoreVertical className="size-4" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onEdit()}>
<Pencil className="size-4 mr-2" />
{t('campaigns.lists.edit')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canMoveUp}
onSelect={() => onMoveToStart()}
>
<ArrowUpToLine className="size-4 mr-2" />
{t('moderation.menu.moveToTop')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!canMoveUp} onSelect={() => onMoveUp()}>
{t('moderation.menu.moveUp')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canMoveDown}
onSelect={() => onMoveDown()}
>
{t('moderation.menu.moveDown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onDelete()}
className="text-destructive focus:text-destructive"
>
<Trash2 className="size-4 mr-2" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/** Six-dot drag handle. Inline SVG to avoid an extra lucide import. */
function DragHandleIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
<circle cx="5" cy="3" r="1.4" />
<circle cx="11" cy="3" r="1.4" />
<circle cx="5" cy="8" r="1.4" />
<circle cx="11" cy="8" r="1.4" />
<circle cx="5" cy="13" r="1.4" />
<circle cx="11" cy="13" r="1.4" />
</svg>
);
}
@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Pencil } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { IconPicker } from '@/components/IconPicker';
import { LucideIcon } from '@/components/LucideIcon';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface ListFormInitial {
title: string;
description?: string;
icon: string;
}
interface ListFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Edit mode: prefilled values and existing slug. Create mode: omit.
*/
initial?: ListFormInitial;
/** Heading shown above the form. */
mode: 'create' | 'edit';
onSubmit: (values: ListFormInitial) => Promise<void>;
}
/**
* Shared form used by both Create and Edit list flows. Holds title,
* description, and icon name. The icon is picked through {@link IconPicker}
* — a modal-on-modal pattern. Lucide's bundled set is ~1500 icons; the
* picker is lazy-loaded so the create button doesn't pull the whole
* library into the main chunk.
*/
export function ListFormDialog({
open,
onOpenChange,
initial,
mode,
onSubmit,
}: ListFormDialogProps) {
const { t } = useTranslation();
const [title, setTitle] = useState(initial?.title ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [icon, setIcon] = useState(initial?.icon ?? 'List');
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Reset form whenever the dialog re-opens with fresh initial values.
useEffect(() => {
if (!open) return;
setTitle(initial?.title ?? '');
setDescription(initial?.description ?? '');
setIcon(initial?.icon ?? 'List');
}, [open, initial]);
const trimmedTitle = title.trim();
const canSubmit = trimmedTitle.length > 0 && !submitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setSubmitting(true);
try {
await onSubmit({
title: trimmedTitle,
description: description.trim() || undefined,
icon,
});
onOpenChange(false);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: mode === 'create'
? t('campaigns.lists.createFailed')
: t('campaigns.lists.updateFailed'),
description: msg,
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={(o) => !submitting && onOpenChange(o)}>
<DialogContent
className="max-w-md rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<DialogTitle>
{mode === 'create'
? t('campaigns.lists.create')
: t('campaigns.lists.edit')}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{mode === 'create'
? t('campaigns.lists.createDesc')
: t('campaigns.lists.editDesc')}
</DialogDescription>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="list-title">
{t('campaigns.lists.titleField')}
</Label>
<Input
id="list-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('campaigns.lists.titlePlaceholder')}
maxLength={80}
autoFocus={mode === 'create'}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="list-description">
{t('campaigns.lists.descriptionField')}{' '}
<span className="text-muted-foreground font-normal">
({t('forms.optional')})
</span>
</Label>
<Textarea
id="list-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('campaigns.lists.descriptionPlaceholder')}
maxLength={240}
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-1.5">
<Label>{t('campaigns.lists.iconField')}</Label>
<button
type="button"
onClick={() => setIconPickerOpen(true)}
className={cn(
'group inline-flex items-center gap-3 rounded-lg border px-3 py-2 text-sm',
'hover:border-primary/40 hover:bg-primary/5 motion-safe:transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)}
>
<span className="inline-flex size-8 items-center justify-center rounded-md bg-muted text-foreground">
<LucideIcon name={icon} className="size-4" />
</span>
<span className="font-mono">{icon}</span>
<Pencil
className="size-3.5 text-muted-foreground ml-auto group-hover:text-foreground"
aria-hidden
/>
</button>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting && <Loader2 className="size-4 animate-spin mr-2" />}
{mode === 'create'
? t('campaigns.lists.createSubmit')
: t('campaigns.lists.editSubmit')}
</Button>
</div>
</DialogContent>
</Dialog>
<IconPicker
open={iconPickerOpen}
onOpenChange={setIconPickerOpen}
value={icon}
onSelect={setIcon}
/>
</>
);
}
@@ -0,0 +1,240 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { HandHeart, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import { useAllCampaigns, toQuerySort } from '@/hooks/useAllCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import type { ParsedCampaign } from '@/lib/campaign';
interface CampaignsDiscoverySectionProps {
/**
* Where this section's filter state lives:
*
* • `'url'` — flat URL params (`?q=&sort=&country=`). Used by the
* dedicated `/campaigns` page so search results are
* shareable and survive refresh.
* • `'local'` — local-only state. Used by `/` where three
* discovery sections coexist and can't all own `?q=`.
*/
filterPersistence: 'url' | 'local';
/**
* Visible-row cap for the **idle** view. The active
* (search / sort / country) view always shows the full result set,
* because the user has explicitly asked to browse. Defaults to
* unlimited (`undefined`).
*/
idleLimit?: number;
/**
* Optional hoisted Show-hidden state. When provided, the toolbar
* exposes the Show-hidden switch and uses this state. The page can
* read the same value to drive a separate Hidden collapsible. When
* omitted, the switch never appears.
*
* The switch is available to **every viewer** (not gated on
* moderator status). The moderation labels are public on relays
* regardless, so transparent moderation — letting anyone unhide
* what's been suppressed — is the only honest UX. See
* `AllCampaignsPage`.
*/
showHidden?: {
value: boolean;
onChange: (next: boolean) => void;
/** Hidden-count badge for the toolbar chip. */
count?: number;
};
}
/**
* Unified campaigns discovery section: section header + toolbar +
* idle/active grid.
*
* The section has two display modes:
*
* 1. **Idle** (no search, no sort, no country picked) — renders
* every non-hidden campaign in pure reverse-chronological order.
* The home page has its own dedicated Featured row, so this
* shelf doesn't repeat that ordering; viewers on `/campaigns`
* see "what's new" rather than "what mods picked."
* 2. **Active** — renders the full ranked / chronological / country-
* scoped result set.
*
* Hidden campaigns are excluded by default. Any viewer can flip the
* Show-hidden switch in the toolbar; the section reads that state
* from the `showHidden` prop so a page can persist it across
* multiple shelves (e.g. the Hidden collapsible mod section). The
* switch is intentionally NOT gated on moderator status — the
* Campaigns page is the censorship-resistant view, so everyone can
* unhide what mods have suppressed.
*
* Search is post-filtered client-side across title / summary / story /
* location / categories — relay NIP-50 sort-by-top doesn't account
* for sats raised, which is the ranking signal users actually want
* when searching for campaigns.
*/
export function CampaignsDiscoverySection({
filterPersistence,
idleLimit,
showHidden: showHiddenProp,
}: CampaignsDiscoverySectionProps) {
const { t } = useTranslation();
const filters = useDiscoveryFilters({
urlPrefix: filterPersistence === 'url' ? '' : undefined,
enableCountry: true,
});
const activeQuery = filters.debouncedSearch.trim();
const isActive =
activeQuery !== '' || filters.sort !== 'default' || !!filters.country;
const { data: campaigns, isLoading } = useAllCampaigns({
sort: toQuerySort(filters.sort),
search: activeQuery,
countryCode: filters.country,
limit: 200,
});
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
const showHiddenValue = showHiddenProp?.value ?? false;
// Visible campaigns in the **active** branch: every campaign
// matching the search / sort / country, minus hidden (unless the
// viewer opted in). Featured items are intentionally NOT pulled
// out — when the user is actively browsing, they want a ranked or
// chronological grid, not the curated shelf.
//
// The toggle is intentionally available to every viewer (not gated
// on moderator status) so that on the Campaigns page anyone can
// unhide what mods have suppressed. That's a censorship-resistance
// property: the moderation labels are public, so transparent
// moderation is the only honest UX.
const visible = useMemo(() => {
const all = campaigns ?? [];
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
const out: ParsedCampaign[] = [];
for (const c of all) {
if (hiddenCoords.has(c.aTag)) {
if (showHiddenValue) out.push(c);
} else {
out.push(c);
}
}
return out;
}, [campaigns, moderation, showHiddenValue]);
// Idle-mode list: every non-hidden campaign in pure
// reverse-chronological order (newest first). The home page has its
// own dedicated Featured row, so this discovery shelf doesn't need
// to repeat that ordering — viewers landing on `/campaigns` expect
// "what's new" not "what mods picked." `visible` already arrives
// newest-first from `useAllCampaigns` (default sort), so we just
// pass it through.
const idleCampaigns = useMemo<ParsedCampaign[]>(() => {
return idleLimit ? visible.slice(0, idleLimit) : visible;
}, [visible, idleLimit]);
const showSkeleton = isLoading || !moderationReady;
const listForRender = isActive ? visible : idleCampaigns;
const hiddenCount = showHiddenProp?.count ?? 0;
const hiddenAllOfThem = !isActive && hiddenCount > 0 && !showHiddenValue;
return (
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{activeQuery ? t('common.search') : t('campaigns.all.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{activeQuery
? t('common.searchResultsCount', { count: visible.length })
: t('campaigns.all.sectionTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={filters.searchInput}
onQueryChange={filters.setSearchInput}
sort={filters.sort}
onSortChange={filters.setSort}
sortOptions={['top', 'new']}
searchPlaceholderKey="campaigns.all.searchPlaceholder"
searchAriaLabelKey="campaigns.all.searchAriaLabel"
showHidden={
showHiddenProp
? {
value: showHiddenProp.value,
onChange: showHiddenProp.onChange,
count: hiddenCount,
}
: undefined
}
country={filters.country}
onCountryChange={filters.setCountry}
/>
</div>
{showSkeleton ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
) : listForRender.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div className="space-y-1.5">
{activeQuery ? (
<>
<h3 className="text-lg font-semibold">
{t('campaigns.all.noMatch', { query: activeQuery })}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.noMatchHint')}
</p>
</>
) : hiddenAllOfThem ? (
<>
<h3 className="text-lg font-semibold">
{t('campaigns.all.allHidden')}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.allHiddenHint')}
</p>
</>
) : (
<>
<h3 className="text-lg font-semibold">
{t('campaigns.all.empty')}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.emptyHint')}
</p>
</>
)}
</div>
<Button asChild>
<StartCampaignLink>
<PlusCircle className="size-4 mr-2" />
{t('campaigns.all.startCampaign')}
</StartCampaignLink>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{listForRender.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,253 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import {
CommunityMiniCard,
CommunityMiniCardSkeleton,
} from '@/components/discovery/CommunityMiniCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useNip50Search } from '@/hooks/useNip50Search';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
interface GroupsDiscoverySectionProps {
/**
* Where this section's filter state lives. See
* `CampaignsDiscoverySection` for the rationale.
*/
filterPersistence: 'url' | 'local';
/**
* Visible-row cap for the **idle** featured-first view. The active
* (search / sort) view always shows the full result set. Defaults
* to unlimited (`undefined`).
*/
idleLimit?: number;
/**
* Optional hoisted Show-hidden state. When provided, the toolbar
* exposes the mod-only switch and uses this state. The page can
* read the same value to drive a separate Hidden collapsible.
*/
showHidden?: {
value: boolean;
onChange: (next: boolean) => void;
/** Hidden-count badge for the toolbar chip. */
count?: number;
};
}
/**
* Unified groups discovery section: section header + toolbar +
* idle/active grid.
*
* • **Idle** (default sort, empty query) — renders ONLY
* moderator-featured groups. No fallback to a chronological "all
* groups" grid: that produced a flash of unrelated communities
* while the relay returned every kind-34550 event before the
* client-side Agora-tag filter ran. The skeleton is gated on the
* featured query itself so the idle view goes
* skeleton → curated grid without an intermediate state.
*
* • **Active** (search / Top / New) — renders the full relay
* search result set, post-filtered against name / description /
* content client-side because group names live in tags and most
* NIP-50 relays only match `content`.
*
* Groups aren't country-scoped (a community is its own scope), so
* the country picker is intentionally omitted from the toolbar even
* though Campaigns and Pledges expose it.
*/
export function GroupsDiscoverySection({
filterPersistence,
idleLimit,
showHidden: showHiddenProp,
}: GroupsDiscoverySectionProps) {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const filters = useDiscoveryFilters({
urlPrefix: filterPersistence === 'url' ? '' : undefined,
enableCountry: false,
});
const trimmedSearch = filters.debouncedSearch.trim();
const showHiddenValue = showHiddenProp?.value ?? false;
const hiddenCount = showHiddenProp?.count ?? 0;
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<ParsedCommunity>({
kind: COMMUNITY_DEFINITION_KIND,
query: filters.debouncedSearch,
sort: filters.sort,
parse: parseCommunityEvent,
// Group names and descriptions live in tags, not `content`. Relay
// NIP-50 implementations that only match content silently miss
// obvious title hits — widen client-side by also checking these
// tag values.
getKeywordHaystack: (event) => {
const name = event.tags.find(([n]) => n === 'name')?.[1] ?? '';
const description = event.tags.find(([n]) => n === 'description')?.[1] ?? '';
return [name, description, event.content];
},
});
const { data: orgModeration, isReady: orgModerationReady } =
useOrganizationModeration();
const searchHits = useMemo(() => {
if (!searchHitsRaw) return undefined;
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
const visible: ParsedCommunity[] = [];
for (const c of searchHitsRaw) {
if (hiddenCoords.has(c.aTag)) {
if (showHiddenValue) visible.push(c);
} else {
visible.push(c);
}
}
return visible;
}, [searchHitsRaw, orgModeration, showHiddenValue]);
// Featured groups — the curated list moderators publish. This is
// the entire idle-mode payload: no chronological fallback, no
// client-side tag filter, no "fetch everything and pick the Agora
// ones out of it" dance. Hidden coords are dropped (unless a
// moderator has flipped Show hidden on).
const { data: featuredOrgs, isLoading: featuredOrgsLoading } =
useFeaturedOrganizations();
const featuredGroups = useMemo<ParsedCommunity[]>(() => {
if (!featuredOrgs) return [];
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
const list = featuredOrgs
.map((entry) => entry.community)
.filter((c) => (isMod && showHiddenValue) || !hiddenCoords.has(c.aTag));
return idleLimit ? list.slice(0, idleLimit) : list;
}, [featuredOrgs, orgModeration, isMod, showHiddenValue, idleLimit]);
// Idle-render skeleton gate. `useFeaturedOrganizations` is
// internally gated on `moderationReady`, so while the moderation
// labels are still loading, the hook is *disabled* and reports
// `isLoading: false` / `data: undefined`. Treating that as "not
// loading" would render the empty state for a moment before the
// curated grid pops in; tracking moderation-readiness here keeps
// the skeleton on screen until we know what's featured.
const idleLoading =
!orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined;
return (
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch ? t('common.search') : t('groups.list.allGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('groups.list.allGroupsTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={filters.searchInput}
onQueryChange={filters.setSearchInput}
sort={filters.sort}
onSortChange={filters.setSort}
sortOptions={['top', 'new']}
searchPlaceholderKey="groups.list.searchPlaceholder"
searchAriaLabelKey="groups.list.searchAriaLabel"
showHidden={
isMod && showHiddenProp
? {
value: showHiddenProp.value,
onChange: showHiddenProp.onChange,
count: hiddenCount,
}
: undefined
}
/>
</div>
{isSearching ? (
<>
{isSearchFetching && !searchHits ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : searchHits && searchHits.length > 0 ? (
<CommunityGrid>
{searchHits.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('groups.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('groups.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
)}
</CardContent>
</Card>
)}
</>
) : idleLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : featuredGroups.length > 0 ? (
<CommunityGrid>
{featuredGroups.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
</CardContent>
</Card>
)}
</section>
);
}
@@ -0,0 +1,318 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionShareMenu } from '@/components/ActionShareMenu';
import { Card } from '@/components/ui/card';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { ModerationOverlay } from '@/components/moderation';
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
import { parseAction, useActions, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
import { useNip50Search } from '@/hooks/useNip50Search';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
import { getPledgeCoord } from '@/lib/pledges';
interface PledgesDiscoverySectionProps {
/**
* Where this section's filter state lives. See
* `CampaignsDiscoverySection` for the rationale.
*/
filterPersistence: 'url' | 'local';
/**
* Visible-row cap for the **idle** featured-first view. The active
* (search / sort / country) view always shows the full result set.
* Defaults to unlimited (`undefined`).
*/
idleLimit?: number;
/**
* Optional hoisted Show-hidden state. When provided, the toolbar
* exposes the mod-only switch and uses this state. The page can
* read the same value to drive a separate Hidden collapsible.
*/
showHidden?: {
value: boolean;
onChange: (next: boolean) => void;
/** Hidden-count badge for the toolbar chip. */
count?: number;
};
}
/**
* Unified pledges discovery section: section header + toolbar +
* idle/active grid.
*
* • **Idle** (no search / no sort / no country) — renders the
* moderator-featured pledges, falling back to chronological
* all-pledges when nothing is featured yet.
*
* • **Active** — renders the full search / sort / country-scoped
* result set, post-filtered against title / content client-side.
* Picking a country with an empty query still activates the
* search view — narrowing kind 36639 by NIP-73 `iso3166:XX` +
* legacy `geo:XX` tags produces a useful filtered grid even
* without a typed term.
*/
export function PledgesDiscoverySection({
filterPersistence,
idleLimit,
showHidden: showHiddenProp,
}: PledgesDiscoverySectionProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: btcPrice } = useBtcPrice();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const filters = useDiscoveryFilters({
urlPrefix: filterPersistence === 'url' ? '' : undefined,
enableCountry: true,
});
const trimmedSearch = filters.debouncedSearch.trim();
const showHiddenValue = showHiddenProp?.value ?? false;
const canShowHidden = isMod && showHiddenValue;
const hiddenCount = showHiddenProp?.count ?? 0;
// Country → NIP-73 `#i` tag list. Picking a country with no typed
// query still activates the search view; narrowing a kind by
// external identifier produces a useful filtered grid even without
// a typed term.
const iTags = useMemo<string[] | undefined>(() => {
if (!filters.country) return undefined;
const code = filters.country.toUpperCase();
return [`iso3166:${code}`, `geo:${code}`];
}, [filters.country]);
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<Action>({
kind: 36639,
query: filters.debouncedSearch,
sort: filters.sort,
parse: parseAction,
iTags,
// Pledge titles live in a `title` tag, not `content`. Most NIP-50
// implementations only match content; widen the net client-side.
getKeywordHaystack: (event) => {
const title = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
return [title, event.content];
},
});
// Chronological feed that backs the idle grid (and the
// featured-then-chronological fallback). Gated on `!isSearching`
// because the search branch renders `searchHits` instead and never
// reads `rawActions` / `actions` — leaving this query enabled during
// search burns a 300-event relay round-trip on every keystroke that
// activates the search view. The idle branch is the only consumer,
// and the idle branch only renders when `!isSearching`, so this
// gate strictly removes wasted work.
const { data: rawActions, isLoading: actionsLoading } = useActions({
countryCode: filters.country,
limit: 300,
enabled: !isSearching,
});
const { data: pledgeModeration, isReady: pledgeModerationReady } =
usePledgeModeration();
const featuredPledgeCoords = useMemo(() => {
if (!pledgeModerationReady) return [] as string[];
return Array.from(pledgeModeration.featuredCoords)
.filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
.sort(
(a, b) =>
(pledgeModeration.featuredOrder.get(b) ?? 0) -
(pledgeModeration.featuredOrder.get(a) ?? 0),
);
}, [pledgeModeration, pledgeModerationReady]);
const { data: featuredPledges } = useActions({
coordinates: featuredPledgeCoords,
limit: featuredPledgeCoords.length || 1,
enabled: pledgeModerationReady && featuredPledgeCoords.length > 0,
});
const orderedFeaturedPledges = useMemo(() => {
if (!featuredPledges || !pledgeModerationReady) return [] as Action[];
const order = pledgeModeration.featuredOrder;
return [...featuredPledges].sort((a, b) => {
const aCoord = getPledgeCoord(a);
const bCoord = getPledgeCoord(b);
return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
});
}, [featuredPledges, pledgeModeration, pledgeModerationReady]);
const featuredPledgeCoordSet = useMemo(
() => new Set(featuredPledgeCoords),
[featuredPledgeCoords],
);
const searchHits = useMemo(() => {
if (!searchHitsRaw) return undefined;
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
const visible: Action[] = [];
for (const a of searchHitsRaw) {
const coord = getPledgeCoord(a);
if (hiddenCoords.has(coord)) {
if (canShowHidden) visible.push(a);
} else {
visible.push(a);
}
}
return visible;
}, [searchHitsRaw, pledgeModeration, canShowHidden]);
// Chronological pledge list filtered by country, with
// moderator-hidden items dropped (unless `showHidden` is on).
// Featured pledges are NOT excluded here — the idle render path
// pulls them separately, and the active render path shows the
// full list.
const actions = useMemo(() => {
if (!rawActions) return undefined;
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
const visible: Action[] = [];
for (const action of rawActions) {
const coord = getPledgeCoord(action);
if (hiddenCoords.has(coord)) {
if (canShowHidden) visible.push(action);
} else {
visible.push(action);
}
}
return visible;
}, [rawActions, pledgeModeration, canShowHidden]);
const isLoading = actionsLoading || !pledgeModerationReady;
const isSearchLoading = isSearchFetching || !pledgeModerationReady;
// Idle list: featured first; if none are featured, fall back to
// the chronological all-pledges grid so the section is never blank.
const idlePledges = useMemo<Action[]>(() => {
const list =
orderedFeaturedPledges.length > 0
? orderedFeaturedPledges
: (actions ?? []).filter(
(action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)),
);
return idleLimit ? list.slice(0, idleLimit) : list;
}, [orderedFeaturedPledges, actions, featuredPledgeCoordSet, idleLimit]);
const renderPledge = (action: Action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={getPledgeCoord(action)}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
);
return (
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch ? t('common.search') : t('pledges.list.allPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('pledges.list.allPledgesTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={filters.searchInput}
onQueryChange={filters.setSearchInput}
sort={filters.sort}
onSortChange={filters.setSort}
sortOptions={['top', 'new']}
searchPlaceholderKey="pledges.list.searchPlaceholder"
searchAriaLabelKey="pledges.list.searchAriaLabel"
showHidden={
isMod && showHiddenProp
? {
value: showHiddenProp.value,
onChange: showHiddenProp.onChange,
count: hiddenCount,
}
: undefined
}
country={filters.country}
onCountryChange={filters.setCountry}
/>
</div>
{isSearching ? (
<>
{isSearchLoading && !searchHits ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<PledgeCardSkeleton key={i} />
))}
</div>
) : searchHits && searchHits.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{searchHits.map(renderPledge)}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('pledges.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('pledges.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
)}
</div>
</Card>
)}
</>
) : isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<PledgeCardSkeleton key={i} />
))}
</div>
) : idlePledges.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{idlePledges.map(renderPledge)}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
</div>
</Card>
)}
</section>
);
}
+160 -50
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Check, EyeOff, Eye, MoreHorizontal,
ShieldCheck, ShieldOff, Sparkles, SparklesIcon,
Check, EyeOff, Eye, ListPlus, MoreHorizontal,
Sparkles, SparklesIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
@@ -30,13 +31,16 @@ import type { ModerationLabel } from '@/lib/agoraModeration';
export type ModerationSurface = 'campaign' | 'pledge' | 'group';
/**
* Which axes the menu should render. Campaigns have all three; pledges
* and groups don't have an approval axis. The order in this array does
* NOT determine render order — the menu always renders Approve → Hide →
* Feature top-to-bottom when present, which keeps the three surfaces
* visually consistent.
* Which axes the menu should render. Two are defined: `hide` (used
* by every surface) and `featured` (used by pledges and groups; the
* campaign surface stopped opting into this axis when the curated
* Lists feature replaced campaign-level featuring). The prop exists
* so future surfaces can selectively expose one axis if needed. The
* order in this array does NOT determine render order — the menu
* always renders Hide → Feature top-to-bottom when present, which
* keeps the surfaces visually consistent.
*/
export type ModerationAxis = 'approval' | 'hide' | 'featured';
export type ModerationAxis = 'hide' | 'featured';
interface ModerationItemsProps {
/** Addressable coordinate of the entity (`<kind>:<pubkey>:<d>`). */
@@ -91,22 +95,42 @@ function ModerationItemsShell({
axes,
moderation,
moderate,
getFeatureRank,
onAddToList,
}: {
coord: string;
entityTitle: string;
axes: readonly ModerationAxis[];
moderation: ReturnType<typeof useCampaignModeration>['data'];
moderate: ReturnType<typeof useCampaignModeration>['moderate'];
/**
* Optional per-surface hook that computes the `rank` tag to publish
* on a `featured` action. The display sort is descending by rank,
* so returning `min(existing ranks) - 1` makes a newly-featured
* entity land at the **bottom** of the surface's featured shelf
* (append semantics).
*
* Only the `featured` action consults this — `unfeatured`,
* `hidden`, and `unhidden` ignore it. Surfaces that don't pass it
* keep the legacy `created_at`-fallback behavior, which puts the
* newest feature on top.
*/
getFeatureRank?: () => number | undefined;
/**
* Optional click handler for the "Add to list…" row. When provided,
* the row is rendered above the standard axis controls. Only the
* campaign surface currently passes this — the menu item opens a
* per-campaign membership modal in {@link CampaignItemsInner}.
*/
onAddToList?: () => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isApproved = moderation.approvedCoords.has(coord);
const isHidden = moderation.hiddenCoords.has(coord);
const isFeatured = moderation.featuredCoords.has(coord);
const hasApproval = axes.includes('approval');
const hasHide = axes.includes('hide');
const hasFeatured = axes.includes('featured');
@@ -114,7 +138,12 @@ function ModerationItemsShell({
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
// `featured` actions on surfaces with append-semantics carry an
// explicit rank so the new label lands at the bottom of the
// descending-rank shelf. Other axes / surfaces leave `rank`
// undefined and rely on `created_at` fallback.
const rank = action === 'featured' ? getFeatureRank?.() : undefined;
await moderate.mutateAsync({ coord, action, rank });
toast({ title: verbPast, description: entityTitle });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -135,21 +164,14 @@ function ModerationItemsShell({
</DropdownMenuLabel>
<DropdownMenuSeparator />
{hasApproval && (
isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', t('moderation.menu.toastUnapproved'))} disabled={!!busy}>
<ShieldOff className="h-4 w-4 mr-2" />
{t('moderation.menu.unapprove')}
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> {t('moderation.menu.approvedState')}
</span>
{onAddToList && (
<>
<DropdownMenuItem onClick={() => onAddToList()}>
<ListPlus className="h-4 w-4 mr-2" />
{t('moderation.menu.addToList')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', t('moderation.menu.toastApproved'))} disabled={!!busy}>
<ShieldCheck className="h-4 w-4 mr-2" />
{t('moderation.menu.approve')}
</DropdownMenuItem>
)
{(hasHide || hasFeatured) && <DropdownMenuSeparator />}
</>
)}
{hasHide && (
@@ -173,7 +195,7 @@ function ModerationItemsShell({
)
)}
{hasFeatured && (hasApproval || hasHide) && <DropdownMenuSeparator />}
{hasFeatured && hasHide && <DropdownMenuSeparator />}
{hasFeatured && (
isFeatured ? (
@@ -199,17 +221,50 @@ function ModerationItemsShell({
// hook so a pledge card never subscribes to the campaign label query
// (and vice versa).
function CampaignItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
function CampaignItemsInner(props: {
coord: string;
entityTitle: string;
axes: readonly ModerationAxis[];
/**
* Called when the moderator clicks "Add to list…". The host (a
* dropdown menu) closes itself on item-select, which would unmount
* a dialog rendered here as a sibling. The host holds the modal
* state instead — see {@link ModerationMenu}.
*/
onAddToList?: () => void;
}) {
const { data, moderate } = useCampaignModeration();
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
// The campaign surface stopped exposing the `featured` axis when
// the curated Lists feature replaced campaign-level featuring, so
// the rank computation is dead weight here. We still pass through
// the shared shell because the shell drives the "Add to list…" row
// plus Hide / Unhide.
return (
<ModerationItemsShell
coord={props.coord}
entityTitle={props.entityTitle}
axes={props.axes}
moderation={data}
moderate={moderate}
onAddToList={props.onAddToList}
/>
);
}
function PledgeItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
function PledgeItemsInner(props: {
coord: string;
entityTitle: string;
axes: readonly ModerationAxis[];
}) {
const { data, moderate } = usePledgeModeration({ coordinates: [props.coord] });
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
}
function GroupItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
function GroupItemsInner(props: {
coord: string;
entityTitle: string;
axes: readonly ModerationAxis[];
}) {
const { data, moderate } = useOrganizationModeration();
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
}
@@ -238,19 +293,50 @@ function GroupItemsInner(props: { coord: string; entityTitle: string; axes: read
* share/owner items), use {@link ModerationMenu} or
* {@link ModerationOverlay} — both wrap this component in their own
* trigger.
*
* **Campaign-only "Add to list…" row.** Callers embedding the campaign
* surface can pass `onAddToList` to render an extra row above the
* standard axes. The host is responsible for owning the dialog state
* (the dropdown unmounts its own children on close, which would tear
* down a sibling dialog rendered here). {@link ModerationMenu} does
* this automatically; other hosts (`ActionShareMenu` etc.) only need
* to pass the callback if they want the row inline.
*/
export function ModerationMenuItems(props: ModerationItemsProps) {
export function ModerationMenuItems(
props: ModerationItemsProps & { onAddToList?: () => void },
) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
const inner = { coord: props.coord, entityTitle: props.entityTitle, axes: props.axes };
switch (props.surface) {
case 'campaign': return <CampaignItemsInner {...inner} />;
case 'pledge': return <PledgeItemsInner {...inner} />;
case 'group': return <GroupItemsInner {...inner} />;
case 'campaign':
return (
<CampaignItemsInner
coord={props.coord}
entityTitle={props.entityTitle}
axes={props.axes}
onAddToList={props.onAddToList}
/>
);
case 'pledge':
return (
<PledgeItemsInner
coord={props.coord}
entityTitle={props.entityTitle}
axes={props.axes}
/>
);
case 'group':
return (
<GroupItemsInner
coord={props.coord}
entityTitle={props.entityTitle}
axes={props.axes}
/>
);
}
}
@@ -268,30 +354,54 @@ export function ModerationMenuItems(props: ModerationItemsProps) {
* Used directly on detail pages (no overlay wrapper). For card grids,
* prefer {@link ModerationOverlay}, which bundles this kebab with a
* "Hidden" badge in an absolutely-positioned corner.
*
* Campaign surfaces additionally get an "Add to list…" row at the top
* that opens a per-campaign membership modal. The modal's state lives
* at this trigger level (not inside `DropdownMenuContent`) because the
* dropdown unmounts its children on close — a sibling dialog mounted
* inside the content would be torn down on the same tick.
*/
export function ModerationMenu({ className, ...rest }: ModerationMenuProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const [membershipOpen, setMembershipOpen] = useState(false);
if (!isMod) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t(ariaLabelKey(rest.surface))}
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<ModerationMenuItems {...rest} />
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t(ariaLabelKey(rest.surface))}
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<ModerationMenuItems
{...rest}
onAddToList={
rest.surface === 'campaign'
? () => setMembershipOpen(true)
: undefined
}
/>
</DropdownMenuContent>
</DropdownMenu>
{rest.surface === 'campaign' && (
<CampaignListMembershipDialog
open={membershipOpen}
onOpenChange={setMembershipOpen}
campaignCoord={rest.coord}
campaignTitle={rest.entityTitle}
/>
)}
</>
);
}
@@ -39,6 +39,17 @@ interface ModeratorCollapsibleSectionProps {
* pages render this inside an already-padded `<main>` and pass no
* override. */
triggerPaddingClassName?: string;
/**
* Explicit initial open state. When omitted, the section auto-opens
* for short queues (`count <= 6`) and collapses for long ones —
* the legacy heuristic.
*
* Pass `false` to force the section closed on first render
* regardless of count (e.g. the Hidden queue on the home page,
* where mods want to scan Pending first and only dig into Hidden
* when needed).
*/
defaultOpen?: boolean;
}
/**
@@ -61,8 +72,9 @@ export function ModeratorCollapsibleSection({
children,
size = 'default',
triggerPaddingClassName,
defaultOpen,
}: ModeratorCollapsibleSectionProps) {
const [open, setOpen] = useState(count <= 6);
const [open, setOpen] = useState(defaultOpen ?? count <= 6);
return (
<Collapsible open={open} onOpenChange={setOpen} asChild>
@@ -1,10 +1,10 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Megaphone } from 'lucide-react';
import { useQueries } from '@tanstack/react-query';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
@@ -81,12 +81,11 @@ export function ProfileCampaignsTab({
: t('profile.campaigns.emptyOther', { name: displayName })}
</p>
{isOwnProfile && (
<Link
to="/campaigns/new"
<StartCampaignLink
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
>
{t('profile.campaigns.startLink')}
</Link>
</StartCampaignLink>
)}
</div>
</Card>
+7
View File
@@ -379,6 +379,13 @@ export interface AppConfig {
aiModel: string;
/** Custom system prompt for the Agent. Empty string = use the default template. */
aiSystemPrompt: string;
/**
* URL of the DeepL-backed Cloudflare Worker used to translate user-generated
* content (the "Translate" button on notes). Defaults to the build-time
* `VITE_TRANSLATE_WORKER_URL` env value. Empty string falls back to the
* hardcoded worker URL in the translate flow.
*/
translateWorkerUrl: string;
}
/** Configuration for a single widget in the right sidebar. */
+1 -1
View File
@@ -3,7 +3,7 @@ import { createContext, useContext } from 'react';
/**
* The two top-level roles a new user can pick during onboarding. Drives
* downstream copy (creator vs. donor framing) and the role-pick CTA target
* (creator → /campaigns/new, donor → /campaigns/all).
* (creator → /campaigns/new, donor → /campaigns).
*
* `null` before the user has answered the role-picker step.
*/
+15
View File
@@ -5,10 +5,25 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
import { parseCampaignEvents } from '@/hooks/useCampaigns';
import type { Nip50Sort } from '@/hooks/useNip50Search';
/** Sort modes for the All Campaigns page. */
export type CampaignSort = 'top' | 'none';
/**
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) onto
* `useAllCampaigns`'s vocabulary (`top` / `none`). `'new'` and `'default'`
* both map to `'none'` (chronological) — discovery sections apply the
* "show featured only when idle" framing on top of the chronological
* feed, so the underlying query doesn't need to distinguish them.
*
* Exported so the section component and any page-level consumer using
* the same hook stay aligned through one helper instead of two
* hand-rolled ternaries.
*/
export const toQuerySort = (s: Nip50Sort): CampaignSort =>
s === 'top' ? 'top' : 'none';
interface UseAllCampaignsOptions {
/** Sort mode. `top` ranks by total sats raised; `none` is chronological. */
sort: CampaignSort;
+443
View File
@@ -0,0 +1,443 @@
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import {
CAMPAIGN_LIST_KIND,
CAMPAIGN_LIST_HASHTAG,
CAMPAIGN_LIST_INDEX_D,
CAMPAIGN_LIST_INDEX_HASHTAG,
isValidIconName,
isValidListSlug,
slugifyListTitle,
} from '@/lib/campaignLists';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { NostrEvent } from '@nostrify/nostrify';
interface CreateListInput {
title: string;
description?: string;
icon: string;
}
interface UpdateListMetaInput {
slug: string;
title?: string;
description?: string;
icon?: string;
}
/**
* All write paths for moderator-curated campaign lists.
*
* Every action throws if the current user isn't on the campaign
* moderator allowlist (Team Soapbox follow pack). The UI hides the
* affordances entirely for non-moderators; this gate is defense in
* depth so a stray button or a future bug can't publish a list under a
* non-moderator pubkey.
*
* **Read-modify-write.** Mutating an existing list — meta edits,
* membership changes, reorders — first calls `fetchFreshEvent` against
* relays so we never publish on top of a stale cached version. The
* resulting event is passed back as `prev` to `useNostrPublish`, which
* preserves `published_at` per NIP-24.
*
* **Cross-moderator edits.** A moderator who edits another moderator's
* list publishes their own event under their own pubkey with the same
* slug. The read fold (`foldCampaignLists`) picks the newest event per
* `(pubkey, slug)`, so the most recent revision wins — but only for
* that pubkey's list copy. The list-of-lists index, in contrast, is a
* single sentinel `d` tag that any moderator may publish; the newest
* index across all moderators wins.
*
* Concurrent reorders by two moderators resolve to whoever publishes
* last. This matches the rest of the moderation namespace.
*/
export function useCampaignListActions() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const requireMod = useCallback(() => {
if (!user) throw new Error('Not logged in');
if (!moderators || !moderators.includes(user.pubkey)) {
throw new Error('Not a campaign moderator');
}
}, [user, moderators]);
/** Build the standard tag set for a list event. */
const buildListTags = useCallback(
(input: {
slug: string;
title: string;
description?: string;
icon: string;
coords: string[];
}): string[][] => {
const tags: string[][] = [
['d', input.slug],
['title', input.title],
['icon', input.icon],
['t', CAMPAIGN_LIST_HASHTAG],
['alt', `Agora campaign list: ${input.title}`],
];
if (input.description) {
tags.push(['description', input.description]);
}
for (const coord of input.coords) {
tags.push(['a', coord]);
}
return tags;
},
[],
);
/** Fetch the current user's existing list event for a slug. */
const fetchOwnList = useCallback(
async (slug: string): Promise<NostrEvent | null> => {
if (!user) return null;
return fetchFreshEvent(nostr, {
kinds: [CAMPAIGN_LIST_KIND],
authors: [user.pubkey],
'#d': [slug],
});
},
[nostr, user],
);
/** Fetch the current user's index sentinel event, if any. */
const fetchOwnIndex = useCallback(async (): Promise<NostrEvent | null> => {
if (!user) return null;
return fetchFreshEvent(nostr, {
kinds: [CAMPAIGN_LIST_KIND],
authors: [user.pubkey],
'#d': [CAMPAIGN_LIST_INDEX_D],
});
}, [nostr, user]);
/** Invalidate the campaign-lists query so the strip refetches. */
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['campaign-lists'] });
}, [queryClient]);
/**
* Parse the existing list metadata from a fresh event. Used by every
* RMW action so it preserves whatever the latest revision says about
* title / icon / description and only mutates what the action changed.
*/
const readListFields = useCallback(
(event: NostrEvent | null) => {
const tags = event?.tags ?? [];
const get = (name: string) =>
tags.find(([n, v]) => n === name && typeof v === 'string')?.[1];
const coords: string[] = [];
for (const tag of tags) {
if (tag[0] === 'a' && typeof tag[1] === 'string') coords.push(tag[1]);
}
return {
title: get('title') ?? '',
description: get('description'),
icon: get('icon') ?? 'List',
coords,
};
},
[],
);
/**
* Create a new list. Generates a slug from the title, collision-checks
* it against the user's own existing lists, then publishes the list
* event AND a refreshed index event appending the new list to the end
* of the strip.
*/
const createList = useCallback(
async (input: CreateListInput) => {
requireMod();
if (!user) throw new Error('Not logged in');
const title = input.title.trim();
if (!title) throw new Error('Title is required');
if (!isValidIconName(input.icon)) {
throw new Error(`Invalid icon name: ${input.icon}`);
}
const description = input.description?.trim() || undefined;
// Generate a unique slug. Collision = the user already authored a
// list at this slug; suffix `-2`, `-3`, … until clear. We bound the
// search at 50 to avoid an unbounded loop in the (impossible)
// worst case where the relay always returns an event.
const base = slugifyListTitle(title);
let slug = base;
for (let i = 2; i <= 50; i++) {
const existing = await fetchOwnList(slug);
if (!existing) break;
slug = `${base}-${i}`;
}
if (!isValidListSlug(slug)) {
throw new Error(`Could not generate a valid slug for "${title}"`);
}
// Publish the new list event.
await publishEvent({
kind: CAMPAIGN_LIST_KIND,
content: '',
tags: buildListTags({
slug,
title,
description,
icon: input.icon,
coords: [],
}),
});
// Update the index to append the new list to the end of the strip.
const newListCoord = `${CAMPAIGN_LIST_KIND}:${user.pubkey}:${slug}`;
const prevIndex = await fetchOwnIndex();
const existingRefs = prevIndex
? prevIndex.tags
.filter(([n, v]) => n === 'a' && typeof v === 'string')
.map(([, v]) => v as string)
: [];
const dedup = new Set(existingRefs);
if (!dedup.has(newListCoord)) existingRefs.push(newListCoord);
await publishIndex(existingRefs, prevIndex);
invalidate();
return { slug };
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[requireMod, user, fetchOwnList, fetchOwnIndex, publishEvent, buildListTags, invalidate],
);
/** Publish the index sentinel event with the given ordered refs. */
const publishIndex = useCallback(
async (orderedRefs: string[], prev: NostrEvent | null) => {
const tags: string[][] = [
['d', CAMPAIGN_LIST_INDEX_D],
['title', 'Agora Campaign Lists — display order'],
['t', CAMPAIGN_LIST_INDEX_HASHTAG],
['alt', 'Order of curated campaign lists'],
];
for (const ref of orderedRefs) {
tags.push(['a', ref]);
}
await publishEvent({
kind: CAMPAIGN_LIST_KIND,
content: '',
tags,
prev: prev ?? undefined,
});
},
[publishEvent],
);
/** Update a list's title / description / icon, preserving membership. */
const updateListMeta = useCallback(
async (input: UpdateListMetaInput) => {
requireMod();
const fresh = await fetchOwnList(input.slug);
if (!fresh) throw new Error(`List not found: ${input.slug}`);
const current = readListFields(fresh);
const nextTitle = input.title?.trim() ?? current.title;
if (!nextTitle) throw new Error('Title is required');
const nextDescription =
input.description === undefined
? current.description
: input.description.trim() || undefined;
const nextIcon = input.icon ?? current.icon;
if (!isValidIconName(nextIcon)) {
throw new Error(`Invalid icon name: ${nextIcon}`);
}
await publishEvent({
kind: CAMPAIGN_LIST_KIND,
content: fresh.content ?? '',
tags: buildListTags({
slug: input.slug,
title: nextTitle,
description: nextDescription,
icon: nextIcon,
coords: current.coords,
}),
prev: fresh,
});
invalidate();
},
[requireMod, fetchOwnList, readListFields, publishEvent, buildListTags, invalidate],
);
/**
* Append a campaign coordinate to a list. No-op if already present.
* Operates on the moderator's own copy of the list.
*/
const addCampaignToList = useCallback(
async (slug: string, coord: string) => {
requireMod();
const fresh = await fetchOwnList(slug);
if (!fresh) throw new Error(`List not found: ${slug}`);
const current = readListFields(fresh);
if (current.coords.includes(coord)) return;
const nextCoords = [...current.coords, coord];
await publishEvent({
kind: CAMPAIGN_LIST_KIND,
content: fresh.content ?? '',
tags: buildListTags({
slug,
title: current.title,
description: current.description,
icon: current.icon,
coords: nextCoords,
}),
prev: fresh,
});
invalidate();
},
[requireMod, fetchOwnList, readListFields, publishEvent, buildListTags, invalidate],
);
/** Remove a campaign coordinate from a list. */
const removeCampaignFromList = useCallback(
async (slug: string, coord: string) => {
requireMod();
const fresh = await fetchOwnList(slug);
if (!fresh) throw new Error(`List not found: ${slug}`);
const current = readListFields(fresh);
const nextCoords = current.coords.filter((c) => c !== coord);
if (nextCoords.length === current.coords.length) return;
await publishEvent({
kind: CAMPAIGN_LIST_KIND,
content: fresh.content ?? '',
tags: buildListTags({
slug,
title: current.title,
description: current.description,
icon: current.icon,
coords: nextCoords,
}),
prev: fresh,
});
invalidate();
},
[requireMod, fetchOwnList, readListFields, publishEvent, buildListTags, invalidate],
);
/** Replace the list's membership order in one shot. */
const reorderCampaignsInList = useCallback(
async (slug: string, newCoords: string[]) => {
requireMod();
const fresh = await fetchOwnList(slug);
if (!fresh) throw new Error(`List not found: ${slug}`);
const current = readListFields(fresh);
// Filter the proposed order to the membership we currently know
// about, then append anything from the latest membership that
// somehow wasn't represented (an addition since the UI fetched).
const known = new Set(current.coords);
const seen = new Set<string>();
const nextCoords: string[] = [];
for (const c of newCoords) {
if (!known.has(c) || seen.has(c)) continue;
seen.add(c);
nextCoords.push(c);
}
for (const c of current.coords) {
if (!seen.has(c)) nextCoords.push(c);
}
await publishEvent({
kind: CAMPAIGN_LIST_KIND,
content: fresh.content ?? '',
tags: buildListTags({
slug,
title: current.title,
description: current.description,
icon: current.icon,
coords: nextCoords,
}),
prev: fresh,
});
invalidate();
},
[requireMod, fetchOwnList, readListFields, publishEvent, buildListTags, invalidate],
);
/**
* Reorder the topic strip itself. `orderedListCoords` is the desired
* ordering of `30003:<author>:<slug>` references — the same coord
* shape stored in the index event.
*/
const reorderLists = useCallback(
async (orderedListCoords: string[]) => {
requireMod();
const prev = await fetchOwnIndex();
// De-dupe, preserving order.
const seen = new Set<string>();
const next: string[] = [];
for (const c of orderedListCoords) {
if (seen.has(c)) continue;
seen.add(c);
next.push(c);
}
await publishIndex(next, prev);
invalidate();
},
[requireMod, fetchOwnIndex, publishIndex, invalidate],
);
/**
* Delete a list. Publishes a NIP-09 kind 5 deletion request for the
* list event, AND a fresh index event with the list removed so the
* strip drops the entry immediately. Other moderators' index events
* may still reference the deleted coord; the read fold tolerates
* missing coords gracefully.
*/
const deleteList = useCallback(
async (slug: string) => {
requireMod();
if (!user) throw new Error('Not logged in');
const fresh = await fetchOwnList(slug);
if (!fresh) return;
// NIP-09 deletion. We reference both the event id and the
// addressable coordinate so any replayed older revision is also
// suppressed.
const listCoord = `${CAMPAIGN_LIST_KIND}:${user.pubkey}:${slug}`;
await publishEvent({
kind: 5,
content: 'Campaign list deleted',
tags: [
['e', fresh.id],
['a', listCoord],
['k', String(CAMPAIGN_LIST_KIND)],
],
});
// Update the index to drop the deleted coord.
const prevIndex = await fetchOwnIndex();
const remainingRefs = prevIndex
? prevIndex.tags
.filter(([n, v]) => n === 'a' && typeof v === 'string' && v !== listCoord)
.map(([, v]) => v as string)
: [];
await publishIndex(remainingRefs, prevIndex);
invalidate();
},
[requireMod, user, fetchOwnList, fetchOwnIndex, publishEvent, publishIndex, invalidate],
);
return {
isMod,
createList,
updateListMeta,
deleteList,
addCampaignToList,
removeCampaignFromList,
reorderCampaignsInList,
reorderLists,
};
}
+85
View File
@@ -0,0 +1,85 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
CAMPAIGN_LIST_KIND,
CAMPAIGN_LIST_HASHTAG,
CAMPAIGN_LIST_INDEX_HASHTAG,
type ParsedCampaignList,
foldCampaignLists,
} from '@/lib/campaignLists';
import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults';
import { DITTO_RELAY } from '@/lib/appRelays';
import type { NostrEvent } from '@nostrify/nostrify';
interface UseCampaignListsResult {
/** Lists in display order — index-ordered first, then newest fallback. */
lists: ParsedCampaignList[];
/** The newest sentinel "order" event, or `undefined` if none yet. */
indexEvent: NostrEvent | undefined;
}
/**
* Reads curator-authored campaign lists (kind 30003 with the
* `agora.campaign-list` hashtag) plus the optional list-of-lists order
* sentinel (`agora.campaign-lists.index`).
*
* **Trust model.** Lists are an editorial surface curated by a single
* pubkey ({@link LIST_CURATOR_PUBKEY}). The relay query pins `authors:`
* to that pubkey, so a kind 30003 with our hashtag from anyone else —
* including a label moderator — never appears. This is deliberately
* narrower than label moderation (`useCampaignModerators`), where any
* follow-pack member is trusted to sign approve / hide labels.
*
* Because the curator is a hardcoded constant, this query depends on no
* other query — it fires on first paint with no waterfall.
*
* Lists *and* the index are pulled in a single filter via
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
* round-trip on first load.
*/
export function useCampaignLists() {
const { nostr } = useNostr();
const query = useQuery<UseCampaignListsResult>({
queryKey: ['campaign-lists', LIST_CURATOR_PUBKEY],
queryFn: async ({ signal }) => {
// Query the canonical app relay directly. The same reasoning as
// `useCampaignModerators` applies: a fast empty EOSE from a
// less-populated relay should not race the moderation surface to
// "no lists" while the curated relay still holds them.
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
[
{
kinds: [CAMPAIGN_LIST_KIND],
authors: [LIST_CURATOR_PUBKEY],
'#t': [CAMPAIGN_LIST_HASHTAG, CAMPAIGN_LIST_INDEX_HASHTAG],
limit: 500,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return foldCampaignLists(events);
},
staleTime: 30_000,
});
return query;
}
/** Lookup a single list by slug from the cached collection. */
export function useCampaignList(slug: string | undefined) {
const all = useCampaignLists();
const list = useMemo(() => {
if (!slug || !all.data) return undefined;
return all.data.lists.find((l) => l.slug === slug);
}, [slug, all.data]);
return {
list,
isLoading: all.isLoading,
error: all.error,
};
}
+52 -21
View File
@@ -25,21 +25,21 @@ type CampaignModerationData = ModerationData;
/**
* Fetches and folds campaign-moderation label events authored by Team
* Soapbox members. Returns approval / hide / featured rollups per campaign
* Soapbox members. Returns hide / featured rollups per campaign
* coordinate.
*
* **Display rule** consumers should follow:
* - Featured row on `/` iff `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
* - Community Campaigns grid on `/` iff `approvedCoords.has(coord) && !hiddenCoords.has(coord) && !featuredCoords.has(coord)` (featured dedupe).
* - Discover shelf iff `approvedCoords.has(coord) && !hiddenCoords.has(coord)`.
* - "Pending" (moderator-only sections) iff `!approvedCoords.has(coord) && !hiddenCoords.has(coord)`.
* - Featured row on `/` iff `featuredCoords.has(coord) && !hiddenCoords.has(coord)`, ordered by `featuredOrder` descending.
* - Discover shelf on `/campaigns` iff `!hiddenCoords.has(coord)`.
* - "Hidden" (moderator-only sections) iff `hiddenCoords.has(coord)`.
* - Featured is independent of Approved at the protocol level; hide always wins.
* - Hide always wins over featured.
*
* The mutation `moderate({ coord, action })` publishes a single kind 1985
* event labeling one campaign in the `agora.moderation` namespace. Callers
* MUST be in the moderator set or the relay-side `authors:` filter on read
* will silently ignore the new event.
* The mutation `moderate({ coord, action, rank? })` publishes a single
* kind 1985 event labeling one campaign in the `agora.moderation`
* namespace. Callers MUST be in the moderator set or the relay-side
* `authors:` filter on read will silently ignore the new event. The
* optional `rank` writes a `["rank", "<integer>"]` tag for moderator-
* driven ordering of the featured row — see `useReorderCampaign`.
*/
export function useCampaignModeration() {
const { nostr } = useNostr();
@@ -84,28 +84,59 @@ export function useCampaignModeration() {
});
const moderate = useMutation({
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
mutationFn: async ({
coord,
action,
rank,
}: {
coord: string;
action: ModerationLabel;
/**
* Optional explicit rank for the label, written into a
* `["rank", "<number>"]` tag on the event. Used by
* `useReorderCampaign` to position a campaign within the
* featured row — the moderation fold uses the rank as the
* sort key (descending), falling back to `created_at` when
* no rank tag is present.
*
* The event itself is always signed with `created_at = now`
* so the fold's "newest event per (coord, axis)" rule picks
* up the reorder publish even when the chosen rank is lower
* than the current label's rank — without that, moving a
* campaign downward would be silently rejected by the fold.
*
* Omit for normal hide / feature actions.
*/
rank?: number;
}) => {
// Quick parse-check on the coord so we don't sign garbage.
if (!coord.startsWith(`${CAMPAIGN_KIND}:`)) {
throw new Error(`Coordinate must start with ${CAMPAIGN_KIND}:`);
}
const tags: string[][] = [
['L', AGORA_MODERATION_NAMESPACE],
['l', action, AGORA_MODERATION_NAMESPACE],
['a', coord],
['alt', `Campaign moderation: ${action}`],
];
if (rank !== undefined && Number.isFinite(rank)) {
// Store as a plain integer string. The fold parses with
// `Number(...)` so a non-numeric value would degrade to the
// `created_at` fallback rather than throwing.
tags.push(['rank', String(Math.trunc(rank))]);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
tags: [
['L', AGORA_MODERATION_NAMESPACE],
['l', action, AGORA_MODERATION_NAMESPACE],
['a', coord],
['alt', `Campaign moderation: ${action}`],
],
tags,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaign-moderation'] });
// Moderation decisions (approve / hide / feature) gate which campaigns
// surface on the home page, discover shelf, and community grids — so
// the list queries need to refetch too, otherwise the moderator's UI
// still shows the old approval state until refresh.
// Moderation decisions (hide / feature) gate which campaigns
// surface on the home page and discover shelf — so the list
// queries need to refetch too, otherwise the moderator's UI
// still shows the old state until refresh.
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
queryClient.invalidateQueries({ queryKey: ['campaigns-all-scores'] });
+21 -56
View File
@@ -1,71 +1,36 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { TEAM_SOAPBOX } from '@/lib/agoraDefaults';
import { DITTO_RELAY } from '@/lib/appRelays';
/** A 64-character lowercase hex string. */
const HEX_64_RE = /^[0-9a-f]{64}$/;
import { CAMPAIGN_MODERATORS } from '@/lib/agoraDefaults';
/**
* Returns the hex pubkeys of campaign moderators — the `p` tags of the
* Team Soapbox follow pack (kind 39089).
* Returns the hex pubkeys of campaign moderators — the pubkeys allowed to
* sign approve / hide labels in the `agora.moderation` namespace (see
* NIP.md).
*
* A campaign appears on `/` and Discover only if a moderator has labeled it
* `approved` (see {@link useCampaignModeration}). A moderator's `hidden`
* label always wins over any approval. The pack itself is authored by a
* single admin pubkey, so we pin `authors` to that pubkey to prevent anyone
* else from publishing a same-`d` event and self-appointing.
* label always wins over any approval.
*
* **Phase 1 tradeoff:** the pack is fetched live every cold session. We
* accept the 1-round-trip latency in exchange for not shipping a release
* every time the moderator roster changes. If perf matters, snapshot the
* `p` tags into a hardcoded array and short-circuit this hook.
* **Hardcoded snapshot.** This used to fetch the Team Soapbox follow pack
* (kind 39089) live every cold session, which put a single-relay round-trip
* — up to an 8s EOSE timeout — on the critical path of every
* moderation-gated surface (home, Discover, profile campaigns, etc.). The
* roster changes rarely, so the membership is now snapshotted in
* {@link CAMPAIGN_MODERATORS} and served synchronously with zero network
* cost. Update that array (and re-cut a release) when the pack changes.
*
* @see TEAM_SOAPBOX (src/lib/agoraDefaults.ts) for the pack coordinate.
* The hook keeps its `useQuery` return shape so existing consumers
* (`{ data, isLoading, ... }`) continue to work unchanged; the query is a
* pure synchronous read with no `queryFn` network call.
*
* @see CAMPAIGN_MODERATORS (src/lib/agoraDefaults.ts) for the pubkey list.
* @see NIP.md "Campaign moderation labels" for the namespace this powers.
*/
export function useCampaignModerators() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['campaign-moderators', TEAM_SOAPBOX.pubkey, TEAM_SOAPBOX.identifier],
queryFn: async ({ signal }) => {
// The home page gates campaign visibility on this pack. Query the
// canonical app relay directly so a fast empty EOSE from another relay
// cannot race the pack out and make the page render as empty.
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
[
{
kinds: [TEAM_SOAPBOX.kind],
// Pinning to the pack author is required: kind 39089 is
// addressable, so without this anyone could publish a competing
// event with the same `d` and force themselves into the moderator
// list. (See AGENTS.md `nostr-security`.)
authors: [TEAM_SOAPBOX.pubkey],
'#d': [TEAM_SOAPBOX.identifier],
limit: 1,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
if (events.length === 0) return [] as string[];
// The pack is replaceable; relays may serve old revisions alongside the
// current one. Keep the newest.
const newest = events.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest,
);
// Filter malformed `p` tags so a typo doesn't blow up downstream
// relay filters (which reject non-hex `authors:` entries).
return newest.tags
.filter(([name, value]) => name === 'p' && typeof value === 'string' && HEX_64_RE.test(value))
.map(([, pubkey]) => pubkey);
},
staleTime: 10 * 60_000,
gcTime: 60 * 60_000,
queryKey: ['campaign-moderators', 'snapshot'],
queryFn: () => CAMPAIGN_MODERATORS.slice(),
staleTime: Infinity,
gcTime: Infinity,
});
}
+10 -1
View File
@@ -12,6 +12,14 @@ import {
interface UseDiscoverCommunitiesOptions {
/** Maximum number of communities to fetch. Default: 24. */
limit?: number;
/**
* Gate the underlying query. Useful for callers that only need the
* full kind-34550 universe under a moderator role (e.g. the Hidden
* section on `/groups`); skipping the fetch for everyone else avoids
* a global relay round-trip whose results would only feed
* moderator-only UI. Defaults to `true`.
*/
enabled?: boolean;
}
/**
@@ -28,12 +36,13 @@ interface UseDiscoverCommunitiesOptions {
* the card just shows a gradient fallback.
*/
export function useDiscoverCommunities(options: UseDiscoverCommunitiesOptions = {}) {
const { limit = 24 } = options;
const { limit = 24, enabled = true } = options;
const { nostr } = useNostr();
const relay = nostr.relay(DITTO_RELAY);
return useQuery<ParsedCommunity[]>({
queryKey: ['discover-communities', limit],
enabled,
queryFn: async ({ signal }) => {
const events = await relay.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit }],
+204
View File
@@ -0,0 +1,204 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
import type { Nip50Sort } from '@/hooks/useNip50Search';
/**
* Type-guard for the `?sort=` URL param value used by every discovery
* section (Campaigns, Groups, Pledges).
*
* - `'top'` and `'new'` map to the toolbar's active sort modes.
* - Anything else (missing, empty, legacy values) collapses to
* `'default'`, the curated featured-first idle state.
*
* Exported because the dedicated discovery pages (`/campaigns`,
* `/pledges`) read `?sort=` independently from the section's hook to
* thread the value into ancillary derivations (hidden-list cache
* lookups, create-X href country prefills). One canonical parser
* keeps page-level and section-level reads in lockstep.
*/
export function parseSort(value: string | null): Nip50Sort {
if (value === 'top') return 'top';
if (value === 'new') return 'new';
return 'default';
}
export interface DiscoveryFilters {
/**
* Live search input value, updated on every keystroke. Bind this to
* the toolbar's `<input>` so typing stays responsive.
*/
searchInput: string;
setSearchInput: (next: string) => void;
/**
* Debounced search value. Use this as the input to relay queries
* and as the source for "is this section actively searching?"
* checks. URL writes also happen on this value, so the URL doesn't
* churn on every keystroke.
*/
debouncedSearch: string;
/** Active sort mode. */
sort: Nip50Sort;
setSort: (next: Nip50Sort) => void;
/** Selected ISO-3166 alpha-2 country code, or `undefined` for global. */
country: string | undefined;
setCountry: (next: string | undefined) => void;
}
interface UseDiscoveryFiltersOptions {
/**
* URL-namespace for persisted filters, or `undefined` for local-only
* state.
*
* • `''` — flat URL params (`?q=…&sort=…&country=…`). The dedicated
* browse pages (`/campaigns`, `/groups`, `/pledges`) want
* this so search results are shareable / linkable and survive
* refresh.
*
* • `undefined` — purely local state, no URL writes. The home
* page (`/`) hosts all three sections at once. Pushing each
* section's filters into the URL there would either collide
* (three sections want `?q=`) or pollute the path with six to
* nine prefixed params on every keystroke. Keeping state local
* means refreshing `/` lands on the curated idle view, which
* matches what we want anyway.
*
* • Any other string — namespaced URL params
* (`?fooQ=&fooSort=&fooCountry=`). Reserved for future surfaces
* that need multiple coexisting filter sets in the URL.
*/
urlPrefix?: string;
/**
* Whether the section exposes a country picker. When `false`, the
* country slot stays `undefined` and the `country` URL param is
* never read or written even if a stale value sits in the URL.
* Defaults to `true`.
*/
enableCountry?: boolean;
}
/**
* Filter state machine shared by every discovery section.
*
* Owns three pieces of state — search input (debounced), sort mode,
* country code — and (optionally) mirrors them to URL params so deep
* links and browser back/forward work. Defaults are stripped on write
* so the canonical URL stays clean (`/campaigns`, not
* `/campaigns?q=&sort=`).
*
* Debouncing lives inside this hook (300ms) so consumers don't have
* to thread the debounced value back in — that would create a
* circular dependency with the URL-sync effect. Consumers should
* pass `debouncedSearch` straight to their relay query.
*
* URL writes use `replace: true` so typing doesn't pile entries onto
* the history stack.
*/
export function useDiscoveryFilters({
urlPrefix,
enableCountry = true,
}: UseDiscoveryFiltersOptions): DiscoveryFilters {
const useUrl = urlPrefix !== undefined;
// Always call the hook — React's rules — but only read/write through
// it when `useUrl` is true.
const [searchParams, setSearchParams] = useSearchParams();
const qKey = useUrl ? (urlPrefix === '' ? 'q' : `${urlPrefix}Q`) : '';
const sortKey = useUrl ? (urlPrefix === '' ? 'sort' : `${urlPrefix}Sort`) : '';
const countryKey = useUrl
? urlPrefix === ''
? 'country'
: `${urlPrefix}Country`
: '';
// Seed state from the URL on first render so deep links / refreshes
// restore the user's last view, then run the toolbar from local
// state and push debounced changes back to the URL.
const [searchInput, setSearchInputState] = useState(
useUrl ? (searchParams.get(qKey) ?? '') : '',
);
const [sort, setSortState] = useState<Nip50Sort>(
useUrl ? parseSort(searchParams.get(sortKey)) : 'default',
);
const [country, setCountryState] = useState<string | undefined>(
useUrl && enableCountry
? (searchParams.get(countryKey) ?? undefined)
: undefined,
);
const debouncedSearch = useDebounce(searchInput, 300);
// URL → state. Handles browser back/forward and direct deep-link
// navigation while a section is mounted (e.g. clicking an internal
// link that updates `?sort=top`). We compare before assigning to
// avoid React render loops.
useEffect(() => {
if (!useUrl) return;
const urlQuery = searchParams.get(qKey) ?? '';
if (urlQuery !== searchInput && urlQuery !== debouncedSearch) {
setSearchInputState(urlQuery);
}
const urlSort = parseSort(searchParams.get(sortKey));
if (urlSort !== sort) setSortState(urlSort);
if (enableCountry) {
const urlCountry = searchParams.get(countryKey) ?? undefined;
if (urlCountry !== country) setCountryState(urlCountry);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// Debounced search → URL. Strip empty values so the canonical URL
// stays clean.
useEffect(() => {
if (!useUrl) return;
const next = new URLSearchParams(searchParams);
const trimmed = debouncedSearch.trim();
if (trimmed) next.set(qKey, trimmed);
else next.delete(qKey);
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch, useUrl]);
const setSort = useCallback(
(next: Nip50Sort) => {
setSortState(next);
if (!useUrl) return;
const params = new URLSearchParams(searchParams);
if (next === 'default') params.delete(sortKey);
else params.set(sortKey, next);
setSearchParams(params, { replace: true });
},
[useUrl, searchParams, setSearchParams, sortKey],
);
const setCountry = useCallback(
(next: string | undefined) => {
if (!enableCountry) return;
setCountryState(next);
if (!useUrl) return;
const params = new URLSearchParams(searchParams);
if (next) params.set(countryKey, next);
else params.delete(countryKey);
setSearchParams(params, { replace: true });
},
[enableCountry, useUrl, searchParams, setSearchParams, countryKey],
);
const setSearchInput = useCallback((next: string) => {
setSearchInputState(next);
// URL writes happen on `debouncedSearch` flipping, not per keystroke.
}, []);
return {
searchInput,
setSearchInput,
debouncedSearch,
sort,
setSort,
country,
setCountry,
};
}
+2
View File
@@ -102,6 +102,8 @@ export interface EncryptedSettings {
aiModel?: string;
/** Override the AI system prompt for the Agent */
aiSystemPrompt?: string;
/** Custom translation worker URL (only synced when non-empty) */
translateWorkerUrl?: string;
}
/**
+3
View File
@@ -237,6 +237,9 @@ export function useInitialSync() {
if (parsed.linkPreviewUrl) {
updates.linkPreviewUrl = parsed.linkPreviewUrl;
}
if (parsed.translateWorkerUrl) {
updates.translateWorkerUrl = parsed.translateWorkerUrl;
}
return updates;
});
-16
View File
@@ -28,15 +28,6 @@ type OrganizationModerationData = ModerationData;
* to the campaign side (we fetch every namespace-tagged label authored by
* moderators) — the surface separation is purely client-side.
*
* **Two-axis model.** Unlike campaigns, organizations don't have an
* `approved` axis. Every Agora-tagged organization is publicly visible
* by default; moderation reduces to `featured` (lift into the curated
* shelf) and `hidden` (suppress from public discovery). The shared
* fold helper still tracks `approvedCoords` for type symmetry with the
* campaign hook, but the org UI never emits or reads it — moderators
* SHOULD NOT publish `approved` / `unapproved` labels against kind
* 34550 coordinates.
*
* **Display rule** consumers should follow:
* - Featured shelf on `/communities` iff
* `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
@@ -99,13 +90,6 @@ export function useOrganizationModeration() {
if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) {
throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`);
}
// Organizations use a two-axis model — only `featured` / `unfeatured`
// / `hidden` / `unhidden` are valid here. Reject `approved` /
// `unapproved` defensively so a stray UI bug can't poison the
// label stream with axis-mixed events.
if (action === 'approved' || action === 'unapproved') {
throw new Error(`Organizations do not support the ${action} label`);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
-16
View File
@@ -37,15 +37,6 @@ interface UsePledgeModerationOptions {
* relay-side query is identical to the other two surfaces — surface
* separation is purely client-side.
*
* **Two-axis model.** Like organizations, pledges don't have an
* `approved` axis. Every Agora-tagged pledge is publicly visible by
* default; moderation reduces to `featured` (lift into a curated slot)
* and `hidden` (suppress from public discovery). The shared fold helper
* still tracks `approvedCoords` for type symmetry with the campaign
* hook, but the pledge UI never emits or reads it — moderators SHOULD
* NOT publish `approved` / `unapproved` labels against kind 36639
* coordinates.
*
* **Display rule** consumers should follow:
* - Hide enforcement on `/pledges` and any pledge discovery surface:
* non-moderators MUST NOT see `hidden` pledges. Moderators MAY see
@@ -111,13 +102,6 @@ export function usePledgeModeration({ coordinates, enabled = true }: UsePledgeMo
if (!coord.startsWith(`${PLEDGE_KIND}:`)) {
throw new Error(`Coordinate must start with ${PLEDGE_KIND}:`);
}
// Pledges use a two-axis model — only `featured` / `unfeatured` /
// `hidden` / `unhidden` are valid here. Reject `approved` /
// `unapproved` defensively so a stray UI bug can't poison the
// label stream with axis-mixed events.
if (action === 'approved' || action === 'unapproved') {
throw new Error(`Pledges do not support the ${action} label`);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
+96 -44
View File
@@ -2,31 +2,23 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import ar from './locales/ar.json';
// English is bundled statically so it's available synchronously on first
// paint as the fallback language — this prevents a flash of untranslated
// (key-path) content while a non-English locale is still loading.
import en from './locales/en.json';
import es from './locales/es.json';
import fa from './locales/fa.json';
import fr from './locales/fr.json';
import hi from './locales/hi.json';
import id from './locales/id.json';
import km from './locales/km.json';
import ps from './locales/ps.json';
import pt from './locales/pt.json';
import ru from './locales/ru.json';
import sn from './locales/sn.json';
import sw from './locales/sw.json';
import tr from './locales/tr.json';
import zh from './locales/zh.json';
import zhHant from './locales/zh-Hant.json';
/**
* i18next initialization for Agora.
*
* All Phase-1 locales are bundled statically. Adding a new locale is a
* three-line change: add the import above, add it to `resources` below,
* and add its code to `SUPPORTED_LANGUAGES`. The language switcher UI
* reads from `SUPPORTED_LANGUAGES` so it picks up new entries
* automatically.
* Only English is bundled eagerly. Every other locale is loaded on demand
* via a dynamic `import()` (see `loadLocale`), so each language becomes its
* own lazily-fetched chunk and the initial bundle ships a single locale
* instead of all of them (~2 MB of JSON saved on first load).
*
* Adding a new locale is a two-step change: add its code to
* `SUPPORTED_LANGUAGES` below, and add a `case` to `loadLocale`'s dynamic
* import map. The language switcher UI reads from `SUPPORTED_LANGUAGES` so
* it picks up new entries automatically.
*/
export interface SupportedLanguage {
@@ -74,34 +66,86 @@ function applyDocumentDirection(lng: string): void {
document.documentElement.dir = isRTLLanguage(base) ? 'rtl' : 'ltr';
}
/**
* Normalize an i18next language code to the locale chunk that backs it.
*
* i18next can hand us region-tagged codes (`en-US`, `pt-BR`) and the
* Traditional Chinese aliases (`zh-TW`, `zh-HK`). Each must resolve to one
* of the JSON files in `./locales`. Returns `undefined` when no chunk
* exists (e.g. `en`, which is bundled statically and needs no fetch).
*/
function resolveLocaleFile(lng: string): string | undefined {
const lower = lng.toLowerCase();
if (lower === 'en' || lower.startsWith('en-')) return undefined;
// Traditional Chinese: zh-Hant / zh-TW / zh-HK all share one resource.
if (lower === 'zh-hant' || lower === 'zh-tw' || lower === 'zh-hk') return 'zh-Hant';
// Everything else maps on its base code (pt-BR -> pt, etc.).
const base = lower.split('-')[0];
// zh (Simplified) keeps its own file.
return base;
}
/**
* Lazily fetch a locale's translation bundle and register it with i18next.
*
* Each `import()` is statically analyzable by Vite, so every locale lands in
* its own chunk that's only downloaded when that language is actually
* selected (or detected on startup). English is bundled eagerly and skipped
* here. Returns once the bundle is registered (or immediately for English /
* already-loaded locales).
*/
const loadedLocales = new Set<string>(['en']);
async function loadLocale(lng: string): Promise<void> {
const file = resolveLocaleFile(lng);
if (!file || loadedLocales.has(file)) return;
// The explicit map keeps the dynamic imports statically analyzable so Vite
// emits one chunk per locale (a bare template-literal import would bundle
// every JSON file into a single shared chunk and defeat the split).
const loaders: Record<string, () => Promise<{ default: Record<string, unknown> }>> = {
ar: () => import('./locales/ar.json'),
es: () => import('./locales/es.json'),
fa: () => import('./locales/fa.json'),
fr: () => import('./locales/fr.json'),
hi: () => import('./locales/hi.json'),
id: () => import('./locales/id.json'),
km: () => import('./locales/km.json'),
ps: () => import('./locales/ps.json'),
pt: () => import('./locales/pt.json'),
ru: () => import('./locales/ru.json'),
sn: () => import('./locales/sn.json'),
sw: () => import('./locales/sw.json'),
tr: () => import('./locales/tr.json'),
zh: () => import('./locales/zh.json'),
'zh-Hant': () => import('./locales/zh-Hant.json'),
};
const loader = loaders[file];
if (!loader) return;
const mod = await loader();
// The Traditional Chinese file backs three language codes; register all so
// i18next resolves `zh-Hant`, `zh-TW`, and `zh-HK` without re-fetching.
const codes = file === 'zh-Hant' ? ['zh-Hant', 'zh-TW', 'zh-HK'] : [file];
for (const code of codes) {
i18n.addResourceBundle(code, 'translation', mod.default, true, true);
}
loadedLocales.add(file);
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
ar: { translation: ar },
// Only English ships in the main bundle; the rest are added at runtime
// by `loadLocale` once detected or selected.
en: { translation: en },
es: { translation: es },
fa: { translation: fa },
fr: { translation: fr },
hi: { translation: hi },
id: { translation: id },
km: { translation: km },
ps: { translation: ps },
pt: { translation: pt },
ru: { translation: ru },
sn: { translation: sn },
sw: { translation: sw },
tr: { translation: tr },
zh: { translation: zh },
// Traditional Chinese is registered under three codes pointing at the
// same resource object so device-language detection works whether the
// browser reports `zh-Hant`, `zh-TW`, or `zh-HK`. Mainland `zh-CN`
// continues to resolve to `zh` (Simplified) via `nonExplicitSupportedLngs`.
'zh-Hant': { translation: zhHant },
'zh-TW': { translation: zhHant },
'zh-HK': { translation: zhHant },
},
// Defer rendering until the detected language's bundle is registered, so
// non-English users don't flash English before their locale loads.
partialBundledLanguages: true,
fallbackLng: 'en',
// SUPPORTED_LANGUAGES drives the switcher UI; `zh-TW` and `zh-HK` are
// also accepted by the detector so Taiwan/HK device locales route to
@@ -119,10 +163,18 @@ i18n
},
});
// Load the locale the detector picked on startup. If it isn't English the
// bundle is fetched in the background; once registered, i18next re-renders
// translated components via the `languageChanged`/`loaded` events.
void loadLocale(i18n.language);
// Fetch a locale's bundle before/at the moment the user switches to it.
i18n.on('languageChanged', (lng) => {
void loadLocale(lng);
applyDocumentDirection(lng);
});
// Apply once on init (LanguageDetector has already picked the language).
applyDocumentDirection(i18n.language);
// Re-apply whenever the user switches languages.
i18n.on('languageChanged', applyDocumentDirection);
export default i18n;
+91
View File
@@ -357,6 +357,35 @@ function getETagValue(filter: NostrFilter): string {
return ((filter as Record<string, unknown>)['#e'] as string[])[0];
}
/**
* A filter that queries by a single `#a` (addressable coordinate) tag with
* kinds and limit. e.g. `{ kinds: [8333], '#a': [aTag], limit: 500 }`.
* Must NOT have `authors` — that's a different pattern.
*
* Used by per-card hooks like `useCampaignDonations` (one card → one REQ),
* which fan out to N REQs when N cards mount in the same render. Batching
* collapses them into a single `'#a': [aTag1, aTag2, …]` REQ.
*/
function isATagFilter(filter: NostrFilter): boolean {
const keys = Object.keys(filter);
return (
keys.every((k) => k === 'kinds' || k === '#a' || k === 'limit') &&
Array.isArray(filter.kinds) &&
filter.kinds.length > 0 &&
!filter.authors &&
(filter as Record<string, unknown>)['#a'] !== undefined &&
Array.isArray((filter as Record<string, unknown>)['#a']) &&
((filter as Record<string, unknown>)['#a'] as string[]).length === 1
);
}
/**
* Extract the single `#a` value from a filter known to have one.
*/
function getATagValue(filter: NostrFilter): string {
return ((filter as Record<string, unknown>)['#a'] as string[])[0];
}
/**
* Check if a multi-filter array can be batched: every filter must be an
* e-tag or q-tag filter referencing the same single event ID.
@@ -434,6 +463,8 @@ export class NostrBatcher {
private dTagCollectors = new Map<string, BatchCollector<NostrEvent | undefined>>();
/** Keyed by sorted kinds string for #e-tag batching. Returns arrays. */
private eTagCollectors = new Map<string, BatchCollector<NostrEvent[]>>();
/** Keyed by sorted kinds string for #a-tag batching. Returns arrays. */
private aTagCollectors = new Map<string, BatchCollector<NostrEvent[]>>();
/** Keyed by serialized filter shapes for multi-filter #e/#q batching. */
private multiFilterCollectors = new Map<string, BatchCollector<NostrEvent[]>>();
@@ -516,6 +547,26 @@ export class NostrBatcher {
return collector.request(eventId, opts?.signal);
}
// { kinds: [...], '#a': [aTag] } (no authors)
// The dominant feed-page leak: each CampaignCard's `useCampaignDonations`
// fires `{ kinds: [8333], '#a': [aTag], limit: 500 }` independently,
// so 25 cards = 25 REQs. Batching collapses them per (kinds, limit)
// shape into one REQ.
if (isATagFilter(filter)) {
const aTag = getATagValue(filter);
const kindsKey = [...filter.kinds!].sort().join(',');
const limit = filter.limit ?? 50;
const collectorKey = `${kindsKey}:${limit}`;
let collector = this.aTagCollectors.get(collectorKey);
if (!collector) {
collector = new BatchCollector((aTags, signal) =>
this.executeATagBatch(filter.kinds!, aTags, limit, signal),
);
this.aTagCollectors.set(collectorKey, collector);
}
return collector.request(aTag, opts?.signal);
}
// { kinds: [k], authors: [a], '#d': [d] }
if (isDTagFilter(filter)) {
const kind = filter.kinds![0];
@@ -742,6 +793,46 @@ export class NostrBatcher {
return results;
}
private async executeATagBatch(
kinds: number[],
aTags: string[],
perItemLimit: number,
signal: AbortSignal,
): Promise<Map<string, NostrEvent[]>> {
const results = new Map<string, NostrEvent[]>();
try {
const events = await this.pool.query(
[{ kinds, '#a': aTags, limit: aTags.length * perItemLimit }],
{ signal },
);
// Group results by which addressable coordinate they reference via a-tag.
// A single event may reference multiple coordinates (e.g. a zap receipt
// tagging both a campaign and a pledge); attribute it to each matching
// coord so every caller waiting on those aTags receives it.
const byATag = new Map<string, NostrEvent[]>();
const aTagSet = new Set(aTags);
for (const event of events) {
for (const tag of event.tags) {
if (tag[0] === 'a' && aTagSet.has(tag[1])) {
const existing = byATag.get(tag[1]) ?? [];
existing.push(event);
byATag.set(tag[1], existing);
}
}
}
for (const aTag of aTags) {
results.set(aTag, byATag.get(aTag) ?? []);
}
} catch {
for (const aTag of aTags) {
results.set(aTag, []);
}
}
return results;
}
private async executeMultiFilterBatch(
templateFilters: NostrFilter[],
eventIds: string[],
+53
View File
@@ -60,3 +60,56 @@ export const TEAM_SOAPBOX = {
identifier: teamSoapboxDecoded.data.identifier,
relays: teamSoapboxDecoded.data.relays,
} as const;
/**
* The single pubkey allowed to author campaign **lists** (kind 30003 with
* the `agora.campaign-list` hashtag) and the list-of-lists index sentinel.
*
* This is deliberately narrower than the moderator allowlist
* ({@link CAMPAIGN_MODERATORS}). That allowlist governs **labels** —
* approve / hide moderation in the `agora.moderation` namespace — where
* any pack member is trusted to sign. Lists are an editorial surface (the
* home hero row, the topic strip) curated by one person (MK Fain / Team
* Soapbox), so a list authored by anyone else — including another
* moderator — is dropped before it reaches the UI.
*
* It happens to equal the follow-pack author (`TEAM_SOAPBOX.pubkey`),
* which is the same single admin identity, so we derive it from there
* rather than duplicating the hex.
*/
export const LIST_CURATOR_PUBKEY = TEAM_SOAPBOX.pubkey;
/**
* Hardcoded snapshot of the campaign-moderator pubkeys — the `p` tags of
* the Team Soapbox follow pack ({@link TEAM_SOAPBOX}) as of the snapshot
* date below.
*
* These pubkeys form the authoritative allowlist for **labels**: who may
* sign approve / hide moderation in the `agora.moderation` namespace (see
* NIP.md and `useCampaignModerators`). A campaign appears on `/` and
* Discover only if one of these pubkeys labeled it `approved`; a `hidden`
* label from any of them always wins.
*
* **Why hardcoded.** The pack used to be fetched live every cold session
* (kind 39089), which put a single-relay round-trip — up to an 8s EOSE
* timeout — on the critical path of every moderation-gated surface. The
* roster changes rarely, so we snapshot it here and pay zero network cost.
* When the pack membership changes, update this array (and re-cut a
* release). Source of truth remains the on-relay pack; this is a copy.
*
* Snapshot taken from pack event `740838e6…fac76` (created_at 1779321391).
*/
export const CAMPAIGN_MODERATORS: readonly string[] = [
'781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5',
'0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
'3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
'86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47',
'47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4',
'ce97367c75d7d91fb9bc3bc6ff5bb3bdb52c18941bfce2f368616dcbf0adfd2f',
'0574536d3ef4d65faf95b42393610b8475d22f4c294649d46c50d5d36f75267c',
'be7358c4fe50148cccafc02ea205d80145e253889aa3958daafa8637047c840e',
'2093baa8621c5b255e8f4fc2c6fdfc10d8a5598a25517664efaba860735f1030',
'8f53782e8693e88afb710b6d68182ad973973c8822caa237bb60288b125673ca',
'c839bc85846f24fc6b777548fe654672377f4cc2a04cab19cddec75b2f8b4dbd',
] as const;
+74 -34
View File
@@ -2,15 +2,20 @@ import type { NostrEvent } from '@nostrify/nostrify';
/**
* Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in
* the `agora.moderation` namespace). Both campaigns (kind 33863) and
* organizations (kind 34550) ride the same label stream and the same
* moderator pack (Team Soapbox); the only thing that varies between them is
* the kind prefix on the `a` tag.
* the `agora.moderation` namespace). Campaigns (kind 33863), organizations
* (kind 34550), and pledges (kind 36639) all ride the same label stream and
* the same moderator pack (Team Soapbox); the only thing that varies
* between them is the kind prefix on the `a` tag.
*
* Centralizing the constants, types, and folding logic here keeps the two
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`)
* from drifting apart on namespace strings, axis semantics, or the
* surfacing-rule contract documented in NIP.md.
* Centralizing the constants, types, and folding logic here keeps the
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`,
* `usePledgeModeration`) from drifting apart on namespace strings, axis
* semantics, or the surfacing-rule contract documented in NIP.md.
*
* Two axes are defined: `hide` (universal) and `featured` (universal).
* The approval axis was removed once Featured became the single positive
* curation mechanism on the home page — see NIP.md and the project
* changelog for the history.
*/
/** NIP-32 label kind. */
@@ -19,10 +24,8 @@ export const LABEL_KIND = 1985;
/** Label namespace for Agora's moderation labels. */
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
/** The six possible label values in the moderation namespace. */
/** The four possible label values in the moderation namespace. */
export type ModerationLabel =
| 'approved'
| 'unapproved'
| 'hidden'
| 'unhidden'
| 'featured'
@@ -36,11 +39,25 @@ interface AxisDecision {
pubkey: string;
/** Created-at of the latest label. */
createdAt: number;
/**
* Optional explicit rank from a `["rank", "<number>"]` tag on the
* event. Reorder operations publish this so the sort key is
* independent of `created_at` — the fold's "newest event per
* (coord, axis)" rule would otherwise reject a label that
* attempts to move a campaign downward (lower `created_at` than
* the current label).
*
* `undefined` for labels published before the reorder feature
* shipped, or for normal hide / feature actions that don't carry
* a rank. Callers compute an effective sort key with
* `rank ?? createdAt`, giving legacy labels a sensible default
* while letting reorder labels override.
*/
rank?: number;
}
/** Per-coordinate rollup of approval + hide + featured state. */
/** Per-coordinate rollup of hide + featured state. */
export interface ModerationState {
approval?: AxisDecision; // `approved` or `unapproved`
hide?: AxisDecision; // `hidden` or `unhidden`
featured?: AxisDecision; // `featured` or `unfeatured`
}
@@ -53,15 +70,25 @@ export interface ModerationState {
export interface ModerationData {
/** Map of `<kind>:<pubkey>:<d>` -> rollup. */
byCoord: Map<string, ModerationState>;
/** Coordinates where the latest approval label is `approved`. */
approvedCoords: Set<string>;
/** Coordinates where the latest hide label is `hidden`. */
hiddenCoords: Set<string>;
/** Coordinates where the latest featured label is `featured`. */
featuredCoords: Set<string>;
/**
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
* sort featured rows newest-first.
* Map of `coord` -> sort key for the featured row, descending.
*
* The value is the rank carried by the latest `featured` label's
* `["rank", "<number>"]` tag, falling back to the label's
* `created_at` when no rank tag is present. Moderators reorder
* featured campaigns by republishing the `featured` label with a
* chosen rank (see `useReorderCampaign`); the fold always picks
* the newest-`created_at` label per `(coord, axis)`, so reorder
* publishes carry both a fresh `created_at = now` AND an explicit
* rank that controls the sort.
*
* The fallback to `created_at` makes legacy labels (published
* before the rank tag existed) sort sensibly — newer features
* float to the top, exactly as before the rank tag landed.
*/
featuredOrder: Map<string, number>;
/** Pubkeys that were considered moderators when the query ran. */
@@ -70,17 +97,12 @@ export interface ModerationData {
export const EMPTY_MODERATION_DATA: ModerationData = {
byCoord: new Map(),
approvedCoords: new Set(),
hiddenCoords: new Set(),
featuredCoords: new Set(),
featuredOrder: new Map(),
moderators: [],
};
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
return value === 'approved' || value === 'unapproved';
}
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
return value === 'hidden' || value === 'unhidden';
}
@@ -89,6 +111,21 @@ function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
return value === 'featured' || value === 'unfeatured';
}
/**
* Extract the rank value from a `["rank", "<number>"]` tag if present,
* otherwise `undefined`. The value is parsed as a finite Number — a
* non-numeric rank tag is treated as if it wasn't there so callers can
* fall back to `created_at` cleanly.
*/
function extractRank(event: NostrEvent): number | undefined {
const tag = event.tags.find(([n]) => n === 'rank');
if (!tag) return undefined;
const raw = tag[1];
if (typeof raw !== 'string') return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
/**
* Fold a flat list of label events into per-coordinate rollups by axis.
* The newest event per `(coord, axis)` wins.
@@ -98,7 +135,9 @@ function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
* into each other even though they share a namespace and signer set.
*
* Events with a value outside the moderation namespace, or with no `l` tag
* in that namespace, are dropped.
* in that namespace, are dropped. Legacy `approved` / `unapproved` labels
* (from the previous approval axis) are silently ignored — the axis was
* retired in favor of Featured-only positive curation.
*/
export function foldModerationLabels(
events: NostrEvent[],
@@ -118,35 +157,36 @@ export function foldModerationLabels(
)?.[1];
if (!aTag) continue;
const rank = extractRank(event);
const state = byCoord.get(aTag) ?? {};
if (isApprovalLabel(value)) {
if (!state.approval || event.created_at > state.approval.createdAt) {
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
}
} else if (isHideLabel(value)) {
if (isHideLabel(value)) {
if (!state.hide || event.created_at > state.hide.createdAt) {
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
}
} else if (isFeaturedLabel(value)) {
if (!state.featured || event.created_at > state.featured.createdAt) {
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
}
}
// Unknown values (including legacy `approved`/`unapproved`) drop out
// silently. The approval axis is retired; clients that still see
// such labels in their cache simply ignore them.
byCoord.set(aTag, state);
}
const approvedCoords = new Set<string>();
const hiddenCoords = new Set<string>();
const featuredCoords = new Set<string>();
const featuredOrder = new Map<string, number>();
for (const [coord, state] of byCoord) {
if (state.approval?.label === 'approved') approvedCoords.add(coord);
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
if (state.featured?.label === 'featured') {
featuredCoords.add(coord);
featuredOrder.set(coord, state.featured.createdAt);
// Effective sort key: explicit rank tag wins, falling back to
// the label's created_at so labels published before the rank
// tag existed still sort correctly (newest-featured first).
featuredOrder.set(coord, state.featured.rank ?? state.featured.createdAt);
}
}
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
return { byCoord, hiddenCoords, featuredCoords, featuredOrder, moderators };
}
+11 -4
View File
@@ -126,7 +126,7 @@ export async function fetchAddressData(
baseUrls: string[],
signal?: AbortSignal,
): Promise<AddressData> {
const response = await esploraFetch(baseUrls, `/address/${address}`, { signal });
const response = await esploraFetch(baseUrls, `/address/${address}`, { signal, retryStatuses: [404] });
if (!response.ok) {
throw new Error('Failed to fetch balance');
@@ -479,7 +479,7 @@ export async function fetchAddressTxs(
const path = lastSeenTxid
? `/address/${address}/txs/chain/${lastSeenTxid}`
: `/address/${address}/txs`;
const response = await esploraFetch(baseUrls, path, { signal });
const response = await esploraFetch(baseUrls, path, { signal, retryStatuses: [404] });
if (!response.ok) {
throw new Error('Failed to fetch address transactions');
@@ -519,7 +519,7 @@ export async function fetchUTXOs(
baseUrls: string[],
signal?: AbortSignal,
): Promise<UTXO[]> {
const response = await esploraFetch(baseUrls, `/address/${address}/utxo`, { signal });
const response = await esploraFetch(baseUrls, `/address/${address}/utxo`, { signal, retryStatuses: [404] });
if (!response.ok) throw new Error('Failed to fetch UTXOs');
return response.json();
}
@@ -545,7 +545,12 @@ export interface FeeRates {
* @param signal Optional abort signal (e.g. from TanStack Query).
*/
export async function getFeeRates(baseUrls: string[], signal?: AbortSignal): Promise<FeeRates> {
const response = await esploraFetch(baseUrls, `/fee-estimates`, { signal });
// `/fee-estimates` is always present on a healthy Esplora backend, so a 404
// never means "not found" — it means the endpoint is misbehaving (notably
// mempool.space serving 404 instead of 429 to rate-limited mobile clients).
// Treat it as a retryable failure so we fail over to the next endpoint
// instead of trusting the 404 and giving up.
const response = await esploraFetch(baseUrls, `/fee-estimates`, { signal, retryStatuses: [404] });
if (!response.ok) throw new Error('Failed to fetch fee estimates');
const data = await response.json();
@@ -648,6 +653,8 @@ export async function broadcastTransaction(
method: 'POST',
body: txHex,
signal,
// A 404 on broadcast is never a legitimate "not found" — fail over.
retryStatuses: [404],
});
if (!response.ok) {
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import {
classifyBroadcastError,
isFeeRecoverable,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
/**
* Real-world reject strings emitted by:
*
* - bitcoind Core 25.x / 26.x via `sendrawtransaction`
* - mempool.space's Esplora `/tx` endpoint (passes the bitcoind body through
* unchanged with a 400 status)
* - Blockstream Esplora (same)
* - Blockbook's WebSocket `sendTransaction` (`data.error.message` is the
* verbatim bitcoind text)
* - Our own `broadcastTransaction` wrapper that prefixes `Broadcast failed: `
* on Esplora responses
*
* Each fixture is the actual string we'd see in `err.message` at the toast /
* alert call site. If a node operator's wrapping ever drifts, the classifier
* should still bucket the underlying reject reason.
*/
describe('classifyBroadcastError', () => {
it('classifies the canonical min-relay-fee reject with numeric pair', () => {
const result = classifyBroadcastError(
new Error('min relay fee not met, 245 < 1000'),
);
expect(result.kind).toBe('feeTooLow');
expect((result as Extract<BroadcastErrorKind, { kind: 'feeTooLow' }>).actualFeeRate).toBe(245);
expect((result as Extract<BroadcastErrorKind, { kind: 'feeTooLow' }>).minRelayFeeRate).toBe(1000);
});
it('parses the wrapped sendrawtransaction RPC form', () => {
const result = classifyBroadcastError(
new Error(
'sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met, 245 < 1000"}',
),
);
expect(result.kind).toBe('feeTooLow');
expect((result as Extract<BroadcastErrorKind, { kind: 'feeTooLow' }>).minRelayFeeRate).toBe(1000);
});
it('parses an Esplora-wrapped fee-too-low body', () => {
const result = classifyBroadcastError(
new Error('Broadcast failed: sendrawtransaction RPC error: min relay fee not met, 1 < 5'),
);
expect(result.kind).toBe('feeTooLow');
const fee = result as Extract<BroadcastErrorKind, { kind: 'feeTooLow' }>;
expect(fee.actualFeeRate).toBe(1);
expect(fee.minRelayFeeRate).toBe(5);
});
it('classifies feeTooLow without numbers when the format deviates', () => {
const result = classifyBroadcastError(new Error('min relay fee not met'));
expect(result.kind).toBe('feeTooLow');
expect((result as Extract<BroadcastErrorKind, { kind: 'feeTooLow' }>).minRelayFeeRate).toBeUndefined();
});
it('classifies replacement-fee rejection separately from a flat fee-too-low', () => {
expect(
classifyBroadcastError(new Error('insufficient fee, rejecting replacement')).kind,
).toBe('rbfReplacementFeeTooLow');
});
it('classifies mempool-min-fee separately from a flat fee-too-low', () => {
expect(
classifyBroadcastError(new Error('mempool min fee not met, 245 < 1000')).kind,
).toBe('mempoolFull');
});
it('classifies absurdly-high-fee', () => {
expect(
classifyBroadcastError(new Error('absurdly-high-fee')).kind,
).toBe('absurdlyHighFee');
});
it('classifies long mempool chains', () => {
expect(
classifyBroadcastError(
new Error('too-long-mempool-chain, too many descendants for tx ...'),
).kind,
).toBe('tooLongChain');
});
it('classifies double-spends and missing inputs', () => {
expect(
classifyBroadcastError(new Error('txn-mempool-conflict')).kind,
).toBe('mempoolConflict');
expect(
classifyBroadcastError(new Error('bad-txns-inputs-missingorspent')).kind,
).toBe('mempoolConflict');
expect(
classifyBroadcastError(new Error('Missing inputs')).kind,
).toBe('mempoolConflict');
});
it('classifies dust outputs as badInputs (not feeTooLow)', () => {
expect(
classifyBroadcastError(new Error('dust')).kind,
).toBe('badInputs');
expect(
classifyBroadcastError(new Error('bad-txns-out-of-range')).kind,
).toBe('badInputs');
});
it('classifies generic bad-txns- consensus rejects', () => {
expect(
classifyBroadcastError(new Error('bad-txns-vin-empty')).kind,
).toBe('badInputs');
});
it('classifies framing errors from broadcastBlockbookTx as network', () => {
const samples = [
'Blockbook WebSocket error (1006: abnormal closure)',
'Blockbook WebSocket closed (code=1011)',
'Blockbook WebSocket connect timed out',
'Blockbook sendTransaction timed out',
'Request aborted',
'NetworkError when attempting to fetch resource',
'Failed to fetch',
];
for (const msg of samples) {
expect(classifyBroadcastError(new Error(msg)).kind).toBe('network');
}
});
it('falls back to unknown for unrecognized strings, preserving the raw text', () => {
const result = classifyBroadcastError(new Error('something totally novel'));
expect(result.kind).toBe('unknown');
expect((result as Extract<BroadcastErrorKind, { kind: 'unknown' }>).raw).toBe(
'something totally novel',
);
});
it('handles non-Error inputs gracefully', () => {
expect(classifyBroadcastError('min relay fee not met, 245 < 1000').kind).toBe('feeTooLow');
expect(classifyBroadcastError({ message: 'mempool min fee not met' }).kind).toBe('mempoolFull');
expect(classifyBroadcastError(null).kind).toBe('unknown');
expect(classifyBroadcastError(undefined).kind).toBe('unknown');
expect(classifyBroadcastError({}).kind).toBe('unknown');
});
it('is case-insensitive', () => {
expect(
classifyBroadcastError(new Error('MIN RELAY FEE NOT MET, 1 < 5')).kind,
).toBe('feeTooLow');
expect(
classifyBroadcastError(new Error('Insufficient Fee, Rejecting Replacement')).kind,
).toBe('rbfReplacementFeeTooLow');
});
});
describe('isFeeRecoverable', () => {
it('marks fee-related rejects as recoverable via bump', () => {
expect(isFeeRecoverable('feeTooLow')).toBe(true);
expect(isFeeRecoverable('rbfReplacementFeeTooLow')).toBe(true);
expect(isFeeRecoverable('mempoolFull')).toBe(true);
});
it('rejects non-fee categories', () => {
expect(isFeeRecoverable('absurdlyHighFee')).toBe(false);
expect(isFeeRecoverable('badInputs')).toBe(false);
expect(isFeeRecoverable('mempoolConflict')).toBe(false);
expect(isFeeRecoverable('tooLongChain')).toBe(false);
expect(isFeeRecoverable('network')).toBe(false);
expect(isFeeRecoverable('unknown')).toBe(false);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Broadcast-error classification for Bitcoin transactions.
*
* Both broadcast paths in the app (`broadcastBlockbookTx` over the
* Blockbook WebSocket, and `broadcastTransaction` against an Esplora REST
* `/tx` endpoint) surface `bitcoind`'s `sendrawtransaction` RPC error
* string verbatim — sometimes wrapped (e.g. `Broadcast failed: <body>` from
* the Esplora path) and sometimes accompanied by a network-framing string
* (Blockbook timeout / WebSocket close / abort).
*
* Those raw strings are useless to a non-technical donor — "min relay fee
* not met, 245 < 1000" doesn't tell them what to do. This module maps the
* canonical bitcoind / mempool reject reasons onto a small enum the UI can
* use to render an actionable alert with a "bump fee and retry" button.
*
* The matcher is intentionally substring-based. bitcoind has shipped
* dozens of subtly different reject strings over the years (some prefixed
* with `sendrawtransaction RPC error: …`, some not; some wrapped in JSON,
* some not) and any node operator can stick their own text on top.
* Substring matching against the stable "reason code" portions is
* robust enough without trying to track every framing.
*
* When a number pair is parsed out of the canonical
* `min relay fee not met, <actual> < <minimum>` form we surface both —
* the UI uses them to display a concrete "current minimum: N sat/vB"
* hint and, in the HD flow, to seed a custom fee rate.
*/
/**
* Classified broadcast failure. The UI renders different copy and recovery
* actions per kind. `network` is for failures that never reached the relay
* (WebSocket close, timeout, abort); `unknown` is the bucket for anything
* the classifier doesn't recognize.
*/
export type BroadcastErrorKind =
| {
kind: 'feeTooLow';
/** Parsed minimum-fee figure in sat/vB if the relay surfaced one. */
minRelayFeeRate?: number;
/** Parsed actual-fee figure in sat/vB if the relay surfaced one. */
actualFeeRate?: number;
}
| { kind: 'rbfReplacementFeeTooLow' }
| { kind: 'mempoolFull' }
| { kind: 'mempoolConflict' }
| { kind: 'absurdlyHighFee' }
| { kind: 'tooLongChain' }
| { kind: 'badInputs' }
| { kind: 'network' }
| { kind: 'unknown'; raw: string };
/**
* The `min relay fee not met, <actual> < <minimum>` form `bitcoind` emits
* when a tx is below the configured minrelayfee or the live mempool floor.
* Both numbers are sats-per-1000-vbytes (i.e. sat/kB), but in practice
* most node operators report them already converted to sat/vB. We pass
* the values through unchanged and the UI labels them as sat/vB; for
* mempool.space / Blockstream Esplora this matches user expectations.
*/
const MIN_RELAY_FEE_RE = /min relay fee not met[^0-9]*([0-9]+(?:\.[0-9]+)?)\s*<\s*([0-9]+(?:\.[0-9]+)?)/i;
function parseRawMessage(error: unknown): string {
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
if (error && typeof error === 'object' && 'message' in error) {
const msg = (error as { message?: unknown }).message;
if (typeof msg === 'string') return msg;
}
try {
return String(error);
} catch {
return '';
}
}
/**
* Substring matching is case-insensitive; bitcoind emits lowercase reject
* reasons but wrapping layers (Esplora's `Broadcast failed:` prefix, our
* own framing) preserve case as-is.
*/
function includesCI(haystack: string, needle: string): boolean {
return haystack.toLowerCase().includes(needle.toLowerCase());
}
/**
* Classify a broadcast error so the UI can render an actionable recovery
* affordance. Always returns a value; defaults to `{ kind: 'unknown' }`
* with the original message preserved so callers can fall back to the raw
* text where useful.
*/
export function classifyBroadcastError(error: unknown): BroadcastErrorKind {
const raw = parseRawMessage(error);
if (!raw) return { kind: 'unknown', raw: '' };
// Network-framing errors emitted by `broadcastBlockbookTx` before the
// request ever reaches bitcoind. These never carry a bitcoind reject
// reason so they have to be matched first or we'd mis-bucket them.
if (
includesCI(raw, 'WebSocket error')
|| includesCI(raw, 'WebSocket closed')
|| includesCI(raw, 'WebSocket connect timed out')
|| includesCI(raw, 'timed out')
|| includesCI(raw, 'Request aborted')
|| includesCI(raw, 'NetworkError')
|| includesCI(raw, 'Failed to fetch')
) {
return { kind: 'network' };
}
// RBF: a replacement tx must pay a higher *absolute* fee AND a higher
// fee rate than what it replaces. bitcoind emits this exact string.
if (includesCI(raw, 'insufficient fee, rejecting replacement')) {
return { kind: 'rbfReplacementFeeTooLow' };
}
// Mempool min fee: the node's mempool is at capacity and the floor for
// accepting new txs has risen above the static minrelayfee. Different
// root cause from a flat minrelayfee failure but the user-visible fix is
// the same (raise the fee), so the UI can collapse them if needed —
// we still classify separately for copy.
if (includesCI(raw, 'mempool min fee not met')) {
return { kind: 'mempoolFull' };
}
// Canonical fee-too-low. Try to parse the numeric pair; if the format
// shifted (different node version, custom wrapping, JSON-escaped), the
// regex falls back to the bare kind without numbers.
if (includesCI(raw, 'min relay fee not met') || includesCI(raw, 'min_relay_fee_not_met')) {
const m = raw.match(MIN_RELAY_FEE_RE);
if (m) {
const actual = Number(m[1]);
const minimum = Number(m[2]);
return {
kind: 'feeTooLow',
actualFeeRate: Number.isFinite(actual) ? actual : undefined,
minRelayFeeRate: Number.isFinite(minimum) ? minimum : undefined,
};
}
return { kind: 'feeTooLow' };
}
// bitcoind: "fee rate ... below ... feefilter" or "min fee not met"
// catch-all for older / non-Core implementations.
if (
includesCI(raw, 'fee rate')
&& (includesCI(raw, 'below') || includesCI(raw, 'too low'))
) {
return { kind: 'feeTooLow' };
}
// bitcoind dust check fires for outputs below the dust threshold. The
// resulting reject string varies (`dust`, `bad-txns-out-of-range`) but
// the user fix is "increase the amount", not the fee — bucket under
// `badInputs` so the UI doesn't suggest a fee bump.
if (
includesCI(raw, 'dust')
&& !includesCI(raw, 'absurdly')
) {
return { kind: 'badInputs' };
}
// Sanity ceiling: bitcoind refuses to broadcast a tx whose fee is wildly
// above the absurd-fee threshold (default 0.1 BTC). Almost always a coin-
// selection bug; we surface a distinct kind so the UI doesn't suggest
// "raise the fee further".
if (includesCI(raw, 'absurdly-high-fee') || includesCI(raw, 'absurdly high fee')) {
return { kind: 'absurdlyHighFee' };
}
// Long unconfirmed chains (default limit: 25 ancestors / descendants).
// User can't fix this by adjusting the fee on this tx; they need to wait
// for an ancestor to confirm or use CPFP elsewhere.
if (
includesCI(raw, 'too-long-mempool-chain')
|| includesCI(raw, 'too many unconfirmed')
|| includesCI(raw, 'ancestor')
) {
return { kind: 'tooLongChain' };
}
// Double-spend / conflict with an existing mempool entry.
if (
includesCI(raw, 'txn-mempool-conflict')
|| includesCI(raw, 'replacement-adds-unconfirmed')
|| includesCI(raw, 'missing inputs')
|| includesCI(raw, 'bad-txns-inputs-missingorspent')
) {
return { kind: 'mempoolConflict' };
}
// Generic `bad-txns-*` consensus failures. These are unrecoverable from
// the dialog (the tx itself is malformed) — surface as `badInputs` so the
// UI tells the user to start over rather than offering a fee bump.
if (includesCI(raw, 'bad-txns-')) {
return { kind: 'badInputs' };
}
return { kind: 'unknown', raw };
}
/**
* Convenience: does this kind indicate that bumping the fee on a fresh
* broadcast would plausibly succeed? Used by the UI to decide whether to
* surface the "Use a higher fee" CTA.
*/
export function isFeeRecoverable(kind: BroadcastErrorKind['kind']): boolean {
return (
kind === 'feeTooLow'
|| kind === 'rbfReplacementFeeTooLow'
|| kind === 'mempoolFull'
);
}
+33 -4
View File
@@ -1,6 +1,14 @@
export const BITCOIN_FEE_SPEED_ORDER = ['fastest', 'halfHour', 'hour', 'economy'] as const;
export type BitcoinFeeSpeed = typeof BITCOIN_FEE_SPEED_ORDER[number];
/** The preset confirmation-speed tiers, in display order. */
export type PresetBitcoinFeeSpeed = typeof BITCOIN_FEE_SPEED_ORDER[number];
/**
* A fee selection: one of the preset tiers, or `'custom'` for a
* user-entered sat/vB rate (used when the estimate API is down or the
* user wants explicit control).
*/
export type BitcoinFeeSpeed = PresetBitcoinFeeSpeed | 'custom';
export interface BitcoinFeeRates {
fastestFee: number;
@@ -9,7 +17,7 @@ export interface BitcoinFeeRates {
economyFee: number;
}
export function getBitcoinFeeRate(rates: BitcoinFeeRates, speed: BitcoinFeeSpeed): number {
export function getBitcoinFeeRate(rates: BitcoinFeeRates, speed: PresetBitcoinFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
@@ -20,10 +28,10 @@ export function getBitcoinFeeRate(rates: BitcoinFeeRates, speed: BitcoinFeeSpeed
export function getUniqueBitcoinFeeSpeeds(
rates: BitcoinFeeRates | undefined,
): BitcoinFeeSpeed[] {
): PresetBitcoinFeeSpeed[] {
if (!rates) return [...BITCOIN_FEE_SPEED_ORDER];
const seen = new Set<number>();
const result: BitcoinFeeSpeed[] = [];
const result: PresetBitcoinFeeSpeed[] = [];
for (const speed of BITCOIN_FEE_SPEED_ORDER) {
const rate = getBitcoinFeeRate(rates, speed);
if (!seen.has(rate)) {
@@ -33,3 +41,24 @@ export function getUniqueBitcoinFeeSpeeds(
}
return result;
}
/**
* Resolve the effective sat/vB rate for the current selection.
*
* For `'custom'` the user-typed value wins (parsed and floored, valid only
* when ≥ 1). For preset tiers we read the loaded rates; returns `undefined`
* when rates haven't loaded (or a custom value isn't a usable rate), which
* callers should treat as "not ready" rather than a real rate.
*/
export function resolveBitcoinFeeRate(
speed: BitcoinFeeSpeed,
rates: BitcoinFeeRates | undefined,
customFeeRate: string,
): number | undefined {
if (speed === 'custom') {
const parsed = Math.floor(Number(customFeeRate));
return Number.isFinite(parsed) && parsed >= 1 ? parsed : undefined;
}
if (!rates) return undefined;
return getBitcoinFeeRate(rates, speed);
}
+87 -5
View File
@@ -1,5 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import slugify from 'slugify';
import { COUNTRIES } from '@/lib/countries';
import { parseCountryIdentifier } from '@/lib/countryIdentifiers';
@@ -281,17 +282,98 @@ export function encodeCampaignNaddr(campaign: ParsedCampaign, relays?: string[])
});
}
/**
* Strip Unicode bidi controls, zero-width characters, and BOMs from a
* user-supplied title before it lands in an event tag or feeds the slug
* deriver. These code points are invisible in most rendering contexts
* but survive copy-paste — they're routinely auto-inserted by RTL
* keyboards (RLM/LRM/FSI/PDI), and they're a phishing vector when
* preserved in display strings.
*
* - `\u200B-\u200F` zero-width space / joiner / non-joiner / LRM / RLM
* - `\u202A-\u202E` LRE / RLE / PDF / LRO / RLO bidi embedding+override
* - `\u2066-\u2069` LRI / RLI / FSI / PDI bidi isolates
* - `\uFEFF` zero-width no-break space (BOM)
*
* Whitespace (including non-breaking variants) is preserved here —
* trimming is the caller's job.
*/
export function sanitizeCampaignTitle(input: string): string {
return input.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '');
}
/**
* Slugifies a free-form string into a `d` tag value. Lowercase, ASCII-only,
* hyphenated. Returns an empty string if nothing remains after stripping.
*
* Non-Latin scripts (Arabic, Cyrillic, Greek, Persian, Georgian, etc.) are
* transliterated to ASCII via the `slugify` package's built-in charMap
* before the strict-ASCII filter runs — so an Arabic title like `حملة`
* becomes `hmlh` instead of collapsing to empty. Combining marks (diacritics
* on Latin letters) are stripped via NFKD so `café` becomes `cafe`.
*
* The output is suitable for direct comparison against the strict d-tag
* regex `/^[a-z0-9][a-z0-9-]{0,63}$/`; callers that need a guaranteed-
* non-empty d-tag should use {@link buildCampaignSlug}, which adds a random
* fallback for inputs that don't transliterate to any ASCII alphanumeric.
*/
export function slugifyCampaignIdentifier(input: string): string {
return input
.toLowerCase()
// Drop bidi/zero-width controls first so they don't affect the slug
// (RLM/LRM around a Latin title would otherwise survive into the
// transliteration step as `\u200F` → no charMap entry → kept verbatim
// → filtered, but only after pinning down the leading-hyphen position).
const cleaned = sanitizeCampaignTitle(input);
// `slugify` runs its charMap (covers Arabic, Persian, Cyrillic, Greek,
// Georgian, Armenian, Vietnamese, common Latin diacritics, currency
// symbols, smart quotes, etc.) and lowercases. We follow up with our
// own NFKD + combining-mark strip to catch any Latin diacritics that
// slugify's map missed, then collapse to the strict d-tag charset.
const transliterated = slugify(cleaned, {
lower: true,
// We strip everything outside [a-z0-9] ourselves below, so let
// slugify keep punctuation as-is — its `strict` mode would drop
// useful separators that we'd rather convert to hyphens.
strict: false,
trim: true,
});
return transliterated
.normalize('NFKD')
// strip combining marks
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u0300-\u036f]/g, '') // strip combining marks
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
.slice(0, 64)
// Re-trim trailing hyphens introduced by the 64-char truncation.
.replace(/-+$/, '');
}
/**
* Derive a publishable d-tag from a campaign title.
*
* Returns a `{ slug, isFallback }` pair:
* - `slug` — a valid d-tag matching `/^[a-z0-9][a-z0-9-]{0,63}$/`.
* - `isFallback` — `true` when the title contained no ASCII-transliterable
* characters (e.g. emoji-only, or scripts not covered by the
* transliteration map), and the slug is a random 10-character
* identifier of the form `campaign-XXXXXX`.
*
* The fallback exists so users typing titles in scripts like Chinese,
* Japanese, Korean, Thai, Tamil, etc. can still publish a campaign —
* the human-readable title lives in the `title` tag, so an opaque
* d-tag has no user-facing cost beyond an uglier URL.
*/
export function buildCampaignSlug(input: string): { slug: string; isFallback: boolean } {
const slug = slugifyCampaignIdentifier(input);
if (slug && /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
return { slug, isFallback: false };
}
return { slug: `campaign-${randomHex(6)}`, isFallback: true };
}
/** Cryptographically-random lowercase hex string of the given byte length. */
function randomHex(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
}
+75
View File
@@ -0,0 +1,75 @@
import {
Gavel,
GraduationCap,
Heart,
HeartHandshake,
KeyRound,
Megaphone,
Newspaper,
PawPrint,
Plane,
ShieldAlert,
ShieldCheck,
Siren,
Stethoscope,
Tent,
Venus,
Vote,
type LucideIcon,
} from 'lucide-react';
/**
* Curated set of campaign categories the wizard surfaces as a chip
* picker on the final step. Each entry maps a stable, lowercased
* `t`-tag slug (the value persisted on the event) to a translation
* key (under `campaignsCreate.categories.*` in the locale files) and
* a Lucide icon. The set is deliberately fixed — adding new entries
* means adding the slug here, the translation everywhere, and an
* icon. Categories are stored as ordinary `t` tags, indistinguishable
* from any other content tag at the protocol level; the picker is
* just a curated UI on top of the same field.
*
* **Editorial focus.** Agora's mission is funding the kinds of
* activism HRF and the World Liberty Congress champion — human
* rights, democracy, press freedom, political prisoners — so the
* preset list leads with those themes. Everyday humanitarian needs
* (emergency relief, medical, education, community) round out the
* grid so the picker still covers the breadth of legitimate
* fundraising. Categories that used to ship here but didn't match
* the editorial focus (adoption, church, family, memorial, event,
* mission) were dropped pre-launch; campaigns published before the
* drop keep their on-chain `t` tags intact but no longer light up a
* pill in the editor.
*/
export interface CampaignCategory {
/** Lowercase, hyphenated slug persisted as a `t` tag on the event. */
slug: string;
/** i18n key under `campaignsCreate.categories.*`. */
labelKey: string;
/** Lucide icon component rendered next to the label in the picker. */
Icon: LucideIcon;
}
export const CAMPAIGN_CATEGORIES: readonly CampaignCategory[] = [
{ slug: 'human-rights', labelKey: 'campaignsCreate.categories.humanRights', Icon: Heart },
{ slug: 'democracy', labelKey: 'campaignsCreate.categories.democracy', Icon: Vote },
{ slug: 'press-freedom', labelKey: 'campaignsCreate.categories.pressFreedom', Icon: Newspaper },
{ slug: 'political-prisoners', labelKey: 'campaignsCreate.categories.politicalPrisoners', Icon: KeyRound },
{ slug: 'humanitarian-aid', labelKey: 'campaignsCreate.categories.humanitarianAid', Icon: Tent },
{ slug: 'civil-resistance', labelKey: 'campaignsCreate.categories.civilResistance', Icon: Megaphone },
{ slug: 'digital-rights', labelKey: 'campaignsCreate.categories.digitalRights', Icon: ShieldCheck },
{ slug: 'anti-corruption', labelKey: 'campaignsCreate.categories.antiCorruption', Icon: ShieldAlert },
{ slug: 'women-girls', labelKey: 'campaignsCreate.categories.womenGirls', Icon: Venus },
{ slug: 'refugees', labelKey: 'campaignsCreate.categories.refugees', Icon: Plane },
{ slug: 'legal-aid', labelKey: 'campaignsCreate.categories.legalAid', Icon: Gavel },
{ slug: 'emergency-relief', labelKey: 'campaignsCreate.categories.emergencyRelief', Icon: Siren },
{ slug: 'animal-rights', labelKey: 'campaignsCreate.categories.animalRights', Icon: PawPrint },
{ slug: 'education', labelKey: 'campaignsCreate.categories.education', Icon: GraduationCap },
{ slug: 'medical', labelKey: 'campaignsCreate.categories.medical', Icon: Stethoscope },
{ slug: 'community', labelKey: 'campaignsCreate.categories.community', Icon: HeartHandshake },
] as const;
/** Set of valid category slugs for O(1) lookup. */
export const CAMPAIGN_CATEGORY_SLUGS = new Set<string>(
CAMPAIGN_CATEGORIES.map((c) => c.slug),
);
+278
View File
@@ -0,0 +1,278 @@
import type { NostrEvent } from '@nostrify/nostrify';
import slugify from 'slugify';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/**
* Curated topic lists of campaigns.
*
* Each list is a single NIP-51 **kind 30003 (Bookmark Set)** event authored
* by a campaign moderator (a `p` in the Team Soapbox follow pack — see
* `useCampaignModerators`). The ordered `a` tags are the list members, in
* display order; the title / description / icon live in standard NIP-51
* tags plus one Agora-specific `icon` tag holding the Lucide icon name.
*
* **Tag layout (per list event):**
* ```
* ['d', '<slug>'] // stable lowercased slug
* ['title', '<display name>'] // NIP-51
* ['description', '<optional blurb>'] // NIP-51, optional
* ['icon', '<LucideIconName>'] // PascalCase, looked up via LucideIcon component
* ['t', 'agora.campaign-list'] // hashtag namespace so all lists can be queried in one filter
* ['a', '33863:<pubkey>:<d>'] // one per campaign, ARRAY ORDER = display order
* ['alt', 'Agora campaign list: <title>'] // NIP-31
* ```
*
* **List-of-lists order** is encoded as a separate sentinel kind 30003
* event with `d = 'agora.campaign-lists.index'` whose `a` tags reference
* the list events themselves (`30003:<authorPubkey>:<slug>`) in the
* desired display order. Any moderator may publish an index; at read time
* the newest-`created_at` index across all moderators wins. Lists not in
* the current index fall to the end of the strip in newest-first order so
* a freshly-created list is visible until a moderator reorders.
*
* **Trust model.** Read paths MUST gate `authors:` on the moderator
* allowlist (`useCampaignModerators`). Without that gate, any pubkey could
* publish a kind 30003 event with the `agora.campaign-list` hashtag and
* appear in the strip. The fold also picks the newest event per
* `(pubkey, d)` for a single list — concurrent edits from two moderators
* resolve to whoever publishes last, matching the rest of the moderation
* namespace.
*/
/** Kind 30003 — NIP-51 Bookmark Set. */
export const CAMPAIGN_LIST_KIND = 30003;
/** Hashtag marker that identifies an Agora campaign list. */
export const CAMPAIGN_LIST_HASHTAG = 'agora.campaign-list';
/**
* Hashtag marker and `d` tag for the sentinel "lists order" event.
* Both values are deliberately equal so a single `#t` filter pulls back
* both the list events and the index event in one round trip.
*/
export const CAMPAIGN_LIST_INDEX_HASHTAG = 'agora.campaign-lists.index';
export const CAMPAIGN_LIST_INDEX_D = 'agora.campaign-lists.index';
/** A 64-character lowercase hex string. */
const HEX_64_RE = /^[0-9a-f]{64}$/;
/** A list slug — kebab-case, lowercase ASCII, digits, hyphens. */
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
/** Lucide icon name — PascalCase, allow letters and digits only. */
const ICON_NAME_RE = /^[A-Z][A-Za-z0-9]{0,63}$/;
/**
* A parsed Agora campaign list, ready for rendering. The membership
* (`coords`) is in display order — the order in which the `a` tags
* appeared on the source event.
*/
export interface ParsedCampaignList {
/** Underlying kind 30003 event. */
event: NostrEvent;
/** `d` tag — stable slug used in URLs and as the index reference. */
slug: string;
/** Author pubkey (the moderator who last published this revision). */
authorPubkey: string;
/** Coordinate `30003:<authorPubkey>:<slug>`. */
aTag: string;
/** Display name. */
title: string;
/** Optional short description. */
description?: string;
/** Lucide icon component name (PascalCase). Already validated. */
icon: string;
/** Optional sanitized cover image URL (from a `cover` tag). */
cover?: string;
/** Ordered list of campaign coordinates (`33863:<pubkey>:<d>`). */
coords: string[];
/** `created_at` of the source event. */
createdAt: number;
}
/** Parse a single kind 30003 event into a list, or `null` if invalid. */
export function parseCampaignList(event: NostrEvent): ParsedCampaignList | null {
if (event.kind !== CAMPAIGN_LIST_KIND) return null;
const getTag = (name: string) =>
event.tags.find(([n, v]) => n === name && typeof v === 'string')?.[1];
const slug = getTag('d');
if (!slug || !SLUG_RE.test(slug)) return null;
// The index sentinel is not a renderable list.
if (slug === CAMPAIGN_LIST_INDEX_D) return null;
// Must carry the campaign-list hashtag to be considered an Agora list —
// not every kind 30003 authored by a moderator is one of ours.
const isCampaignList = event.tags.some(
([n, v]) => n === 't' && v === CAMPAIGN_LIST_HASHTAG,
);
if (!isCampaignList) return null;
const title = getTag('title')?.trim();
if (!title) return null;
const description = getTag('description')?.trim() || undefined;
// Icon defaults to a generic `List` if the publisher omitted or chose an
// invalid name. The picker UI enforces a valid Lucide name on write,
// so this fallback only triggers for hand-crafted events or rare typos.
const rawIcon = getTag('icon')?.trim();
const icon = rawIcon && ICON_NAME_RE.test(rawIcon) ? rawIcon : 'List';
const cover = sanitizeUrl(getTag('cover'));
// Membership: `a` tags pointing at campaign coordinates, in array order.
// Filter to the campaign kind and a well-formed `kind:hexpubkey:slug`.
const coords: string[] = [];
const coordPrefix = `${CAMPAIGN_KIND}:`;
const seen = new Set<string>();
for (const tag of event.tags) {
if (tag[0] !== 'a' || typeof tag[1] !== 'string') continue;
const value = tag[1];
if (!value.startsWith(coordPrefix)) continue;
const parts = value.split(':');
if (parts.length < 3) continue;
const pubkey = parts[1];
const dTag = parts.slice(2).join(':');
if (!HEX_64_RE.test(pubkey) || !dTag) continue;
if (seen.has(value)) continue;
seen.add(value);
coords.push(value);
}
return {
event,
slug,
authorPubkey: event.pubkey,
aTag: `${CAMPAIGN_LIST_KIND}:${event.pubkey}:${slug}`,
title,
description,
icon,
cover,
coords,
createdAt: event.created_at,
};
}
/**
* Extract the list coord order from a sentinel "index" event. Returns the
* ordered list of `30003:<author>:<slug>` references that the index points
* at. Invalid `a` tags are dropped.
*/
export function parseCampaignListIndex(event: NostrEvent): string[] {
if (event.kind !== CAMPAIGN_LIST_KIND) return [];
// The index sentinel uses a dedicated `d` tag.
const d = event.tags.find(([n]) => n === 'd')?.[1];
if (d !== CAMPAIGN_LIST_INDEX_D) return [];
const refs: string[] = [];
const seen = new Set<string>();
const listPrefix = `${CAMPAIGN_LIST_KIND}:`;
for (const tag of event.tags) {
if (tag[0] !== 'a' || typeof tag[1] !== 'string') continue;
const value = tag[1];
if (!value.startsWith(listPrefix)) continue;
const parts = value.split(':');
if (parts.length < 3) continue;
const pubkey = parts[1];
const slug = parts.slice(2).join(':');
if (!HEX_64_RE.test(pubkey) || !SLUG_RE.test(slug)) continue;
if (seen.has(value)) continue;
seen.add(value);
refs.push(value);
}
return refs;
}
/**
* Compose a sorted list array from raw events. Lists are deduped per
* `(pubkey, slug)`, newest `created_at` wins. The optional index event
* dictates display order — referenced lists appear in index order; any
* remaining lists fall to the end in newest-first order so a brand-new
* list is visible until a moderator reorders.
*
* @param events Mixed bag of kind 30003 events from moderator authors.
* @returns `{ lists, indexEvent }` — `indexEvent` is the newest
* sentinel event across all moderators, or `undefined`
* when no moderator has published one yet.
*/
export function foldCampaignLists(events: NostrEvent[]): {
lists: ParsedCampaignList[];
indexEvent: NostrEvent | undefined;
} {
// Bucket: lists vs. index events. We let the parsers tell us which is
// which (parseCampaignList rejects the index `d`, parseCampaignListIndex
// rejects everything else).
const listsByAuthorSlug = new Map<string, ParsedCampaignList>();
let indexEvent: NostrEvent | undefined;
for (const event of events) {
if (event.kind !== CAMPAIGN_LIST_KIND) continue;
const d = event.tags.find(([n]) => n === 'd')?.[1];
if (!d) continue;
if (d === CAMPAIGN_LIST_INDEX_D) {
if (!indexEvent || event.created_at > indexEvent.created_at) {
indexEvent = event;
}
continue;
}
const parsed = parseCampaignList(event);
if (!parsed) continue;
const key = `${parsed.authorPubkey}:${parsed.slug}`;
const prev = listsByAuthorSlug.get(key);
if (!prev || parsed.createdAt > prev.createdAt) {
listsByAuthorSlug.set(key, parsed);
}
}
const all = Array.from(listsByAuthorSlug.values());
const byCoord = new Map(all.map((l) => [l.aTag, l]));
// Apply index order. Lists referenced by the index appear first, in the
// index's order; remaining lists are appended newest-first.
let lists: ParsedCampaignList[] = [];
const consumed = new Set<string>();
if (indexEvent) {
const orderRefs = parseCampaignListIndex(indexEvent);
for (const ref of orderRefs) {
const found = byCoord.get(ref);
if (found && !consumed.has(found.aTag)) {
lists.push(found);
consumed.add(found.aTag);
}
}
}
const tail = all
.filter((l) => !consumed.has(l.aTag))
.sort((a, b) => b.createdAt - a.createdAt);
lists = lists.concat(tail);
return { lists, indexEvent };
}
/**
* Generate a kebab-case slug from a free-form title. Collisions are the
* caller's responsibility (see useCampaignListActions.createList).
*/
export function slugifyListTitle(title: string): string {
const base = slugify(title, { lower: true, strict: true, trim: true });
// Clamp to 64 chars and trim leading/trailing hyphens.
const clamped = base.slice(0, 64).replace(/^-+|-+$/g, '');
// Ensure first char is alphanumeric per SLUG_RE.
return clamped.replace(/^-+/, '') || 'list';
}
/** Validate a slug against the on-write regex. Exposed for tests / forms. */
export function isValidListSlug(slug: string): boolean {
return SLUG_RE.test(slug);
}
/** Validate a Lucide icon name. */
export function isValidIconName(name: string): boolean {
return ICON_NAME_RE.test(name);
}
+10 -3
View File
@@ -1,8 +1,15 @@
import { iso31662 } from 'iso-3166';
import { getSubdivisionName, getSubdivisionWikipediaTitle } from './subdivisions';
import { SUBDIVISION_CODES as SUBDIVISION_CODE_LIST } from './subdivisionCodes';
/** Authoritative set of ISO 3166-2 subdivision codes for validation. */
const SUBDIVISION_CODES = new Set(iso31662.map((s) => s.code));
/**
* Authoritative set of ISO 3166-2 subdivision codes for validation.
*
* Backed by a build-time-generated code list (`subdivisionCodes.ts`) rather
* than importing the full `iso-3166` package, which would drag ~244 KB of
* subdivision objects into the critical-path bundle. Regenerate the list with
* `node scripts/gen-subdivision-codes.mjs`.
*/
const SUBDIVISION_CODES = new Set(SUBDIVISION_CODE_LIST);
/** ISO 3166-1 alpha-2 country code to country name and flag emoji mapping. */
export const COUNTRIES: Record<string, { name: string; flag: string }> = {
+27 -2
View File
@@ -123,6 +123,26 @@ interface EsploraFetchOptions extends Omit<RequestInit, 'signal'> {
* caller as-is.
*/
skipStatuses?: number[];
/**
* Additional HTTP statuses to treat as a retryable endpoint failure for
* *this* call — failover to the next URL AND cool the endpoint down — on top
* of the global {@link RETRYABLE_STATUS} set.
*
* Use this for paths that are *always present* on a healthy Esplora backend
* (e.g. `/fee-estimates`, `/address/…`, `/tx` broadcast), where a `404` is
* never a legitimate "not found" but a sign the endpoint is misbehaving —
* notably mempool.space returning `404` instead of `429` to rate-limited
* clients (common on carrier-NAT'd mobile connections). Without this, the
* `404` is mistaken for a real answer, returned to the caller, and no
* failover happens.
*
* Do NOT use for paths where `404` is a meaningful answer — e.g.
* `/tx/{txid}` lookups, where "not found" means the tx genuinely isn't
* known yet.
*
* Defaults to `[]`.
*/
retryStatuses?: number[];
}
/** Error thrown when every endpoint in the list is unreachable or cooled down. */
@@ -225,6 +245,7 @@ export async function esploraFetch(
const {
skipStatuses = [],
retryStatuses = [],
signal: callerSignal,
timeoutMs = DEFAULT_TIMEOUT_MS,
...fetchInit
@@ -238,6 +259,7 @@ export async function esploraFetch(
}
const skip = new Set(skipStatuses);
const retry = new Set(retryStatuses);
const causes: Array<{ url: string; reason: string }> = [];
const now = Date.now();
@@ -296,8 +318,11 @@ export async function esploraFetch(
continue;
}
// 5xx / 429 / 408 → cool down and try the next URL.
if (RETRYABLE_STATUS.has(response.status)) {
// 5xx / 429 / 408 → cool down and try the next URL. Callers can extend
// this set per-call via `retryStatuses` for always-present paths where a
// 404 means "misbehaving endpoint" rather than "genuinely not found"
// (e.g. mempool.space returning 404 to rate-limited mobile clients).
if (RETRYABLE_STATUS.has(response.status) || retry.has(response.status)) {
markFailure(baseUrl, Date.now());
causes.push({ url: baseUrl, reason: `HTTP ${response.status}` });
continue;
+80
View File
@@ -0,0 +1,80 @@
import type { ForwardRefExoticComponent } from 'react';
import type { LucideProps } from 'lucide-react';
type LucideComponent = ForwardRefExoticComponent<
Omit<LucideProps, 'ref'> & React.RefAttributes<SVGSVGElement>
>;
/**
* Lazy registry of every named icon exported by `lucide-react`.
*
* `lucide-react` exports ~1500 individual icon components. Statically
* importing the whole library would defeat tree-shaking for the entire
* app, so this module is the *only* place that imports it with a
* namespace import. Both `LucideIcon` (the display wrapper) and
* `IconPicker` go through `loadLucideRegistry()`, which dynamically
* imports `lucide-react` and emits the icons as a separate Vite chunk.
*
* The registry caches the resolved module so subsequent calls are
* synchronous-fast (Promise.resolve of the cached value).
*
* **Validation.** We expose `entries()` filtered to (a) the PascalCase
* names we accept on write (see `isValidIconName` in
* `src/lib/campaignLists.ts`) and (b) components that look like icon
* components (have a `render` or `$$typeof` marker). Anything failing
* either check is dropped silently — that keeps non-icon exports
* (`createLucideIcon`, the `LucideProps` interface re-export, etc.)
* out of the picker.
*/
let cached: Promise<Record<string, LucideComponent>> | null = null;
/** Camelcase or generic exports we deliberately want to exclude. */
const EXCLUDED_NAMES = new Set<string>([
'createLucideIcon',
'Icon',
'LucideIcon',
'LucideProps',
'default',
]);
/** PascalCase: starts with an uppercase letter, no underscores. */
const PASCAL_CASE_RE = /^[A-Z][A-Za-z0-9]+$/;
/** Load (and cache) the full Lucide module. Subsequent calls are free. */
function loadModule(): Promise<Record<string, LucideComponent>> {
if (!cached) {
cached = import('lucide-react').then((mod) => {
const out: Record<string, LucideComponent> = {};
for (const [name, value] of Object.entries(mod)) {
if (EXCLUDED_NAMES.has(name)) continue;
if (!PASCAL_CASE_RE.test(name)) continue;
// Skip the "Icon"-suffixed deprecated aliases that lucide-react
// ships for backwards compatibility — they double-count the list.
if (name.endsWith('Icon') && name !== 'Icon') continue;
// Skip the "Lucide"-prefixed aliases for the same reason.
if (name.startsWith('Lucide') && name !== 'Lucide') continue;
if (typeof value !== 'object' && typeof value !== 'function') continue;
out[name] = value as LucideComponent;
}
return out;
});
}
return cached;
}
/** Resolve a single icon by name. Returns `null` if not in the registry. */
export async function getLucideIcon(name: string): Promise<LucideComponent | null> {
const reg = await loadModule();
return reg[name] ?? null;
}
/** Return all `{ name, component }` entries in alphabetical order. */
export async function getAllLucideIcons(): Promise<
Array<{ name: string; Component: LucideComponent }>
> {
const reg = await loadModule();
return Object.keys(reg)
.sort((a, b) => a.localeCompare(b))
.map((name) => ({ name, Component: reg[name] }));
}
+13
View File
@@ -1,5 +1,18 @@
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
/**
* Addressable coordinate for a pledge (kind 36639): `36639:<pubkey>:<d>`.
*
* Accepts any object carrying `pubkey` and `id` so this helper stays in
* the lib layer without taking a hook dep on `Action`. Both the moderation
* label system (NIP-32 / kind 1985 `a`-tags) and the share-link generator
* (NIP-09 deletion requests, naddr encoders) hand-rolled the same string
* three times before this consolidation; one source of truth now.
*/
export function getPledgeCoord({ pubkey, id }: { pubkey: string; id: string }): string {
return `36639:${pubkey}:${id}`;
}
export function formatPledgeAmount(sats: number, btcPrice: number | undefined): string {
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
return `${formatSats(sats)} sats`;
+2
View File
@@ -178,6 +178,7 @@ export const AppConfigSchema = z.object({
aiApiKey: z.string().optional(),
aiModel: z.string().optional(),
aiSystemPrompt: z.string().optional(),
translateWorkerUrl: z.string().optional(),
});
// ─── BuildConfigSchema (build-time app config) ───────────────────────
@@ -277,4 +278,5 @@ export const EncryptedSettingsSchema = z.looseObject({
aiApiKey: z.string().optional(),
aiModel: z.string().optional(),
aiSystemPrompt: z.string().optional(),
translateWorkerUrl: z.string().optional(),
});
File diff suppressed because one or more lines are too long
+214 -43
View File
@@ -107,12 +107,15 @@
"profile": {
"title": "اجعله ملفك",
"subtitle": "أخبر الآخرين قليلًا عن نفسك. كلّها اختيارية، يمكنك تغييرها في أيّ وقت.",
"campaignTitle": "أضف وجهك لحملتك",
"campaignSubtitle": "يساعد الاسم والصورة الناس على التواصل مع حملتك.",
"nameLabel": "الاسم المعروض",
"namePlaceholder": "اسمك",
"aboutLabel": "نبذة",
"aboutPlaceholder": "نبذة قصيرة عنك…",
"avatarLabel": "الصورة الرمزية",
"uploadAvatar": "رفع صورة رمزية",
"advanced": "المزيد",
"finish": "إنهاء",
"saving": "جارٍ الحفظ…",
"skip": "تخطٍّ في الوقت الحالي",
@@ -179,10 +182,11 @@
"coverImage": "صورة الغلاف",
"description": "الوصف",
"timezone": "المنطقة الزمنية",
"publishing": "جارٍ النشر…",
"uploadingCover": "جارٍ رفع الغلاف…",
"countrySearchPlaceholder": "ابحث عن البلدان",
"imageDropzone": "انقر أو اسحب صورة هنا"
"imageDropzone": "انقر أو اسحب صورة هنا",
"countryClearAria": "مسح البلد",
"flagOfAria": "علم {{name}}",
"countryHint": "يُنشر <0>i: iso3166:{{code}}</0> للترتيب حسب البلد."
},
"organizationContext": {
"attachedToGroup": "مرتبط بالمجموعة",
@@ -216,8 +220,8 @@
"myPledgesTagline": "التعهدات التي أنشأتها.",
"featuredPledges": "تعهدات مميزة",
"featuredPledgesTagline": "تعهدات يسلّط فريق {{appName}} الضوء عليها.",
"allPledges": "كل التعهدات",
"allPledgesTagline": "تصفّح كل تعهد على الشبكة.",
"allPledges": "التعهدات",
"allPledgesTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح كل تعهد.",
"sectionActive": "التعهدات النشطة",
"sectionUpcoming": "التعهدات القادمة",
"sectionPast": "التعهدات السابقة",
@@ -271,11 +275,7 @@
"titlePlaceholder": "توثيق تنظيف شاطئ",
"country": "البلد",
"countryPlaceholder": "ابحث عن البلدان",
"countryClearAria": "مسح البلد",
"flagOfAria": "علم {{name}}",
"countryHint": "يُنشر <0>i: iso3166:{{code}}</0> للترتيب حسب البلد.",
"tags": "الوسوم",
"tagsPlaceholder": "تنظيف-شاطئ، توثيق-احتجاج، انقطاع-إنترنت",
"coverImage": "صورة الغلاف",
"description": "الوصف",
"descriptionPlaceholder": "اشرح الفعل أو الدليل أو النتيجة التي تريد إلهامها، وما الذي يجب أن تتضمنه المساهمات، وكيف تخطط لتقييمها...",
@@ -285,8 +285,6 @@
"timezone": "المنطقة الزمنية",
"timezoneNote": "سيتم تفسير أوقات البدء والموعد النهائي بهذه المنطقة الزمنية.",
"submit": "إنشاء تعهد",
"publishing": "جارٍ النشر…",
"uploadingCover": "جارٍ رفع الغلاف…",
"altText": "تعهد {{appName}}: {{title}}",
"successToast": "تم إنشاء التعهد",
"errorToast": "تعذّر إنشاء التعهد",
@@ -297,7 +295,18 @@
"errorPledgeInvalid": "يجب أن يكون مبلغ التعهد مبلغًا موجبًا بالدولار.",
"errorPriceUnavailable": "في انتظار سعر BTC/USD لحساب مبلغ التعهد.",
"errorCoverInvalid": "يجب أن تكون صورة الغلاف رابط https:// صالحًا.",
"errorDeadlinePast": "لا يمكن أن يكون الموعد النهائي في الماضي."
"errorDeadlinePast": "لا يمكن أن يكون الموعد النهائي في الماضي.",
"wizard": {
"titleStepTitle": "سمِّ تعهدك",
"titleStepSubtitle": "طلب واضح وشرح موجز لما ستموّله.",
"pledgeStepTitle": "حدّد تعهدك",
"pledgeStepSubtitle": "المبلغ الذي ستدفعه، بالدولار الأمريكي، وموعد نهائي اختياري.",
"coverStepTitle": "أضف صورة غلاف",
"coverStepSubtitle": "صورة واحدة تُمثّل التعهد في كل بطاقة.",
"tagsStepTitle": "البلد والفئات",
"tagsStepSubtitle": "ساعد الأشخاص المناسبين على العثور على تعهدك.",
"launchNow": "تخطّي التالي والإطلاق"
}
},
"detail": {
"seoTitle": "{{title}} | تعهد {{appName}}",
@@ -347,8 +356,8 @@
"myGroupsTagline": "المجموعات التي أسستها أو تشرف عليها أو تتابعها.",
"featuredGroups": "المجموعات المميزة",
"featuredGroupsTagline": "مجموعات بارزة تستحق اهتمامك.",
"allGroups": "كل المجموعات",
"allGroupsTagline": "تصفّح مجموعات {{appName}}، أو ابحث في كل المجموعات على نوستر.",
"allGroups": "المجموعات",
"allGroupsTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح كل مجموعة.",
"loginToSeeTitle": "سجّل الدخول لرؤية مجموعاتك",
"loginToSeeBody": "ستظهر هنا المجموعات التي أسستها أو التي تشرف عليها.",
"noGroupsTitle": "لا توجد مجموعات بعد",
@@ -395,9 +404,6 @@
"descriptionPlaceholder": "عن ماذا تدور هذه المجموعة؟",
"country": "البلد",
"countryPlaceholder": "ابحث عن البلدان",
"countryClearAria": "مسح البلد",
"flagOfAria": "علم {{name}}",
"countryHint": "يُنشر <0>i: iso3166:{{code}}</0> للترتيب حسب البلد.",
"tags": "الوسوم",
"tagsPlaceholder": "تعاضد، أخبار-محلية، حقوق-رقمية",
"coverImage": "صورة الغلاف",
@@ -421,7 +427,18 @@
"errorNameInvalid": "يجب أن يحتوي الاسم على حروف أو أرقام لإنشاء رابط للمجموعة.",
"errorEditLatestMissing": "تعذّر العثور على أحدث نسخة لهذه المجموعة لتحديثها.",
"errorCoverInvalid": "يجب أن تكون صورة الغلاف رابط https:// صالحًا.",
"errorSlugCollision": "لديك بالفعل مجموعة بالمعرّف «{{slug}}». اختر اسمًا آخر."
"errorSlugCollision": "لديك بالفعل مجموعة بالمعرّف «{{slug}}». اختر اسمًا آخر.",
"wizard": {
"nameStepTitle": "سمِّ مجموعتك",
"nameStepSubtitle": "اسم قصير وواضح يتعرّف عليه الأعضاء.",
"coverStepTitle": "أضف صورة غلاف",
"coverStepSubtitle": "صورة واحدة تُمثّل المجموعة في كل بطاقة.",
"moderatorsStepTitle": "ادعُ مشرفين",
"moderatorsStepSubtitle": "اختياري — يمكنهم اعتماد المحتوى وإزالة الأعضاء إلى جانبك.",
"tagsStepTitle": "البلد والفئات",
"tagsStepSubtitle": "ساعد الأشخاص المناسبين على العثور على مجموعتك.",
"launchNow": "تخطّي التالي والإطلاق"
}
},
"detail": {
"by": "بواسطة",
@@ -481,9 +498,19 @@
"myWalletDefault": "محفظتي",
"walletChoose": "اختر محفظة",
"walletCustom": "مخصصة",
"walletUseCustom": "استخدم محفظة أخرى بدلاً من ذلك",
"walletDestinationLanding": "ستصل التبرعات هنا",
"walletDestinationNote": "سيتم نشر هذه المحفظة كوجهة التبرعات لحملتك.",
"walletUseMine": "استخدم محفظة Agora الخاصة بي",
"acceptAll": "قبول جميع أنواع الدفع",
"acceptPublic": "قبول الدفعات العامة فقط",
"acceptPrivate": "قبول الدفعات الخاصة فقط",
"acceptAllShort": "قبول الكل",
"acceptPublicShort": "عامة فقط",
"acceptPrivateShort": "خاصة فقط",
"acceptAllHint": "قبول الدفعات العامة على السلسلة والدفعات الصامتة الخاصة.",
"acceptPublicHint": "قبول التبرعات على السلسلة إلى عنوان عام فقط.",
"acceptPrivateHint": "قبول الدفعات الصامتة فقط — تبقى عناوين المتبرعين خاصة.",
"customWalletIntro": "أدخل عنوان بيتكوين، رمز دفع صامت، أو كليهما. يلزم واحد على الأقل.",
"bitcoinAddress": "عنوان بيتكوين",
"bitcoinAddressPlaceholder": "bc1q… أو bc1p…",
@@ -493,11 +520,26 @@
"spInvalid": "ليس رمز دفع صامت BIP-352 معروفًا (sp1…).",
"country": "البلد",
"countryPlaceholder": "ابحث عن البلدان",
"countryClearAria": "مسح البلد",
"flagOfAria": "علم {{name}}",
"countryHint": "يُنشر <0>i: iso3166:{{code}}</0> للترتيب حسب البلد.",
"tags": "الوسوم",
"tagsPlaceholder": "دفاع-قانوني، تعاضد، أخبار-محلية",
"categories": {
"humanRights": "حقوق الإنسان",
"democracy": "الديمقراطية",
"pressFreedom": "حرية الصحافة",
"politicalPrisoners": "المعتقلون السياسيون",
"humanitarianAid": "العمل الإنساني",
"civilResistance": "المقاومة المدنية",
"digitalRights": "الحقوق الرقمية",
"antiCorruption": "مكافحة الفساد",
"womenGirls": "النساء والفتيات",
"refugees": "اللاجئون والمنفيون",
"legalAid": "المساعدة القانونية",
"emergencyRelief": "الإغاثة الطارئة",
"animalRights": "حقوق الحيوان",
"education": "التعليم",
"medical": "الرعاية الطبية",
"community": "المجتمع"
},
"banner": "صورة البانر",
"story": "القصة",
"storyPlaceholder": "شارك الخلفية والمستفيدين وكيفية استخدام الأموال.",
@@ -537,7 +579,21 @@
"errorHdDeriveFailed": "تعذّر اشتقاق عنوان على السلسلة جديد من محفظتك.",
"errorHdDeriveInvalid": "فشل التحقق من العنوان المشتق. الرجاء إضافة عنوان مخصص.",
"errorWalletRequiredFallback": "نقطة محفظة مطلوبة.",
"errorPublishedInvalid": "فشل التحقق من الحدث المنشور. الرجاء التحديث والمحاولة مرة أخرى."
"errorPublishedInvalid": "فشل التحقق من الحدث المنشور. الرجاء التحديث والمحاولة مرة أخرى.",
"wizard": {
"titleStepTitle": "سمِّ حملتك",
"titleStepSubtitle": "اسم قصير وواضح يتعرّف عليه المتبرعون.",
"walletStepTitle": "اختر مَن يتلقى التبرعات",
"walletStepSubtitle": "محفظة Agora الخاصة بك جاهزة لتلقّي تبرعات Bitcoin لهذه الحملة.",
"bannerStepTitle": "أضف صورة بانر",
"bannerStepSubtitle": "صورة واحدة لافتة تُمثّل الحملة في كل بطاقة.",
"storyStepTitle": "احكِ قصتك",
"storyStepSubtitle": "من المستفيد وكيف ستُستخدم الأموال.",
"next": "التالي",
"back": "رجوع",
"skip": "تخطٍّ",
"launchNow": "تخطّي التالي والإطلاق"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | حملات {{appName}}",
@@ -687,27 +743,51 @@
"startCampaign": "ابدأ حملة",
"howItWorks": "كيف يعمل",
"exploreCampaigns": "تصفّح الحملات",
"featured": "مميّزة",
"featuredDesc": "حملات منتقاة بعناية من فريق {{appName}}.",
"community": "حملات المجتمع",
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
"browseAll": "تصفّح كل الحملات ←",
"pending": "بانتظار الموافقة",
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
"wlcDesc": "حملات منتقاة من World Liberty Congress.",
"allCampaigns": "كل الحملات",
"allCampaignsDesc": "كل الحملات على الشبكة، بالترتيب الزمني.",
"browseAll": "تصفّح كل الحملات",
"hidden": "مخفية",
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
"yourCampaigns": "حملاتك",
"yourCampaignsDesc": "حملاتك منشورة على Nostr وتعمل التبرعات عبر الرابط. ستظهر على الصفحة الرئيسية بمجرد أن يوافق عليها مشرف من فريق Soapbox.",
"yourCampaignsDesc": "حملاتك منشورة على Nostr وتعمل التبرعات عبر رابط الحملة. تصفّح كل الحملات على /campaigns؛ ويختار فريق {{appName}} مجموعة منتقاة لعرضها على الصفحة الرئيسية.",
"empty": "لا توجد حملات بعد",
"emptyHint": "كن أول من يبدأ حملة تمويل على {{appName}}. اروِ قصتك، اختر المستفيدين، وشارك الرابط."
"emptyHint": "كن أول من يبدأ حملة تمويل على {{appName}}. اروِ قصتك، اختر المستفيدين، وشارك الرابط.",
"whyDifferent": {
"eyebrow": "لماذا {{appName}}",
"title": "مبني بشكل مختلف.",
"lede": "بيتكوين مباشرة من المتبرع إلى الناشط. لا منصة في المنتصف، لا حافظ يحمل الكيس، لا إذن مطلوب.",
"block1": {
"heading": "بعكس GoFundMe",
"body": "لا يمكن لأي منصة تجميد تبرعاتك، أو طلب استرداد، أو إنهاء حملتك بسبب خلافات في السياسة. لا Stripe، ولا Visa، ولا بنك يقف في المنتصف ويستطيع قطعك في منتصف الحملة.",
"bullet1": "محصّن ضد التجميد — لا فيتو من المنصة",
"bullet2": "لا معالج مدفوعات يستطيع سحب القابس",
"bullet3": "صفر رسوم منصة"
},
"block2": {
"heading": "بعكس منصات ‘البتكوين’ الأخرى",
"body": "لا عقدة Lightning مركزية، ولا حافظ، ولا LSP قد يفشل أو ينقطع. تُسوّى الأموال مباشرة على Bitcoin إلى محفظة تتحكم فيها. لو اختفى {{appName}} غدًا، لاستمرت كل حملة في العمل.",
"bullet1": "لا محفظة وصائية يمكن استنزافها أو تجميدها",
"bullet2": "تُسوّى على السلسلة إلى محفظة تملكها",
"bullet3": "تعمل حتى لو اختفى {{appName}}"
},
"block3": {
"heading": "علني أو خاص. الخيار لك.",
"body": "يختار الناشطون خيار الاستلام الذي يناسب نموذج التهديد الخاص بهم. يرى المتبرعون رمز QR واحدًا؛ والمحفظة تختار البروتوكول المناسب.",
"publicLabel": "علني",
"publicSummary": "يعمل في كل محفظة Bitcoin. سريع وقابل للتحقق على السلسلة.",
"privateLabel": "خاص",
"privateSummary": "مدفوعات صامتة BIP-352. تصل التبرعات إلى مخرجات غير قابلة للربط."
},
"readMore": "اقرأ التفصيل الكامل"
}
},
"all": {
"title": "كل الحملات",
"title": "الحملات",
"seoTitle": "كل الحملات",
"description": "تصفّح كل الحملات المنشورة على Agora.",
"sectionTagline": "تصفّح كل قضيّة على الشبكة.",
"sectionTagline": "الحملات المميّزة أولاً، ثم بقية الشبكة. ابحث أو رتّب للتصفية.",
"heroKicker": "الحملات",
"heroHeading": "كل قضيّة،",
"heroHeadingLine2": "في مكان واحد.",
@@ -728,6 +808,54 @@
"allHiddenHint": "تم إخفاء كل الحملات على الشبكة من قِبَل المشرفين. فعّل «إظهار المخفية» لرؤيتها.",
"empty": "لا توجد حملات بعد",
"emptyHint": "لم تُنشَر أي حملة بعد. كن الأول."
},
"lists": {
"stripAria": "قوائم مواضيع منتقاة للحملات",
"create": "قائمة جديدة",
"createDesc": "أنشئ قائمة مواضيع جديدة. ثم انتقِ إليها حملات من أي صفحة حملة.",
"createSubmit": "إنشاء القائمة",
"createFailed": "فشل إنشاء القائمة",
"edit": "تعديل القائمة",
"editDesc": "حدّث عنوان القائمة أو وصفها أو أيقونتها.",
"editSubmit": "حفظ التغييرات",
"updateFailed": "فشل تحديث القائمة",
"delete": "حذف القائمة",
"deleteFailed": "فشل حذف القائمة",
"deleteConfirmTitle": "حذف هذه القائمة؟",
"deleteConfirmDesc": "ستُزال «{{title}}» من شريط المواضيع. لن تتأثر الحملات نفسها.",
"titleField": "العنوان",
"titlePlaceholder": "مثلاً: حرية الصحافة",
"descriptionField": "الوصف",
"descriptionPlaceholder": "وصف قصير يوضّح ما الذي ينتمي إلى هذه القائمة.",
"iconField": "الأيقونة",
"menuAria": "خيارات قائمة {{title}}",
"listActions": "إجراءات القائمة",
"memberMenuAria": "خيارات قائمة الحملة",
"backToCampaigns": "العودة إلى الحملات",
"detailTitle": "قائمة حملات",
"campaignsCount_one": "{{count}} حملة",
"campaignsCount_other": "{{count}} حملة",
"addCampaign": "إضافة حملة",
"addCampaignDesc": "ابحث في الشبكة واختر حملة لإضافتها إلى هذه القائمة.",
"addFailed": "فشل الإضافة إلى القائمة",
"addToList": "إضافة",
"alreadyAdded": "مُضافة",
"added": "مُضافة",
"membershipTitle": "إضافة إلى القوائم",
"membershipDesc": "اختر القوائم التي يجب أن تظهر فيها \"{{title}}\".",
"membershipEmpty": "لا توجد قوائم بعد. أنشئ قائمة لبدء الانتقاء.",
"searchPlaceholder": "ابحث في الحملات…",
"searchEmpty": "لا توجد حملات تطابق هذا البحث.",
"removeFromList": "إزالة من القائمة",
"removeFailed": "فشل الإزالة من القائمة",
"empty": "هذه القائمة فارغة.",
"emptyMod": "هذه القائمة فارغة. أضِف حملات للبدء بانتقائها.",
"iconPicker": {
"title": "اختر أيقونة",
"description": "اختر أي أيقونة من مكتبة Lucide.",
"search": "ابحث في الأيقونات…",
"empty": "لا توجد أيقونات تطابق هذا البحث."
}
}
},
"moderation": {
@@ -737,21 +865,27 @@
"ariaPledge": "إدارة التعهد",
"ariaGroup": "إدارة المجموعة",
"failedAction": "فشل {{action}}",
"approve": "اعتماد",
"unapprove": "إلغاء الاعتماد",
"approvedState": "معتمدة",
"failedReorder": "فشل إعادة الترتيب",
"hide": "إخفاء",
"unhide": "إلغاء الإخفاء",
"hiddenState": "مخفية",
"feature": "تمييز",
"unfeature": "إلغاء التمييز",
"featuredState": "مميّزة",
"toastApproved": "تم الاعتماد للصفحة الرئيسية",
"toastUnapproved": "أُزيلت من الصفحة الرئيسية",
"moveToTop": "نقل إلى الأعلى",
"moveUp": "تحريك للأعلى",
"moveDown": "تحريك للأسفل",
"addToList": "إضافة إلى قائمة…",
"dragHandle": "اسحب لإعادة الترتيب (الموضع {{index}})",
"toastHidden": "تم الإخفاء",
"toastUnhidden": "تم إلغاء الإخفاء",
"toastFeatured": "تم التمييز",
"toastUnfeatured": "أُزيلت من المميزة"
"toastUnfeatured": "أُزيلت من المميزة",
"toast": {
"movedToTop": "تم النقل إلى الأعلى",
"movedUp": "تم التحريك للأعلى",
"movedDown": "تم التحريك للأسفل"
}
}
},
"settings": {
@@ -1107,13 +1241,25 @@
"bitcoinAddress": "عنوان بيتكوين",
"silentPayment": "عنوان دفع صامت",
"toLabel": "إلى",
"clear": "مسح المستلم"
"clear": "مسح المستلم",
"choosePaymentMethod": "اختر طريقة الدفع للمتابعة"
},
"feeSpeed": {
"fastest": "~10 دقائق",
"halfHour": "~30 دقيقة",
"hour": "~ساعة",
"economy": "~يوم"
"economy": "~يوم",
"custom": "مخصّص"
},
"fee": {
"loading": "جارٍ التحميل…",
"unavailable": "غير متاح",
"loadFailed": "تعذّر تحميل معدلات الرسوم.",
"retry": "إعادة المحاولة",
"orCustom": "أو أدخل معدلاً مخصّصاً أدناه.",
"loadingTiers": "جارٍ تحميل معدلات الرسوم…",
"customPlaceholder": "مثال: 5",
"customAriaLabel": "معدل رسوم مخصّص بوحدة sat/vB"
},
"progress": {
"building": "جارٍ بناء المعاملة…",
@@ -1129,11 +1275,36 @@
"enterAmount": "أدخل مبلغاً.",
"insufficient": "البيتكوين غير كافٍ لهذا المبلغ + رسوم الشبكة.",
"waitingPrice": "في انتظار سعر BTC…",
"noneYet": "ليس لديك بيتكوين بعد."
"noneYet": "ليس لديك بيتكوين بعد.",
"feesNotLoadedYet": "لم يتم تحميل معدلات الرسوم بعد.",
"feeRateTooLow": "أدخل معدل رسوم لا يقل عن 1 sat/vB."
},
"toast": {
"failedTitle": "فشلت المعاملة"
},
"broadcastError": {
"feeTooLowTitle": "رسوم الشبكة منخفضة جداً",
"feeTooLowBodyWithMin": "شبكة البيتكوين ترفض هذه الرسوم. الحد الأدنى حالياً نحو {{min}} sat/vB.",
"feeTooLowBody": "شبكة البيتكوين ترفض هذه الرسوم. اختر مستوى أسرع أو ارفع معدّلك المخصّص.",
"rbfTitle": "الاستبدال يتطلب رسوماً أعلى",
"rbfBody": "يجب أن تدفع معاملة الاستبدال أكثر من الأصلية. ارفع الرسوم وحاول مجدداً.",
"mempoolFullTitle": "شبكة البيتكوين مزدحمة",
"mempoolFullBody": "الـ mempool ممتلئ ورسومك غير تنافسية. ارفع الرسوم للمرور.",
"networkTitle": "تعذّر الوصول إلى شبكة البيتكوين",
"networkBody": "تحقق من اتصالك وحاول مجدداً.",
"mempoolConflictTitle": "معاملة متعارضة",
"mempoolConflictBody": "أحد المدخلات تم إنفاقه فعلاً أو يجري إنفاقه في معاملة أخرى.",
"tooLongChainTitle": "معاملات غير مؤكدة كثيرة جداً",
"tooLongChainBody": "لديك سلسلة طويلة من المعاملات غير المؤكدة. انتظر تأكيد إحداها وحاول مجدداً.",
"badInputsTitle": "تم رفض المعاملة",
"badInputsBody": "رفضت الشبكة هذه المعاملة. عدّل المبلغ أو المستلم وحاول مجدداً.",
"absurdlyHighFeeTitle": "الرسوم مرتفعة بشكل غير معتاد",
"absurdlyHighFeeBody": "الرسوم المقدّرة مرتفعة بشكل مريب. أعد تحميل معدلات الرسوم وحاول مجدداً.",
"unknownTitle": "فشلت المعاملة",
"useHigherFee": "استخدم رسوماً أعلى",
"tryAgain": "إعادة المحاولة",
"atMaxFeeTier": "أنت بالفعل على المستوى الأسرع."
},
"scanError": {
"title": "تعذّر قراءة رمز QR هذا",
"description": "يُتوقّع عنوان بيتكوين، أو عنوان دفع صامت (sp1…)، أو رابط bitcoin:."
+234 -47
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "Make it yours",
"subtitle": "Tell others a bit about yourself. All optional, change it any time.",
"campaignTitle": "Put a face to your campaign",
"campaignSubtitle": "A name and photo help people connect with your campaign.",
"nameLabel": "Display name",
"namePlaceholder": "Your name",
"aboutLabel": "Bio",
"aboutPlaceholder": "A little about you…",
"avatarLabel": "Avatar",
"uploadAvatar": "Upload avatar",
"advanced": "More",
"finish": "Finish",
"saving": "Saving…",
"skip": "Skip for now",
@@ -610,6 +613,9 @@
"publishing": "Publishing…",
"uploadingCover": "Uploading cover…",
"countrySearchPlaceholder": "Search countries",
"countryClearAria": "Clear country",
"flagOfAria": "Flag of {{name}}",
"countryHint": "Publishes <0>i: iso3166:{{code}}</0> for country sorting.",
"imageDropzone": "Click or drag an image here"
},
"organizationContext": {
@@ -644,8 +650,8 @@
"myPledgesTagline": "Pledges you've created.",
"featuredPledges": "Featured pledges",
"featuredPledgesTagline": "Pledges highlighted by the {{appName}} team.",
"allPledges": "All pledges",
"allPledgesTagline": "Browse every pledge on the network.",
"allPledges": "Pledges",
"allPledgesTagline": "Highlighted by moderators. Search or sort to browse every pledge.",
"sectionActive": "Active pledges",
"sectionUpcoming": "Upcoming pledges",
"sectionPast": "Past pledges",
@@ -703,11 +709,7 @@
"titlePlaceholder": "Document a beach cleanup",
"country": "Country",
"countryPlaceholder": "Search countries",
"countryClearAria": "Clear country",
"flagOfAria": "Flag of {{name}}",
"countryHint": "Publishes <0>i: iso3166:{{code}}</0> for country sorting.",
"tags": "Tags",
"tagsPlaceholder": "beach-cleanup, protest-documentation, internet-blackout",
"coverImage": "Cover image",
"description": "Description",
"descriptionPlaceholder": "Explain the action, evidence, or outcome you want to inspire, what submissions should include, and how you plan to evaluate them...",
@@ -717,8 +719,6 @@
"timezone": "Timezone",
"timezoneNote": "Start and deadline times will be interpreted in this timezone.",
"submit": "Create pledge",
"publishing": "Publishing…",
"uploadingCover": "Uploading cover…",
"altText": "{{appName}} pledge: {{title}}",
"successToast": "Pledge created",
"errorToast": "Could not create pledge",
@@ -729,7 +729,18 @@
"errorPledgeInvalid": "Pledge amount must be a positive USD amount.",
"errorPriceUnavailable": "Waiting for BTC/USD price to calculate the pledge amount.",
"errorCoverInvalid": "Cover image must be a valid https:// URL.",
"errorDeadlinePast": "Deadline cannot be in the past."
"errorDeadlinePast": "Deadline cannot be in the past.",
"wizard": {
"titleStepTitle": "Name your pledge",
"titleStepSubtitle": "A clear ask and a short explanation of what you'll fund.",
"pledgeStepTitle": "Set your pledge",
"pledgeStepSubtitle": "How much you'll pay, in USD, and an optional deadline.",
"coverStepTitle": "Add a cover image",
"coverStepSubtitle": "One image carries the pledge on every card.",
"tagsStepTitle": "Country and categories",
"tagsStepSubtitle": "Help the right people find your pledge.",
"launchNow": "Skip Next & Launch"
}
},
"detail": {
"seoTitle": "{{title}} | {{appName}} Pledge",
@@ -779,8 +790,8 @@
"myGroupsTagline": "Groups you've founded, moderate, or follow.",
"featuredGroups": "Featured groups",
"featuredGroupsTagline": "Standout groups worth your attention.",
"allGroups": "All groups",
"allGroupsTagline": "Browse {{appName}} groups, or search across every group on Nostr.",
"allGroups": "Groups",
"allGroupsTagline": "Highlighted by moderators. Search or sort to browse every group.",
"searchPlaceholder": "Search groups\u2026",
"searchAriaLabel": "Search groups",
"noMatch": "No groups match \u201c{{query}}\u201d",
@@ -831,9 +842,6 @@
"descriptionPlaceholder": "What is this group about?",
"country": "Country",
"countryPlaceholder": "Search countries",
"countryClearAria": "Clear country",
"flagOfAria": "Flag of {{name}}",
"countryHint": "Publishes <0>i: iso3166:{{code}}</0> for country sorting.",
"tags": "Tags",
"tagsPlaceholder": "mutual-aid, local-news, digital-rights",
"coverImage": "Cover image",
@@ -857,7 +865,18 @@
"errorNameInvalid": "Name must include letters or numbers so a group URL can be created.",
"errorEditLatestMissing": "Could not find the latest version of this group to update.",
"errorCoverInvalid": "Cover image must be a valid https:// URL.",
"errorSlugCollision": "You already have a group with the identifier \"{{slug}}\". Choose another name."
"errorSlugCollision": "You already have a group with the identifier \"{{slug}}\". Choose another name.",
"wizard": {
"nameStepTitle": "Name your group",
"nameStepSubtitle": "A short, clear name members will recognize.",
"coverStepTitle": "Add a cover image",
"coverStepSubtitle": "One image carries the group on every card.",
"moderatorsStepTitle": "Invite moderators",
"moderatorsStepSubtitle": "Optional — they can approve content and remove members alongside you.",
"tagsStepTitle": "Country and categories",
"tagsStepSubtitle": "Help the right people find your group.",
"launchNow": "Skip Next & Launch"
}
},
"detail": {
"by": "by",
@@ -916,10 +935,20 @@
"myWalletLabel": "{{name}}'s wallet",
"myWalletDefault": "My wallet",
"walletChoose": "Choose a wallet",
"walletCustom": "Custom",
"walletCustom": "Custom wallet",
"walletUseCustom": "Use another wallet instead",
"walletDestinationLanding": "Donations will land here",
"walletDestinationNote": "This wallet will be published as the donation destination for your campaign.",
"walletUseMine": "Use my Agora wallet",
"acceptAll": "Accept all payment types",
"acceptPublic": "Accept public payments only",
"acceptPrivate": "Accept private payments only",
"acceptAllShort": "Accept All",
"acceptPublicShort": "Public Only",
"acceptPrivateShort": "Private Only",
"acceptAllHint": "Accept both public on-chain and private silent payments.",
"acceptPublicHint": "Only accept on-chain donations to a public address.",
"acceptPrivateHint": "Only accept silent payments — donor addresses stay private.",
"customWalletIntro": "Enter a Bitcoin address, a silent-payment code, or both. At least one is required.",
"bitcoinAddress": "Bitcoin address",
"bitcoinAddressPlaceholder": "bc1q… or bc1p…",
@@ -928,12 +957,27 @@
"onchainInvalid": "Not a recognized mainnet Bitcoin address (bc1q… / bc1p…).",
"spInvalid": "Not a recognized BIP-352 silent-payment code (sp1…).",
"country": "Country",
"countryPlaceholder": "Search countries",
"countryClearAria": "Clear country",
"flagOfAria": "Flag of {{name}}",
"countryHint": "Publishes <0>i: iso3166:{{code}}</0> for country sorting.",
"tags": "Tags",
"countryPlaceholder": "Search countries",
"tags": "Tags",
"tagsPlaceholder": "legal-defense, mutual-aid, local-news",
"categories": {
"humanRights": "Human Rights",
"democracy": "Democracy",
"pressFreedom": "Press Freedom",
"politicalPrisoners": "Political Prisoners",
"humanitarianAid": "Humanitarian Aid",
"civilResistance": "Civil Resistance",
"digitalRights": "Digital Rights",
"antiCorruption": "Anti-Corruption",
"womenGirls": "Women & Girls",
"refugees": "Refugees & Exiles",
"legalAid": "Legal Aid",
"emergencyRelief": "Emergency Relief",
"animalRights": "Animal Rights",
"education": "Education",
"medical": "Medical",
"community": "Community"
},
"banner": "Banner image",
"story": "Story",
"storyPlaceholder": "Share the background, who benefits, and how funds will be used.",
@@ -973,7 +1017,25 @@
"errorHdDeriveFailed": "Could not derive a fresh on-chain address from your wallet.",
"errorHdDeriveInvalid": "Derived wallet address failed validation. Please add a custom address instead.",
"errorWalletRequiredFallback": "Wallet endpoint is required.",
"errorPublishedInvalid": "Published event failed validation. Please refresh and try again."
"errorPublishedInvalid": "Published event failed validation. Please refresh and try again.",
"wizard": {
"titleStepTitle": "Name your campaign",
"titleStepSubtitle": "A short, clear name donors will recognize.",
"walletStepTitle": "Choose who receives donations",
"walletStepSubtitle": "Your Agora wallet is ready to receive Bitcoin donations for this campaign.",
"bannerStepTitle": "Add a banner",
"bannerStepSubtitle": "One striking image carries the campaign on every card.",
"storyStepTitle": "Tell your story",
"storyStepSubtitle": "Who benefits and how the funds will be used.",
"goalStepTitle": "Goal and deadline",
"goalStepSubtitle": "Both optional — leave blank for an open-ended campaign.",
"tagsStepTitle": "Country and categories",
"tagsStepSubtitle": "Help the right people find your campaign.",
"next": "Next",
"back": "Back",
"skip": "Skip",
"launchNow": "Skip Next & Launch"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} Fundraisers",
@@ -1123,31 +1185,55 @@
"startCampaign": "Start a campaign",
"howItWorks": "How it works",
"exploreCampaigns": "Explore campaigns",
"featured": "Featured",
"featuredDesc": "Hand-picked campaigns from the {{appName}} team.",
"community": "Community Campaigns",
"communityDesc": "Help fund the changes worth making.",
"browseAll": "Browse all campaigns →",
"wlcDesc": "Campaigns curated by World Liberty Congress.",
"allCampaigns": "All campaigns",
"allCampaignsDesc": "Every campaign on the network, in chronological order.",
"browseAll": "Browse all campaigns",
"searchPlaceholder": "Search campaigns\u2026",
"searchAriaLabel": "Search campaigns",
"noMatch": "No campaigns match \u201c{{query}}\u201d",
"noMatchHint": "Try a different search term, or clear the search.",
"pending": "Pending approval",
"pendingDesc": "Campaigns on the network that no Team Soapbox moderator has approved or hidden yet.",
"pendingEmpty": "Nothing awaiting review.",
"hidden": "Hidden",
"hiddenDesc": "Campaigns suppressed from the public homepage. Use the kebab menu on a card to unhide.",
"hiddenDesc": "Campaigns suppressed from public discovery. Use the kebab menu on a card to unhide.",
"hiddenEmpty": "No campaigns are currently hidden.",
"yourCampaigns": "Your campaigns",
"yourCampaignsDesc": "Your campaigns are live on Nostr and donations work via the campaign link. They appear on the homepage once a Team Soapbox moderator approves them.",
"yourCampaignsDesc": "Your campaigns are live on Nostr and donations work via the campaign link. Browse all campaigns at /campaigns; the {{appName}} team features a curated selection on the homepage.",
"empty": "No campaigns yet",
"emptyHint": "Be the first to start a fundraiser on {{appName}}. Tell your story, choose your beneficiaries, and share the link."
"emptyHint": "Be the first to start a fundraiser on {{appName}}. Tell your story, choose your beneficiaries, and share the link.",
"whyDifferent": {
"eyebrow": "Why {{appName}}",
"title": "Built different.",
"lede": "Direct Bitcoin from donor to activist. No platform in the way, no custodian holding the bag, no permission required.",
"block1": {
"heading": "Unlike GoFundMe",
"body": "No platform can freeze your donations, demand refunds, or terminate your campaign over policy disagreements. No Stripe, no Visa, no bank sits in the middle and can cut you off mid-campaign.",
"bullet1": "Freeze-proof \u2014 no platform veto",
"bullet2": "No payment processor can pull the plug",
"bullet3": "Zero platform fees"
},
"block2": {
"heading": "Unlike other \u2018Bitcoin\u2019 platforms",
"body": "No central Lightning node, custodian, or LSP to fail or go offline. Funds settle directly on Bitcoin to a wallet you control. If {{appName}} disappeared tomorrow, every campaign would keep working.",
"bullet1": "No custodial wallet to drain or freeze",
"bullet2": "Settles on-chain to a wallet you own",
"bullet3": "Works even if {{appName}} vanishes"
},
"block3": {
"heading": "Public or private. Your choice.",
"body": "Activists pick the receiving option that matches their threat model. Donors see a single QR; the wallet picks the right protocol.",
"publicLabel": "Public",
"publicSummary": "Works in every Bitcoin wallet. Fast and verifiable on-chain.",
"privateLabel": "Private",
"privateSummary": "BIP-352 silent payments. Donations land at unlinkable outputs."
},
"readMore": "Read the full breakdown"
}
},
"all": {
"title": "All Campaigns",
"title": "Campaigns",
"seoTitle": "All campaigns",
"description": "Browse every campaign published on Agora.",
"sectionTagline": "Browse every cause on the network.",
"sectionTagline": "Featured campaigns first, then the rest of the network. Search or sort to refine.",
"heroKicker": "Campaigns",
"heroHeading": "Every cause,",
"heroHeadingLine2": "in one place.",
@@ -1168,6 +1254,54 @@
"allHiddenHint": "Every campaign on the network has been hidden by moderators. Toggle \u201cShow hidden\u201d to view them.",
"empty": "No campaigns yet",
"emptyHint": "No campaigns have been published yet. Be the first."
},
"lists": {
"stripAria": "Curated campaign topic lists",
"create": "New list",
"createDesc": "Create a new topic list. Curate campaigns into it from any campaign page.",
"createSubmit": "Create list",
"createFailed": "Failed to create list",
"edit": "Edit list",
"editDesc": "Update the list's title, description, or icon.",
"editSubmit": "Save changes",
"updateFailed": "Failed to update list",
"delete": "Delete list",
"deleteFailed": "Failed to delete list",
"deleteConfirmTitle": "Delete this list?",
"deleteConfirmDesc": "\"{{title}}\" will be removed from the topic strip. The campaigns themselves are not affected.",
"titleField": "Title",
"titlePlaceholder": "e.g. Press Freedom",
"descriptionField": "Description",
"descriptionPlaceholder": "A short blurb explaining what belongs in this list.",
"iconField": "Icon",
"menuAria": "{{title}} list options",
"listActions": "List actions",
"memberMenuAria": "Campaign list options",
"backToCampaigns": "Back to campaigns",
"detailTitle": "Campaign list",
"campaignsCount_one": "{{count}} campaign",
"campaignsCount_other": "{{count}} campaigns",
"addCampaign": "Add campaign",
"addCampaignDesc": "Search the network and pick a campaign to add to this list.",
"addFailed": "Failed to add to list",
"addToList": "Add",
"added": "Added",
"alreadyAdded": "Added",
"membershipTitle": "Add to lists",
"membershipDesc": "Choose which lists \"{{title}}\" should appear in.",
"membershipEmpty": "No lists yet. Create one to start curating.",
"searchPlaceholder": "Search campaigns…",
"searchEmpty": "No campaigns match this search.",
"removeFromList": "Remove from list",
"removeFailed": "Failed to remove from list",
"empty": "This list is empty.",
"emptyMod": "This list is empty. Add campaigns to start curating it.",
"iconPicker": {
"title": "Choose an icon",
"description": "Pick any icon from the Lucide library.",
"search": "Search icons…",
"empty": "No icons match this search."
}
}
},
"moderation": {
@@ -1178,21 +1312,27 @@
"ariaPledge": "Moderate pledge",
"ariaGroup": "Moderate group",
"failedAction": "Failed to {{action}}",
"approve": "Approve",
"unapprove": "Unapprove",
"approvedState": "Approved",
"failedReorder": "Failed to reorder",
"hide": "Hide",
"unhide": "Unhide",
"hiddenState": "Hidden",
"feature": "Feature",
"unfeature": "Unfeature",
"featuredState": "Featured",
"toastApproved": "Approved for homepage",
"toastUnapproved": "Removed from homepage",
"moveToTop": "Move to top",
"moveUp": "Move up",
"moveDown": "Move down",
"addToList": "Add to list…",
"dragHandle": "Drag to reorder (position {{index}})",
"toastHidden": "Hidden",
"toastUnhidden": "Unhidden",
"toastFeatured": "Featured",
"toastUnfeatured": "Removed from featured"
"toastUnfeatured": "Removed from featured",
"toast": {
"movedToTop": "Moved to top",
"movedUp": "Moved up",
"movedDown": "Moved down"
}
}
},
"settings": {
@@ -1516,17 +1656,27 @@
},
"scan": {
"title": "Scan for stranded payments",
"description": "Choose the block height to start scanning from. Recovery checks every block from there to the chain tip.",
"fromHeightLabel": "Start block height",
"tipHint": "Current chain tip: {{tip}}",
"description": "Choose how far back to scan. Recovery checks the blockchain from the selected time window to the present.",
"since": "Since",
"overrideActive": "Using the From block override below. Clear it to use a time window instead.",
"advanced": "Advanced",
"fromBlock": "From block",
"connectingIndexer": "Connecting to indexer…",
"tipHint": "Indexer tip: {{tip}}",
"recoveryWindowHint": "Default covers the known affected recovery window.",
"upToDate": "From block is past the chain tip — nothing to scan.",
"start": "Scan",
"cancel": "Cancel scan",
"progress": "Scanning block {{current}} of {{to}} — {{found}} found",
"tipMissing": "Resolving chain tip…"
},
"resolveFailed": {
"title": "Couldn't look up the start block",
"description": "mempool.space is unreachable right now. Enter a starting block under Advanced → From block to scan anyway."
},
"noFunds": {
"title": "Nothing to recover",
"description": "No stranded silent payments were found in the scanned range. Try an earlier start height if you expected funds."
"description": "No stranded silent payments were found in the scanned range. Try a longer time window if you expected funds."
},
"found": {
"title": "Stranded payments found",
@@ -1615,13 +1765,25 @@
"bitcoinAddress": "Bitcoin address",
"silentPayment": "Silent payment address",
"toLabel": "To",
"clear": "Clear recipient"
"clear": "Clear recipient",
"choosePaymentMethod": "Choose a payment method to continue"
},
"feeSpeed": {
"fastest": "~10 min",
"halfHour": "~30 min",
"hour": "~1 hour",
"economy": "~1 day"
"economy": "~1 day",
"custom": "Custom"
},
"fee": {
"loading": "loading…",
"unavailable": "unavailable",
"loadFailed": "Couldn't load fee rates.",
"retry": "Retry",
"orCustom": "Or enter a custom rate below.",
"loadingTiers": "Loading fee rates…",
"customPlaceholder": "e.g. 5",
"customAriaLabel": "Custom fee rate in sat/vB"
},
"progress": {
"building": "Building transaction…",
@@ -1634,6 +1796,8 @@
"enterRecipient": "Enter a Bitcoin address or sp1… silent payment address.",
"noSpendable": "No spendable Bitcoin in this wallet.",
"feesNotLoaded": "Fee rates not loaded.",
"feesNotLoadedYet": "Fee rates haven't loaded yet.",
"feeRateTooLow": "Enter a fee rate of at least 1 sat/vB.",
"enterAmount": "Enter an amount.",
"insufficient": "Not enough Bitcoin for this amount + network fee.",
"waitingPrice": "Waiting for BTC price…",
@@ -1646,6 +1810,29 @@
"toast": {
"failedTitle": "Transaction failed"
},
"broadcastError": {
"feeTooLowTitle": "Network fee too low",
"feeTooLowBodyWithMin": "The Bitcoin network is rejecting this fee. The minimum right now is about {{min}} sat/vB.",
"feeTooLowBody": "The Bitcoin network is rejecting this fee. Pick a faster tier or raise your custom rate.",
"rbfTitle": "Replacement needs a higher fee",
"rbfBody": "The replacement transaction must pay more than the original. Raise the fee and try again.",
"mempoolFullTitle": "Bitcoin network is congested",
"mempoolFullBody": "The mempool is full and your fee isn't competitive. Raise the fee to get through.",
"networkTitle": "Couldn't reach the Bitcoin network",
"networkBody": "Check your connection and try again.",
"mempoolConflictTitle": "Conflicting transaction",
"mempoolConflictBody": "One of the inputs has already been spent or is being spent by another transaction.",
"tooLongChainTitle": "Too many unconfirmed transactions",
"tooLongChainBody": "You have a long chain of unconfirmed transactions. Wait for one to confirm and try again.",
"badInputsTitle": "Transaction was rejected",
"badInputsBody": "The network rejected this transaction. Adjust the amount or recipient and try again.",
"absurdlyHighFeeTitle": "Fee is unusually high",
"absurdlyHighFeeBody": "The estimated fee is suspiciously high. Reload fee rates and try again.",
"unknownTitle": "Transaction failed",
"useHigherFee": "Use a higher fee",
"tryAgain": "Try again",
"atMaxFeeTier": "You're already on the fastest tier."
},
"success": {
"title": "Bitcoin sent",
"satsAmount": "{{sats}} sats",
+213 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "Hazlo tuyo",
"subtitle": "Cuéntales a los demás un poco sobre ti. Todo es opcional, puedes cambiarlo en cualquier momento.",
"campaignTitle": "Ponle cara a tu campaña",
"campaignSubtitle": "Un nombre y una foto ayudan a que la gente conecte con tu campaña.",
"nameLabel": "Nombre visible",
"namePlaceholder": "Tu nombre",
"aboutLabel": "Biografía",
"aboutPlaceholder": "Un poco sobre ti…",
"avatarLabel": "Avatar",
"uploadAvatar": "Subir avatar",
"advanced": "Más",
"finish": "Finalizar",
"saving": "Guardando…",
"skip": "Omitir por ahora",
@@ -183,10 +186,11 @@
"coverImage": "Imagen de portada",
"description": "Descripción",
"timezone": "Zona horaria",
"publishing": "Publicando…",
"uploadingCover": "Subiendo portada…",
"countrySearchPlaceholder": "Buscar países",
"imageDropzone": "Haz clic o arrastra una imagen aquí"
"imageDropzone": "Haz clic o arrastra una imagen aquí",
"countryClearAria": "Borrar país",
"flagOfAria": "Bandera de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para el orden por país."
},
"organizationContext": {
"attachedToGroup": "Adjunto al grupo",
@@ -220,8 +224,8 @@
"myPledgesTagline": "Promesas que has creado.",
"featuredPledges": "Promesas destacadas",
"featuredPledgesTagline": "Promesas destacadas por el equipo de {{appName}}.",
"allPledges": "Todas las promesas",
"allPledgesTagline": "Explora todas las promesas de la red.",
"allPledges": "Promesas",
"allPledgesTagline": "Destacadas por los moderadores. Busca u ordena para explorar todas las promesas.",
"sectionActive": "Promesas activas",
"sectionUpcoming": "Promesas próximas",
"sectionPast": "Promesas pasadas",
@@ -279,11 +283,7 @@
"titlePlaceholder": "Documentar una limpieza de playa",
"country": "País",
"countryPlaceholder": "Buscar países",
"countryClearAria": "Borrar país",
"flagOfAria": "Bandera de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para el orden por país.",
"tags": "Etiquetas",
"tagsPlaceholder": "limpieza-playa, documentación-protesta, apagón-internet",
"coverImage": "Imagen de portada",
"description": "Descripción",
"descriptionPlaceholder": "Explica la acción, las pruebas o el resultado que quieres inspirar, qué deben incluir las propuestas y cómo planeas evaluarlas...",
@@ -293,8 +293,6 @@
"timezone": "Zona horaria",
"timezoneNote": "Las horas de inicio y plazo se interpretarán en esta zona horaria.",
"submit": "Crear promesa",
"publishing": "Publicando…",
"uploadingCover": "Subiendo portada…",
"altText": "Promesa en {{appName}}: {{title}}",
"successToast": "Promesa creada",
"errorToast": "No se pudo crear la promesa",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "La cantidad prometida debe ser un valor positivo en USD.",
"errorPriceUnavailable": "Esperando el precio BTC/USD para calcular la cantidad prometida.",
"errorCoverInvalid": "La imagen de portada debe ser una URL https:// válida.",
"errorDeadlinePast": "El plazo no puede estar en el pasado."
"errorDeadlinePast": "El plazo no puede estar en el pasado.",
"wizard": {
"titleStepTitle": "Nombra tu promesa",
"titleStepSubtitle": "Una petición clara y una breve explicación de lo que financiarás.",
"pledgeStepTitle": "Define tu promesa",
"pledgeStepSubtitle": "Cuánto pagarás, en USD, y un plazo opcional.",
"coverStepTitle": "Añade una imagen de portada",
"coverStepSubtitle": "Una imagen acompaña a la promesa en cada tarjeta.",
"tagsStepTitle": "País y categorías",
"tagsStepSubtitle": "Ayuda a que las personas adecuadas encuentren tu promesa.",
"launchNow": "Omitir y Publicar"
}
},
"detail": {
"seoTitle": "{{title}} | Promesa de {{appName}}",
@@ -355,8 +364,8 @@
"myGroupsTagline": "Grupos que fundaste, moderas o sigues.",
"featuredGroups": "Grupos destacados",
"featuredGroupsTagline": "Grupos destacados que merecen tu atención.",
"allGroups": "Todos los grupos",
"allGroupsTagline": "Explora los grupos de {{appName}} o busca entre todos los grupos de Nostr.",
"allGroups": "Grupos",
"allGroupsTagline": "Destacados por los moderadores. Busca u ordena para explorar todos los grupos.",
"loginToSeeTitle": "Inicia sesión para ver tus grupos",
"loginToSeeBody": "Los grupos que fundaste o moderas aparecerán aquí.",
"noGroupsTitle": "Aún no hay grupos",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "¿De qué trata este grupo?",
"country": "País",
"countryPlaceholder": "Buscar países",
"countryClearAria": "Borrar país",
"flagOfAria": "Bandera de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para el orden por país.",
"tags": "Etiquetas",
"tagsPlaceholder": "ayuda-mutua, noticias-locales, derechos-digitales",
"coverImage": "Imagen de portada",
@@ -433,7 +439,18 @@
"errorNameInvalid": "El nombre debe incluir letras o números para crear una URL del grupo.",
"errorEditLatestMissing": "No se pudo encontrar la última versión de este grupo para actualizarlo.",
"errorCoverInvalid": "La imagen de portada debe ser una URL https:// válida.",
"errorSlugCollision": "Ya tienes un grupo con el identificador «{{slug}}». Elige otro nombre."
"errorSlugCollision": "Ya tienes un grupo con el identificador «{{slug}}». Elige otro nombre.",
"wizard": {
"nameStepTitle": "Nombra tu grupo",
"nameStepSubtitle": "Un nombre corto y claro que los miembros reconocerán.",
"coverStepTitle": "Añade una imagen de portada",
"coverStepSubtitle": "Una imagen acompaña al grupo en cada tarjeta.",
"moderatorsStepTitle": "Invita moderadores",
"moderatorsStepSubtitle": "Opcional — pueden aprobar contenido y eliminar miembros junto contigo.",
"tagsStepTitle": "País y categorías",
"tagsStepSubtitle": "Ayuda a que las personas adecuadas encuentren tu grupo.",
"launchNow": "Omitir y Publicar"
}
},
"detail": {
"by": "por",
@@ -493,9 +510,19 @@
"myWalletDefault": "Mi cartera",
"walletChoose": "Elige una cartera",
"walletCustom": "Personalizada",
"walletUseCustom": "Usar otra cartera",
"walletDestinationLanding": "Las donaciones llegarán aquí",
"walletDestinationNote": "Esta cartera se publicará como el destino de las donaciones de tu campaña.",
"walletUseMine": "Usar mi cartera de Agora",
"acceptAll": "Aceptar todos los pagos",
"acceptPublic": "Aceptar solo pagos públicos",
"acceptPrivate": "Aceptar solo pagos privados",
"acceptAllShort": "Todos",
"acceptPublicShort": "Solo públicos",
"acceptPrivateShort": "Solo privados",
"acceptAllHint": "Acepta pagos públicos on-chain y pagos silenciosos privados.",
"acceptPublicHint": "Solo acepta donaciones on-chain a una dirección pública.",
"acceptPrivateHint": "Solo acepta pagos silenciosos — las direcciones de los donantes permanecen privadas.",
"customWalletIntro": "Ingresa una dirección de Bitcoin, un código de pago silencioso o ambos. Se requiere al menos uno.",
"bitcoinAddress": "Dirección de Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… o bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "No es un código de pago silencioso BIP-352 reconocido (sp1…).",
"country": "País",
"countryPlaceholder": "Buscar países",
"countryClearAria": "Borrar país",
"flagOfAria": "Bandera de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para el orden por país.",
"tags": "Etiquetas",
"tagsPlaceholder": "defensa-legal, ayuda-mutua, noticias-locales",
"categories": {
"humanRights": "Derechos humanos",
"democracy": "Democracia",
"pressFreedom": "Libertad de prensa",
"politicalPrisoners": "Presos políticos",
"humanitarianAid": "Ayuda humanitaria",
"civilResistance": "Resistencia civil",
"digitalRights": "Derechos digitales",
"antiCorruption": "Lucha anticorrupción",
"womenGirls": "Mujeres y niñas",
"refugees": "Refugiados y exiliados",
"legalAid": "Asistencia legal",
"emergencyRelief": "Ayuda de emergencia",
"animalRights": "Derechos de los animales",
"education": "Educación",
"medical": "Salud",
"community": "Comunidad"
},
"banner": "Imagen de portada",
"story": "Historia",
"storyPlaceholder": "Comparte el contexto, a quién beneficia y cómo se usarán los fondos.",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "No se pudo derivar una dirección on-chain nueva desde tu cartera.",
"errorHdDeriveInvalid": "La dirección derivada no pasó la validación. Por favor agrega una dirección personalizada.",
"errorWalletRequiredFallback": "Se requiere un punto de cartera.",
"errorPublishedInvalid": "El evento publicado falló la validación. Recarga e inténtalo de nuevo."
"errorPublishedInvalid": "El evento publicado falló la validación. Recarga e inténtalo de nuevo.",
"wizard": {
"titleStepTitle": "Nombra tu campaña",
"titleStepSubtitle": "Un nombre corto y claro que los donantes reconocerán.",
"walletStepTitle": "Elige quién recibe las donaciones",
"walletStepSubtitle": "Tu cartera de Agora está lista para recibir donaciones en Bitcoin para esta campaña.",
"bannerStepTitle": "Añade una portada",
"bannerStepSubtitle": "Una imagen impactante acompaña a la campaña en cada tarjeta.",
"storyStepTitle": "Cuenta tu historia",
"storyStepSubtitle": "Quién se beneficia y cómo se usarán los fondos.",
"next": "Siguiente",
"back": "Atrás",
"skip": "Omitir",
"launchNow": "Omitir y Publicar"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Recaudaciones de {{appName}}",
@@ -699,31 +755,55 @@
"startCampaign": "Iniciar una campaña",
"howItWorks": "Cómo funciona",
"exploreCampaigns": "Explorar campañas",
"featured": "Destacadas",
"featuredDesc": "Campañas seleccionadas por el equipo de {{appName}}.",
"community": "Campañas de la comunidad",
"communityDesc": "Ayuda a financiar los cambios que valen la pena.",
"browseAll": "Ver todas las campañas →",
"pending": "Pendientes de aprobación",
"pendingDesc": "Campañas presentes en la red que ningún moderador del equipo Soapbox ha aprobado u ocultado todavía.",
"pendingEmpty": "Nada pendiente de revisión.",
"wlcDesc": "Campañas curadas por el World Liberty Congress.",
"allCampaigns": "Todas las campañas",
"allCampaignsDesc": "Todas las campañas de la red, en orden cronológico.",
"browseAll": "Ver todas las campañas",
"hidden": "Ocultas",
"hiddenDesc": "Campañas suprimidas de la página de inicio pública. Usa el menú de la tarjeta para mostrarlas de nuevo.",
"hiddenEmpty": "No hay campañas ocultas actualmente.",
"yourCampaigns": "Tus campañas",
"yourCampaignsDesc": "Tus campañas están activas en Nostr y las donaciones funcionan mediante el enlace. Aparecerán en la página de inicio cuando un moderador del equipo Soapbox las apruebe.",
"yourCampaignsDesc": "Tus campañas están activas en Nostr y las donaciones funcionan mediante el enlace de la campaña. Explora todas las campañas en /campaigns; el equipo de {{appName}} destaca una selección curada en la página de inicio.",
"empty": "Aún no hay campañas",
"emptyHint": "Sé el primero en iniciar una recaudación en {{appName}}. Cuenta tu historia, elige a los beneficiarios y comparte el enlace.",
"whyDifferent": {
"eyebrow": "Por qué {{appName}}",
"title": "Construido diferente.",
"lede": "Bitcoin directo del donante al activista. Ninguna plataforma en medio, ningún custodio cargando con la bolsa, ningún permiso requerido.",
"block1": {
"heading": "A diferencia de GoFundMe",
"body": "Ninguna plataforma puede congelar tus donaciones, exigir reembolsos o cerrar tu campaña por desacuerdos sobre políticas. Ni Stripe, ni Visa, ni un banco se interponen en medio y pueden cortarte a mitad de campaña.",
"bullet1": "A prueba de congelaciones — sin veto de plataforma",
"bullet2": "Ningún procesador de pagos puede tirar del enchufe",
"bullet3": "Cero comisiones de plataforma"
},
"block2": {
"heading": "A diferencia de otras plataformas 'Bitcoin'",
"body": "Sin nodo Lightning central, custodio o LSP que pueda fallar o caerse. Los fondos se liquidan directamente en Bitcoin a una billetera que tú controlas. Si {{appName}} desapareciera mañana, cada campaña seguiría funcionando.",
"bullet1": "Sin billetera custodiada que vaciar o congelar",
"bullet2": "Se liquida on-chain a una billetera que tú posees",
"bullet3": "Funciona incluso si {{appName}} desaparece"
},
"block3": {
"heading": "Público o privado. Tú eliges.",
"body": "Los activistas eligen la opción de recepción que se ajusta a su modelo de amenaza. Los donantes ven un único QR; la billetera elige el protocolo correcto.",
"publicLabel": "Público",
"publicSummary": "Funciona en todas las billeteras de Bitcoin. Rápido y verificable on-chain.",
"privateLabel": "Privado",
"privateSummary": "Pagos silenciosos BIP-352. Las donaciones llegan a salidas no enlazables."
},
"readMore": "Leer el análisis completo"
},
"searchPlaceholder": "Buscar campañas…",
"searchAriaLabel": "Buscar campañas",
"noMatch": "Ninguna campaña coincide con «{{query}}»",
"noMatchHint": "Prueba con otro término de búsqueda o bórrala."
},
"all": {
"title": "Todas las campañas",
"title": "Campañas",
"seoTitle": "Todas las campañas",
"description": "Explora todas las campañas publicadas en Agora.",
"sectionTagline": "Explora cada causa en la red.",
"sectionTagline": "Primero las campañas destacadas, luego el resto de la red. Busca u ordena para refinar.",
"heroKicker": "Campañas",
"heroHeading": "Cada causa,",
"heroHeadingLine2": "en un solo lugar.",
@@ -744,6 +824,54 @@
"allHiddenHint": "Todas las campañas de la red han sido ocultadas por moderadores. Activa «Mostrar ocultas» para verlas.",
"empty": "Aún no hay campañas",
"emptyHint": "Todavía no se ha publicado ninguna campaña. Sé el primero."
},
"lists": {
"stripAria": "Listas temáticas de campañas curadas",
"create": "Nueva lista",
"createDesc": "Crea una nueva lista temática. Cura campañas en ella desde cualquier página de campaña.",
"createSubmit": "Crear lista",
"createFailed": "No se pudo crear la lista",
"edit": "Editar lista",
"editDesc": "Actualiza el título, la descripción o el icono de la lista.",
"editSubmit": "Guardar cambios",
"updateFailed": "No se pudo actualizar la lista",
"delete": "Eliminar lista",
"deleteFailed": "No se pudo eliminar la lista",
"deleteConfirmTitle": "¿Eliminar esta lista?",
"deleteConfirmDesc": "«{{title}}» se eliminará de la franja de temas. Las campañas en sí no se ven afectadas.",
"titleField": "Título",
"titlePlaceholder": "p. ej. Libertad de prensa",
"descriptionField": "Descripción",
"descriptionPlaceholder": "Una breve descripción que explique qué entra en esta lista.",
"iconField": "Icono",
"menuAria": "Opciones de la lista {{title}}",
"listActions": "Acciones de la lista",
"memberMenuAria": "Opciones de la campaña en la lista",
"backToCampaigns": "Volver a las campañas",
"detailTitle": "Lista de campañas",
"campaignsCount_one": "{{count}} campaña",
"campaignsCount_other": "{{count}} campañas",
"addCampaign": "Añadir campaña",
"addCampaignDesc": "Busca en la red y elige una campaña para añadirla a esta lista.",
"addFailed": "No se pudo añadir a la lista",
"addToList": "Añadir",
"alreadyAdded": "Añadida",
"added": "Añadida",
"membershipTitle": "Añadir a listas",
"membershipDesc": "Elige en qué listas debe aparecer \"{{title}}\".",
"membershipEmpty": "Aún no hay listas. Crea una para empezar a curar.",
"searchPlaceholder": "Buscar campañas…",
"searchEmpty": "Ninguna campaña coincide con esta búsqueda.",
"removeFromList": "Quitar de la lista",
"removeFailed": "No se pudo quitar de la lista",
"empty": "Esta lista está vacía.",
"emptyMod": "Esta lista está vacía. Añade campañas para empezar a curarla.",
"iconPicker": {
"title": "Elige un icono",
"description": "Elige cualquier icono de la biblioteca Lucide.",
"search": "Buscar iconos…",
"empty": "Ningún icono coincide con esta búsqueda."
}
}
},
"moderation": {
@@ -753,21 +881,27 @@
"ariaPledge": "Moderar promesa",
"ariaGroup": "Moderar grupo",
"failedAction": "No se pudo {{action}}",
"approve": "Aprobar",
"unapprove": "Desaprobar",
"approvedState": "Aprobado",
"failedReorder": "No se pudo reordenar",
"hide": "Ocultar",
"unhide": "Mostrar",
"hiddenState": "Oculto",
"feature": "Destacar",
"unfeature": "Quitar de destacados",
"featuredState": "Destacado",
"toastApproved": "Aprobado para la página de inicio",
"toastUnapproved": "Eliminado de la página de inicio",
"moveToTop": "Mover al principio",
"moveUp": "Subir",
"moveDown": "Bajar",
"addToList": "Añadir a la lista…",
"dragHandle": "Arrastra para reordenar (posición {{index}})",
"toastHidden": "Ocultado",
"toastUnhidden": "Restaurado",
"toastFeatured": "Destacado",
"toastUnfeatured": "Eliminado de destacados"
"toastUnfeatured": "Eliminado de destacados",
"toast": {
"movedToTop": "Movido al principio",
"movedUp": "Movido hacia arriba",
"movedDown": "Movido hacia abajo"
}
}
},
"settings": {
@@ -1123,13 +1257,25 @@
"bitcoinAddress": "Dirección de Bitcoin",
"silentPayment": "Dirección de pago silencioso",
"toLabel": "Para",
"clear": "Borrar destinatario"
"clear": "Borrar destinatario",
"choosePaymentMethod": "Elige un método de pago para continuar"
},
"feeSpeed": {
"fastest": "~10 min",
"halfHour": "~30 min",
"hour": "~1 hora",
"economy": "~1 día"
"economy": "~1 día",
"custom": "Personalizada"
},
"fee": {
"loading": "cargando…",
"unavailable": "no disponible",
"loadFailed": "No se pudieron cargar las tasas de comisión.",
"retry": "Reintentar",
"orCustom": "O introduce una tasa personalizada abajo.",
"loadingTiers": "Cargando las tasas de comisión…",
"customPlaceholder": "p. ej. 5",
"customAriaLabel": "Tasa de comisión personalizada en sat/vB"
},
"progress": {
"building": "Construyendo la transacción…",
@@ -1145,7 +1291,9 @@
"enterAmount": "Introduce una cantidad.",
"insufficient": "No hay Bitcoin suficiente para esta cantidad + la comisión de red.",
"waitingPrice": "Esperando el precio del BTC…",
"noneYet": "Aún no tienes Bitcoin."
"noneYet": "Aún no tienes Bitcoin.",
"feesNotLoadedYet": "Las tasas de comisión aún no se han cargado.",
"feeRateTooLow": "Introduce una tasa de comisión de al menos 1 sat/vB."
},
"scanError": {
"title": "No se pudo leer ese código QR",
@@ -1154,6 +1302,29 @@
"toast": {
"failedTitle": "La transacción falló"
},
"broadcastError": {
"feeTooLowTitle": "Comisión de red demasiado baja",
"feeTooLowBodyWithMin": "La red de Bitcoin está rechazando esta comisión. El mínimo ahora mismo es de unos {{min}} sat/vB.",
"feeTooLowBody": "La red de Bitcoin está rechazando esta comisión. Elige un nivel más rápido o sube tu tasa personalizada.",
"rbfTitle": "El reemplazo necesita una comisión mayor",
"rbfBody": "La transacción de reemplazo debe pagar más que la original. Sube la comisión e inténtalo de nuevo.",
"mempoolFullTitle": "La red de Bitcoin está congestionada",
"mempoolFullBody": "El mempool está lleno y tu comisión no es competitiva. Súbela para pasar.",
"networkTitle": "No se pudo conectar con la red de Bitcoin",
"networkBody": "Comprueba tu conexión e inténtalo de nuevo.",
"mempoolConflictTitle": "Transacción en conflicto",
"mempoolConflictBody": "Una de las entradas ya se gastó o la está gastando otra transacción.",
"tooLongChainTitle": "Demasiadas transacciones sin confirmar",
"tooLongChainBody": "Tienes una cadena larga de transacciones sin confirmar. Espera a que se confirme una e inténtalo de nuevo.",
"badInputsTitle": "La transacción fue rechazada",
"badInputsBody": "La red rechazó esta transacción. Ajusta la cantidad o el destinatario e inténtalo de nuevo.",
"absurdlyHighFeeTitle": "La comisión es inusualmente alta",
"absurdlyHighFeeBody": "La comisión estimada es sospechosamente alta. Recarga las tasas e inténtalo de nuevo.",
"unknownTitle": "La transacción falló",
"useHigherFee": "Usar una comisión más alta",
"tryAgain": "Reintentar",
"atMaxFeeTier": "Ya estás en el nivel más rápido."
},
"success": {
"title": "Bitcoin enviado",
"satsAmount": "{{sats}} sats",
+213 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "آن را از آن خود کنید",
"subtitle": "کمی دربارهٔ خودتان به دیگران بگویید. همه اختیاری است، هر زمان می‌توانید تغییر دهید.",
"campaignTitle": "چهره‌ای به کمپین خود بدهید",
"campaignSubtitle": "نام و عکس به مردم کمک می‌کند با کمپین شما ارتباط برقرار کنند.",
"nameLabel": "نام نمایشی",
"namePlaceholder": "نام شما",
"aboutLabel": "بیوگرافی",
"aboutPlaceholder": "کمی دربارهٔ شما…",
"avatarLabel": "آواتار",
"uploadAvatar": "بارگذاری آواتار",
"advanced": "بیشتر",
"finish": "پایان",
"saving": "در حال ذخیره…",
"skip": "فعلاً رد شو",
@@ -183,10 +186,11 @@
"coverImage": "تصویر کاور",
"description": "توضیح",
"timezone": "منطقه زمانی",
"publishing": "در حال انتشار…",
"uploadingCover": "در حال بارگذاری کاور…",
"countrySearchPlaceholder": "جستجوی کشورها",
"imageDropzone": "برای انتخاب تصویر کلیک کنید یا آن را اینجا بکشید"
"imageDropzone": "برای انتخاب تصویر کلیک کنید یا آن را اینجا بکشید",
"countryClearAria": "پاک کردن کشور",
"flagOfAria": "پرچم {{name}}",
"countryHint": "<0>i: iso3166:{{code}}</0> برای مرتب‌سازی بر اساس کشور منتشر می‌شود."
},
"organizationContext": {
"attachedToGroup": "پیوست‌شده به گروه",
@@ -220,8 +224,8 @@
"myPledgesTagline": "تعهدهایی که ایجاد کرده‌ای.",
"featuredPledges": "تعهدهای ویژه",
"featuredPledgesTagline": "تعهدهایی که تیم {{appName}} برجسته کرده است.",
"allPledges": "همهٔ تعهدها",
"allPledgesTagline": "همهٔ تعهدهای موجود در شبکه را مرور کن.",
"allPledges": "تعهدها",
"allPledgesTagline": "برگزیده‌ٔ ناظران. برای مرور همهٔ تعهدها جستجو یا مرتب‌سازی کن.",
"sectionActive": "تعهدهای فعال",
"sectionUpcoming": "تعهدهای پیش‌رو",
"sectionPast": "تعهدهای گذشته",
@@ -279,11 +283,7 @@
"titlePlaceholder": "مستندسازی پاکسازی ساحل",
"country": "کشور",
"countryPlaceholder": "جستجوی کشورها",
"countryClearAria": "پاک کردن کشور",
"flagOfAria": "پرچم {{name}}",
"countryHint": "<0>i: iso3166:{{code}}</0> برای مرتب‌سازی بر اساس کشور منتشر می‌شود.",
"tags": "برچسب‌ها",
"tagsPlaceholder": "پاکسازی-ساحل، مستندسازی-اعتراض، قطعی-اینترنت",
"coverImage": "تصویر جلد",
"description": "توضیح",
"descriptionPlaceholder": "کنش، شواهد یا پیامدی که می‌خواهید الهام‌بخش شوید، آنچه پاسخ‌ها باید شامل باشد و نحوهٔ ارزیابی آن‌ها را شرح دهید...",
@@ -293,8 +293,6 @@
"timezone": "منطقهٔ زمانی",
"timezoneNote": "زمان شروع و مهلت در این منطقهٔ زمانی تفسیر می‌شوند.",
"submit": "ایجاد تعهد",
"publishing": "در حال انتشار…",
"uploadingCover": "در حال بارگذاری جلد…",
"altText": "تعهد {{appName}}: {{title}}",
"successToast": "تعهد ایجاد شد",
"errorToast": "ایجاد تعهد ممکن نشد",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "مبلغ تعهد باید یک مقدار مثبت به دلار باشد.",
"errorPriceUnavailable": "در انتظار قیمت BTC/USD برای محاسبهٔ مبلغ تعهد.",
"errorCoverInvalid": "تصویر جلد باید یک نشانی https:// معتبر باشد.",
"errorDeadlinePast": "مهلت نمی‌تواند در گذشته باشد."
"errorDeadlinePast": "مهلت نمی‌تواند در گذشته باشد.",
"wizard": {
"titleStepTitle": "به تعهدت نام بده",
"titleStepSubtitle": "درخواستی روشن و توضیحی کوتاه از آنچه تأمین مالی می‌کنی.",
"pledgeStepTitle": "تعهدت را تعیین کن",
"pledgeStepSubtitle": "چه مبلغی به دلار می‌پردازی و یک مهلت اختیاری.",
"coverStepTitle": "یک تصویر جلد اضافه کن",
"coverStepSubtitle": "یک تصویر، تعهد را روی هر کارت همراهی می‌کند.",
"tagsStepTitle": "کشور و دسته‌ها",
"tagsStepSubtitle": "کمک کن آدم‌های درست تعهدت را پیدا کنند.",
"launchNow": "رد کن و راه‌اندازی کن"
}
},
"detail": {
"seoTitle": "{{title}} | تعهد {{appName}}",
@@ -355,8 +364,8 @@
"myGroupsTagline": "گروه‌هایی که ساخته‌ای، مدیریت می‌کنی یا دنبال می‌کنی.",
"featuredGroups": "گروه‌های ویژه",
"featuredGroupsTagline": "گروه‌های برجسته‌ای که ارزش توجه تو را دارند.",
"allGroups": "همهٔ گروه‌ها",
"allGroupsTagline": "گروه‌های {{appName}} را مرور کن یا میان همهٔ گروه‌های Nostr جستجو کن.",
"allGroups": "گروه‌ها",
"allGroupsTagline": "برگزیده‌ٔ ناظران. برای مرور همهٔ گروه‌ها جستجو یا مرتب‌سازی کن.",
"loginToSeeTitle": "برای دیدن گروه‌هایت وارد شو",
"loginToSeeBody": "گروه‌هایی که ساخته‌ای یا مدیریت می‌کنی اینجا ظاهر می‌شوند.",
"noGroupsTitle": "هنوز گروهی نیست",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "این گروه دربارهٔ چیست؟",
"country": "کشور",
"countryPlaceholder": "جستجوی کشورها",
"countryClearAria": "پاک کردن کشور",
"flagOfAria": "پرچم {{name}}",
"countryHint": "<0>i: iso3166:{{code}}</0> برای مرتب‌سازی بر اساس کشور منتشر می‌شود.",
"tags": "برچسب‌ها",
"tagsPlaceholder": "کمک-متقابل، اخبار-محلی، حقوق-دیجیتال",
"coverImage": "تصویر جلد",
@@ -433,7 +439,18 @@
"errorNameInvalid": "نام باید شامل حروف یا اعداد باشد تا یک نشانی برای گروه ساخته شود.",
"errorEditLatestMissing": "آخرین نسخهٔ این گروه برای به‌روزرسانی یافت نشد.",
"errorCoverInvalid": "تصویر جلد باید یک نشانی https:// معتبر باشد.",
"errorSlugCollision": "از قبل گروهی با شناسهٔ «{{slug}}» داری. نام دیگری انتخاب کن."
"errorSlugCollision": "از قبل گروهی با شناسهٔ «{{slug}}» داری. نام دیگری انتخاب کن.",
"wizard": {
"nameStepTitle": "به گروهت نام بده",
"nameStepSubtitle": "نامی کوتاه و روشن که اعضا بشناسند.",
"coverStepTitle": "یک تصویر جلد اضافه کن",
"coverStepSubtitle": "یک تصویر، گروه را روی هر کارت همراهی می‌کند.",
"moderatorsStepTitle": "از مدیران دعوت کن",
"moderatorsStepSubtitle": "اختیاری — آن‌ها می‌توانند در کنار تو محتوا را تأیید و اعضا را حذف کنند.",
"tagsStepTitle": "کشور و دسته‌ها",
"tagsStepSubtitle": "کمک کن آدم‌های درست گروهت را پیدا کنند.",
"launchNow": "رد کن و راه‌اندازی کن"
}
},
"detail": {
"by": "توسط",
@@ -493,9 +510,19 @@
"myWalletDefault": "کیف پول من",
"walletChoose": "یک کیف پول انتخاب کن",
"walletCustom": "سفارشی",
"walletUseCustom": "به جای آن از کیف پول دیگری استفاده کن",
"walletDestinationLanding": "اهدا‌ها اینجا می‌رسند",
"walletDestinationNote": "این کیف پول به عنوان مقصد اهدا‌های کمپین تو منتشر خواهد شد.",
"walletUseMine": "از کیف پول Agora من استفاده کن",
"acceptAll": "پذیرش همهٔ نوع‌های پرداخت",
"acceptPublic": "پذیرش فقط پرداخت‌های عمومی",
"acceptPrivate": "پذیرش فقط پرداخت‌های خصوصی",
"acceptAllShort": "همه",
"acceptPublicShort": "فقط عمومی",
"acceptPrivateShort": "فقط خصوصی",
"acceptAllHint": "هم پرداخت‌های عمومی روی زنجیره و هم پرداخت‌های بی‌صدای خصوصی پذیرفته می‌شوند.",
"acceptPublicHint": "فقط اهداهای روی زنجیره به یک نشانی عمومی پذیرفته می‌شوند.",
"acceptPrivateHint": "فقط پرداخت‌های بی‌صدا — نشانی اهداکنندگان خصوصی می‌ماند.",
"customWalletIntro": "یک نشانی بیت‌کوین، یک کد پرداخت بی‌صدا یا هر دو را وارد کن. حداقل یکی الزامی است.",
"bitcoinAddress": "نشانی بیت‌کوین",
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "کد پرداخت بی‌صدای BIP-352 شناخته‌شده نیست (sp1…).",
"country": "کشور",
"countryPlaceholder": "جستجوی کشورها",
"countryClearAria": "پاک کردن کشور",
"flagOfAria": "پرچم {{name}}",
"countryHint": "<0>i: iso3166:{{code}}</0> برای مرتب‌سازی بر اساس کشور منتشر می‌شود.",
"tags": "برچسب‌ها",
"tagsPlaceholder": "دفاع-حقوقی، کمک-متقابل، اخبار-محلی",
"categories": {
"humanRights": "حقوق بشر",
"democracy": "دموکراسی",
"pressFreedom": "آزادی مطبوعات",
"politicalPrisoners": "زندانیان سیاسی",
"humanitarianAid": "کمک‌های بشردوستانه",
"civilResistance": "مقاومت مدنی",
"digitalRights": "حقوق دیجیتال",
"antiCorruption": "مبارزه با فساد",
"womenGirls": "زنان و دختران",
"refugees": "پناهندگان و تبعیدیان",
"legalAid": "کمک حقوقی",
"emergencyRelief": "امدادرسانی اضطراری",
"animalRights": "حقوق حیوانات",
"education": "آموزش",
"medical": "پزشکی",
"community": "اجتماع"
},
"banner": "تصویر بنر",
"story": "داستان",
"storyPlaceholder": "پیشینه، ذی‌نفعان و نحوهٔ استفاده از منابع را بیان کن.",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "نشانی روی زنجیرهٔ تازه از کیف پولت استخراج نشد.",
"errorHdDeriveInvalid": "نشانی استخراج‌شده اعتبارسنجی نشد. لطفاً یک نشانی سفارشی اضافه کن.",
"errorWalletRequiredFallback": "نقطهٔ کیف پول الزامی است.",
"errorPublishedInvalid": "رویداد منتشرشده اعتبارسنجی نشد. لطفاً صفحه را به‌روز کن و دوباره تلاش کن."
"errorPublishedInvalid": "رویداد منتشرشده اعتبارسنجی نشد. لطفاً صفحه را به‌روز کن و دوباره تلاش کن.",
"wizard": {
"titleStepTitle": "به کمپینت نام بده",
"titleStepSubtitle": "نامی کوتاه و روشن که اهداکنندگان به‌یاد بسپارند.",
"walletStepTitle": "انتخاب کن چه کسی اهدا‌ها را دریافت کند",
"walletStepSubtitle": "کیف پول آگورای تو آماده دریافت اهدا‌های Bitcoin برای این کمپین است.",
"bannerStepTitle": "یک بنر اضافه کن",
"bannerStepSubtitle": "یک تصویر گیرا، کمپین را روی هر کارت همراهی می‌کند.",
"storyStepTitle": "داستانت را تعریف کن",
"storyStepSubtitle": "چه کسانی بهره‌مند می‌شوند و این مبالغ چگونه خرج خواهند شد.",
"next": "بعدی",
"back": "بازگشت",
"skip": "رد کردن",
"launchNow": "رد کن و راه‌اندازی کن"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | کمپین‌های {{appName}}",
@@ -699,31 +755,55 @@
"startCampaign": "شروع یک کمپین",
"howItWorks": "چگونه کار می‌کند",
"exploreCampaigns": "مرور کمپین‌ها",
"featured": "ویژه",
"featuredDesc": "کمپین‌های منتخب تیم {{appName}}.",
"community": "کمپین‌های جامعه",
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
"browseAll": "← مرور همه کمپین‌ها",
"pending": "در انتظار تأیید",
"pendingDesc": "کمپین‌هایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آن‌ها را تأیید یا پنهان نکرده است.",
"pendingEmpty": "چیزی برای بررسی نیست.",
"wlcDesc": "کمپین‌های گزینش‌شده توسط کنگرهٔ آزادی جهانی (World Liberty Congress).",
"allCampaigns": "همه کمپین‌ها",
"allCampaignsDesc": "همه کمپین‌های شبکه، به ترتیب زمانی.",
"browseAll": "مرور همه کمپین‌ها",
"hidden": "پنهان‌شده",
"hiddenDesc": "کمپین‌هایی که از صفحه اصلی عمومی حذف شده‌اند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
"yourCampaigns": "کمپین‌های شما",
"yourCampaignsDesc": "کمپین‌های شما در Nostr فعال هستند و کمک‌های مالی از طریق لینک کار می‌کنند. به محض تأیید توسط یک ناظر تیم Soapbox، در صفحه اصلی ظاهر می‌شوند.",
"yourCampaignsDesc": "کمپین‌های شما در Nostr فعال هستند و کمک‌های مالی از طریق لینک کمپین کار می‌کنند. همه کمپین‌ها را در /campaigns مرور کنید؛ تیم {{appName}} مجموعه‌ای منتخب را در صفحه اصلی معرفی می‌کند.",
"empty": "هنوز کمپینی وجود ندارد",
"emptyHint": "اولین نفری باشید که در {{appName}} کمپین راه‌اندازی می‌کند. داستان خود را بگویید، ذی‌نفعان را انتخاب کنید، و لینک را به اشتراک بگذارید.",
"whyDifferent": {
"eyebrow": "چرا {{appName}}",
"title": "متفاوت ساخته شده است.",
"lede": "Bitcoin مستقیم از اهداکننده به کنش‌گر. بدون پلتفرمی در میانه، بدون امانت‌داری که کیسه را نگه دارد، بدون نیاز به اجازه.",
"block1": {
"heading": "برخلاف GoFundMe",
"body": "هیچ پلتفرمی نمی‌تواند اهداهای شما را مسدود کند، تقاضای استرداد کند، یا کمپین شما را به‌خاطر اختلاف سیاستی پایان دهد. نه Stripe، نه Visa، نه بانکی در میانه نشسته که بتواند شما را در میانهٔ کمپین قطع کند.",
"bullet1": "ضدِّ انجماد — بدون حقِّ وتوی پلتفرم",
"bullet2": "هیچ پردازنده پرداختی نمی‌تواند پریز را بکشد",
"bullet3": "بدون هیچ کارمزد پلتفرم"
},
"block2": {
"heading": "برخلاف دیگر پلتفرم‌های «بیت‌کوین»",
"body": "هیچ گره Lightning مرکزی، امانت‌دار یا LSP که خراب شود یا آفلاین شود وجود ندارد. وجوه مستقیماً روی Bitcoin به کیف‌پولی که خودتان کنترل می‌کنید تسویه می‌شوند. اگر {{appName}} فردا ناپدید شود، هر کمپین به کار خود ادامه می‌دهد.",
"bullet1": "هیچ کیف‌پول امانی‌ای نیست که خالی یا مسدود شود",
"bullet2": "روی زنجیره به کیف‌پولی که خودتان مالک آن هستید تسویه می‌شود",
"bullet3": "حتی اگر {{appName}} ناپدید شود کار می‌کند"
},
"block3": {
"heading": "عمومی یا خصوصی. انتخاب با شماست.",
"body": "کنش‌گران گزینهٔ دریافتی را انتخاب می‌کنند که با مدل تهدیدشان همخوان است. اهداکنندگان یک QR واحد می‌بینند؛ کیف‌پول پروتکل درست را انتخاب می‌کند.",
"publicLabel": "عمومی",
"publicSummary": "در هر کیف‌پول Bitcoin کار می‌کند. سریع و قابل تأیید روی زنجیره.",
"privateLabel": "خصوصی",
"privateSummary": "پرداخت‌های خاموش BIP-352. اهداها به خروجی‌های غیرقابل‌پیوند می‌رسند."
},
"readMore": "تفکیک کامل را بخوانید"
},
"searchPlaceholder": "جستجوی کمپین‌ها…",
"searchAriaLabel": "جستجوی کمپین‌ها",
"noMatch": "هیچ کمپینی با «{{query}}» مطابقت ندارد",
"noMatchHint": "عبارت جستجوی دیگری را امتحان کنید، یا جستجو را پاک کنید."
},
"all": {
"title": "همه کمپین‌ها",
"title": "کمپین‌ها",
"seoTitle": "همه کمپین‌ها",
"description": "همه کمپین‌های منتشرشده در Agora را مرور کنید.",
"sectionTagline": "هر هدفی را در شبکه مرور کنید.",
"sectionTagline": "ابتدا کمپین‌های ویژه، سپس بقیهٔ شبکه. برای پالایش، جستجو یا مرتب‌سازی کنید.",
"heroKicker": "کمپین‌ها",
"heroHeading": "هر هدف،",
"heroHeadingLine2": "در یک جا.",
@@ -744,6 +824,54 @@
"allHiddenHint": "تمام کمپین‌های روی شبکه توسط ناظران پنهان شده‌اند. «نمایش پنهان‌شده‌ها» را فعال کنید تا آن‌ها را ببینید.",
"empty": "هنوز کمپینی وجود ندارد",
"emptyHint": "هنوز هیچ کمپینی منتشر نشده است. اولین نفر باشید."
},
"lists": {
"stripAria": "فهرست‌های منتخب موضوعی کمپین‌ها",
"create": "فهرست جدید",
"createDesc": "یک فهرست موضوعی جدید بسازید. از هر صفحهٔ کمپین، کمپین‌ها را به آن اضافه کنید.",
"createSubmit": "ساخت فهرست",
"createFailed": "ساخت فهرست ناموفق بود",
"edit": "ویرایش فهرست",
"editDesc": "عنوان، توضیحات یا آیکون فهرست را به‌روزرسانی کنید.",
"editSubmit": "ذخیرهٔ تغییرات",
"updateFailed": "به‌روزرسانی فهرست ناموفق بود",
"delete": "حذف فهرست",
"deleteFailed": "حذف فهرست ناموفق بود",
"deleteConfirmTitle": "این فهرست حذف شود؟",
"deleteConfirmDesc": "\"{{title}}\" از نوار موضوعات حذف خواهد شد. خود کمپین‌ها تحت تأثیر قرار نمی‌گیرند.",
"titleField": "عنوان",
"titlePlaceholder": "مثلاً آزادی مطبوعات",
"descriptionField": "توضیحات",
"descriptionPlaceholder": "توضیح کوتاهی دربارهٔ اینکه چه چیزی در این فهرست جای می‌گیرد.",
"iconField": "آیکون",
"menuAria": "گزینه‌های فهرست {{title}}",
"listActions": "اقدامات فهرست",
"memberMenuAria": "گزینه‌های فهرست کمپین",
"backToCampaigns": "بازگشت به کمپین‌ها",
"detailTitle": "فهرست کمپین",
"campaignsCount_one": "{{count}} کمپین",
"campaignsCount_other": "{{count}} کمپین",
"addCampaign": "افزودن کمپین",
"addCampaignDesc": "در شبکه جستجو کنید و کمپینی را برای افزودن به این فهرست انتخاب کنید.",
"addFailed": "افزودن به فهرست ناموفق بود",
"addToList": "افزودن",
"alreadyAdded": "افزوده شد",
"added": "افزوده شد",
"membershipTitle": "افزودن به فهرست‌ها",
"membershipDesc": "انتخاب کنید که \"{{title}}\" در کدام فهرست‌ها نمایش داده شود.",
"membershipEmpty": "هنوز فهرستی وجود ندارد. برای شروع تنظیم، فهرستی ایجاد کنید.",
"searchPlaceholder": "جستجوی کمپین‌ها…",
"searchEmpty": "هیچ کمپینی با این جستجو مطابقت ندارد.",
"removeFromList": "حذف از فهرست",
"removeFailed": "حذف از فهرست ناموفق بود",
"empty": "این فهرست خالی است.",
"emptyMod": "این فهرست خالی است. برای شروع تنظیم آن، کمپین اضافه کنید.",
"iconPicker": {
"title": "یک آیکون انتخاب کنید",
"description": "هر آیکونی را از کتابخانهٔ Lucide انتخاب کنید.",
"search": "جستجوی آیکون‌ها…",
"empty": "هیچ آیکونی با این جستجو مطابقت ندارد."
}
}
},
"moderation": {
@@ -753,21 +881,27 @@
"ariaPledge": "نظارت بر تعهد",
"ariaGroup": "نظارت بر گروه",
"failedAction": "{{action}} ناموفق بود",
"approve": "تأیید",
"unapprove": "لغو تأیید",
"approvedState": "تأییدشده",
"hide": "پنهان کردن",
"unhide": "آشکار کردن",
"hiddenState": "پنهان",
"feature": "ویژه کردن",
"unfeature": "لغو ویژه‌سازی",
"featuredState": "ویژه",
"toastApproved": "برای صفحه اصلی تأیید شد",
"toastUnapproved": "از صفحه اصلی حذف شد",
"moveToTop": "انتقال به بالا",
"moveUp": "جابجایی به بالا",
"moveDown": "جابجایی به پایین",
"addToList": "افزودن به فهرست…",
"dragHandle": "برای تغییر ترتیب بکشید (موقعیت {{index}})",
"failedReorder": "تغییر ترتیب ناموفق بود",
"toastHidden": "پنهان شد",
"toastUnhidden": "آشکار شد",
"toastFeatured": "ویژه شد",
"toastUnfeatured": "از فهرست ویژه‌ها حذف شد"
"toastUnfeatured": "از فهرست ویژه‌ها حذف شد",
"toast": {
"movedToTop": "به بالا منتقل شد",
"movedUp": "به بالا جابجا شد",
"movedDown": "به پایین جابجا شد"
}
}
},
"settings": {
@@ -1123,13 +1257,25 @@
"bitcoinAddress": "نشانی بیت‌کوین",
"silentPayment": "نشانی پرداخت خاموش",
"toLabel": "به",
"clear": "پاک کردن گیرنده"
"clear": "پاک کردن گیرنده",
"choosePaymentMethod": "برای ادامه یک روش پرداخت انتخاب کنید"
},
"feeSpeed": {
"fastest": "~۱۰ دقیقه",
"halfHour": "~۳۰ دقیقه",
"hour": "~۱ ساعت",
"economy": "~۱ روز"
"economy": "~۱ روز",
"custom": "سفارشی"
},
"fee": {
"loading": "در حال بارگذاری…",
"unavailable": "در دسترس نیست",
"loadFailed": "بارگذاری نرخ‌های کارمزد ممکن نشد.",
"retry": "تلاش دوباره",
"orCustom": "یا یک نرخ سفارشی را در زیر وارد کنید.",
"loadingTiers": "در حال بارگذاری نرخ‌های کارمزد…",
"customPlaceholder": "مثلاً ۵",
"customAriaLabel": "نرخ کارمزد سفارشی به sat/vB"
},
"progress": {
"building": "در حال ساخت تراکنش…",
@@ -1145,11 +1291,36 @@
"enterAmount": "یک مبلغ وارد کنید.",
"insufficient": "بیت‌کوین کافی برای این مبلغ + کارمزد شبکه ندارید.",
"waitingPrice": "در انتظار قیمت BTC…",
"noneYet": "هنوز بیت‌کوینی ندارید."
"noneYet": "هنوز بیت‌کوینی ندارید.",
"feesNotLoadedYet": "نرخ‌های کارمزد هنوز بارگذاری نشده‌اند.",
"feeRateTooLow": "یک نرخ کارمزد دست‌کم ۱ sat/vB وارد کنید."
},
"toast": {
"failedTitle": "تراکنش ناموفق بود"
},
"broadcastError": {
"feeTooLowTitle": "کارمزد شبکه بسیار پایین است",
"feeTooLowBodyWithMin": "شبکهٔ Bitcoin این کارمزد را رد می‌کند. حداقل کنونی حدود {{min}} sat/vB است.",
"feeTooLowBody": "شبکهٔ Bitcoin این کارمزد را رد می‌کند. یک سطح سریع‌تر انتخاب کنید یا نرخ سفارشی خود را افزایش دهید.",
"rbfTitle": "جایگزینی به کارمزد بالاتری نیاز دارد",
"rbfBody": "تراکنش جایگزین باید بیش از تراکنش اصلی کارمزد بپردازد. کارمزد را افزایش دهید و دوباره تلاش کنید.",
"mempoolFullTitle": "شبکهٔ Bitcoin شلوغ است",
"mempoolFullBody": "mempool پُر است و کارمزد شما رقابتی نیست. کارمزد را افزایش دهید تا عبور کند.",
"networkTitle": "دسترسی به شبکهٔ Bitcoin ممکن نشد",
"networkBody": "اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"mempoolConflictTitle": "تراکنش متعارض",
"mempoolConflictBody": "یکی از ورودی‌ها از پیش خرج شده یا توسط تراکنش دیگری در حال خرج شدن است.",
"tooLongChainTitle": "تعداد بسیار زیادی تراکنش تأیید نشده",
"tooLongChainBody": "زنجیرهٔ بلندی از تراکنش‌های تأیید نشده دارید. منتظر تأیید یکی از آن‌ها بمانید و دوباره تلاش کنید.",
"badInputsTitle": "تراکنش رد شد",
"badInputsBody": "شبکه این تراکنش را رد کرد. مبلغ یا گیرنده را تنظیم کنید و دوباره تلاش کنید.",
"absurdlyHighFeeTitle": "کارمزد به‌طور غیرعادی بالاست",
"absurdlyHighFeeBody": "کارمزد تخمینی به‌طور مشکوکی بالاست. نرخ‌های کارمزد را دوباره بارگذاری کنید و تلاش کنید.",
"unknownTitle": "تراکنش ناموفق بود",
"useHigherFee": "استفاده از کارمزد بالاتر",
"tryAgain": "تلاش دوباره",
"atMaxFeeTier": "هم‌اکنون روی سریع‌ترین سطح هستید."
},
"success": {
"title": "بیت‌کوین ارسال شد",
"satsAmount": "{{sats}} ساتوشی",
+238 -50
View File
@@ -110,12 +110,15 @@
"profile": {
"title": "Personnalisez-le",
"subtitle": "Présentez-vous brièvement aux autres. Tout est facultatif, modifiable à tout moment.",
"campaignTitle": "Mettez un visage sur votre campagne",
"campaignSubtitle": "Un nom et une photo aident les gens à se connecter à votre campagne.",
"nameLabel": "Nom affiché",
"namePlaceholder": "Votre nom",
"aboutLabel": "Bio",
"aboutPlaceholder": "Un mot sur vous…",
"avatarLabel": "Avatar",
"uploadAvatar": "Téléverser un avatar",
"advanced": "Plus",
"finish": "Terminer",
"saving": "Enregistrement…",
"skip": "Passer pour l'instant",
@@ -279,9 +282,18 @@
"unknown": "INCONNU"
},
"kindHeader": {
"photo": { "action": "a partagé une", "noun": "photo" },
"encryptedMessage": { "action": "a envoyé un", "noun": "message chiffré" },
"letter": { "action": "a envoyé une", "noun": "lettre" },
"photo": {
"action": "a partagé une",
"noun": "photo"
},
"encryptedMessage": {
"action": "a envoyé un",
"noun": "message chiffré"
},
"letter": {
"action": "a envoyé une",
"noun": "lettre"
},
"treasureHidCreated": "a caché un",
"treasureHidUpdated": "a mis à jour un",
"treasureNoun": "trésor",
@@ -605,10 +617,11 @@
"coverImage": "Image de couverture",
"description": "Description",
"timezone": "Fuseau horaire",
"publishing": "Publication…",
"uploadingCover": "Téléversement de la couverture…",
"countrySearchPlaceholder": "Rechercher des pays",
"imageDropzone": "Cliquez ou faites glisser une image ici"
"imageDropzone": "Cliquez ou faites glisser une image ici",
"countryClearAria": "Effacer le pays",
"flagOfAria": "Drapeau de {{name}}",
"countryHint": "Publie <0>i: iso3166:{{code}}</0> pour le tri par pays."
},
"organizationContext": {
"attachedToGroup": "Attaché au groupe",
@@ -642,8 +655,8 @@
"myPledgesTagline": "Les promesses que vous avez créées.",
"featuredPledges": "Promesses en vedette",
"featuredPledgesTagline": "Promesses mises en avant par l'équipe {{appName}}.",
"allPledges": "Toutes les promesses",
"allPledgesTagline": "Parcourez toutes les promesses du réseau.",
"allPledges": "Promesses",
"allPledgesTagline": "Sélectionnées par les modérateurs. Recherchez ou triez pour parcourir toutes les promesses.",
"sectionActive": "Promesses actives",
"sectionUpcoming": "Promesses à venir",
"sectionPast": "Promesses passées",
@@ -701,11 +714,7 @@
"titlePlaceholder": "Documenter un nettoyage de plage",
"country": "Pays",
"countryPlaceholder": "Rechercher des pays",
"countryClearAria": "Effacer le pays",
"flagOfAria": "Drapeau de {{name}}",
"countryHint": "Publie <0>i: iso3166:{{code}}</0> pour le tri par pays.",
"tags": "Étiquettes",
"tagsPlaceholder": "nettoyage-plage, documentation-manifestation, coupure-internet",
"coverImage": "Image de couverture",
"description": "Description",
"descriptionPlaceholder": "Expliquez l'action, la preuve ou le résultat que vous souhaitez inspirer, ce que les soumissions devraient inclure, et comment vous prévoyez de les évaluer...",
@@ -715,8 +724,6 @@
"timezone": "Fuseau horaire",
"timezoneNote": "Les heures de début et d'échéance seront interprétées dans ce fuseau horaire.",
"submit": "Créer la promesse",
"publishing": "Publication…",
"uploadingCover": "Téléversement de la couverture…",
"altText": "Promesse {{appName}} : {{title}}",
"successToast": "Promesse créée",
"errorToast": "Impossible de créer la promesse",
@@ -727,7 +734,18 @@
"errorPledgeInvalid": "Le montant de la promesse doit être un montant positif en USD.",
"errorPriceUnavailable": "En attente du prix BTC/USD pour calculer le montant de la promesse.",
"errorCoverInvalid": "L'image de couverture doit être une URL https:// valide.",
"errorDeadlinePast": "L'échéance ne peut pas être dans le passé."
"errorDeadlinePast": "L'échéance ne peut pas être dans le passé.",
"wizard": {
"titleStepTitle": "Nommez votre promesse",
"titleStepSubtitle": "Une demande claire et une courte explication de ce que vous financerez.",
"pledgeStepTitle": "Définissez votre promesse",
"pledgeStepSubtitle": "Combien vous paierez, en USD, et une échéance facultative.",
"coverStepTitle": "Ajoutez une image de couverture",
"coverStepSubtitle": "Une image porte la promesse sur chaque carte.",
"tagsStepTitle": "Pays et catégories",
"tagsStepSubtitle": "Aidez les bonnes personnes à trouver votre promesse.",
"launchNow": "Passer et lancer"
}
},
"detail": {
"seoTitle": "{{title}} | Promesse {{appName}}",
@@ -777,8 +795,8 @@
"myGroupsTagline": "Les groupes que vous avez fondés, modérez ou suivez.",
"featuredGroups": "Groupes mis en avant",
"featuredGroupsTagline": "Des groupes remarquables qui méritent votre attention.",
"allGroups": "Tous les groupes",
"allGroupsTagline": "Parcourez les groupes {{appName}}, ou cherchez parmi tous les groupes de Nostr.",
"allGroups": "Groupes",
"allGroupsTagline": "Sélectionnés par les modérateurs. Recherchez ou triez pour parcourir tous les groupes.",
"loginToSeeTitle": "Connectez-vous pour voir vos groupes",
"loginToSeeBody": "Les groupes que vous avez fondés ou modérés apparaîtront ici.",
"noGroupsTitle": "Aucun groupe pour l'instant",
@@ -829,9 +847,6 @@
"descriptionPlaceholder": "De quoi parle ce groupe ?",
"country": "Pays",
"countryPlaceholder": "Rechercher des pays",
"countryClearAria": "Effacer le pays",
"flagOfAria": "Drapeau de {{name}}",
"countryHint": "Publie <0>i: iso3166:{{code}}</0> pour le tri par pays.",
"tags": "Étiquettes",
"tagsPlaceholder": "entraide, actus-locales, droits-numériques",
"coverImage": "Image de couverture",
@@ -855,7 +870,18 @@
"errorNameInvalid": "Le nom doit contenir des lettres ou des chiffres pour qu'une URL de groupe puisse être créée.",
"errorEditLatestMissing": "Impossible de trouver la dernière version de ce groupe à mettre à jour.",
"errorCoverInvalid": "L'image de couverture doit être une URL https:// valide.",
"errorSlugCollision": "Vous avez déjà un groupe avec l'identifiant « {{slug}} ». Choisissez un autre nom."
"errorSlugCollision": "Vous avez déjà un groupe avec l'identifiant « {{slug}} ». Choisissez un autre nom.",
"wizard": {
"nameStepTitle": "Nommez votre groupe",
"nameStepSubtitle": "Un nom court et clair que les membres reconnaîtront.",
"coverStepTitle": "Ajoutez une image de couverture",
"coverStepSubtitle": "Une image porte le groupe sur chaque carte.",
"moderatorsStepTitle": "Invitez des modérateurs",
"moderatorsStepSubtitle": "Facultatif — ils peuvent approuver le contenu et retirer des membres à vos côtés.",
"tagsStepTitle": "Pays et catégories",
"tagsStepSubtitle": "Aidez les bonnes personnes à trouver votre groupe.",
"launchNow": "Passer et lancer"
}
},
"detail": {
"by": "par",
@@ -915,9 +941,19 @@
"myWalletDefault": "Mon portefeuille",
"walletChoose": "Choisir un portefeuille",
"walletCustom": "Personnalisé",
"walletUseCustom": "Utiliser un autre portefeuille",
"walletDestinationLanding": "Les dons arriveront ici",
"walletDestinationNote": "Ce portefeuille sera publié comme destination des dons pour votre campagne.",
"walletUseMine": "Utiliser mon portefeuille Agora",
"acceptAll": "Accepter tous les types de paiement",
"acceptPublic": "Accepter uniquement les paiements publics",
"acceptPrivate": "Accepter uniquement les paiements privés",
"acceptAllShort": "Tous",
"acceptPublicShort": "Publics uniquement",
"acceptPrivateShort": "Privés uniquement",
"acceptAllHint": "Accepter les paiements publics on-chain et les paiements silencieux privés.",
"acceptPublicHint": "N'accepter que les dons on-chain vers une adresse publique.",
"acceptPrivateHint": "N'accepter que les paiements silencieux — les adresses des donateurs restent privées.",
"customWalletIntro": "Saisissez une adresse Bitcoin, un code de paiement silencieux, ou les deux. Au moins un est obligatoire.",
"bitcoinAddress": "Adresse Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
@@ -926,12 +962,27 @@
"onchainInvalid": "Adresse Bitcoin mainnet non reconnue (bc1q… / bc1p…).",
"spInvalid": "Code de paiement silencieux BIP-352 non reconnu (sp1…).",
"country": "Pays",
"countryPlaceholder": "Rechercher des pays",
"countryClearAria": "Effacer le pays",
"flagOfAria": "Drapeau de {{name}}",
"countryHint": "Publie <0>i: iso3166:{{code}}</0> pour le tri par pays.",
"countryPlaceholder": "Rechercher des pays",
"tags": "Étiquettes",
"tagsPlaceholder": "défense-juridique, entraide, actus-locales",
"categories": {
"humanRights": "Droits humains",
"democracy": "Démocratie",
"pressFreedom": "Liberté de la presse",
"politicalPrisoners": "Prisonniers politiques",
"humanitarianAid": "Aide humanitaire",
"civilResistance": "Résistance civile",
"digitalRights": "Droits numériques",
"antiCorruption": "Lutte contre la corruption",
"womenGirls": "Femmes et filles",
"refugees": "Réfugiés et exilés",
"legalAid": "Aide juridique",
"emergencyRelief": "Aide d'urgence",
"animalRights": "Droits des animaux",
"education": "Éducation",
"medical": "Médical",
"community": "Communauté"
},
"banner": "Image de bannière",
"story": "Histoire",
"storyPlaceholder": "Partagez le contexte, qui en bénéficie et comment les fonds seront utilisés.",
@@ -971,7 +1022,21 @@
"errorHdDeriveFailed": "Impossible de dériver une nouvelle adresse on-chain depuis votre portefeuille.",
"errorHdDeriveInvalid": "L'adresse de portefeuille dérivée a échoué à la validation. Veuillez ajouter une adresse personnalisée à la place.",
"errorWalletRequiredFallback": "Le point de terminaison du portefeuille est obligatoire.",
"errorPublishedInvalid": "L'événement publié a échoué à la validation. Veuillez actualiser et réessayer."
"errorPublishedInvalid": "L'événement publié a échoué à la validation. Veuillez actualiser et réessayer.",
"wizard": {
"titleStepTitle": "Nommez votre campagne",
"titleStepSubtitle": "Un nom court et clair que les donateurs reconnaîtront.",
"walletStepTitle": "Choisissez qui reçoit les dons",
"walletStepSubtitle": "Votre portefeuille Agora est prêt à recevoir des dons en Bitcoin pour cette campagne.",
"bannerStepTitle": "Ajoutez une bannière",
"bannerStepSubtitle": "Une image marquante porte la campagne sur chaque carte.",
"storyStepTitle": "Racontez votre histoire",
"storyStepSubtitle": "À qui profitent les fonds et comment ils seront utilisés.",
"next": "Suivant",
"back": "Retour",
"skip": "Passer",
"launchNow": "Passer et lancer"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Collectes de fonds {{appName}}",
@@ -1121,31 +1186,55 @@
"startCampaign": "Démarrer une campagne",
"howItWorks": "Comment ça marche",
"exploreCampaigns": "Explorer les campagnes",
"featured": "Mis en avant",
"featuredDesc": "Campagnes sélectionnées par l'équipe de {{appName}}.",
"community": "Campagnes communautaires",
"communityDesc": "Aidez à financer les changements qui valent la peine d'être menés.",
"browseAll": "Parcourir toutes les campagnes →",
"pending": "En attente d'approbation",
"pendingDesc": "Campagnes sur le réseau qu'aucun modérateur de Team Soapbox n'a encore approuvées ou masquées.",
"pendingEmpty": "Rien en attente d'examen.",
"wlcDesc": "Campagnes sélectionnées par le World Liberty Congress.",
"allCampaigns": "Toutes les campagnes",
"allCampaignsDesc": "Toutes les campagnes du réseau, par ordre chronologique.",
"browseAll": "Parcourir toutes les campagnes",
"hidden": "Masquées",
"hiddenDesc": "Campagnes supprimées de la page d'accueil publique. Utilisez le menu en kebab d'une carte pour les démasquer.",
"hiddenEmpty": "Aucune campagne n'est actuellement masquée.",
"yourCampaigns": "Vos campagnes",
"yourCampaignsDesc": "Vos campagnes sont en ligne sur Nostr et les dons fonctionnent via le lien de la campagne. Elles apparaissent sur la page d'accueil dès qu'un modérateur de Team Soapbox les approuve.",
"yourCampaignsDesc": "Vos campagnes sont en ligne sur Nostr et les dons fonctionnent via le lien de la campagne. Parcourez toutes les campagnes sur /campaigns ; l'équipe de {{appName}} met en avant une sélection sur la page d'accueil.",
"empty": "Aucune campagne pour l'instant",
"emptyHint": "Soyez le premier à démarrer une collecte de fonds sur {{appName}}. Racontez votre histoire, choisissez vos bénéficiaires et partagez le lien.",
"whyDifferent": {
"eyebrow": "Pourquoi {{appName}}",
"title": "Conçu différemment.",
"lede": "Du Bitcoin en direct, du donateur à l'activiste. Aucune plateforme au milieu, aucun dépositaire pour assumer le risque, aucune permission à demander.",
"block1": {
"heading": "Contrairement à GoFundMe",
"body": "Aucune plateforme ne peut geler vos dons, exiger des remboursements ou mettre fin à votre campagne pour des désaccords politiques. Ni Stripe, ni Visa, ni banque ne se trouve au milieu et ne peut vous couper en pleine campagne.",
"bullet1": "Ingelable — aucun veto de plateforme",
"bullet2": "Aucun processeur de paiement ne peut tirer la prise",
"bullet3": "Zéro frais de plateforme"
},
"block2": {
"heading": "Contrairement aux autres plateformes « Bitcoin »",
"body": "Pas de nœud Lightning central, de dépositaire ou de LSP qui peut tomber en panne ou se déconnecter. Les fonds sont réglés directement sur Bitcoin vers un portefeuille que vous contrôlez. Si {{appName}} disparaissait demain, toutes les campagnes continueraient de fonctionner.",
"bullet1": "Pas de portefeuille dépositaire à vider ou à geler",
"bullet2": "Règlement sur la chaîne vers un portefeuille que vous possédez",
"bullet3": "Fonctionne même si {{appName}} disparaît"
},
"block3": {
"heading": "Public ou privé. À vous de choisir.",
"body": "Les activistes choisissent l'option de réception qui correspond à leur modèle de menace. Les donateurs voient un seul QR ; le portefeuille choisit le bon protocole.",
"publicLabel": "Public",
"publicSummary": "Fonctionne dans tous les portefeuilles Bitcoin. Rapide et vérifiable sur la chaîne.",
"privateLabel": "Privé",
"privateSummary": "Paiements silencieux BIP-352. Les dons arrivent à des sorties non-traçables."
},
"readMore": "Lire l'analyse complète"
},
"searchPlaceholder": "Rechercher des campagnes…",
"searchAriaLabel": "Rechercher des campagnes",
"noMatch": "Aucune campagne ne correspond à « {{query}} »",
"noMatchHint": "Essayez un autre terme de recherche, ou effacez la recherche."
},
"all": {
"title": "Toutes les campagnes",
"title": "Campagnes",
"seoTitle": "Toutes les campagnes",
"description": "Parcourez toutes les campagnes publiées sur Agora.",
"sectionTagline": "Parcourez toutes les causes du réseau.",
"sectionTagline": "Les campagnes mises en avant d'abord, puis le reste du réseau. Recherchez ou triez pour affiner.",
"heroKicker": "Campagnes",
"heroHeading": "Chaque cause,",
"heroHeadingLine2": "au même endroit.",
@@ -1166,6 +1255,54 @@
"allHiddenHint": "Toutes les campagnes du réseau ont été masquées par les modérateurs. Activez « Afficher les masquées » pour les voir.",
"empty": "Aucune campagne pour l'instant",
"emptyHint": "Aucune campagne n'a encore été publiée. Soyez le premier."
},
"lists": {
"stripAria": "Listes thématiques de campagnes",
"create": "Nouvelle liste",
"createDesc": "Créez une nouvelle liste thématique. Ajoutez-y des campagnes depuis n'importe quelle page de campagne.",
"createSubmit": "Créer la liste",
"createFailed": "Échec de la création de la liste",
"edit": "Modifier la liste",
"editDesc": "Mettez à jour le titre, la description ou l'icône de la liste.",
"editSubmit": "Enregistrer les modifications",
"updateFailed": "Échec de la mise à jour de la liste",
"delete": "Supprimer la liste",
"deleteFailed": "Échec de la suppression de la liste",
"deleteConfirmTitle": "Supprimer cette liste ?",
"deleteConfirmDesc": "« {{title}} » sera retirée de la barre des thèmes. Les campagnes elles-mêmes ne sont pas affectées.",
"titleField": "Titre",
"titlePlaceholder": "ex. Liberté de la presse",
"descriptionField": "Description",
"descriptionPlaceholder": "Un court résumé expliquant ce qui appartient à cette liste.",
"iconField": "Icône",
"menuAria": "Options de la liste {{title}}",
"listActions": "Actions de la liste",
"memberMenuAria": "Options de la liste de campagnes",
"backToCampaigns": "Retour aux campagnes",
"detailTitle": "Liste de campagnes",
"campaignsCount_one": "{{count}} campagne",
"campaignsCount_other": "{{count}} campagnes",
"addCampaign": "Ajouter une campagne",
"addCampaignDesc": "Recherchez sur le réseau et choisissez une campagne à ajouter à cette liste.",
"addFailed": "Échec de l'ajout à la liste",
"addToList": "Ajouter",
"alreadyAdded": "Ajoutée",
"added": "Ajoutée",
"membershipTitle": "Ajouter aux listes",
"membershipDesc": "Choisissez les listes dans lesquelles \"{{title}}\" doit apparaître.",
"membershipEmpty": "Aucune liste pour le moment. Créez-en une pour commencer la curation.",
"searchPlaceholder": "Rechercher des campagnes…",
"searchEmpty": "Aucune campagne ne correspond à cette recherche.",
"removeFromList": "Retirer de la liste",
"removeFailed": "Échec du retrait de la liste",
"empty": "Cette liste est vide.",
"emptyMod": "Cette liste est vide. Ajoutez des campagnes pour commencer à la composer.",
"iconPicker": {
"title": "Choisir une icône",
"description": "Choisissez n'importe quelle icône de la bibliothèque Lucide.",
"search": "Rechercher des icônes…",
"empty": "Aucune icône ne correspond à cette recherche."
}
}
},
"moderation": {
@@ -1176,21 +1313,27 @@
"ariaPledge": "Modérer la promesse",
"ariaGroup": "Modérer le groupe",
"failedAction": "Échec de l'action {{action}}",
"approve": "Approuver",
"unapprove": "Désapprouver",
"approvedState": "Approuvée",
"failedReorder": "Échec de la réorganisation",
"hide": "Masquer",
"unhide": "Démasquer",
"hiddenState": "Masquée",
"feature": "Mettre en avant",
"unfeature": "Retirer de la sélection",
"featuredState": "Mise en avant",
"toastApproved": "Approuvée pour la page d'accueil",
"toastUnapproved": "Retirée de la page d'accueil",
"moveToTop": "Déplacer en haut",
"moveUp": "Déplacer vers le haut",
"moveDown": "Déplacer vers le bas",
"addToList": "Ajouter à la liste…",
"dragHandle": "Glisser pour réorganiser (position {{index}})",
"toastHidden": "Masquée",
"toastUnhidden": "Démasquée",
"toastFeatured": "Mise en avant",
"toastUnfeatured": "Retirée de la sélection"
"toastUnfeatured": "Retirée de la sélection",
"toast": {
"movedToTop": "Déplacée en haut",
"movedUp": "Déplacée vers le haut",
"movedDown": "Déplacée vers le bas"
}
}
},
"settings": {
@@ -1546,13 +1689,25 @@
"bitcoinAddress": "Adresse Bitcoin",
"silentPayment": "Adresse de paiement silencieux",
"toLabel": "À",
"clear": "Effacer le destinataire"
"clear": "Effacer le destinataire",
"choosePaymentMethod": "Choisissez un mode de paiement pour continuer"
},
"feeSpeed": {
"fastest": "~10 min",
"halfHour": "~30 min",
"hour": "~1 heure",
"economy": "~1 jour"
"economy": "~1 jour",
"custom": "Personnalisé"
},
"fee": {
"loading": "chargement…",
"unavailable": "indisponible",
"loadFailed": "Impossible de charger les taux de frais.",
"retry": "Réessayer",
"orCustom": "Ou saisissez un taux personnalisé ci-dessous.",
"loadingTiers": "Chargement des taux de frais…",
"customPlaceholder": "p. ex. 5",
"customAriaLabel": "Taux de frais personnalisé en sat/vB"
},
"progress": {
"building": "Construction de la transaction…",
@@ -1568,7 +1723,9 @@
"enterAmount": "Saisissez un montant.",
"insufficient": "Pas assez de Bitcoin pour ce montant + frais réseau.",
"waitingPrice": "En attente du prix BTC…",
"noneYet": "Vous n'avez pas encore de Bitcoin."
"noneYet": "Vous n'avez pas encore de Bitcoin.",
"feesNotLoadedYet": "Les taux de frais ne sont pas encore chargés.",
"feeRateTooLow": "Saisissez un taux de frais d'au moins 1 sat/vB."
},
"scanError": {
"title": "Impossible de lire ce QR code",
@@ -1577,6 +1734,29 @@
"toast": {
"failedTitle": "Échec de la transaction"
},
"broadcastError": {
"feeTooLowTitle": "Frais de réseau trop bas",
"feeTooLowBodyWithMin": "Le réseau Bitcoin rejette ces frais. Le minimum actuel est d'environ {{min}} sat/vB.",
"feeTooLowBody": "Le réseau Bitcoin rejette ces frais. Choisissez un palier plus rapide ou augmentez votre taux personnalisé.",
"rbfTitle": "Le remplacement exige des frais plus élevés",
"rbfBody": "La transaction de remplacement doit payer plus que l'originale. Augmentez les frais et réessayez.",
"mempoolFullTitle": "Le réseau Bitcoin est congestionné",
"mempoolFullBody": "Le mempool est plein et vos frais ne sont pas compétitifs. Augmentez les frais pour passer.",
"networkTitle": "Impossible de joindre le réseau Bitcoin",
"networkBody": "Vérifiez votre connexion et réessayez.",
"mempoolConflictTitle": "Transaction en conflit",
"mempoolConflictBody": "L'une des entrées a déjà été dépensée ou est en train d'être dépensée par une autre transaction.",
"tooLongChainTitle": "Trop de transactions non confirmées",
"tooLongChainBody": "Vous avez une longue chaîne de transactions non confirmées. Attendez qu'une soit confirmée et réessayez.",
"badInputsTitle": "Transaction rejetée",
"badInputsBody": "Le réseau a rejeté cette transaction. Ajustez le montant ou le destinataire et réessayez.",
"absurdlyHighFeeTitle": "Frais anormalement élevés",
"absurdlyHighFeeBody": "Les frais estimés sont étonnamment élevés. Rechargez les taux de frais et réessayez.",
"unknownTitle": "Échec de la transaction",
"useHigherFee": "Utiliser des frais plus élevés",
"tryAgain": "Réessayer",
"atMaxFeeTier": "Vous êtes déjà au palier le plus rapide."
},
"success": {
"title": "Bitcoin envoyé",
"satsAmount": "{{sats}} sats",
@@ -2141,10 +2321,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "À propos d'Agora" },
"payments": { "label": "Dons Bitcoin sur Agora" },
"about-nostr": { "label": "À propos de Nostr" },
"legacy": { "label": "Héritage" }
"getting-started": {
"label": "À propos d'Agora"
},
"payments": {
"label": "Dons Bitcoin sur Agora"
},
"about-nostr": {
"label": "À propos de Nostr"
},
"legacy": {
"label": "Héritage"
}
},
"items": {
"what-is-ditto": {
+185 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "इसे अपना बनाएँ",
"subtitle": "दूसरों को अपने बारे में थोड़ा बताएँ। सब वैकल्पिक है, कभी भी बदल सकते हैं।",
"campaignTitle": "अपने अभियान को एक चेहरा दें",
"campaignSubtitle": "नाम और फ़ोटो लोगों को आपके अभियान से जुड़ने में मदद करते हैं।",
"nameLabel": "दिखने वाला नाम",
"namePlaceholder": "आपका नाम",
"aboutLabel": "बायो",
"aboutPlaceholder": "अपने बारे में थोड़ा सा…",
"avatarLabel": "अवतार",
"uploadAvatar": "अवतार अपलोड करें",
"advanced": "अधिक",
"finish": "पूरा करें",
"saving": "सेव हो रहा है…",
"skip": "अभी छोड़ दें",
@@ -615,10 +618,11 @@
"coverImage": "कवर इमेज",
"description": "विवरण",
"timezone": "टाइमज़ोन",
"publishing": "पब्लिश हो रहा है…",
"uploadingCover": "कवर अपलोड हो रहा है…",
"countrySearchPlaceholder": "देश खोजें",
"imageDropzone": "यहाँ क्लिक करें या इमेज खींचकर डालें"
"imageDropzone": "यहाँ क्लिक करें या इमेज खींचकर डालें",
"countryClearAria": "देश साफ़ करें",
"flagOfAria": "{{name}} का झंडा",
"countryHint": "देश के क्रम के लिए <0>i: iso3166:{{code}}</0> पब्लिश करता है।"
},
"organizationContext": {
"attachedToGroup": "ग्रुप से जुड़ी",
@@ -652,8 +656,8 @@
"myPledgesTagline": "आपके बनाए हुए प्लेज।",
"featuredPledges": "विशेष प्लेज",
"featuredPledgesTagline": "{{appName}} टीम द्वारा चुने गए प्लेज।",
"allPledges": "सभी प्लेज",
"allPledgesTagline": "नेटवर्क पर मौजूद हर प्लेज देखें।",
"allPledges": "प्लेज",
"allPledgesTagline": "मॉडरेटर द्वारा चयनित। हर प्लेज देखने के लिए खोजें या क्रमबद्ध करें।",
"sectionActive": "सक्रिय प्लेज",
"sectionUpcoming": "आने वाले प्लेज",
"sectionPast": "पिछले प्लेज",
@@ -711,11 +715,7 @@
"titlePlaceholder": "एक beach cleanup का दस्तावेज़",
"country": "देश",
"countryPlaceholder": "देश खोजें",
"countryClearAria": "देश साफ़ करें",
"flagOfAria": "{{name}} का झंडा",
"countryHint": "देश के क्रम के लिए <0>i: iso3166:{{code}}</0> पब्लिश करता है।",
"tags": "टैग",
"tagsPlaceholder": "beach-cleanup, protest-documentation, internet-blackout",
"coverImage": "कवर इमेज",
"description": "विवरण",
"descriptionPlaceholder": "उस कार्रवाई, सबूत, या नतीजे को समझाएँ जिसके लिए आप प्रेरणा देना चाहते हैं, सबमिशन में क्या होना चाहिए, और आप उनका मूल्यांकन कैसे करेंगे...",
@@ -725,8 +725,6 @@
"timezone": "टाइमज़ोन",
"timezoneNote": "शुरू और अंतिम समय इसी टाइमज़ोन में समझे जाएँगे।",
"submit": "प्लेज बनाएँ",
"publishing": "पब्लिश हो रहा है…",
"uploadingCover": "कवर अपलोड हो रहा है…",
"altText": "{{appName}} प्लेज: {{title}}",
"successToast": "प्लेज बन गया",
"errorToast": "प्लेज नहीं बना सके",
@@ -737,7 +735,18 @@
"errorPledgeInvalid": "प्लेज राशि एक धनात्मक USD राशि होनी चाहिए।",
"errorPriceUnavailable": "प्लेज राशि की गणना के लिए BTC/USD दाम का इंतज़ार है।",
"errorCoverInvalid": "कवर इमेज एक मान्य https:// URL होनी चाहिए।",
"errorDeadlinePast": "अंतिम तारीख़ बीते समय की नहीं हो सकती।"
"errorDeadlinePast": "अंतिम तारीख़ बीते समय की नहीं हो सकती।",
"wizard": {
"titleStepTitle": "अपने प्लेज को नाम दें",
"titleStepSubtitle": "एक स्पष्ट माँग और जो फंड करेंगे उसकी छोटी व्याख्या।",
"pledgeStepTitle": "अपना प्लेज तय करें",
"pledgeStepSubtitle": "आप कितना देंगे, USD में, और एक वैकल्पिक अंतिम तारीख़।",
"coverStepTitle": "एक कवर इमेज जोड़ें",
"coverStepSubtitle": "एक इमेज हर कार्ड पर प्लेज को आगे बढ़ाती है।",
"tagsStepTitle": "देश और श्रेणियाँ",
"tagsStepSubtitle": "सही लोगों को आपका प्लेज ढूँढने में मदद करें।",
"launchNow": "छोड़ें और लॉन्च करें"
}
},
"detail": {
"seoTitle": "{{title}} | {{appName}} प्लेज",
@@ -787,8 +796,8 @@
"myGroupsTagline": "जिन ग्रुप को आपने बनाया, मॉडरेट किया, या फ़ॉलो किया है।",
"featuredGroups": "फ़ीचर्ड ग्रुप",
"featuredGroupsTagline": "आपके ध्यान के लायक ख़ास ग्रुप।",
"allGroups": "सभी ग्रुप",
"allGroupsTagline": "{{appName}} ग्रुप ब्राउज़ करें, या Nostr के हर ग्रुप में खोजें।",
"allGroups": "ग्रुप",
"allGroupsTagline": "मॉडरेटर द्वारा चयनित। हर ग्रुप देखने के लिए खोजें या क्रमबद्ध करें।",
"loginToSeeTitle": "अपने ग्रुप देखने के लिए लॉग इन करें",
"loginToSeeBody": "आपने जो ग्रुप बनाए या मॉडरेट किए हैं वे यहाँ दिखेंगे।",
"noGroupsTitle": "अभी कोई ग्रुप नहीं",
@@ -839,9 +848,6 @@
"descriptionPlaceholder": "यह ग्रुप किस बारे में है?",
"country": "देश",
"countryPlaceholder": "देश खोजें",
"countryClearAria": "देश साफ़ करें",
"flagOfAria": "{{name}} का झंडा",
"countryHint": "देश के क्रम के लिए <0>i: iso3166:{{code}}</0> पब्लिश करता है।",
"tags": "टैग",
"tagsPlaceholder": "mutual-aid, local-news, digital-rights",
"coverImage": "कवर इमेज",
@@ -865,7 +871,18 @@
"errorNameInvalid": "नाम में अक्षर या अंक होने चाहिए ताकि ग्रुप URL बन सके।",
"errorEditLatestMissing": "इस ग्रुप का सबसे नया संस्करण अपडेट करने के लिए नहीं मिला।",
"errorCoverInvalid": "कवर इमेज एक मान्य https:// URL होनी चाहिए।",
"errorSlugCollision": "आपके पास पहले से \"{{slug}}\" पहचानकर्ता वाला ग्रुप है। दूसरा नाम चुनें।"
"errorSlugCollision": "आपके पास पहले से \"{{slug}}\" पहचानकर्ता वाला ग्रुप है। दूसरा नाम चुनें।",
"wizard": {
"nameStepTitle": "अपने ग्रुप को नाम दें",
"nameStepSubtitle": "एक छोटा, स्पष्ट नाम जिसे सदस्य पहचान सकें।",
"coverStepTitle": "एक कवर इमेज जोड़ें",
"coverStepSubtitle": "एक इमेज हर कार्ड पर ग्रुप को आगे बढ़ाती है।",
"moderatorsStepTitle": "मॉडरेटर आमंत्रित करें",
"moderatorsStepSubtitle": "वैकल्पिक — वे आपके साथ कंटेंट मंज़ूर कर सकते हैं और सदस्यों को हटा सकते हैं।",
"tagsStepTitle": "देश और श्रेणियाँ",
"tagsStepSubtitle": "सही लोगों को आपका ग्रुप ढूँढने में मदद करें।",
"launchNow": "छोड़ें और लॉन्च करें"
}
},
"detail": {
"by": "द्वारा",
@@ -925,9 +942,19 @@
"myWalletDefault": "मेरा वॉलेट",
"walletChoose": "वॉलेट चुनें",
"walletCustom": "कस्टम",
"walletUseCustom": "इसके बजाय कोई दूसरा वॉलेट उपयोग करें",
"walletDestinationLanding": "डोनेशन यहाँ आएँगे",
"walletDestinationNote": "यह वॉलेट आपके कैंपेन के डोनेशन डेस्टिनेशन के रूप में प्रकाशित किया जाएगा।",
"walletUseMine": "मेरे Agora वॉलेट का उपयोग करें",
"acceptAll": "सभी भुगतान प्रकार स्वीकार करें",
"acceptPublic": "केवल सार्वजनिक भुगतान स्वीकार करें",
"acceptPrivate": "केवल निजी भुगतान स्वीकार करें",
"acceptAllShort": "सभी स्वीकारें",
"acceptPublicShort": "केवल सार्वजनिक",
"acceptPrivateShort": "केवल निजी",
"acceptAllHint": "सार्वजनिक ऑन-चेन और निजी साइलेंट पेमेंट दोनों स्वीकार करें।",
"acceptPublicHint": "केवल सार्वजनिक एड्रेस पर ऑन-चेन दान स्वीकार करें।",
"acceptPrivateHint": "केवल साइलेंट पेमेंट स्वीकार करें — दानदाता के एड्रेस निजी रहते हैं।",
"customWalletIntro": "एक Bitcoin एड्रेस, एक साइलेंट-पेमेंट कोड, या दोनों दर्ज करें। कम से कम एक ज़रूरी है।",
"bitcoinAddress": "Bitcoin एड्रेस",
"bitcoinAddressPlaceholder": "bc1q… या bc1p…",
@@ -937,11 +964,26 @@
"spInvalid": "यह कोई पहचाना BIP-352 साइलेंट-पेमेंट कोड नहीं है (sp1…)।",
"country": "देश",
"countryPlaceholder": "देश खोजें",
"countryClearAria": "देश साफ़ करें",
"flagOfAria": "{{name}} का झंडा",
"countryHint": "देश के क्रम के लिए <0>i: iso3166:{{code}}</0> पब्लिश करता है।",
"tags": "टैग",
"tagsPlaceholder": "legal-defense, mutual-aid, local-news",
"categories": {
"humanRights": "मानवाधिकार",
"democracy": "लोकतंत्र",
"pressFreedom": "प्रेस की स्वतंत्रता",
"politicalPrisoners": "राजनीतिक क़ैदी",
"humanitarianAid": "मानवीय सहायता",
"civilResistance": "नागरिक प्रतिरोध",
"digitalRights": "डिजिटल अधिकार",
"antiCorruption": "भ्रष्टाचार-विरोधी",
"womenGirls": "महिलाएँ और लड़कियाँ",
"refugees": "शरणार्थी और निर्वासित",
"legalAid": "क़ानूनी सहायता",
"emergencyRelief": "आपातकालीन राहत",
"animalRights": "पशु अधिकार",
"education": "शिक्षा",
"medical": "चिकित्सा",
"community": "कम्युनिटी"
},
"banner": "बैनर इमेज",
"story": "कहानी",
"storyPlaceholder": "पृष्ठभूमि, किसे लाभ होगा, और फंड का उपयोग कैसे होगा, यह शेयर करें।",
@@ -981,7 +1023,21 @@
"errorHdDeriveFailed": "आपके वॉलेट से नया ऑन-चेन एड्रेस derive नहीं कर सके।",
"errorHdDeriveInvalid": "Derive किया गया वॉलेट एड्रेस सत्यापन में विफल। कृपया एक कस्टम एड्रेस जोड़ें।",
"errorWalletRequiredFallback": "वॉलेट endpoint ज़रूरी है।",
"errorPublishedInvalid": "पब्लिश किया गया event सत्यापन में विफल। कृपया रिफ्रेश करके दोबारा कोशिश करें।"
"errorPublishedInvalid": "पब्लिश किया गया event सत्यापन में विफल। कृपया रिफ्रेश करके दोबारा कोशिश करें।",
"wizard": {
"titleStepTitle": "अपने कैंपेन को नाम दें",
"titleStepSubtitle": "एक छोटा, स्पष्ट नाम जिसे डोनर पहचान सकें।",
"walletStepTitle": "चुनें कि डोनेशन कौन प्राप्त करेगा",
"walletStepSubtitle": "आपका Agora वॉलेट इस कैंपेन के लिए Bitcoin डोनेशन प्राप्त करने को तैयार है।",
"bannerStepTitle": "एक बैनर जोड़ें",
"bannerStepSubtitle": "एक प्रभावशाली इमेज हर कार्ड पर कैंपेन को आगे बढ़ाती है।",
"storyStepTitle": "अपनी कहानी बताएँ",
"storyStepSubtitle": "किसे फ़ायदा होगा और फंड का इस्तेमाल कैसे होगा।",
"next": "आगे",
"back": "पीछे",
"skip": "छोड़ें",
"launchNow": "छोड़ें और लॉन्च करें"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} फंडरेज़र",
@@ -1131,19 +1187,15 @@
"startCampaign": "कैंपेन शुरू करें",
"howItWorks": "यह कैसे काम करता है",
"exploreCampaigns": "कैंपेन देखें",
"featured": "फ़ीचर्ड",
"featuredDesc": "{{appName}} टीम द्वारा चुने गए कैंपेन",
"community": "कम्युनिटी कैंपेन",
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
"browseAll": "सभी कैंपेन देखें →",
"pending": "मंज़ूरी का इंतज़ार",
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
"wlcDesc": "World Liberty Congress द्वारा चुने गए कैंपेन।",
"allCampaigns": "सभी कैंपेन",
"allCampaignsDesc": "नेटवर्क के सभी कैंपेन, कालक्रम के अनुसार।",
"browseAll": "सभी कैंपेन देखें",
"hidden": "छुपा हुआ",
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
"yourCampaigns": "आपके कैंपेन",
"yourCampaignsDesc": "आपके कैंपेन Nostr पर लाइव हैं और डोनेशन कैंपेन लिंक से काम करते हैं। ये होमपेज पर तब दिखेंगे जब Team Soapbox का कोई मॉडरेटर इन्हें मंज़ूरी देगा।",
"yourCampaignsDesc": "आपके कैंपेन Nostr पर लाइव हैं और डोनेशन कैंपेन लिंक से काम करते हैं। सभी कैंपेन /campaigns पर देखें; {{appName}} टीम होमपेज पर चुनिंदा कैंपेन फ़ीचर करती है।",
"empty": "अभी कोई कैंपेन नहीं",
"emptyHint": "{{appName}} पर फंडरेज़र शुरू करने वाले पहले बनें। अपनी कहानी बताएँ, लाभार्थी चुनें, और लिंक शेयर करें।",
"searchPlaceholder": "कैंपेन खोजें…",
@@ -1152,10 +1204,10 @@
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।"
},
"all": {
"title": "सभी कैंपेन",
"title": "कैंपेन",
"seoTitle": "सभी कैंपेन",
"description": "Agora पर पब्लिश हुए हर कैंपेन को देखें।",
"sectionTagline": "नेटवर्क पर हर मक़सद को देखें।",
"sectionTagline": "पहले फ़ीचर्ड कैंपेन, फिर बाक़ी नेटवर्क। नतीजों को परिष्कृत करने के लिए खोजें या क्रमबद्ध करें।",
"heroKicker": "कैंपेन",
"heroHeading": "हर मक़सद,",
"heroHeadingLine2": "एक ही जगह।",
@@ -1176,6 +1228,54 @@
"allHiddenHint": "नेटवर्क पर हर कैंपेन मॉडरेटरों ने छुपा रखा है। उन्हें देखने के लिए “छुपे हुए दिखाएँ” टॉगल करें।",
"empty": "अभी कोई कैंपेन नहीं",
"emptyHint": "अभी कोई कैंपेन पब्लिश नहीं हुआ है। पहले बनें।"
},
"lists": {
"stripAria": "क्यूरेटेड कैंपेन टॉपिक सूचियाँ",
"create": "नई सूची",
"createDesc": "एक नई टॉपिक सूची बनाएँ। किसी भी कैंपेन पेज से कैंपेन उसमें जोड़कर क्यूरेट करें।",
"createSubmit": "सूची बनाएँ",
"createFailed": "सूची नहीं बनाई जा सकी",
"edit": "सूची संपादित करें",
"editDesc": "सूची का शीर्षक, विवरण या आइकन अपडेट करें।",
"editSubmit": "बदलाव सहेजें",
"updateFailed": "सूची अपडेट नहीं हो सकी",
"delete": "सूची हटाएँ",
"deleteFailed": "सूची नहीं हटाई जा सकी",
"deleteConfirmTitle": "यह सूची हटाएँ?",
"deleteConfirmDesc": "\"{{title}}\" को टॉपिक स्ट्रिप से हटा दिया जाएगा। कैंपेन ख़ुद प्रभावित नहीं होंगे।",
"titleField": "शीर्षक",
"titlePlaceholder": "जैसे प्रेस की आज़ादी",
"descriptionField": "विवरण",
"descriptionPlaceholder": "एक छोटा-सा ब्योरा जो बताए कि इस सूची में क्या आता है।",
"iconField": "आइकन",
"menuAria": "{{title}} सूची विकल्प",
"listActions": "सूची कार्रवाइयाँ",
"memberMenuAria": "कैंपेन सूची विकल्प",
"backToCampaigns": "कैंपेन पर वापस जाएँ",
"detailTitle": "कैंपेन सूची",
"campaignsCount_one": "{{count}} कैंपेन",
"campaignsCount_other": "{{count}} कैंपेन",
"addCampaign": "कैंपेन जोड़ें",
"addCampaignDesc": "नेटवर्क खोजें और इस सूची में जोड़ने के लिए कोई कैंपेन चुनें।",
"addFailed": "सूची में जोड़ा नहीं जा सका",
"addToList": "जोड़ें",
"alreadyAdded": "जोड़ा गया",
"added": "जोड़ा गया",
"membershipTitle": "सूचियों में जोड़ें",
"membershipDesc": "चुनें कि \"{{title}}\" किन सूचियों में दिखाई देना चाहिए।",
"membershipEmpty": "अभी कोई सूची नहीं है। क्यूरेट करना शुरू करने के लिए एक बनाएँ।",
"searchPlaceholder": "कैंपेन खोजें…",
"searchEmpty": "इस खोज से मेल खाने वाला कोई कैंपेन नहीं है।",
"removeFromList": "सूची से हटाएँ",
"removeFailed": "सूची से हटाया नहीं जा सका",
"empty": "यह सूची ख़ाली है।",
"emptyMod": "यह सूची ख़ाली है। इसे क्यूरेट करना शुरू करने के लिए कैंपेन जोड़ें।",
"iconPicker": {
"title": "एक आइकन चुनें",
"description": "Lucide लाइब्रेरी से कोई भी आइकन चुनें।",
"search": "आइकन खोजें…",
"empty": "इस खोज से मेल खाने वाला कोई आइकन नहीं है।"
}
}
},
"moderation": {
@@ -1186,21 +1286,27 @@
"ariaPledge": "प्लेज मॉडरेट करें",
"ariaGroup": "ग्रुप मॉडरेट करें",
"failedAction": "{{action}} नहीं हो सका",
"approve": "मंज़ूरी दें",
"unapprove": "मंज़ूरी हटाएँ",
"approvedState": "मंज़ूर",
"hide": "छुपाएँ",
"unhide": "अनहाइड करें",
"hiddenState": "छुपा हुआ",
"feature": "फ़ीचर करें",
"unfeature": "फ़ीचर से हटाएँ",
"featuredState": "फ़ीचर्ड",
"toastApproved": "होमपेज के लिए मंज़ूरी दी गई",
"toastUnapproved": "होमपेज से हटाया गया",
"toastHidden": "छुपा दिया गया",
"toastUnhidden": "अनहाइड कर दिया गया",
"toastFeatured": "फ़ीचर कर दिया गया",
"toastUnfeatured": "फ़ीचर से हटाया गया"
"toastUnfeatured": "फ़ीचर से हटाया गया",
"failedReorder": "क्रम बदलने में विफल",
"moveToTop": "सबसे ऊपर ले जाएँ",
"moveUp": "ऊपर ले जाएँ",
"moveDown": "नीचे ले जाएँ",
"addToList": "सूची में जोड़ें…",
"dragHandle": "क्रम बदलने के लिए खींचें (स्थिति {{index}})",
"toast": {
"movedToTop": "सबसे ऊपर ले जाया गया",
"movedUp": "ऊपर ले जाया गया",
"movedDown": "नीचे ले जाया गया"
}
}
},
"settings": {
@@ -1492,13 +1598,25 @@
"bitcoinAddress": "Bitcoin एड्रेस",
"silentPayment": "साइलेंट पेमेंट एड्रेस",
"toLabel": "किसे",
"clear": "प्राप्तकर्ता हटाएँ"
"clear": "प्राप्तकर्ता हटाएँ",
"choosePaymentMethod": "जारी रखने के लिए भुगतान विधि चुनें"
},
"feeSpeed": {
"fastest": "~10 मिनट",
"halfHour": "~30 मिनट",
"hour": "~1 घंटा",
"economy": "~1 दिन"
"economy": "~1 दिन",
"custom": "कस्टम"
},
"fee": {
"loading": "लोड हो रहा है…",
"unavailable": "अनुपलब्ध",
"loadFailed": "Fee rates लोड नहीं हो सकीं।",
"retry": "फिर कोशिश करें",
"orCustom": "या नीचे एक कस्टम रेट डालें।",
"loadingTiers": "Fee rates लोड हो रही हैं…",
"customPlaceholder": "उदा. 5",
"customAriaLabel": "sat/vB में कस्टम fee rate"
},
"progress": {
"building": "Transaction बन रहा है…",
@@ -1514,11 +1632,36 @@
"enterAmount": "एक राशि दर्ज करें।",
"insufficient": "इस राशि + नेटवर्क फ़ीस के लिए Bitcoin पर्याप्त नहीं।",
"waitingPrice": "BTC दाम का इंतज़ार है…",
"noneYet": "आपके पास अभी कोई Bitcoin नहीं है।"
"noneYet": "आपके पास अभी कोई Bitcoin नहीं है।",
"feesNotLoadedYet": "Fee rates अभी तक लोड नहीं हुई हैं।",
"feeRateTooLow": "कम से कम 1 sat/vB का fee rate डालें।"
},
"toast": {
"failedTitle": "Transaction विफल"
},
"broadcastError": {
"feeTooLowTitle": "नेटवर्क fee बहुत कम है",
"feeTooLowBodyWithMin": "Bitcoin नेटवर्क इस fee को अस्वीकार कर रहा है। अभी न्यूनतम लगभग {{min}} sat/vB है।",
"feeTooLowBody": "Bitcoin नेटवर्क इस fee को अस्वीकार कर रहा है। कोई तेज़ tier चुनें या अपना custom rate बढ़ाएँ।",
"rbfTitle": "Replacement के लिए ज़्यादा fee चाहिए",
"rbfBody": "Replacement transaction को मूल से ज़्यादा fee देनी होगी। Fee बढ़ाकर दोबारा कोशिश करें।",
"mempoolFullTitle": "Bitcoin नेटवर्क पर भीड़ है",
"mempoolFullBody": "Mempool भरा हुआ है और आपकी fee प्रतिस्पर्धी नहीं है। आगे बढ़ने के लिए fee बढ़ाएँ।",
"networkTitle": "Bitcoin नेटवर्क तक नहीं पहुँचा जा सका",
"networkBody": "अपना कनेक्शन जाँचें और दोबारा कोशिश करें।",
"mempoolConflictTitle": "विरोधाभासी transaction",
"mempoolConflictBody": "एक input पहले ही खर्च हो चुका है या किसी दूसरे transaction द्वारा खर्च किया जा रहा है।",
"tooLongChainTitle": "बहुत सारे unconfirmed transactions",
"tooLongChainBody": "आपके पास unconfirmed transactions की लंबी chain है। किसी एक के confirm होने का इंतज़ार करें और दोबारा कोशिश करें।",
"badInputsTitle": "Transaction अस्वीकृत हो गया",
"badInputsBody": "नेटवर्क ने इस transaction को अस्वीकार कर दिया। राशि या प्राप्तकर्ता बदलकर दोबारा कोशिश करें।",
"absurdlyHighFeeTitle": "Fee असामान्य रूप से ज़्यादा है",
"absurdlyHighFeeBody": "अनुमानित fee संदिग्ध रूप से ज़्यादा है। Fee rates दोबारा लोड करें और कोशिश करें।",
"unknownTitle": "Transaction विफल",
"useHigherFee": "ज़्यादा fee इस्तेमाल करें",
"tryAgain": "दोबारा कोशिश करें",
"atMaxFeeTier": "आप पहले से सबसे तेज़ tier पर हैं।"
},
"scanError": {
"title": "वह QR कोड पढ़ा नहीं जा सका",
"description": "एक Bitcoin एड्रेस, साइलेंट पेमेंट एड्रेस (sp1…), या bitcoin: URI अपेक्षित था।"
+185 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "Jadikan milik Anda",
"subtitle": "Beri tahu orang lain sedikit tentang diri Anda. Semua opsional, bisa diubah kapan saja.",
"campaignTitle": "Beri wajah pada kampanye Anda",
"campaignSubtitle": "Nama dan foto membantu orang terhubung dengan kampanye Anda.",
"nameLabel": "Nama tampilan",
"namePlaceholder": "Nama Anda",
"aboutLabel": "Bio",
"aboutPlaceholder": "Sedikit tentang Anda…",
"avatarLabel": "Avatar",
"uploadAvatar": "Unggah avatar",
"advanced": "Lainnya",
"finish": "Selesai",
"saving": "Menyimpan…",
"skip": "Lewati dulu",
@@ -615,10 +618,11 @@
"coverImage": "Gambar sampul",
"description": "Deskripsi",
"timezone": "Zona waktu",
"publishing": "Memublikasikan…",
"uploadingCover": "Mengunggah sampul…",
"countrySearchPlaceholder": "Cari negara",
"imageDropzone": "Klik atau seret gambar ke sini"
"imageDropzone": "Klik atau seret gambar ke sini",
"countryClearAria": "Hapus negara",
"flagOfAria": "Bendera {{name}}",
"countryHint": "Memublikasikan <0>i: iso3166:{{code}}</0> untuk pengurutan negara."
},
"organizationContext": {
"attachedToGroup": "Terlampir ke grup",
@@ -652,8 +656,8 @@
"myPledgesTagline": "Ikrar yang Anda buat.",
"featuredPledges": "Ikrar pilihan",
"featuredPledgesTagline": "Ikrar yang disorot oleh tim {{appName}}.",
"allPledges": "Semua ikrar",
"allPledgesTagline": "Jelajahi setiap ikrar di jaringan.",
"allPledges": "Ikrar",
"allPledgesTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi setiap ikrar.",
"sectionActive": "Ikrar aktif",
"sectionUpcoming": "Ikrar mendatang",
"sectionPast": "Ikrar lampau",
@@ -711,11 +715,7 @@
"titlePlaceholder": "Mendokumentasikan pembersihan pantai",
"country": "Negara",
"countryPlaceholder": "Cari negara",
"countryClearAria": "Hapus negara",
"flagOfAria": "Bendera {{name}}",
"countryHint": "Memublikasikan <0>i: iso3166:{{code}}</0> untuk pengurutan negara.",
"tags": "Tag",
"tagsPlaceholder": "pembersihan-pantai, dokumentasi-protes, pemadaman-internet",
"coverImage": "Gambar sampul",
"description": "Deskripsi",
"descriptionPlaceholder": "Jelaskan aksi, bukti, atau hasil yang ingin Anda inspirasi, apa yang harus disertakan dalam kiriman, dan bagaimana Anda akan mengevaluasinya...",
@@ -725,8 +725,6 @@
"timezone": "Zona waktu",
"timezoneNote": "Waktu mulai dan tenggat akan diinterpretasikan dalam zona waktu ini.",
"submit": "Buat ikrar",
"publishing": "Memublikasikan…",
"uploadingCover": "Mengunggah sampul…",
"altText": "Ikrar {{appName}}: {{title}}",
"successToast": "Ikrar dibuat",
"errorToast": "Tidak dapat membuat ikrar",
@@ -737,7 +735,18 @@
"errorPledgeInvalid": "Jumlah ikrar harus berupa nilai USD positif.",
"errorPriceUnavailable": "Menunggu harga BTC/USD untuk menghitung jumlah ikrar.",
"errorCoverInvalid": "Gambar sampul harus berupa URL https:// yang valid.",
"errorDeadlinePast": "Tenggat tidak boleh di masa lalu."
"errorDeadlinePast": "Tenggat tidak boleh di masa lalu.",
"wizard": {
"titleStepTitle": "Beri nama ikrar Anda",
"titleStepSubtitle": "Permintaan yang jelas dan penjelasan singkat tentang apa yang akan Anda danai.",
"pledgeStepTitle": "Tetapkan ikrar Anda",
"pledgeStepSubtitle": "Berapa yang akan Anda bayar, dalam USD, dan tenggat opsional.",
"coverStepTitle": "Tambahkan gambar sampul",
"coverStepSubtitle": "Satu gambar membawa ikrar di setiap kartu.",
"tagsStepTitle": "Negara dan kategori",
"tagsStepSubtitle": "Bantu orang yang tepat menemukan ikrar Anda.",
"launchNow": "Lewati Berikutnya & Luncurkan"
}
},
"detail": {
"seoTitle": "{{title}} | Ikrar {{appName}}",
@@ -787,8 +796,8 @@
"myGroupsTagline": "Grup yang Anda dirikan, moderasi, atau ikuti.",
"featuredGroups": "Grup unggulan",
"featuredGroupsTagline": "Grup menonjol yang layak Anda perhatikan.",
"allGroups": "Semua grup",
"allGroupsTagline": "Jelajahi grup {{appName}}, atau cari di setiap grup di Nostr.",
"allGroups": "Grup",
"allGroupsTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi setiap grup.",
"loginToSeeTitle": "Masuk untuk melihat grup Anda",
"loginToSeeBody": "Grup yang Anda dirikan atau moderasi akan muncul di sini.",
"noGroupsTitle": "Belum ada grup",
@@ -839,9 +848,6 @@
"descriptionPlaceholder": "Tentang apa grup ini?",
"country": "Negara",
"countryPlaceholder": "Cari negara",
"countryClearAria": "Hapus negara",
"flagOfAria": "Bendera {{name}}",
"countryHint": "Memublikasikan <0>i: iso3166:{{code}}</0> untuk pengurutan negara.",
"tags": "Tag",
"tagsPlaceholder": "bantuan-bersama, berita-lokal, hak-digital",
"coverImage": "Gambar sampul",
@@ -865,7 +871,18 @@
"errorNameInvalid": "Nama harus mengandung huruf atau angka agar URL grup bisa dibuat.",
"errorEditLatestMissing": "Tidak dapat menemukan versi terbaru grup ini untuk diperbarui.",
"errorCoverInvalid": "Gambar sampul harus berupa URL https:// yang valid.",
"errorSlugCollision": "Anda sudah memiliki grup dengan pengenal \"{{slug}}\". Pilih nama lain."
"errorSlugCollision": "Anda sudah memiliki grup dengan pengenal \"{{slug}}\". Pilih nama lain.",
"wizard": {
"nameStepTitle": "Beri nama grup Anda",
"nameStepSubtitle": "Nama singkat dan jelas yang mudah dikenali anggota.",
"coverStepTitle": "Tambahkan gambar sampul",
"coverStepSubtitle": "Satu gambar membawa grup di setiap kartu.",
"moderatorsStepTitle": "Undang moderator",
"moderatorsStepSubtitle": "Opsional — mereka dapat menyetujui konten dan menghapus anggota bersama Anda.",
"tagsStepTitle": "Negara dan kategori",
"tagsStepSubtitle": "Bantu orang yang tepat menemukan grup Anda.",
"launchNow": "Lewati Berikutnya & Luncurkan"
}
},
"detail": {
"by": "oleh",
@@ -925,9 +942,19 @@
"myWalletDefault": "Dompet saya",
"walletChoose": "Pilih dompet",
"walletCustom": "Kustom",
"walletUseCustom": "Gunakan dompet lain",
"walletDestinationLanding": "Donasi akan masuk ke sini",
"walletDestinationNote": "Dompet ini akan dipublikasikan sebagai tujuan donasi untuk kampanye Anda.",
"walletUseMine": "Gunakan dompet Agora saya",
"acceptAll": "Terima semua jenis pembayaran",
"acceptPublic": "Hanya terima pembayaran publik",
"acceptPrivate": "Hanya terima pembayaran privat",
"acceptAllShort": "Semua",
"acceptPublicShort": "Hanya Publik",
"acceptPrivateShort": "Hanya Privat",
"acceptAllHint": "Terima pembayaran publik on-chain maupun silent-payment privat.",
"acceptPublicHint": "Hanya terima donasi on-chain ke alamat publik.",
"acceptPrivateHint": "Hanya terima silent-payment — alamat donatur tetap privat.",
"customWalletIntro": "Masukkan alamat Bitcoin, kode silent-payment, atau keduanya. Setidaknya satu wajib diisi.",
"bitcoinAddress": "Alamat Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… atau bc1p…",
@@ -937,11 +964,26 @@
"spInvalid": "Bukan kode silent-payment BIP-352 yang dikenal (sp1…).",
"country": "Negara",
"countryPlaceholder": "Cari negara",
"countryClearAria": "Hapus negara",
"flagOfAria": "Bendera {{name}}",
"countryHint": "Memublikasikan <0>i: iso3166:{{code}}</0> untuk pengurutan negara.",
"tags": "Tag",
"tagsPlaceholder": "pembelaan-hukum, bantuan-bersama, berita-lokal",
"categories": {
"humanRights": "Hak Asasi Manusia",
"democracy": "Demokrasi",
"pressFreedom": "Kebebasan Pers",
"politicalPrisoners": "Tahanan Politik",
"humanitarianAid": "Bantuan Kemanusiaan",
"civilResistance": "Perlawanan Sipil",
"digitalRights": "Hak Digital",
"antiCorruption": "Antikorupsi",
"womenGirls": "Perempuan & Anak Perempuan",
"refugees": "Pengungsi & Orang Buangan",
"legalAid": "Bantuan Hukum",
"emergencyRelief": "Bantuan Darurat",
"animalRights": "Hak Hewan",
"education": "Pendidikan",
"medical": "Medis",
"community": "Komunitas"
},
"banner": "Gambar banner",
"story": "Cerita",
"storyPlaceholder": "Bagikan latar belakang, siapa yang diuntungkan, dan bagaimana dana akan digunakan.",
@@ -981,7 +1023,21 @@
"errorHdDeriveFailed": "Tidak dapat menurunkan alamat on-chain baru dari dompet Anda.",
"errorHdDeriveInvalid": "Alamat dompet turunan gagal validasi. Silakan tambahkan alamat kustom sebagai gantinya.",
"errorWalletRequiredFallback": "Titik dompet wajib diisi.",
"errorPublishedInvalid": "Event yang dipublikasikan gagal validasi. Silakan muat ulang dan coba lagi."
"errorPublishedInvalid": "Event yang dipublikasikan gagal validasi. Silakan muat ulang dan coba lagi.",
"wizard": {
"titleStepTitle": "Beri nama kampanye Anda",
"titleStepSubtitle": "Nama singkat dan jelas yang mudah dikenali donatur.",
"walletStepTitle": "Pilih siapa yang menerima donasi",
"walletStepSubtitle": "Dompet Agora Anda siap menerima donasi Bitcoin untuk kampanye ini.",
"bannerStepTitle": "Tambahkan banner",
"bannerStepSubtitle": "Satu gambar menarik membawa kampanye di setiap kartu.",
"storyStepTitle": "Ceritakan kisah Anda",
"storyStepSubtitle": "Siapa yang terbantu dan bagaimana dana akan digunakan.",
"next": "Berikutnya",
"back": "Kembali",
"skip": "Lewati",
"launchNow": "Lewati Berikutnya & Luncurkan"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Penggalangan Dana {{appName}}",
@@ -1131,19 +1187,15 @@
"startCampaign": "Mulai kampanye",
"howItWorks": "Cara kerjanya",
"exploreCampaigns": "Jelajahi kampanye",
"featured": "Unggulan",
"featuredDesc": "Kampanye pilihan tangan dari tim {{appName}}.",
"community": "Kampanye Komunitas",
"communityDesc": "Bantu danai perubahan yang patut dilakukan.",
"browseAll": "Telusuri semua kampanye →",
"pending": "Menunggu persetujuan",
"pendingDesc": "Kampanye di jaringan yang belum disetujui atau disembunyikan oleh moderator Team Soapbox.",
"pendingEmpty": "Tidak ada yang menunggu peninjauan.",
"wlcDesc": "Kampanye yang dikurasi oleh World Liberty Congress.",
"allCampaigns": "Semua kampanye",
"allCampaignsDesc": "Semua kampanye di jaringan, dalam urutan kronologis.",
"browseAll": "Telusuri semua kampanye",
"hidden": "Tersembunyi",
"hiddenDesc": "Kampanye yang disembunyikan dari beranda publik. Gunakan menu kebab pada kartu untuk menampilkannya kembali.",
"hiddenEmpty": "Tidak ada kampanye yang sedang disembunyikan.",
"yourCampaigns": "Kampanye Anda",
"yourCampaignsDesc": "Kampanye Anda aktif di Nostr dan donasi berfungsi melalui tautan kampanye. Mereka akan muncul di beranda setelah moderator Team Soapbox menyetujuinya.",
"yourCampaignsDesc": "Kampanye Anda aktif di Nostr dan donasi berfungsi melalui tautan kampanye. Telusuri semua kampanye di /campaigns; tim {{appName}} menampilkan pilihan kurasi di beranda.",
"empty": "Belum ada kampanye",
"emptyHint": "Jadilah yang pertama memulai penggalangan dana di {{appName}}. Ceritakan kisah Anda, pilih penerima manfaat, dan bagikan tautannya.",
"searchPlaceholder": "Cari kampanye…",
@@ -1152,10 +1204,10 @@
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian."
},
"all": {
"title": "Semua Kampanye",
"title": "Kampanye",
"seoTitle": "Semua kampanye",
"description": "Telusuri setiap kampanye yang dipublikasikan di Agora.",
"sectionTagline": "Jelajahi setiap aksi di jaringan.",
"sectionTagline": "Kampanye unggulan dulu, kemudian sisanya dari jaringan. Cari atau urutkan untuk menyaring.",
"heroKicker": "Kampanye",
"heroHeading": "Setiap aksi,",
"heroHeadingLine2": "dalam satu tempat.",
@@ -1176,6 +1228,54 @@
"allHiddenHint": "Setiap kampanye di jaringan telah disembunyikan oleh moderator. Aktifkan “Tampilkan yang tersembunyi” untuk melihatnya.",
"empty": "Belum ada kampanye",
"emptyHint": "Belum ada kampanye yang dipublikasikan. Jadilah yang pertama."
},
"lists": {
"stripAria": "Daftar topik kampanye terkurasi",
"create": "Daftar baru",
"createDesc": "Buat daftar topik baru. Kurasi kampanye ke dalamnya dari halaman kampanye mana pun.",
"createSubmit": "Buat daftar",
"createFailed": "Gagal membuat daftar",
"edit": "Edit daftar",
"editDesc": "Perbarui judul, deskripsi, atau ikon daftar.",
"editSubmit": "Simpan perubahan",
"updateFailed": "Gagal memperbarui daftar",
"delete": "Hapus daftar",
"deleteFailed": "Gagal menghapus daftar",
"deleteConfirmTitle": "Hapus daftar ini?",
"deleteConfirmDesc": "\"{{title}}\" akan dihapus dari strip topik. Kampanye itu sendiri tidak terpengaruh.",
"titleField": "Judul",
"titlePlaceholder": "mis. Kebebasan Pers",
"descriptionField": "Deskripsi",
"descriptionPlaceholder": "Penjelasan singkat tentang apa yang termasuk dalam daftar ini.",
"iconField": "Ikon",
"menuAria": "Opsi daftar {{title}}",
"listActions": "Tindakan daftar",
"memberMenuAria": "Opsi daftar kampanye",
"backToCampaigns": "Kembali ke kampanye",
"detailTitle": "Daftar kampanye",
"campaignsCount_one": "{{count}} kampanye",
"campaignsCount_other": "{{count}} kampanye",
"addCampaign": "Tambah kampanye",
"addCampaignDesc": "Cari di jaringan dan pilih kampanye untuk ditambahkan ke daftar ini.",
"addFailed": "Gagal menambahkan ke daftar",
"addToList": "Tambah",
"alreadyAdded": "Ditambahkan",
"added": "Ditambahkan",
"membershipTitle": "Tambahkan ke daftar",
"membershipDesc": "Pilih daftar yang akan memuat \"{{title}}\".",
"membershipEmpty": "Belum ada daftar. Buat satu untuk mulai mengkurasi.",
"searchPlaceholder": "Cari kampanye…",
"searchEmpty": "Tidak ada kampanye yang cocok dengan pencarian ini.",
"removeFromList": "Hapus dari daftar",
"removeFailed": "Gagal menghapus dari daftar",
"empty": "Daftar ini kosong.",
"emptyMod": "Daftar ini kosong. Tambahkan kampanye untuk mulai mengkurasinya.",
"iconPicker": {
"title": "Pilih ikon",
"description": "Pilih ikon apa pun dari pustaka Lucide.",
"search": "Cari ikon…",
"empty": "Tidak ada ikon yang cocok dengan pencarian ini."
}
}
},
"moderation": {
@@ -1186,21 +1286,27 @@
"ariaPledge": "Moderasi ikrar",
"ariaGroup": "Moderasi grup",
"failedAction": "Gagal {{action}}",
"approve": "Setujui",
"unapprove": "Batalkan persetujuan",
"approvedState": "Disetujui",
"hide": "Sembunyikan",
"unhide": "Tampilkan kembali",
"hiddenState": "Tersembunyi",
"feature": "Unggulkan",
"unfeature": "Batalkan unggulan",
"featuredState": "Diunggulkan",
"toastApproved": "Disetujui untuk beranda",
"toastUnapproved": "Dihapus dari beranda",
"toastHidden": "Disembunyikan",
"toastUnhidden": "Ditampilkan kembali",
"toastFeatured": "Diunggulkan",
"toastUnfeatured": "Dihapus dari unggulan"
"toastUnfeatured": "Dihapus dari unggulan",
"failedReorder": "Gagal mengurutkan ulang",
"moveToTop": "Pindahkan ke atas",
"moveUp": "Naikkan",
"moveDown": "Turunkan",
"addToList": "Tambahkan ke daftar…",
"dragHandle": "Seret untuk mengurutkan ulang (posisi {{index}})",
"toast": {
"movedToTop": "Dipindahkan ke atas",
"movedUp": "Dinaikkan",
"movedDown": "Diturunkan"
}
}
},
"settings": {
@@ -1492,13 +1598,25 @@
"bitcoinAddress": "Alamat Bitcoin",
"silentPayment": "Alamat silent payment",
"toLabel": "Kepada",
"clear": "Hapus penerima"
"clear": "Hapus penerima",
"choosePaymentMethod": "Pilih metode pembayaran untuk melanjutkan"
},
"feeSpeed": {
"fastest": "~10 menit",
"halfHour": "~30 menit",
"hour": "~1 jam",
"economy": "~1 hari"
"economy": "~1 hari",
"custom": "Khusus"
},
"fee": {
"loading": "memuat…",
"unavailable": "tidak tersedia",
"loadFailed": "Tidak bisa memuat tarif biaya.",
"retry": "Coba lagi",
"orCustom": "Atau masukkan tarif khusus di bawah.",
"loadingTiers": "Memuat tarif biaya…",
"customPlaceholder": "misal 5",
"customAriaLabel": "Tarif biaya khusus dalam sat/vB"
},
"progress": {
"building": "Membangun transaksi…",
@@ -1514,7 +1632,9 @@
"enterAmount": "Masukkan jumlah.",
"insufficient": "Bitcoin tidak cukup untuk jumlah ini + biaya jaringan.",
"waitingPrice": "Menunggu harga BTC…",
"noneYet": "Anda belum punya Bitcoin."
"noneYet": "Anda belum punya Bitcoin.",
"feesNotLoadedYet": "Tarif biaya belum dimuat.",
"feeRateTooLow": "Masukkan tarif biaya minimal 1 sat/vB."
},
"scanError": {
"title": "Tidak bisa membaca kode QR itu",
@@ -1523,6 +1643,29 @@
"toast": {
"failedTitle": "Transaksi gagal"
},
"broadcastError": {
"feeTooLowTitle": "Biaya jaringan terlalu rendah",
"feeTooLowBodyWithMin": "Jaringan Bitcoin menolak biaya ini. Minimum saat ini sekitar {{min}} sat/vB.",
"feeTooLowBody": "Jaringan Bitcoin menolak biaya ini. Pilih tingkat yang lebih cepat atau naikkan tarif khusus Anda.",
"rbfTitle": "Pengganti butuh biaya lebih tinggi",
"rbfBody": "Transaksi pengganti harus membayar lebih dari yang asli. Naikkan biayanya dan coba lagi.",
"mempoolFullTitle": "Jaringan Bitcoin sedang padat",
"mempoolFullBody": "Mempool penuh dan biaya Anda tidak kompetitif. Naikkan biaya agar bisa lolos.",
"networkTitle": "Tidak bisa menjangkau jaringan Bitcoin",
"networkBody": "Periksa koneksi Anda dan coba lagi.",
"mempoolConflictTitle": "Transaksi berbenturan",
"mempoolConflictBody": "Salah satu input sudah dibelanjakan atau sedang dibelanjakan oleh transaksi lain.",
"tooLongChainTitle": "Terlalu banyak transaksi yang belum terkonfirmasi",
"tooLongChainBody": "Anda punya rantai panjang transaksi yang belum terkonfirmasi. Tunggu sampai ada yang terkonfirmasi dan coba lagi.",
"badInputsTitle": "Transaksi ditolak",
"badInputsBody": "Jaringan menolak transaksi ini. Sesuaikan jumlah atau penerima dan coba lagi.",
"absurdlyHighFeeTitle": "Biaya luar biasa tinggi",
"absurdlyHighFeeBody": "Estimasi biaya mencurigakan tinggi. Muat ulang tarif biaya dan coba lagi.",
"unknownTitle": "Transaksi gagal",
"useHigherFee": "Gunakan biaya lebih tinggi",
"tryAgain": "Coba lagi",
"atMaxFeeTier": "Anda sudah di tingkat tercepat."
},
"success": {
"title": "Bitcoin terkirim",
"satsAmount": "{{sats}} sats",
+213 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "ធ្វើឱ្យវាក្លាយជារបស់អ្នក",
"subtitle": "ប្រាប់អ្នកដទៃបន្តិចអំពីខ្លួនអ្នក។ ទាំងអស់ស្រេចចិត្ត អាចផ្លាស់ប្តូរបានគ្រប់ពេល។",
"campaignTitle": "ដាក់មុខលើយុទ្ធនាការរបស់អ្នក",
"campaignSubtitle": "ឈ្មោះ និងរូបថតជួយឱ្យមនុស្សភ្ជាប់ទំនាក់ទំនងជាមួយយុទ្ធនាការរបស់អ្នក។",
"nameLabel": "ឈ្មោះបង្ហាញ",
"namePlaceholder": "ឈ្មោះរបស់អ្នក",
"aboutLabel": "ប្រវត្តិសង្ខេប",
"aboutPlaceholder": "បន្តិចបន្តួចអំពីអ្នក…",
"avatarLabel": "រូបតំណាង",
"uploadAvatar": "ផ្ទុករូបតំណាង",
"advanced": "ច្រើនទៀត",
"finish": "បញ្ចប់",
"saving": "កំពុងរក្សាទុក…",
"skip": "រំលងជាមុនសិន",
@@ -183,10 +186,11 @@
"coverImage": "រូបភាពគម្រប",
"description": "ការពិពណ៌នា",
"timezone": "តំបន់ពេលវេលា",
"publishing": "កំពុងផ្សព្វផ្សាយ…",
"uploadingCover": "កំពុងផ្ទុកគម្រប…",
"countrySearchPlaceholder": "ស្វែងរកប្រទេស",
"imageDropzone": "ចុច ឬអូសរូបភាពមកទីនេះ"
"imageDropzone": "ចុច ឬអូសរូបភាពមកទីនេះ",
"countryClearAria": "សម្អាតប្រទេស",
"flagOfAria": "ទង់ជាតិ {{name}}",
"countryHint": "ផ្សព្វផ្សាយ <0>i: iso3166:{{code}}</0> សម្រាប់តម្រៀបតាមប្រទេស។"
},
"organizationContext": {
"attachedToGroup": "បានភ្ជាប់ទៅក្រុម",
@@ -220,8 +224,8 @@
"myPledgesTagline": "ការសន្យាដែលអ្នកបានបង្កើត។",
"featuredPledges": "ការសន្យាលេចធ្លោ",
"featuredPledgesTagline": "ការសន្យាដែលត្រូវបានរំលេចដោយក្រុម {{appName}}។",
"allPledges": "ការសន្យាទាំងអស់",
"allPledgesTagline": "រកមើលការសន្យាគ្រប់ៗមួយនៅលើបណ្ដាញ។",
"allPledges": "ការសន្យា",
"allPledgesTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលការសន្យាគ្រប់ៗមួយ។",
"sectionActive": "ការសន្យាសកម្ម",
"sectionUpcoming": "ការសន្យាខាងមុខ",
"sectionPast": "ការសន្យាកន្លងមក",
@@ -279,11 +283,7 @@
"titlePlaceholder": "កត់ត្រាការសម្អាតឆ្នេរ",
"country": "ប្រទេស",
"countryPlaceholder": "ស្វែងរកប្រទេស",
"countryClearAria": "សម្អាតប្រទេស",
"flagOfAria": "ទង់ជាតិ {{name}}",
"countryHint": "ផ្សព្វផ្សាយ <0>i: iso3166:{{code}}</0> សម្រាប់តម្រៀបតាមប្រទេស។",
"tags": "ស្លាក",
"tagsPlaceholder": "សម្អាត-ឆ្នេរ, កត់ត្រា-បាតុកម្ម, ដាច់-អ៊ីនធឺណិត",
"coverImage": "រូបភាពគម្រប",
"description": "ការពិពណ៌នា",
"descriptionPlaceholder": "ពន្យល់សកម្មភាព ភស្តុតាង ឬលទ្ធផលដែលអ្នកចង់ជំរុញ អ្វីដែលការដាក់ស្នើគួរមាន និងរបៀបដែលអ្នកគ្រោងវាយតម្លៃពួកវា...",
@@ -293,8 +293,6 @@
"timezone": "តំបន់ពេលវេលា",
"timezoneNote": "ពេលវេលាចាប់ផ្តើម និងពេលកំណត់នឹងត្រូវបានបកស្រាយក្នុងតំបន់ពេលវេលានេះ។",
"submit": "បង្កើតការសន្យា",
"publishing": "កំពុងផ្សព្វផ្សាយ…",
"uploadingCover": "កំពុងផ្ទុកគម្រប…",
"altText": "ការសន្យា {{appName}}៖ {{title}}",
"successToast": "បានបង្កើតការសន្យា",
"errorToast": "មិនអាចបង្កើតការសន្យា",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "ចំនួនការសន្យាត្រូវតែជាចំនួនវិជ្ជមានជា USD។",
"errorPriceUnavailable": "កំពុងរង់ចាំតម្លៃ BTC/USD ដើម្បីគណនាចំនួនការសន្យា។",
"errorCoverInvalid": "រូបភាពគម្របត្រូវតែជា URL https:// ត្រឹមត្រូវ។",
"errorDeadlinePast": "ពេលកំណត់មិនអាចស្ថិតក្នុងអតីតកាល។"
"errorDeadlinePast": "ពេលកំណត់មិនអាចស្ថិតក្នុងអតីតកាល។",
"wizard": {
"titleStepTitle": "ដាក់ឈ្មោះការសន្យារបស់អ្នក",
"titleStepSubtitle": "សំណើច្បាស់លាស់ និងការពន្យល់ខ្លីៗអំពីអ្វីដែលអ្នកនឹងផ្តល់មូលនិធិ។",
"pledgeStepTitle": "កំណត់ការសន្យារបស់អ្នក",
"pledgeStepSubtitle": "ចំនួនទឹកប្រាក់ដែលអ្នកនឹងបង់ ជា USD និងពេលកំណត់ស្រេចចិត្ត។",
"coverStepTitle": "បន្ថែមរូបភាពគម្រប",
"coverStepSubtitle": "រូបភាពមួយនាំការសន្យានៅលើកាតគ្រប់ទីកន្លែង។",
"tagsStepTitle": "ប្រទេស និងប្រភេទ",
"tagsStepSubtitle": "ជួយឱ្យមនុស្សត្រឹមត្រូវរកឃើញការសន្យារបស់អ្នក។",
"launchNow": "រំលងបន្ទាប់ ហើយចាប់ផ្តើម"
}
},
"detail": {
"seoTitle": "{{title}} | ការសន្យារបស់ {{appName}}",
@@ -355,8 +364,8 @@
"myGroupsTagline": "ក្រុមដែលអ្នកបានបង្កើត គ្រប់គ្រង ឬដាក់តាមដាន។",
"featuredGroups": "ក្រុមលេចធ្លោ",
"featuredGroupsTagline": "ក្រុមលេចធ្លោដែលសក្តិសមនឹងការយកចិត្តទុកដាក់របស់អ្នក។",
"allGroups": "ក្រុមទាំងអស់",
"allGroupsTagline": "រកមើលក្រុម {{appName}} ឬស្វែងរកក្នុងគ្រប់ក្រុមនៅលើ Nostr។",
"allGroups": "ក្រុម",
"allGroupsTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលក្រុមគ្រប់ៗមួយ។",
"loginToSeeTitle": "ចូលដើម្បីមើលក្រុមរបស់អ្នក",
"loginToSeeBody": "ក្រុមដែលអ្នកបានបង្កើត ឬគ្រប់គ្រងនឹងបង្ហាញនៅទីនេះ។",
"noGroupsTitle": "មិនទាន់មានក្រុមទេ",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "ក្រុមនេះនិយាយអំពីអ្វី?",
"country": "ប្រទេស",
"countryPlaceholder": "ស្វែងរកប្រទេស",
"countryClearAria": "សម្អាតប្រទេស",
"flagOfAria": "ទង់ជាតិ {{name}}",
"countryHint": "ផ្សព្វផ្សាយ <0>i: iso3166:{{code}}</0> សម្រាប់តម្រៀបតាមប្រទេស។",
"tags": "ស្លាក",
"tagsPlaceholder": "ជំនួយគ្នាទៅវិញទៅមក, ព័ត៌មានក្នុងតំបន់, សិទ្ធិឌីជីថល",
"coverImage": "រូបភាពគម្រប",
@@ -433,7 +439,18 @@
"errorNameInvalid": "ឈ្មោះត្រូវតែមានអក្សរ ឬលេខ ដើម្បីបង្កើត URL ក្រុម។",
"errorEditLatestMissing": "មិនអាចស្វែងរកកំណែចុងក្រោយរបស់ក្រុមនេះដើម្បីធ្វើបច្ចុប្បន្នភាព។",
"errorCoverInvalid": "រូបភាពគម្របត្រូវតែជា URL https:// ត្រឹមត្រូវ។",
"errorSlugCollision": "អ្នកមានក្រុមដែលមានកំណត់អត្តសញ្ញាណ «{{slug}}» រួចហើយ។ ជ្រើសរើសឈ្មោះផ្សេង។"
"errorSlugCollision": "អ្នកមានក្រុមដែលមានកំណត់អត្តសញ្ញាណ «{{slug}}» រួចហើយ។ ជ្រើសរើសឈ្មោះផ្សេង។",
"wizard": {
"nameStepTitle": "ដាក់ឈ្មោះក្រុមរបស់អ្នក",
"nameStepSubtitle": "ឈ្មោះខ្លី ច្បាស់លាស់ ដែលសមាជិកនឹងស្គាល់។",
"coverStepTitle": "បន្ថែមរូបភាពគម្រប",
"coverStepSubtitle": "រូបភាពមួយនាំក្រុមនៅលើកាតគ្រប់ទីកន្លែង។",
"moderatorsStepTitle": "អញ្ជើញអ្នកសម្របសម្រួល",
"moderatorsStepSubtitle": "ស្រេចចិត្ត — ពួកគេអាចអនុម័តខ្លឹមសារ និងដកសមាជិករួមជាមួយអ្នក។",
"tagsStepTitle": "ប្រទេស និងប្រភេទ",
"tagsStepSubtitle": "ជួយឱ្យមនុស្សត្រឹមត្រូវរកឃើញក្រុមរបស់អ្នក។",
"launchNow": "រំលងបន្ទាប់ ហើយចាប់ផ្តើម"
}
},
"detail": {
"by": "ដោយ",
@@ -493,9 +510,19 @@
"myWalletDefault": "កាបូបរបស់ខ្ញុំ",
"walletChoose": "ជ្រើសរើសកាបូប",
"walletCustom": "ផ្ទាល់ខ្លួន",
"walletUseCustom": "ប្រើកាបូបផ្សេងជំនួសវិញ",
"walletDestinationLanding": "ការបរិច្ចាគនឹងមកដល់ទីនេះ",
"walletDestinationNote": "កាបូបនេះនឹងត្រូវបានផ្សាយជាគោលដៅនៃការបរិច្ចាគសម្រាប់យុទ្ធនាការរបស់អ្នក។",
"walletUseMine": "ប្រើកាបូប Agora របស់ខ្ញុំ",
"acceptAll": "ទទួលយកការទូទាត់គ្រប់ប្រភេទ",
"acceptPublic": "ទទួលយកការទូទាត់សាធារណៈតែប៉ុណ្ណោះ",
"acceptPrivate": "ទទួលយកការទូទាត់ឯកជនតែប៉ុណ្ណោះ",
"acceptAllShort": "ទាំងអស់",
"acceptPublicShort": "សាធារណៈតែប៉ុណ្ណោះ",
"acceptPrivateShort": "ឯកជនតែប៉ុណ្ណោះ",
"acceptAllHint": "ទទួលយកទាំងការទូទាត់ on-chain សាធារណៈ និងការបង់ប្រាក់ស្ងាត់ឯកជន។",
"acceptPublicHint": "ទទួលយកតែការបរិច្ចាគ on-chain ទៅកាន់អាសយដ្ឋានសាធារណៈប៉ុណ្ណោះ។",
"acceptPrivateHint": "ទទួលយកតែការបង់ប្រាក់ស្ងាត់ប៉ុណ្ណោះ — អាសយដ្ឋានរបស់អ្នកបរិច្ចាគនៅតែឯកជន។",
"customWalletIntro": "បញ្ចូលអាសយដ្ឋានប៊ីតខញ លេខកូដបង់ប្រាក់ស្ងាត់ ឬទាំងពីរ។ ត្រូវការយ៉ាងហោចណាស់មួយ។",
"bitcoinAddress": "អាសយដ្ឋានប៊ីតខញ",
"bitcoinAddressPlaceholder": "bc1q… ឬ bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "មិនមែនជាលេខកូដបង់ប្រាក់ស្ងាត់ BIP-352 ដែលត្រូវបានទទួលស្គាល់ទេ (sp1…)។",
"country": "ប្រទេស",
"countryPlaceholder": "ស្វែងរកប្រទេស",
"countryClearAria": "សម្អាតប្រទេស",
"flagOfAria": "ទង់ជាតិ {{name}}",
"countryHint": "ផ្សព្វផ្សាយ <0>i: iso3166:{{code}}</0> សម្រាប់តម្រៀបតាមប្រទេស។",
"tags": "ស្លាក",
"tagsPlaceholder": "ការពារផ្នែកច្បាប់, ជំនួយគ្នាទៅវិញទៅមក, ព័ត៌មានក្នុងតំបន់",
"categories": {
"humanRights": "សិទ្ធិមនុស្ស",
"democracy": "លទ្ធិប្រជាធិបតេយ្យ",
"pressFreedom": "សេរីភាពសារព័ត៌មាន",
"politicalPrisoners": "អ្នកទោសនយោបាយ",
"humanitarianAid": "ជំនួយមនុស្សធម៌",
"civilResistance": "ការតស៊ូស៊ីវិល",
"digitalRights": "សិទ្ធិឌីជីថល",
"antiCorruption": "ប្រឆាំងអំពើពុករលួយ",
"womenGirls": "ស្ត្រី និងក្មេងស្រី",
"refugees": "ជនភៀសខ្លួន និងជននិរទេស",
"legalAid": "ជំនួយផ្នែកច្បាប់",
"emergencyRelief": "ជំនួយសង្គ្រោះបន្ទាន់",
"animalRights": "សិទ្ធិសត្វ",
"education": "ការអប់រំ",
"medical": "វេជ្ជសាស្ត្រ",
"community": "សហគមន៍"
},
"banner": "រូបភាពបដា",
"story": "រឿង",
"storyPlaceholder": "ចែករំលែកប្រវត្តិ អ្នកទទួលផល និងរបៀបដែលថវិកានឹងត្រូវប្រើ។",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "មិនអាចទាញយកអាសយដ្ឋាន on-chain ស្រស់ពីកាបូបរបស់អ្នកបានទេ។",
"errorHdDeriveInvalid": "អាសយដ្ឋានដែលបានទាញយកបរាជ័យក្នុងការវាយតម្លៃ។ សូមបន្ថែមអាសយដ្ឋានផ្ទាល់ខ្លួនជំនួស។",
"errorWalletRequiredFallback": "ត្រូវការចំណុចកាបូប។",
"errorPublishedInvalid": "ព្រឹត្តិការណ៍ដែលបានផ្សព្វផ្សាយបរាជ័យក្នុងការវាយតម្លៃ។ សូមផ្ទុកឡើងវិញ ហើយព្យាយាមម្តងទៀត។"
"errorPublishedInvalid": "ព្រឹត្តិការណ៍ដែលបានផ្សព្វផ្សាយបរាជ័យក្នុងការវាយតម្លៃ។ សូមផ្ទុកឡើងវិញ ហើយព្យាយាមម្តងទៀត។",
"wizard": {
"titleStepTitle": "ដាក់ឈ្មោះយុទ្ធនាការរបស់អ្នក",
"titleStepSubtitle": "ឈ្មោះខ្លី ច្បាស់លាស់ ដែលអ្នកបរិច្ចាគនឹងស្គាល់។",
"walletStepTitle": "ជ្រើសរើសអ្នកដែលទទួលការបរិច្ចាគ",
"walletStepSubtitle": "កាបូប Agora របស់អ្នកត្រៀមរួចរាល់ដើម្បីទទួលការបរិច្ចាគ Bitcoin សម្រាប់យុទ្ធនាការនេះ។",
"bannerStepTitle": "បន្ថែមបដា",
"bannerStepSubtitle": "រូបភាពគួរឱ្យចាប់អារម្មណ៍មួយ នាំយុទ្ធនាការនៅលើកាតគ្រប់ទីកន្លែង។",
"storyStepTitle": "ប្រាប់រឿងរបស់អ្នក",
"storyStepSubtitle": "នរណាខ្លះទទួលផល និងថវិកានឹងត្រូវប្រើយ៉ាងដូចម្តេច។",
"next": "បន្ទាប់",
"back": "ត្រឡប់",
"skip": "រំលង",
"launchNow": "រំលងបន្ទាប់ ហើយចាប់ផ្តើម"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | យុទ្ធនាការរបស់ {{appName}}",
@@ -699,31 +755,55 @@
"startCampaign": "ចាប់ផ្ដើមយុទ្ធនាការ",
"howItWorks": "របៀបដំណើរការ",
"exploreCampaigns": "រកមើលយុទ្ធនាការ",
"featured": "បានជ្រើសរើស",
"featuredDesc": "យុទ្ធនាការដែលជ្រើសរើសដោយក្រុម {{appName}}។",
"community": "យុទ្ធនាការសហគមន៍",
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
"pending": "កំពុងរង់ចាំការអនុម័ត",
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
"wlcDesc": "យុទ្ធនាការដែលបានសម្រិតសម្រាំងដោយសភាសេរីភាពពិភពលោក (World Liberty Congress)។",
"allCampaigns": "យុទ្ធនាការទាំងអស់",
"allCampaignsDesc": "យុទ្ធនាការទាំងអស់នៅលើបណ្តាញ តាមលំដាប់កាលប្បវត្តិ។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"hidden": "បានលាក់",
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
"yourCampaigns": "យុទ្ធនាការរបស់អ្នក",
"yourCampaignsDesc": "យុទ្ធនាការរបស់អ្នកនៅរស់នៅលើ Nostr ហើយការបរិច្ចាគដំណើរការតាមរយៈតំណយុទ្ធនាការ។ វានឹងបង្ហាញនៅទំព័រដើម នៅពេលដែលអ្នកសម្រសម្រួលក្រុម Soapbox អនុម័ត។",
"yourCampaignsDesc": "យុទ្ធនាការរបស់អ្នកនៅរស់នៅលើ Nostr ហើយការបរិច្ចាគដំណើរការតាមរយៈតំណយុទ្ធនាការ។ រកមើលយុទ្ធនាការទាំងអស់នៅ /campaigns; ក្រុម {{appName}} បង្ហាញការជ្រើសរើសដែលបានសម្រិតសម្រាំងនៅទំព័រដើម។",
"empty": "មិនទាន់មានយុទ្ធនាការនៅឡើយ",
"emptyHint": "ធ្វើជាមនុស្សដំបូងដែលចាប់ផ្ដើមការប្រមូលមូលនិធិនៅ {{appName}}។ ប្រាប់រឿងរបស់អ្នក ជ្រើសរើសអ្នកទទួលផល និងចែករំលែកតំណ។",
"whyDifferent": {
"eyebrow": "ហេតុអ្វី {{appName}}",
"title": "សាងសង់ខុសប្លែក។",
"lede": "Bitcoin ដោយផ្ទាល់ ពីអ្នកបរិច្ចាគទៅសកម្មជន។ គ្មានវេទិកានៅរារាំង គ្មានអ្នកថែរក្សាកាន់ថង់ គ្មានការអនុញ្ញាតចាំបាច់ឡើយ។",
"block1": {
"heading": "មិនដូច GoFundMe",
"body": "គ្មានវេទិកាណាអាចបង្ខាំងការបរិច្ចាគរបស់អ្នក ទាមទារសំណង ឬបញ្ចប់យុទ្ធនាការរបស់អ្នកដោយសារភាពមិនយល់ស្របលើគោលនយោបាយឡើយ។ គ្មាន Stripe គ្មាន Visa គ្មានធនាគារដែលឈរនៅកណ្តាល ហើយអាចកាត់ផ្តាច់អ្នកនៅពាក់កណ្តាលយុទ្ធនាការ។",
"bullet1": "មិនអាចបង្ខាំងបាន — គ្មានសិទ្ធិវេតូរបស់វេទិកា",
"bullet2": "គ្មានដំណើរការទូទាត់ណាអាចដកដោតបាន",
"bullet3": "សូន្យកម្រៃវេទិកា"
},
"block2": {
"heading": "មិនដូចវេទិកា ‘Bitcoin’ ផ្សេងទៀត",
"body": "គ្មាន Lightning node កណ្តាល អ្នកថែរក្សា ឬ LSP ដែលអាចបរាជ័យ ឬផ្តាច់ខ្លួនឡើយ។ មូលនិធិទូទាត់ដោយផ្ទាល់នៅលើ Bitcoin ទៅកាន់កាបូបដែលអ្នកគ្រប់គ្រង។ បើ {{appName}} បាត់ខ្លួននៅថ្ងៃស្អែក រាល់យុទ្ធនាការនឹងបន្តដំណើរការ។",
"bullet1": "គ្មានកាបូបអ្នកថែរក្សាដែលត្រូវបឺត ឬបង្ខាំង",
"bullet2": "ទូទាត់លើខ្សែសង្វាក់ទៅកាបូបដែលអ្នកជាម្ចាស់",
"bullet3": "ដំណើរការទោះបី {{appName}} បាត់ខ្លួន"
},
"block3": {
"heading": "សាធារណៈ ឬឯកជន។ ជម្រើសរបស់អ្នក។",
"body": "សកម្មជនជ្រើសរើសជម្រើសទទួលដែលត្រូវនឹងគំរូការគំរាមកំហែងរបស់ខ្លួន។ អ្នកបរិច្ចាគមើលឃើញ QR តែមួយ កាបូបជ្រើសរើសពិធីការត្រឹមត្រូវ។",
"publicLabel": "សាធារណៈ",
"publicSummary": "ដំណើរការក្នុងរាល់កាបូប Bitcoin។ លឿន និងផ្ទៀងផ្ទាត់បាននៅលើខ្សែសង្វាក់។",
"privateLabel": "ឯកជន",
"privateSummary": "ការទូទាត់ស្ងាត់ BIP-352។ ការបរិច្ចាគចូលទៅទិន្នផលដែលមិនអាចភ្ជាប់។"
},
"readMore": "អានការបំបែកពេញលេញ"
},
"searchPlaceholder": "ស្វែងរកយុទ្ធនាការ…",
"searchAriaLabel": "ស្វែងរកយុទ្ធនាការ",
"noMatch": "គ្មានយុទ្ធនាការណាដែលត្រូវនឹង «{{query}}»",
"noMatchHint": "សាកល្បងពាក្យស្វែងរកផ្សេង ឬសម្អាតការស្វែងរក។"
},
"all": {
"title": "យុទ្ធនាការទាំងអស់",
"title": "យុទ្ធនាការ",
"seoTitle": "យុទ្ធនាការទាំងអស់",
"description": "រកមើលរាល់យុទ្ធនាការដែលផ្សព្វផ្សាយលើ Agora។",
"sectionTagline": "រកមើលរាល់បុព្វហេតុនៅលើបណ្ដាញ។",
"sectionTagline": "យុទ្ធនាការលេចធ្លោជាមុន បន្ទាប់មកបណ្ដាញដែលនៅសល់។ ស្វែងរក ឬតម្រៀបដើម្បីបន្ថយលទ្ធផល។",
"heroKicker": "យុទ្ធនាការ",
"heroHeading": "រាល់បុព្វហេតុ",
"heroHeadingLine2": "នៅកន្លែងតែមួយ។",
@@ -744,6 +824,54 @@
"allHiddenHint": "យុទ្ធនាការទាំងអស់នៅលើបណ្ដាញត្រូវបានលាក់ដោយអ្នកសម្របសម្រួល។ បើក «បង្ហាញដែលលាក់» ដើម្បីមើល។",
"empty": "មិនទាន់មានយុទ្ធនាការនៅឡើយ",
"emptyHint": "មិនទាន់មានយុទ្ធនាការផ្សព្វផ្សាយនៅឡើយទេ។ ធ្វើជាមនុស្សដំបូង។"
},
"lists": {
"stripAria": "បញ្ជីប្រធានបទយុទ្ធនាការដែលបានសម្រិតសម្រាំង",
"create": "បញ្ជីថ្មី",
"createDesc": "បង្កើតបញ្ជីប្រធានបទថ្មី។ សម្រិតសម្រាំងយុទ្ធនាការទៅក្នុងវាពីទំព័រយុទ្ធនាការណាមួយ។",
"createSubmit": "បង្កើតបញ្ជី",
"createFailed": "បរាជ័យក្នុងការបង្កើតបញ្ជី",
"edit": "កែសម្រួលបញ្ជី",
"editDesc": "ធ្វើបច្ចុប្បន្នភាពចំណងជើង ការពិពណ៌នា ឬរូបតំណាងរបស់បញ្ជី។",
"editSubmit": "រក្សាទុកការផ្លាស់ប្ដូរ",
"updateFailed": "បរាជ័យក្នុងការធ្វើបច្ចុប្បន្នភាពបញ្ជី",
"delete": "លុបបញ្ជី",
"deleteFailed": "បរាជ័យក្នុងការលុបបញ្ជី",
"deleteConfirmTitle": "លុបបញ្ជីនេះមែនទេ?",
"deleteConfirmDesc": "\"{{title}}\" នឹងត្រូវដកចេញពីរបារប្រធានបទ។ យុទ្ធនាការដោយខ្លួនវាមិនត្រូវបានប៉ះពាល់ទេ។",
"titleField": "ចំណងជើង",
"titlePlaceholder": "ឧ. សេរីភាពសារព័ត៌មាន",
"descriptionField": "ការពិពណ៌នា",
"descriptionPlaceholder": "សេចក្តីសង្ខេបខ្លីពន្យល់ពីអ្វីដែលគួរស្ថិតក្នុងបញ្ជីនេះ។",
"iconField": "រូបតំណាង",
"menuAria": "ជម្រើសបញ្ជី {{title}}",
"listActions": "សកម្មភាពបញ្ជី",
"memberMenuAria": "ជម្រើសបញ្ជីយុទ្ធនាការ",
"backToCampaigns": "ត្រឡប់ទៅយុទ្ធនាការ",
"detailTitle": "បញ្ជីយុទ្ធនាការ",
"campaignsCount_one": "យុទ្ធនាការ {{count}}",
"campaignsCount_other": "យុទ្ធនាការ {{count}}",
"addCampaign": "បន្ថែមយុទ្ធនាការ",
"addCampaignDesc": "ស្វែងរកនៅលើបណ្ដាញ ហើយជ្រើសរើសយុទ្ធនាការដើម្បីបន្ថែមទៅបញ្ជីនេះ។",
"addFailed": "បរាជ័យក្នុងការបន្ថែមទៅបញ្ជី",
"addToList": "បន្ថែម",
"alreadyAdded": "បានបន្ថែម",
"added": "បានបន្ថែម",
"membershipTitle": "បន្ថែមទៅបញ្ជី",
"membershipDesc": "ជ្រើសរើសបញ្ជីដែល \"{{title}}\" គួរលេចឡើង។",
"membershipEmpty": "មិនទាន់មានបញ្ជីទេ។ បង្កើតមួយដើម្បីចាប់ផ្ដើមសម្រិតសម្រាំង។",
"searchPlaceholder": "ស្វែងរកយុទ្ធនាការ…",
"searchEmpty": "គ្មានយុទ្ធនាការត្រូវនឹងការស្វែងរកនេះទេ។",
"removeFromList": "ដកចេញពីបញ្ជី",
"removeFailed": "បរាជ័យក្នុងការដកចេញពីបញ្ជី",
"empty": "បញ្ជីនេះទទេ។",
"emptyMod": "បញ្ជីនេះទទេ។ បន្ថែមយុទ្ធនាការដើម្បីចាប់ផ្ដើមសម្រិតសម្រាំងវា។",
"iconPicker": {
"title": "ជ្រើសរើសរូបតំណាង",
"description": "ជ្រើសរើសរូបតំណាងណាមួយពីបណ្ណាល័យ Lucide។",
"search": "ស្វែងរករូបតំណាង…",
"empty": "គ្មានរូបតំណាងត្រូវនឹងការស្វែងរកនេះទេ។"
}
}
},
"moderation": {
@@ -753,21 +881,27 @@
"ariaPledge": "សម្របសម្រួលការសន្យា",
"ariaGroup": "សម្របសម្រួលក្រុម",
"failedAction": "បរាជ័យក្នុងការ {{action}}",
"approve": "អនុម័ត",
"unapprove": "ដកការអនុម័ត",
"approvedState": "បានអនុម័ត",
"failedReorder": "បរាជ័យក្នុងការរៀបចំឡើងវិញ",
"moveToTop": "ផ្លាស់ទីទៅកំពូល",
"moveUp": "ផ្លាស់ទីឡើងលើ",
"moveDown": "ផ្លាស់ទីចុះក្រោម",
"addToList": "បន្ថែមទៅបញ្ជី…",
"dragHandle": "អូសដើម្បីរៀបចំឡើងវិញ (ទីតាំង {{index}})",
"hide": "លាក់",
"unhide": "ឈប់លាក់",
"hiddenState": "បានលាក់",
"feature": "លេចធ្លោ",
"unfeature": "ដកការលេចធ្លោ",
"featuredState": "បានលេចធ្លោ",
"toastApproved": "បានអនុម័តសម្រាប់ទំព័រដើម",
"toastUnapproved": "បានដកចេញពីទំព័រដើម",
"toastHidden": "បានលាក់",
"toastUnhidden": "បានឈប់លាក់",
"toastFeatured": "បានលេចធ្លោ",
"toastUnfeatured": "បានដកចេញពីការលេចធ្លោ"
"toastUnfeatured": "បានដកចេញពីការលេចធ្លោ",
"toast": {
"movedToTop": "បានផ្លាស់ទីទៅកំពូល",
"movedUp": "បានផ្លាស់ទីឡើងលើ",
"movedDown": "បានផ្លាស់ទីចុះក្រោម"
}
}
},
"settings": {
@@ -1123,13 +1257,25 @@
"bitcoinAddress": "អាសយដ្ឋាន Bitcoin",
"silentPayment": "អាសយដ្ឋានទូទាត់ស្ងាត់",
"toLabel": "ជូន",
"clear": "សម្អាតអ្នកទទួល"
"clear": "សម្អាតអ្នកទទួល",
"choosePaymentMethod": "ជ្រើសរើសវិធីសាស្ត្រទូទាត់ដើម្បីបន្ត"
},
"feeSpeed": {
"fastest": "~១០ នាទី",
"halfHour": "~៣០ នាទី",
"hour": "~១ ម៉ោង",
"economy": "~១ ថ្ងៃ"
"economy": "~១ ថ្ងៃ",
"custom": "ផ្ទាល់ខ្លួន"
},
"fee": {
"loading": "កំពុងផ្ទុក…",
"unavailable": "មិនអាចប្រើបាន",
"loadFailed": "មិនអាចផ្ទុកអត្រាថ្លៃបានទេ។",
"retry": "ព្យាយាមម្ដងទៀត",
"orCustom": "ឬបញ្ចូលអត្រាផ្ទាល់ខ្លួននៅខាងក្រោម។",
"loadingTiers": "កំពុងផ្ទុកអត្រាថ្លៃ…",
"customPlaceholder": "ឧ. 5",
"customAriaLabel": "អត្រាថ្លៃផ្ទាល់ខ្លួនជា sat/vB"
},
"progress": {
"building": "កំពុងបង្កើតប្រតិបត្តិការ…",
@@ -1145,7 +1291,9 @@
"enterAmount": "បញ្ចូលចំនួន។",
"insufficient": "មិនមាន Bitcoin គ្រប់គ្រាន់សម្រាប់ចំនួននេះ + ថ្លៃបណ្តាញ។",
"waitingPrice": "កំពុងរង់ចាំតម្លៃ BTC…",
"noneYet": "អ្នកមិនទាន់មាន Bitcoin ទេ។"
"noneYet": "អ្នកមិនទាន់មាន Bitcoin ទេ។",
"feesNotLoadedYet": "អត្រាថ្លៃមិនទាន់បានផ្ទុកនៅឡើយទេ។",
"feeRateTooLow": "បញ្ចូលអត្រាថ្លៃយ៉ាងហោចណាស់ 1 sat/vB។"
},
"scanError": {
"title": "មិនអាចអានកូដ QR នេះបានទេ",
@@ -1154,6 +1302,29 @@
"toast": {
"failedTitle": "ប្រតិបត្តិការបរាជ័យ"
},
"broadcastError": {
"feeTooLowTitle": "ថ្លៃបណ្តាញទាបពេក",
"feeTooLowBodyWithMin": "បណ្តាញ Bitcoin កំពុងបដិសេធថ្លៃនេះ។ អប្បបរមាបច្ចុប្បន្នគឺប្រហែល {{min}} sat/vB។",
"feeTooLowBody": "បណ្តាញ Bitcoin កំពុងបដិសេធថ្លៃនេះ។ ជ្រើសរើសកម្រិតលឿនជាង ឬបង្កើនអត្រាផ្ទាល់ខ្លួនរបស់អ្នក។",
"rbfTitle": "ការជំនួសត្រូវការថ្លៃខ្ពស់ជាង",
"rbfBody": "ប្រតិបត្តិការជំនួសត្រូវតែបង់ច្រើនជាងប្រតិបត្តិការដើម។ បង្កើនថ្លៃ ហើយព្យាយាមម្តងទៀត។",
"mempoolFullTitle": "បណ្តាញ Bitcoin កំពុងកក",
"mempoolFullBody": "mempool ពេញ ហើយថ្លៃរបស់អ្នកមិនមានការប្រកួតប្រជែងទេ។ បង្កើនថ្លៃដើម្បីឆ្លងកាត់។",
"networkTitle": "មិនអាចភ្ជាប់ទៅបណ្តាញ Bitcoin បានទេ",
"networkBody": "ពិនិត្យការតភ្ជាប់របស់អ្នក ហើយព្យាយាមម្តងទៀត។",
"mempoolConflictTitle": "ប្រតិបត្តិការប៉ះទង្គិច",
"mempoolConflictBody": "ធាតុចូលណាមួយត្រូវបានចំណាយរួចហើយ ឬកំពុងត្រូវបានចំណាយដោយប្រតិបត្តិការមួយផ្សេងទៀត។",
"tooLongChainTitle": "ប្រតិបត្តិការមិនទាន់បញ្ជាក់ច្រើនពេក",
"tooLongChainBody": "អ្នកមានខ្សែសង្វាក់ប្រតិបត្តិការមិនទាន់បញ្ជាក់វែង។ រង់ចាំឱ្យមួយបានបញ្ជាក់ ហើយព្យាយាមម្តងទៀត។",
"badInputsTitle": "ប្រតិបត្តិការត្រូវបានបដិសេធ",
"badInputsBody": "បណ្តាញបានបដិសេធប្រតិបត្តិការនេះ។ កែសម្រួលចំនួន ឬអ្នកទទួល ហើយព្យាយាមម្តងទៀត។",
"absurdlyHighFeeTitle": "ថ្លៃខ្ពស់មិនធម្មតា",
"absurdlyHighFeeBody": "ថ្លៃប៉ាន់ស្មានគួរឱ្យសង្ស័យខ្ពស់ពេក។ ផ្ទុកអត្រាថ្លៃឡើងវិញ ហើយព្យាយាមម្តងទៀត។",
"unknownTitle": "ប្រតិបត្តិការបរាជ័យ",
"useHigherFee": "ប្រើថ្លៃខ្ពស់ជាង",
"tryAgain": "ព្យាយាមម្ដងទៀត",
"atMaxFeeTier": "អ្នកស្ថិតនៅកម្រិតលឿនបំផុតរួចហើយ។"
},
"success": {
"title": "បានផ្ញើ Bitcoin",
"satsAmount": "{{sats}} sats",
+213 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "خپل یې کړئ",
"subtitle": "نورو ته د ځان په اړه یو څه ووایاست. ټول اختیاري دي، هر وخت یې بدلولی شئ.",
"campaignTitle": "خپلې کمپاین ته مخ ورکړئ",
"campaignSubtitle": "نوم او عکس خلکو سره مرسته کوي چې ستاسو کمپاین سره اړیکه ونیسي.",
"nameLabel": "د ښودنې نوم",
"namePlaceholder": "ستاسو نوم",
"aboutLabel": "بیوګرافي",
"aboutPlaceholder": "د ځان په اړه یو څه…",
"avatarLabel": "اواتار",
"uploadAvatar": "اواتار پورته کول",
"advanced": "نور",
"finish": "پای",
"saving": "خوندي کېږي…",
"skip": "اوس یې پرېږدئ",
@@ -183,10 +186,11 @@
"coverImage": "د پوښ انځور",
"description": "توضیح",
"timezone": "وخت زون",
"publishing": "خپرېږي…",
"uploadingCover": "پوښ پورته کېږي…",
"countrySearchPlaceholder": "هېوادونه ولټوئ",
"imageDropzone": "دلته انځور کلیک یا راکش کړئ"
"imageDropzone": "دلته انځور کلیک یا راکش کړئ",
"countryClearAria": "هیواد پاکول",
"flagOfAria": "د {{name}} بیرغ",
"countryHint": "د هیواد د ترتیب لپاره <0>i: iso3166:{{code}}</0> خپریږي."
},
"organizationContext": {
"attachedToGroup": "له ډلې سره تړلی",
@@ -220,8 +224,8 @@
"myPledgesTagline": "هغه ژمنې چې تاسو جوړې کړې دي.",
"featuredPledges": "ځانګړې ژمنې",
"featuredPledgesTagline": "هغه ژمنې چې د {{appName}} ټیم په ګوته کړې دي.",
"allPledges": "ټولې ژمنې",
"allPledgesTagline": "په شبکه کې هره ژمنه وګورئ.",
"allPledges": "ژمنې",
"allPledgesTagline": "د څارونکو لخوا ځانګړې شوې. د ټولو ژمنو لیدلو لپاره لټون یا ترتیب وکړئ.",
"sectionActive": "فعالې ژمنې",
"sectionUpcoming": "راتلونکې ژمنې",
"sectionPast": "تېرې ژمنې",
@@ -279,11 +283,7 @@
"titlePlaceholder": "د ساحل پاکولو مستندول",
"country": "هیواد",
"countryPlaceholder": "هېوادونه ولټوئ",
"countryClearAria": "هیواد پاکول",
"flagOfAria": "د {{name}} بیرغ",
"countryHint": "د هیواد د ترتیب لپاره <0>i: iso3166:{{code}}</0> خپریږي.",
"tags": "ټګونه",
"tagsPlaceholder": "ساحل-پاکول، اعتراض-مستندول، انټرنیټ-بندیدل",
"coverImage": "د پوښ انځور",
"description": "تشریح",
"descriptionPlaceholder": "هغه کړنه، شواهد یا پایله چې غواړئ الهام ورکړئ، د وړاندیزونو لپاره څه شیان دي او څنګه به یې ارزوئ تشریح کړئ...",
@@ -293,8 +293,6 @@
"timezone": "د وخت زون",
"timezoneNote": "د پیل او وروستۍ نېټې وختونه به په دې وخت زون کې تفسیر شي.",
"submit": "ژمنه جوړول",
"publishing": "خپرول…",
"uploadingCover": "د پوښ پورته کول…",
"altText": "د {{appName}} ژمنه: {{title}}",
"successToast": "ژمنه جوړه شوه",
"errorToast": "ژمنه نه جوړیږي",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "د ژمنې اندازه باید د ډالر په مثبته بڼه وي.",
"errorPriceUnavailable": "د BTC/USD نرخ ته انتظار باسئ ترڅو د ژمنې اندازه محاسبه شي.",
"errorCoverInvalid": "د پوښ انځور باید د https:// سمه نښه وي.",
"errorDeadlinePast": "وروستۍ نېټه نشي کولی په تېر وخت کې وي."
"errorDeadlinePast": "وروستۍ نېټه نشي کولی په تېر وخت کې وي.",
"wizard": {
"titleStepTitle": "خپلې ژمنې ته نوم ورکړئ",
"titleStepSubtitle": "روښانه غوښتنه او لنډه څرګندونه چې څه به تمویل کړئ.",
"pledgeStepTitle": "خپله ژمنه وټاکئ",
"pledgeStepSubtitle": "څومره به ورکړئ، په USD کې، او یوه اختیاري وروستۍ نېټه.",
"coverStepTitle": "د پوښ انځور اضافه کړئ",
"coverStepSubtitle": "یو انځور په هر کارت کې ژمنه ښیي.",
"tagsStepTitle": "هیواد او کټګورۍ",
"tagsStepSubtitle": "د دې سره مرسته وکړئ چې مناسب خلک ستاسو ژمنه ومومي.",
"launchNow": "تېر شئ او اوس یې پیل کړئ"
}
},
"detail": {
"seoTitle": "{{title}} | د {{appName}} ژمنه",
@@ -355,8 +364,8 @@
"myGroupsTagline": "هغه ډلې چې تاسو یې جوړې کړي، اداره کوئ، یا یې تعقیبوئ.",
"featuredGroups": "ځانګړې ډلې",
"featuredGroupsTagline": "ځانګړې ډلې چې ستاسو د پاملرنې وړ دي.",
"allGroups": "ټولې ډلې",
"allGroupsTagline": {{appName}} ډلې وڅیړئ، یا د Nostr په هره ډله کې لټون وکړئ.",
"allGroups": "ډلې",
"allGroupsTagline": څارونکو لخوا ځانګړې شوې. د ټولو ډلو لیدلو لپاره لټون یا ترتیب وکړئ.",
"loginToSeeTitle": "د خپلو ډلو لیدلو لپاره ننوځئ",
"loginToSeeBody": "هغه ډلې چې تاسو یې جوړې کړي یا یې اداره کوئ به دلته راڅرګندې شي.",
"noGroupsTitle": "تر اوسه ډلې نشته",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "دا ډله د څه په اړه ده؟",
"country": "هیواد",
"countryPlaceholder": "هېوادونه ولټوئ",
"countryClearAria": "هیواد پاکول",
"flagOfAria": "د {{name}} بیرغ",
"countryHint": "د هیواد د ترتیب لپاره <0>i: iso3166:{{code}}</0> خپریږي.",
"tags": "ټګونه",
"tagsPlaceholder": "ګډه-مرسته، محلي-خبرونه، ډیجیټل-حقونه",
"coverImage": "د پوښ انځور",
@@ -433,7 +439,18 @@
"errorNameInvalid": "نوم باید توري یا عددونه ولري ترڅو د ډلې لینک جوړ شي.",
"errorEditLatestMissing": "د دې ډلې وروستۍ نسخه د تازه کولو لپاره ونه موندل شوه.",
"errorCoverInvalid": "د پوښ انځور باید د https:// سمه نښه وي.",
"errorSlugCollision": "تاسو لا دمخه «{{slug}}» پېژندونکې ډله لرئ. بل نوم وټاکئ."
"errorSlugCollision": "تاسو لا دمخه «{{slug}}» پېژندونکې ډله لرئ. بل نوم وټاکئ.",
"wizard": {
"nameStepTitle": "خپلې ډلې ته نوم ورکړئ",
"nameStepSubtitle": "لنډ، روښانه نوم چې غړي یې وپېژني.",
"coverStepTitle": "د پوښ انځور اضافه کړئ",
"coverStepSubtitle": "یو انځور په هر کارت کې ډله ښیي.",
"moderatorsStepTitle": "منځګړي راوبلئ",
"moderatorsStepSubtitle": "اختیاري — هغوی کولی شي ستاسو سره یوځای محتوا ومني او غړي لرې کړي.",
"tagsStepTitle": "هیواد او کټګورۍ",
"tagsStepSubtitle": "د دې سره مرسته وکړئ چې مناسب خلک ستاسو ډله ومومي.",
"launchNow": "تېر شئ او اوس یې پیل کړئ"
}
},
"detail": {
"by": "له خوا",
@@ -493,9 +510,19 @@
"myWalletDefault": "زما پاکټ",
"walletChoose": "پاکټ وټاکئ",
"walletCustom": "ګمرکي",
"walletUseCustom": "پرځای يې بل پاکټ وکاروئ",
"walletDestinationLanding": "بسپنې به دلته راشي",
"walletDestinationNote": "دا پاکټ به ستاسو د کمپاین د بسپنو د منزل په توګه خپور شي.",
"walletUseMine": "زما د اګورا پاکټ وکاروئ",
"acceptAll": "د ټولو پیسو ډولونو منل",
"acceptPublic": "یوازې د عامه پیسو منل",
"acceptPrivate": "یوازې د خصوصي پیسو منل",
"acceptAllShort": "ټول ومنه",
"acceptPublicShort": "یوازې عامه",
"acceptPrivateShort": "یوازې خصوصي",
"acceptAllHint": "د عامه آن‌چین او خصوصي چپ پیسو دواړه ومنه.",
"acceptPublicHint": "یوازې عامه پته ته آن‌چین مرستې ومنه.",
"acceptPrivateHint": "یوازې چپ پیسې ومنه — د مرسته‌کوونکو پتې پټې پاتې کیږي.",
"customWalletIntro": "د بټ‌کوین پته، د چپ پیسو کوډ، یا دواړه دننه کړئ. لږ تر لږه یو ته اړتیا ده.",
"bitcoinAddress": "د بټ‌کوین پته",
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "د پېژندل شوي BIP-352 د چپ پیسو کوډ نه دی (sp1…).",
"country": "هیواد",
"countryPlaceholder": "هېوادونه ولټوئ",
"countryClearAria": "هیواد پاکول",
"flagOfAria": "د {{name}} بیرغ",
"countryHint": "د هیواد د ترتیب لپاره <0>i: iso3166:{{code}}</0> خپریږي.",
"tags": "ټګونه",
"tagsPlaceholder": "حقوقي-دفاع، ګډه-مرسته، محلي-خبرونه",
"categories": {
"humanRights": "د بشر حقونه",
"democracy": "ډیموکراسي",
"pressFreedom": "د مطبوعاتو ازادي",
"politicalPrisoners": "سیاسي بندیان",
"humanitarianAid": "بشري مرستې",
"civilResistance": "مدني مقاومت",
"digitalRights": "ډیجیټل حقونه",
"antiCorruption": "د فساد ضد",
"womenGirls": "ښځې او نجونې",
"refugees": "کډوال او جلاوطن",
"legalAid": "حقوقي مرسته",
"emergencyRelief": "بیړنۍ مرسته",
"animalRights": "د حیواناتو حقونه",
"education": "زده‌کړه",
"medical": "طبي",
"community": "ټولنه"
},
"banner": "د بنر انځور",
"story": "کیسه",
"storyPlaceholder": "شالید، ګټه اخیستونکي، او د پیسو د کارولو طریقه ووایاست.",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "ستاسو له پاکټ څخه نوې پر چین پته ونشوه استخراج.",
"errorHdDeriveInvalid": "استخراج شوې پته اعتبار نه ولاره. مهرباني وکړئ یوه ګمرکي پته اضافه کړئ.",
"errorWalletRequiredFallback": "د پاکټ نقطه اړینه ده.",
"errorPublishedInvalid": "خپره شوې پیښه اعتبار نه ولاره. مهرباني وکړئ پاڼه تازه کړئ او بیا هڅه وکړئ."
"errorPublishedInvalid": "خپره شوې پیښه اعتبار نه ولاره. مهرباني وکړئ پاڼه تازه کړئ او بیا هڅه وکړئ.",
"wizard": {
"titleStepTitle": "خپل کمپاین ته نوم ورکړئ",
"titleStepSubtitle": "لنډ او روښانه نوم چې بسپنه ورکوونکي یې وپېژني.",
"walletStepTitle": "وټاکئ چې بسپنې به څوک ترلاسه کوي",
"walletStepSubtitle": "ستاسو د اګورا پاکټ د دې کمپاین لپاره د Bitcoin بسپنو ترلاسه کولو ته چمتو دی.",
"bannerStepTitle": "بنر اضافه کړئ",
"bannerStepSubtitle": "یو زړه‌راښکونکی انځور په هر کارت کې کمپاین ښیي.",
"storyStepTitle": "خپله کیسه ووایاست",
"storyStepSubtitle": "څوک ګټه اخلي او بسپنې به څنګه ولګول شي.",
"next": "بل",
"back": "شاته",
"skip": "تېرول",
"launchNow": "تېر شئ او اوس یې پیل کړئ"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | د {{appName}} کمپاینونه",
@@ -699,21 +755,45 @@
"startCampaign": "کمپاین پیل کړئ",
"howItWorks": "څنګه کار کوي",
"exploreCampaigns": "د کمپاینونو لټون",
"featured": "ځانګړي",
"featuredDesc": "د {{appName}} ټیم له خوا ټاکل شوي کمپاینونه.",
"community": "د ټولنې کمپاینونه",
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
"browseAll": "← ټول کمپاینونه وګورئ",
"pending": "د منلو په تمه",
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
"wlcDesc": "د World Liberty Congress لخوا غوره شوي کمپاینونه.",
"allCampaigns": "ټول کمپاینونه",
"allCampaignsDesc": شبکې ټول کمپاینونه، د وخت په ترتیب.",
"browseAll": "ټول کمپاینونه وګورئ",
"hidden": "پټ شوي",
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
"yourCampaigns": "ستاسو کمپاینونه",
"yourCampaignsDesc": "ستاسو کمپاینونه په Nostr کې ژوندي دي او مرستې د کمپاین له لینک څخه کار کوي. کله چې د Soapbox ټیم یو مدیر یې ومني، په کور پاڼه کې به څرګند شي.",
"yourCampaignsDesc": "ستاسو کمپاینونه په Nostr کې ژوندي دي او مرستې د کمپاین له لینک څخه کار کوي. ټول کمپاینونه په /campaigns کې وګورئ؛ د {{appName}} ټیم په کور پاڼه کې یوه ټاکل شوې ټولګه ښیي.",
"empty": "تر اوسه کوم کمپاین نشته",
"emptyHint": "په {{appName}} کې د مرستو راټولولو کمپاین پیل کوونکی لومړی شئ. خپله کیسه ووایاست، ګټه اخیستونکي وټاکئ، او لینک شریک کړئ.",
"whyDifferent": {
"eyebrow": "ولې {{appName}}",
"title": "په بل ډول جوړ شوی.",
"lede": "مستقیم Bitcoin د ډالۍ ورکوونکي څخه فعال ته. هیڅ پلتفارم په منځ کې نشته، هیڅ حافظ پړه نه پر اوږو لري، هیڅ اجازه ته اړتیا نشته.",
"block1": {
"heading": "د GoFundMe برعکس",
"body": "هیڅ پلتفارم ستاسو ډالۍ نشي کنګل کولی، د بیرته‌ورکولو غوښتنه نشي کولی، یا ستاسو کمپاین د پالیسي اختلافاتو لپاره نشي پای ته رسولی. نه Stripe، نه Visa، نه کوم بانک په منځ کې شته چې د کمپاین په نیمایي کې مو قطع کړي.",
"bullet1": "د کنګلولو پروړاندې — د پلتفارم ویټو پرته",
"bullet2": "هیڅ د تادیې پروسس کوونکی نشي پلګ ایستلی",
"bullet3": "د پلتفارم د فیس پرته"
},
"block2": {
"heading": "د نورو ‘Bitcoin’ پلتفارمونو برعکس",
"body": "د مرکزي Lightning نوډ، حافظ، یا LSP پرته چې ناکام شي یا آفلاین شي. پیسې مستقیم په Bitcoin کې یوې والټ ته تسویه کیږي چې تاسو یې کنټرول کوئ. که {{appName}} سبا ورک شي، هر کمپاین به کار ته دوام ورکوي.",
"bullet1": "د حافظ والټ نشته چې وچ یا کنګل شي",
"bullet2": "په زنځیر کې یوې والټ ته چې تاسو یې لرئ تسویه کیږي",
"bullet3": "کار کوي حتی که {{appName}} ورک شي"
},
"block3": {
"heading": "عامه یا محرمه. ستاسو خوښه.",
"body": "فعالان د ترلاسه کولو هغه اختیار غوره کوي چې د هغوی د ګواښ موډل سره سمون لري. ډالۍ ورکوونکي یوازې یو QR ګوري؛ والټ سم پروتوکول غوره کوي.",
"publicLabel": "عامه",
"publicSummary": "په هره د Bitcoin والټ کې کار کوي. چټک او په زنځیر کې قابل تصدیق.",
"privateLabel": "محرمه",
"privateSummary": "BIP-352 چپه تادیې. ډالۍ نه تړل کیدونکو پایلو ته رسي."
},
"readMore": "بشپړ تفصیل ولولئ"
},
"searchPlaceholder": "د کمپاینونو لټون…",
"searchAriaLabel": "د کمپاینونو لټون",
"noMatch": "هیڅ کمپاین له «{{query}}» سره سمون نه لري",
@@ -726,10 +806,10 @@
"heroBody": "په Nostr کې خپور شوی هر د مرستو راټولولو کمپاین په یوه ځای کې راټول شوی. د بشپړې شبکې لټون وکړئ، هغه هدف ومومئ چې درته اهمیت لري، او په مستقیم ډول یې د بټکوین له لارې ملاتړ وکړئ.",
"campaignsCount_one": "په شبکه کې کمپاین",
"campaignsCount_other": "په شبکه کې کمپاینونه",
"title": "ټول کمپاینونه",
"title": "کمپاینونه",
"seoTitle": "ټول کمپاینونه",
"description": "په Agora کې خپور شوي ټول کمپاینونه وګورئ.",
"sectionTagline": "په شبکه کې هر هدف وپلټئ.",
"sectionTagline": "لومړی ځانګړې کمپاینونه، بیا د شبکې پاتې برخه. د سموالي لپاره لټون یا ترتیب وکړئ.",
"searchAriaLabel": "د کمپاینونو لټون",
"searchPlaceholder": "د کمپاینونو لټون…",
"clearSearch": "د لټون پاکول",
@@ -744,6 +824,54 @@
"allHiddenHint": "د شبکې ټول کمپاینونه د څارونکو لخوا پټ شوي. د هغوی لیدلو لپاره «پټ ښودل» فعاله کړئ.",
"empty": "تر اوسه کوم کمپاین نشته",
"emptyHint": "تر اوسه کوم کمپاین نه دی خپور شوی. لومړی شئ."
},
"lists": {
"stripAria": "د کمپاین موضوعاتو ترتیب شوي لیستونه",
"create": "نوی لیست",
"createDesc": "د موضوع یو نوی لیست جوړ کړئ. د هر کمپاین له پاڼې څخه کمپاینونه پکې ترتیب کړئ.",
"createSubmit": "لیست جوړ کړئ",
"createFailed": "د لیست جوړول ناکام شول",
"edit": "لیست سمول",
"editDesc": "د لیست سرلیک، تشریح یا آیکون تازه کړئ.",
"editSubmit": "بدلونونه خوندي کړئ",
"updateFailed": "د لیست تازه کول ناکام شول",
"delete": "لیست ړنګ کړئ",
"deleteFailed": "د لیست ړنګول ناکام شول",
"deleteConfirmTitle": "دا لیست ړنګ شي؟",
"deleteConfirmDesc": "\"{{title}}\" به د موضوعاتو له پټۍ څخه لرې شي. پخپله کمپاینونه به متاثره نه شي.",
"titleField": "سرلیک",
"titlePlaceholder": "د بېلګې په توګه د مطبوعاتو ازادي",
"descriptionField": "تشریح",
"descriptionPlaceholder": "یوه لنډه توضیح چې څرګندوي په دې لیست کې څه شامل دي.",
"iconField": "آیکون",
"menuAria": "د {{title}} لیست اختیارونه",
"listActions": "د لیست کړنې",
"memberMenuAria": "د کمپاین لیست اختیارونه",
"backToCampaigns": "کمپاینونو ته بېرته",
"detailTitle": "د کمپاین لیست",
"campaignsCount_one": "{{count}} کمپاین",
"campaignsCount_other": "{{count}} کمپاینونه",
"addCampaign": "کمپاین زیات کړئ",
"addCampaignDesc": "په شبکه کې لټون وکړئ او یو کمپاین وټاکئ چې دې لیست ته یې اضافه کړئ.",
"addFailed": "لیست ته اضافه کول ناکام شول",
"addToList": "اضافه کول",
"alreadyAdded": "اضافه شوی",
"added": "اضافه شو",
"membershipTitle": "لیستونو ته اضافه کول",
"membershipDesc": "وټاکئ چې \"{{title}}\" باید په کومو لیستونو کې ښکاره شي.",
"membershipEmpty": "تر اوسه هیڅ لیست نشته. د ترتیبولو د پیلولو لپاره یو جوړ کړئ.",
"searchPlaceholder": "د کمپاینونو لټون…",
"searchEmpty": "هیڅ کمپاین له دې لټون سره سمون نه لري.",
"removeFromList": "له لیست څخه لرې کول",
"removeFailed": "له لیست څخه لرې کول ناکام شول",
"empty": "دا لیست خالي دی.",
"emptyMod": "دا لیست خالي دی. د ترتیب پیلولو لپاره کمپاینونه ورزیات کړئ.",
"iconPicker": {
"title": "یو آیکون وټاکئ",
"description": "د Lucide له کتابتون څخه هر آیکون وټاکئ.",
"search": "د آیکونونو لټون…",
"empty": "هیڅ آیکون له دې لټون سره سمون نه لري."
}
}
},
"moderation": {
@@ -753,21 +881,27 @@
"ariaPledge": "د ژمنې څارنه",
"ariaGroup": "د ډلې څارنه",
"failedAction": "په {{action}} کې پاتې راغی",
"approve": "منل",
"unapprove": "د منلو لرې کول",
"approvedState": "منل شوی",
"hide": "پټول",
"unhide": "بېرته ښودل",
"hiddenState": "پټ شوی",
"feature": "ځانګړي کول",
"unfeature": "د ځانګړي حالت لرې کول",
"featuredState": "ځانګړی",
"toastApproved": "د کور پاڼې لپاره منل شوی",
"toastUnapproved": "د کور پاڼې څخه لرې شوی",
"toastHidden": "پټ شوی",
"toastUnhidden": "بېرته ښودل شوی",
"toastFeatured": "ځانګړی شوی",
"toastUnfeatured": "د ځانګړو څخه لرې شوی"
"toastUnfeatured": "د ځانګړو څخه لرې شوی",
"failedReorder": "د بیا ترتیب کولو په کې پاتې راغی",
"moveToTop": "سر ته انتقال",
"moveUp": "پورته انتقال",
"moveDown": "ښکته انتقال",
"addToList": "لیست ته اضافه کړئ…",
"dragHandle": "د بیا ترتیب لپاره کش کړئ (موقعیت {{index}})",
"toast": {
"movedToTop": "سر ته انتقال شو",
"movedUp": "پورته انتقال شو",
"movedDown": "ښکته انتقال شو"
}
}
},
"settings": {
@@ -1123,13 +1257,25 @@
"bitcoinAddress": "د بټکوین پته",
"silentPayment": "د چوپې ورکړې پته",
"toLabel": "ته",
"clear": "ترلاسه‌کوونکی پاک کړئ"
"clear": "ترلاسه‌کوونکی پاک کړئ",
"choosePaymentMethod": "د دوام لپاره د ورکړې طریقه وټاکئ"
},
"feeSpeed": {
"fastest": "~۱۰ دقیقې",
"halfHour": "~۳۰ دقیقې",
"hour": "~۱ ساعت",
"economy": "~۱ ورځ"
"economy": "~۱ ورځ",
"custom": "دودیز"
},
"fee": {
"loading": "لوډېږي…",
"unavailable": "د لاسرسي وړ نه دی",
"loadFailed": "د فیس نرخونه یې ونه لوډل شول.",
"retry": "بیا هڅه وکړئ",
"orCustom": "یا لاندې یو دودیز نرخ دننه کړئ.",
"loadingTiers": "د فیس نرخونه لوډېږي…",
"customPlaceholder": "بېلګه: 5",
"customAriaLabel": "دودیز د فیس نرخ په sat/vB کې"
},
"progress": {
"building": "د معاملې جوړول…",
@@ -1145,7 +1291,9 @@
"enterAmount": "یوه اندازه دننه کړئ.",
"insufficient": "د دې اندازې او د شبکې فیس لپاره کافي بټکوین نشته.",
"waitingPrice": "د BTC قیمت ته انتظار…",
"noneYet": "تاسو لاهم هېڅ بټکوین نه لرئ."
"noneYet": "تاسو لاهم هېڅ بټکوین نه لرئ.",
"feesNotLoadedYet": "د فیس نرخونه لاهم نه دي لوډ شوي.",
"feeRateTooLow": "لږ تر لږه د 1 sat/vB د فیس نرخ دننه کړئ."
},
"scanError": {
"title": "هغه QR کوډ ونه لوستل شو",
@@ -1154,6 +1302,29 @@
"toast": {
"failedTitle": "معامله ناکامه شوه"
},
"broadcastError": {
"feeTooLowTitle": "د شبکې فیس ډېر ټیټ دی",
"feeTooLowBodyWithMin": "د Bitcoin شبکه دا فیس ردوي. اوسنی لږ تر لږه نرخ شاوخوا {{min}} sat/vB دی.",
"feeTooLowBody": "د Bitcoin شبکه دا فیس ردوي. ګړنده درجه وټاکئ یا خپل دودیز نرخ پورته کړئ.",
"rbfTitle": "بدلون ته لوړ فیس په کار دی",
"rbfBody": "بدلون معامله باید له اصلي څخه ډېر فیس ورکړي. فیس پورته کړئ او بیا هڅه وکړئ.",
"mempoolFullTitle": "د Bitcoin شبکه بنده ده",
"mempoolFullBody": "mempool ډک دی او ستاسو فیس سیالۍ ته کافي نه دی. د تېرېدو لپاره فیس پورته کړئ.",
"networkTitle": "د Bitcoin شبکې ته لاسرسی ونه شو",
"networkBody": "خپله اړیکه وګورئ او بیا هڅه وکړئ.",
"mempoolConflictTitle": "ټکرېدونکې معامله",
"mempoolConflictBody": "یوه ننوتنه یې لا له مخکې لګول شوې یا د بلې معاملې لخوا لګول کیږي.",
"tooLongChainTitle": "ډېرې نا تایید شوې معاملې",
"tooLongChainBody": "تاسو د نا تایید شویو معاملو اوږد لړۍ لرئ. انتظار وکړئ تر څو یوه تایید شي او بیا هڅه وکړئ.",
"badInputsTitle": "معامله رد شوه",
"badInputsBody": "شبکې دا معامله رد کړه. اندازه یا ترلاسه‌کوونکی سم کړئ او بیا هڅه وکړئ.",
"absurdlyHighFeeTitle": "فیس په غیر عادي ډول لوړ دی",
"absurdlyHighFeeBody": "اټکل شوی فیس په شکمن ډول لوړ دی. د فیس نرخونه بیا لوډ کړئ او بیا هڅه وکړئ.",
"unknownTitle": "معامله ناکامه شوه",
"useHigherFee": "لوړ فیس وکاروئ",
"tryAgain": "بیا هڅه وکړئ",
"atMaxFeeTier": "تاسو لا له مخکې پر ګړنده درجه یاست."
},
"success": {
"title": "بټکوین ولیږل شو",
"satsAmount": "{{sats}} ساتوشي",
+212 -41
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "Deixe do seu jeito",
"subtitle": "Conte um pouco sobre você. Tudo opcional, você pode alterar a qualquer momento.",
"campaignTitle": "Dê um rosto à sua campanha",
"campaignSubtitle": "Um nome e uma foto ajudam as pessoas a se conectarem com sua campanha.",
"nameLabel": "Nome de exibição",
"namePlaceholder": "Seu nome",
"aboutLabel": "Bio",
"aboutPlaceholder": "Um pouco sobre você…",
"avatarLabel": "Avatar",
"uploadAvatar": "Enviar avatar",
"advanced": "Mais",
"finish": "Concluir",
"saving": "Salvando…",
"skip": "Pular por enquanto",
@@ -615,10 +618,11 @@
"coverImage": "Imagem de capa",
"description": "Descrição",
"timezone": "Fuso horário",
"publishing": "Publicando…",
"uploadingCover": "Enviando capa…",
"countrySearchPlaceholder": "Pesquisar países",
"imageDropzone": "Clique ou arraste uma imagem aqui"
"imageDropzone": "Clique ou arraste uma imagem aqui",
"countryClearAria": "Limpar país",
"flagOfAria": "Bandeira de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para ordenação por país."
},
"organizationContext": {
"attachedToGroup": "Anexado ao grupo",
@@ -652,8 +656,8 @@
"myPledgesTagline": "Promessas que você criou.",
"featuredPledges": "Promessas em destaque",
"featuredPledgesTagline": "Promessas destacadas pela equipe do {{appName}}.",
"allPledges": "Todas as promessas",
"allPledgesTagline": "Explore todas as promessas da rede.",
"allPledges": "Promessas",
"allPledgesTagline": "Destacadas pelos moderadores. Pesquise ou ordene para explorar todas as promessas.",
"sectionActive": "Promessas ativas",
"sectionUpcoming": "Promessas futuras",
"sectionPast": "Promessas passadas",
@@ -711,11 +715,7 @@
"titlePlaceholder": "Documentar uma limpeza de praia",
"country": "País",
"countryPlaceholder": "Pesquisar países",
"countryClearAria": "Limpar país",
"flagOfAria": "Bandeira de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para ordenação por país.",
"tags": "Tags",
"tagsPlaceholder": "limpeza-praia, documentar-protesto, apagao-internet",
"coverImage": "Imagem de capa",
"description": "Descrição",
"descriptionPlaceholder": "Explique a ação, evidência ou resultado que você quer inspirar, o que as submissões devem incluir e como você planeja avaliá-las...",
@@ -725,8 +725,6 @@
"timezone": "Fuso horário",
"timezoneNote": "Os horários de início e prazo serão interpretados neste fuso horário.",
"submit": "Criar promessa",
"publishing": "Publicando…",
"uploadingCover": "Enviando capa…",
"altText": "Promessa {{appName}}: {{title}}",
"successToast": "Promessa criada",
"errorToast": "Não foi possível criar a promessa",
@@ -737,7 +735,18 @@
"errorPledgeInvalid": "O valor da promessa deve ser um valor positivo em USD.",
"errorPriceUnavailable": "Aguardando o preço BTC/USD para calcular o valor da promessa.",
"errorCoverInvalid": "A imagem de capa deve ser uma URL https:// válida.",
"errorDeadlinePast": "O prazo não pode estar no passado."
"errorDeadlinePast": "O prazo não pode estar no passado.",
"wizard": {
"titleStepTitle": "Dê um nome à sua promessa",
"titleStepSubtitle": "Um pedido claro e uma breve explicação do que você vai financiar.",
"pledgeStepTitle": "Defina sua promessa",
"pledgeStepSubtitle": "Quanto você vai pagar, em USD, e um prazo opcional.",
"coverStepTitle": "Adicione uma imagem de capa",
"coverStepSubtitle": "Uma imagem representa a promessa em cada card.",
"tagsStepTitle": "País e categorias",
"tagsStepSubtitle": "Ajude as pessoas certas a encontrar sua promessa.",
"launchNow": "Pular Próximo e Lançar"
}
},
"detail": {
"seoTitle": "{{title}} | Promessa {{appName}}",
@@ -787,8 +796,8 @@
"myGroupsTagline": "Grupos que você fundou, modera ou segue.",
"featuredGroups": "Grupos em destaque",
"featuredGroupsTagline": "Grupos que se destacam e merecem sua atenção.",
"allGroups": "Todos os grupos",
"allGroupsTagline": "Explore os grupos do {{appName}} ou pesquise em todos os grupos do Nostr.",
"allGroups": "Grupos",
"allGroupsTagline": "Destacados pelos moderadores. Pesquise ou ordene para explorar todos os grupos.",
"loginToSeeTitle": "Entre para ver seus grupos",
"loginToSeeBody": "Grupos que você fundou ou modera aparecerão aqui.",
"noGroupsTitle": "Nenhum grupo ainda",
@@ -839,9 +848,6 @@
"descriptionPlaceholder": "Sobre o que é este grupo?",
"country": "País",
"countryPlaceholder": "Pesquisar países",
"countryClearAria": "Limpar país",
"flagOfAria": "Bandeira de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para ordenação por país.",
"tags": "Tags",
"tagsPlaceholder": "ajuda-mutua, noticias-locais, direitos-digitais",
"coverImage": "Imagem de capa",
@@ -865,7 +871,18 @@
"errorNameInvalid": "O nome deve incluir letras ou números para que uma URL de grupo possa ser criada.",
"errorEditLatestMissing": "Não foi possível encontrar a versão mais recente deste grupo para atualizar.",
"errorCoverInvalid": "A imagem de capa deve ser uma URL https:// válida.",
"errorSlugCollision": "Você já tem um grupo com o identificador \"{{slug}}\". Escolha outro nome."
"errorSlugCollision": "Você já tem um grupo com o identificador \"{{slug}}\". Escolha outro nome.",
"wizard": {
"nameStepTitle": "Dê um nome ao seu grupo",
"nameStepSubtitle": "Um nome curto e claro que os membros vão reconhecer.",
"coverStepTitle": "Adicione uma imagem de capa",
"coverStepSubtitle": "Uma imagem representa o grupo em cada card.",
"moderatorsStepTitle": "Convide moderadores",
"moderatorsStepSubtitle": "Opcional — eles podem aprovar conteúdo e remover membros junto com você.",
"tagsStepTitle": "País e categorias",
"tagsStepSubtitle": "Ajude as pessoas certas a encontrar seu grupo.",
"launchNow": "Pular Próximo e Lançar"
}
},
"detail": {
"by": "por",
@@ -925,9 +942,19 @@
"myWalletDefault": "Minha carteira",
"walletChoose": "Escolher uma carteira",
"walletCustom": "Personalizada",
"walletUseCustom": "Usar outra carteira",
"walletDestinationLanding": "As doações chegarão aqui",
"walletDestinationNote": "Esta carteira será publicada como o destino das doações da sua campanha.",
"walletUseMine": "Usar minha carteira Agora",
"acceptAll": "Aceitar todos os tipos de pagamento",
"acceptPublic": "Aceitar apenas pagamentos públicos",
"acceptPrivate": "Aceitar apenas pagamentos privados",
"acceptAllShort": "Aceitar todos",
"acceptPublicShort": "Apenas públicos",
"acceptPrivateShort": "Apenas privados",
"acceptAllHint": "Aceitar pagamentos públicos on-chain e pagamentos silenciosos privados.",
"acceptPublicHint": "Aceitar apenas doações on-chain para um endereço público.",
"acceptPrivateHint": "Aceitar apenas pagamentos silenciosos — os endereços dos doadores permanecem privados.",
"customWalletIntro": "Digite um endereço Bitcoin, um código de pagamento silencioso, ou ambos. Pelo menos um é obrigatório.",
"bitcoinAddress": "Endereço Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
@@ -937,11 +964,26 @@
"spInvalid": "Código de pagamento silencioso BIP-352 não reconhecido (sp1…).",
"country": "País",
"countryPlaceholder": "Search countries",
"countryClearAria": "Limpar país",
"flagOfAria": "Bandeira de {{name}}",
"countryHint": "Publica <0>i: iso3166:{{code}}</0> para ordenação por país.",
"tags": "Tags",
"tagsPlaceholder": "defesa-juridica, ajuda-mutua, noticias-locais",
"categories": {
"humanRights": "Direitos Humanos",
"democracy": "Democracia",
"pressFreedom": "Liberdade de Imprensa",
"politicalPrisoners": "Presos Políticos",
"humanitarianAid": "Ajuda Humanitária",
"civilResistance": "Resistência Civil",
"digitalRights": "Direitos Digitais",
"antiCorruption": "Anticorrupção",
"womenGirls": "Mulheres e Meninas",
"refugees": "Refugiados e Exilados",
"legalAid": "Assistência Jurídica",
"emergencyRelief": "Ajuda Emergencial",
"animalRights": "Direitos dos Animais",
"education": "Educação",
"medical": "Saúde",
"community": "Comunidade"
},
"banner": "Imagem de banner",
"story": "História",
"storyPlaceholder": "Compartilhe o contexto, quem se beneficia e como os fundos serão usados.",
@@ -981,7 +1023,21 @@
"errorHdDeriveFailed": "Não foi possível derivar um novo endereço on-chain da sua carteira.",
"errorHdDeriveInvalid": "O endereço de carteira derivado falhou na validação. Por favor, adicione um endereço personalizado.",
"errorWalletRequiredFallback": "Endpoint de carteira é obrigatório.",
"errorPublishedInvalid": "O evento publicado falhou na validação. Por favor, atualize e tente novamente."
"errorPublishedInvalid": "O evento publicado falhou na validação. Por favor, atualize e tente novamente.",
"wizard": {
"titleStepTitle": "Dê um nome à sua campanha",
"titleStepSubtitle": "Um nome curto e claro que os doadores reconhecerão.",
"walletStepTitle": "Escolha quem recebe as doações",
"walletStepSubtitle": "Sua carteira Agora está pronta para receber doações em Bitcoin para esta campanha.",
"bannerStepTitle": "Adicione um banner",
"bannerStepSubtitle": "Uma imagem marcante representa a campanha em cada card.",
"storyStepTitle": "Conte sua história",
"storyStepSubtitle": "Quem se beneficia e como os recursos serão usados.",
"next": "Próximo",
"back": "Voltar",
"skip": "Pular",
"launchNow": "Pular Próximo e Lançar"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Arrecadações {{appName}}",
@@ -1131,31 +1187,55 @@
"startCampaign": "Iniciar uma campanha",
"howItWorks": "Como funciona",
"exploreCampaigns": "Explorar campanhas",
"featured": "Em destaque",
"featuredDesc": "Campanhas selecionadas pela equipe do {{appName}}.",
"community": "Campanhas da comunidade",
"communityDesc": "Ajude a financiar as mudanças que valem a pena.",
"browseAll": "Navegar por todas as campanhas →",
"pending": "Aguardando aprovação",
"pendingDesc": "Campanhas na rede que nenhum moderador da Team Soapbox aprovou ou ocultou ainda.",
"pendingEmpty": "Nada aguardando revisão.",
"wlcDesc": "Campanhas selecionadas pelo World Liberty Congress.",
"allCampaigns": "Todas as campanhas",
"allCampaignsDesc": "Todas as campanhas da rede, em ordem cronológica.",
"browseAll": "Navegar por todas as campanhas",
"hidden": "Ocultas",
"hiddenDesc": "Campanhas suprimidas da página inicial pública. Use o menu de três pontos em um cartão para reexibir.",
"hiddenEmpty": "Nenhuma campanha está oculta atualmente.",
"yourCampaigns": "Suas campanhas",
"yourCampaignsDesc": "Suas campanhas estão no ar no Nostr e as doações funcionam através do link da campanha. Elas aparecem na página inicial quando um moderador da Team Soapbox as aprova.",
"yourCampaignsDesc": "Suas campanhas estão no ar no Nostr e as doações funcionam através do link da campanha. Navegue por todas as campanhas em /campaigns; a equipe do {{appName}} apresenta uma seleção curada na página inicial.",
"empty": "Nenhuma campanha ainda",
"emptyHint": "Seja o primeiro a iniciar uma arrecadação no {{appName}}. Conte sua história, escolha seus beneficiários e compartilhe o link.",
"whyDifferent": {
"eyebrow": "Por que o {{appName}}",
"title": "Construído diferente.",
"lede": "Bitcoin direto do doador ao ativista. Sem plataforma no meio, sem custodiante segurando o saco, sem pedir permissão.",
"block1": {
"heading": "Diferente do GoFundMe",
"body": "Nenhuma plataforma pode congelar suas doações, exigir reembolsos ou encerrar sua campanha por divergências de política. Sem Stripe, sem Visa, sem banco no meio que pode te cortar no meio da campanha.",
"bullet1": "À prova de congelamento — sem veto de plataforma",
"bullet2": "Nenhum processador de pagamento pode puxar o plugue",
"bullet3": "Zero taxas de plataforma"
},
"block2": {
"heading": "Diferente de outras plataformas Bitcoin",
"body": "Sem nó Lightning central, custodiante ou LSP para falhar ou ficar offline. Os fundos são liquidados diretamente no Bitcoin em uma carteira que você controla. Se o {{appName}} desaparecesse amanhã, toda campanha continuaria funcionando.",
"bullet1": "Sem carteira custodial para drenar ou congelar",
"bullet2": "Liquida on-chain em uma carteira que você possui",
"bullet3": "Funciona mesmo se o {{appName}} desaparecer"
},
"block3": {
"heading": "Público ou privado. Sua escolha.",
"body": "Ativistas escolhem a opção de recebimento que combina com seu modelo de ameaça. Doadores veem um único QR; a carteira escolhe o protocolo certo.",
"publicLabel": "Público",
"publicSummary": "Funciona em toda carteira Bitcoin. Rápido e verificável on-chain.",
"privateLabel": "Privado",
"privateSummary": "Pagamentos silenciosos BIP-352. Doações chegam em saídas inrastreáveis."
},
"readMore": "Ler a análise completa"
},
"searchPlaceholder": "Pesquisar campanhas…",
"searchAriaLabel": "Pesquisar campanhas",
"noMatch": "Nenhuma campanha corresponde a “{{query}}”",
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa."
},
"all": {
"title": "Todas as campanhas",
"title": "Campanhas",
"seoTitle": "Todas as campanhas",
"description": "Navegue por todas as campanhas publicadas no Agora.",
"sectionTagline": "Conheça todas as causas da rede.",
"sectionTagline": "Campanhas em destaque primeiro, depois o restante da rede. Pesquise ou ordene para refinar.",
"heroKicker": "Campanhas",
"heroHeading": "Cada causa,",
"heroHeadingLine2": "em um só lugar.",
@@ -1176,6 +1256,54 @@
"allHiddenHint": "Todas as campanhas na rede foram ocultadas pelos moderadores. Ative “Mostrar ocultas” para visualizá-las.",
"empty": "Nenhuma campanha ainda",
"emptyHint": "Nenhuma campanha foi publicada ainda. Seja o primeiro."
},
"lists": {
"stripAria": "Listas de tópicos de campanhas curadas",
"create": "Nova lista",
"createDesc": "Crie uma nova lista de tópicos. Curadoria de campanhas para ela a partir de qualquer página de campanha.",
"createSubmit": "Criar lista",
"createFailed": "Falha ao criar lista",
"edit": "Editar lista",
"editDesc": "Atualize o título, descrição ou ícone da lista.",
"editSubmit": "Salvar alterações",
"updateFailed": "Falha ao atualizar lista",
"delete": "Excluir lista",
"deleteFailed": "Falha ao excluir lista",
"deleteConfirmTitle": "Excluir esta lista?",
"deleteConfirmDesc": "\"{{title}}\" será removida da faixa de tópicos. As campanhas em si não são afetadas.",
"titleField": "Título",
"titlePlaceholder": "ex. Liberdade de Imprensa",
"descriptionField": "Descrição",
"descriptionPlaceholder": "Uma breve descrição explicando o que pertence a esta lista.",
"iconField": "Ícone",
"menuAria": "Opções da lista {{title}}",
"listActions": "Ações da lista",
"memberMenuAria": "Opções de lista da campanha",
"backToCampaigns": "Voltar para campanhas",
"detailTitle": "Lista de campanhas",
"campaignsCount_one": "{{count}} campanha",
"campaignsCount_other": "{{count}} campanhas",
"addCampaign": "Adicionar campanha",
"addCampaignDesc": "Pesquise na rede e escolha uma campanha para adicionar a esta lista.",
"addFailed": "Falha ao adicionar à lista",
"addToList": "Adicionar",
"alreadyAdded": "Adicionada",
"added": "Adicionada",
"membershipTitle": "Adicionar a listas",
"membershipDesc": "Escolha em quais listas \"{{title}}\" deve aparecer.",
"membershipEmpty": "Ainda não há listas. Crie uma para começar a curar.",
"searchPlaceholder": "Pesquisar campanhas…",
"searchEmpty": "Nenhuma campanha corresponde a esta pesquisa.",
"removeFromList": "Remover da lista",
"removeFailed": "Falha ao remover da lista",
"empty": "Esta lista está vazia.",
"emptyMod": "Esta lista está vazia. Adicione campanhas para começar a curá-la.",
"iconPicker": {
"title": "Escolher um ícone",
"description": "Escolha qualquer ícone da biblioteca Lucide.",
"search": "Pesquisar ícones…",
"empty": "Nenhum ícone corresponde a esta pesquisa."
}
}
},
"moderation": {
@@ -1186,21 +1314,27 @@
"ariaPledge": "Moderar promessa",
"ariaGroup": "Moderar grupo",
"failedAction": "Falha ao {{action}}",
"approve": "Aprovar",
"unapprove": "Desaprovar",
"approvedState": "Aprovado",
"hide": "Ocultar",
"unhide": "Reexibir",
"hiddenState": "Oculto",
"feature": "Destacar",
"unfeature": "Remover destaque",
"featuredState": "Em destaque",
"toastApproved": "Aprovado para a página inicial",
"toastUnapproved": "Removido da página inicial",
"toastHidden": "Ocultado",
"toastUnhidden": "Reexibido",
"toastFeatured": "Destacado",
"toastUnfeatured": "Removido dos destaques"
"toastUnfeatured": "Removido dos destaques",
"moveToTop": "Mover para o topo",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"addToList": "Adicionar à lista…",
"dragHandle": "Arraste para reordenar (posição {{index}})",
"failedReorder": "Falha ao reordenar",
"toast": {
"movedToTop": "Movido para o topo",
"movedUp": "Movido para cima",
"movedDown": "Movido para baixo"
}
}
},
"settings": {
@@ -1556,13 +1690,25 @@
"bitcoinAddress": "Endereço Bitcoin",
"silentPayment": "Endereço de pagamento silencioso",
"toLabel": "Para",
"clear": "Limpar destinatário"
"clear": "Limpar destinatário",
"choosePaymentMethod": "Escolha um método de pagamento para continuar"
},
"feeSpeed": {
"fastest": "~10 min",
"halfHour": "~30 min",
"hour": "~1 hora",
"economy": "~1 dia"
"economy": "~1 dia",
"custom": "Personalizada"
},
"fee": {
"loading": "carregando…",
"unavailable": "indisponível",
"loadFailed": "Não foi possível carregar as taxas.",
"retry": "Tentar novamente",
"orCustom": "Ou insira uma taxa personalizada abaixo.",
"loadingTiers": "Carregando taxas…",
"customPlaceholder": "ex. 5",
"customAriaLabel": "Taxa personalizada em sat/vB"
},
"progress": {
"building": "Construindo transação…",
@@ -1575,6 +1721,8 @@
"enterRecipient": "Digite um endereço Bitcoin ou endereço de pagamento silencioso sp1….",
"noSpendable": "Sem Bitcoin gastável nesta carteira.",
"feesNotLoaded": "Taxas não carregadas.",
"feesNotLoadedYet": "As taxas ainda não foram carregadas.",
"feeRateTooLow": "Insira uma taxa de pelo menos 1 sat/vB.",
"enterAmount": "Digite um valor.",
"insufficient": "Bitcoin insuficiente para este valor + taxa de rede.",
"waitingPrice": "Aguardando preço do BTC…",
@@ -1583,6 +1731,29 @@
"toast": {
"failedTitle": "Transação falhou"
},
"broadcastError": {
"feeTooLowTitle": "Taxa de rede muito baixa",
"feeTooLowBodyWithMin": "A rede Bitcoin está rejeitando essa taxa. O mínimo agora é de cerca de {{min}} sat/vB.",
"feeTooLowBody": "A rede Bitcoin está rejeitando essa taxa. Escolha um nível mais rápido ou aumente sua taxa personalizada.",
"rbfTitle": "A substituição precisa de uma taxa maior",
"rbfBody": "A transação de substituição precisa pagar mais do que a original. Aumente a taxa e tente novamente.",
"mempoolFullTitle": "A rede Bitcoin está congestionada",
"mempoolFullBody": "O mempool está cheio e sua taxa não é competitiva. Aumente a taxa para ser processada.",
"networkTitle": "Não foi possível acessar a rede Bitcoin",
"networkBody": "Verifique sua conexão e tente novamente.",
"mempoolConflictTitle": "Transação conflitante",
"mempoolConflictBody": "Uma das entradas já foi gasta ou está sendo gasta por outra transação.",
"tooLongChainTitle": "Muitas transações não confirmadas",
"tooLongChainBody": "Você tem uma longa cadeia de transações não confirmadas. Aguarde uma delas ser confirmada e tente novamente.",
"badInputsTitle": "A transação foi rejeitada",
"badInputsBody": "A rede rejeitou esta transação. Ajuste o valor ou o destinatário e tente novamente.",
"absurdlyHighFeeTitle": "A taxa está extraordinariamente alta",
"absurdlyHighFeeBody": "A taxa estimada está suspeitamente alta. Recarregue as taxas e tente novamente.",
"unknownTitle": "Transação falhou",
"useHigherFee": "Usar uma taxa maior",
"tryAgain": "Tentar novamente",
"atMaxFeeTier": "Você já está no nível mais rápido."
},
"scanError": {
"title": "Não foi possível ler esse código QR",
"description": "Era esperado um endereço Bitcoin, um endereço de pagamento silencioso (sp1…) ou uma URI bitcoin:."
+213 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "Сделайте его своим",
"subtitle": "Расскажите другим немного о себе. Всё необязательно, можно изменить в любой момент.",
"campaignTitle": "Покажите лицо вашей кампании",
"campaignSubtitle": "Имя и фото помогают людям почувствовать связь с вашей кампанией.",
"nameLabel": "Отображаемое имя",
"namePlaceholder": "Ваше имя",
"aboutLabel": "О себе",
"aboutPlaceholder": "Немного о вас…",
"avatarLabel": "Аватар",
"uploadAvatar": "Загрузить аватар",
"advanced": "Ещё",
"finish": "Готово",
"saving": "Сохранение…",
"skip": "Пропустить",
@@ -615,10 +618,11 @@
"coverImage": "Обложка",
"description": "Описание",
"timezone": "Часовой пояс",
"publishing": "Публикация…",
"uploadingCover": "Загрузка обложки…",
"countrySearchPlaceholder": "Поиск стран",
"imageDropzone": "Нажмите или перетащите изображение сюда"
"imageDropzone": "Нажмите или перетащите изображение сюда",
"countryClearAria": "Очистить страну",
"flagOfAria": "Флаг {{name}}",
"countryHint": "Публикует <0>i: iso3166:{{code}}</0> для сортировки по странам."
},
"organizationContext": {
"attachedToGroup": "Прикреплено к группе",
@@ -652,8 +656,8 @@
"myPledgesTagline": "Обещания, которые вы создали.",
"featuredPledges": "Избранные обещания",
"featuredPledgesTagline": "Обещания, отмеченные командой {{appName}}.",
"allPledges": "Все обещания",
"allPledgesTagline": "Просматривайте все обещания в сети.",
"allPledges": "Обещания",
"allPledgesTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть все обещания.",
"sectionActive": "Активные обещания",
"sectionUpcoming": "Предстоящие обещания",
"sectionPast": "Прошлые обещания",
@@ -711,11 +715,7 @@
"titlePlaceholder": "Задокументировать уборку пляжа",
"country": "Страна",
"countryPlaceholder": "Поиск стран",
"countryClearAria": "Очистить страну",
"flagOfAria": "Флаг {{name}}",
"countryHint": "Публикует <0>i: iso3166:{{code}}</0> для сортировки по странам.",
"tags": "Теги",
"tagsPlaceholder": "уборка-пляжа, документирование-протеста, отключение-интернета",
"coverImage": "Обложка",
"description": "Описание",
"descriptionPlaceholder": "Объясните действие, доказательство или результат, который вы хотите вдохновить, что должны включать заявки и как вы планируете их оценивать...",
@@ -725,8 +725,6 @@
"timezone": "Часовой пояс",
"timezoneNote": "Время начала и срока будет интерпретироваться в этом часовом поясе.",
"submit": "Создать обещание",
"publishing": "Публикация…",
"uploadingCover": "Загрузка обложки…",
"altText": "Обещание {{appName}}: {{title}}",
"successToast": "Обещание создано",
"errorToast": "Не удалось создать обещание",
@@ -737,7 +735,18 @@
"errorPledgeInvalid": "Сумма обещания должна быть положительной суммой в USD.",
"errorPriceUnavailable": "Ожидание цены BTC/USD для расчёта суммы обещания.",
"errorCoverInvalid": "Обложка должна быть валидной URL https://.",
"errorDeadlinePast": "Срок не может быть в прошлом."
"errorDeadlinePast": "Срок не может быть в прошлом.",
"wizard": {
"titleStepTitle": "Назовите своё обещание",
"titleStepSubtitle": "Понятный запрос и краткое объяснение того, что вы профинансируете.",
"pledgeStepTitle": "Установите своё обещание",
"pledgeStepSubtitle": "Сколько вы заплатите в USD, и необязательный срок.",
"coverStepTitle": "Добавьте обложку",
"coverStepSubtitle": "Одно изображение представит обещание на каждой карточке.",
"tagsStepTitle": "Страна и категории",
"tagsStepSubtitle": "Помогите нужным людям найти ваше обещание.",
"launchNow": "Пропустить и запустить"
}
},
"detail": {
"seoTitle": "{{title}} | Обещание {{appName}}",
@@ -787,8 +796,8 @@
"myGroupsTagline": "Группы, которые вы основали, модерируете или на которые подписаны.",
"featuredGroups": "Избранные группы",
"featuredGroupsTagline": "Заметные группы, достойные вашего внимания.",
"allGroups": "Все группы",
"allGroupsTagline": "Просматривайте группы {{appName}} или ищите среди всех групп в Nostr.",
"allGroups": "Группы",
"allGroupsTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть все группы.",
"loginToSeeTitle": "Войдите, чтобы увидеть свои группы",
"loginToSeeBody": "Группы, которые вы основали или модерируете, появятся здесь.",
"noGroupsTitle": "Пока нет групп",
@@ -839,9 +848,6 @@
"descriptionPlaceholder": "О чём эта группа?",
"country": "Страна",
"countryPlaceholder": "Поиск стран",
"countryClearAria": "Очистить страну",
"flagOfAria": "Флаг {{name}}",
"countryHint": "Публикует <0>i: iso3166:{{code}}</0> для сортировки по странам.",
"tags": "Теги",
"tagsPlaceholder": "взаимопомощь, местные-новости, цифровые-права",
"coverImage": "Обложка",
@@ -865,7 +871,18 @@
"errorNameInvalid": "Название должно содержать буквы или цифры, чтобы можно было создать URL группы.",
"errorEditLatestMissing": "Не удалось найти последнюю версию этой группы для обновления.",
"errorCoverInvalid": "Обложка должна быть валидной URL https://.",
"errorSlugCollision": "У вас уже есть группа с идентификатором «{{slug}}». Выберите другое название."
"errorSlugCollision": "У вас уже есть группа с идентификатором «{{slug}}». Выберите другое название.",
"wizard": {
"nameStepTitle": "Назовите свою группу",
"nameStepSubtitle": "Короткое и понятное название, которое узнают участники.",
"coverStepTitle": "Добавьте обложку",
"coverStepSubtitle": "Одно изображение представит группу на каждой карточке.",
"moderatorsStepTitle": "Пригласите модераторов",
"moderatorsStepSubtitle": "Необязательно — они смогут одобрять контент и удалять участников вместе с вами.",
"tagsStepTitle": "Страна и категории",
"tagsStepSubtitle": "Помогите нужным людям найти вашу группу.",
"launchNow": "Пропустить и запустить"
}
},
"detail": {
"by": "от",
@@ -925,9 +942,19 @@
"myWalletDefault": "Мой кошелёк",
"walletChoose": "Выбрать кошелёк",
"walletCustom": "Пользовательский",
"walletUseCustom": "Использовать другой кошелёк",
"walletDestinationLanding": "Пожертвования будут поступать сюда",
"walletDestinationNote": "Этот кошелёк будет опубликован как адрес для пожертвований вашей кампании.",
"walletUseMine": "Использовать мой кошелёк Agora",
"acceptAll": "Принимать все типы платежей",
"acceptPublic": "Принимать только публичные платежи",
"acceptPrivate": "Принимать только приватные платежи",
"acceptAllShort": "Принимать все",
"acceptPublicShort": "Только публичные",
"acceptPrivateShort": "Только приватные",
"acceptAllHint": "Принимать как публичные ончейн-платежи, так и приватные тихие платежи.",
"acceptPublicHint": "Принимать только ончейн-пожертвования на публичный адрес.",
"acceptPrivateHint": "Принимать только тихие платежи — адреса донаторов остаются приватными.",
"customWalletIntro": "Введите Bitcoin-адрес, код тихого платежа или оба. Требуется хотя бы один.",
"bitcoinAddress": "Bitcoin-адрес",
"bitcoinAddressPlaceholder": "bc1q… или bc1p…",
@@ -937,11 +964,26 @@
"spInvalid": "Нераспознанный код тихого платежа BIP-352 (sp1…).",
"country": "Страна",
"countryPlaceholder": "Search countries",
"countryClearAria": "Очистить страну",
"flagOfAria": "Флаг {{name}}",
"countryHint": "Публикует <0>i: iso3166:{{code}}</0> для сортировки по странам.",
"tags": "Теги",
"tagsPlaceholder": "правовая-защита, взаимопомощь, местные-новости",
"categories": {
"humanRights": "Права человека",
"democracy": "Демократия",
"pressFreedom": "Свобода прессы",
"politicalPrisoners": "Политзаключённые",
"humanitarianAid": "Гуманитарная помощь",
"civilResistance": "Гражданское сопротивление",
"digitalRights": "Цифровые права",
"antiCorruption": "Борьба с коррупцией",
"womenGirls": "Женщины и девочки",
"refugees": "Беженцы и изгнанники",
"legalAid": "Правовая помощь",
"emergencyRelief": "Экстренная помощь",
"animalRights": "Права животных",
"education": "Образование",
"medical": "Медицина",
"community": "Сообщество"
},
"banner": "Баннер",
"story": "История",
"storyPlaceholder": "Поделитесь контекстом, кто получит выгоду и как будут использованы средства.",
@@ -981,7 +1023,21 @@
"errorHdDeriveFailed": "Не удалось получить свежий адрес в блокчейне из вашего кошелька.",
"errorHdDeriveInvalid": "Полученный адрес кошелька не прошёл проверку. Пожалуйста, добавьте пользовательский адрес.",
"errorWalletRequiredFallback": "Эндпойнт кошелька обязателен.",
"errorPublishedInvalid": "Опубликованное событие не прошло проверку. Пожалуйста, обновите и попробуйте снова."
"errorPublishedInvalid": "Опубликованное событие не прошло проверку. Пожалуйста, обновите и попробуйте снова.",
"wizard": {
"titleStepTitle": "Назовите свою кампанию",
"titleStepSubtitle": "Короткое и понятное название, которое запомнят доноры.",
"walletStepTitle": "Выберите, кто получает пожертвования",
"walletStepSubtitle": "Ваш кошелёк Agora готов принимать пожертвования в Bitcoin для этой кампании.",
"bannerStepTitle": "Добавьте баннер",
"bannerStepSubtitle": "Одно яркое изображение представит кампанию на каждой карточке.",
"storyStepTitle": "Расскажите свою историю",
"storyStepSubtitle": "Кому это поможет и как будут использованы средства.",
"next": "Далее",
"back": "Назад",
"skip": "Пропустить",
"launchNow": "Пропустить и запустить"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Сборы средств {{appName}}",
@@ -1131,31 +1187,55 @@
"startCampaign": "Запустить кампанию",
"howItWorks": "Как это работает",
"exploreCampaigns": "Исследовать кампании",
"featured": "Избранные",
"featuredDesc": "Кампании, отобранные командой {{appName}}.",
"community": "Кампании сообщества",
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
"browseAll": "Просмотреть все кампании →",
"pending": "Ожидают одобрения",
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
"pendingEmpty": "Ничего не ждёт проверки.",
"wlcDesc": "Кампании, отобранные World Liberty Congress.",
"allCampaigns": "Все кампании",
"allCampaignsDesc": "Все кампании в сети, в хронологическом порядке.",
"browseAll": "Просмотреть все кампании",
"hidden": "Скрытые",
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
"yourCampaigns": "Ваши кампании",
"yourCampaignsDesc": "Ваши кампании в эфире в Nostr, и пожертвования работают через ссылку кампании. Они появляются на главной странице, когда модератор Team Soapbox их одобряет.",
"yourCampaignsDesc": "Ваши кампании уже в эфире в Nostr, и пожертвования работают через ссылку кампании. Просмотрите все кампании на /campaigns; команда {{appName}} выделяет отобранную подборку на главной странице.",
"empty": "Пока нет кампаний",
"emptyHint": "Будьте первым, кто запустит сбор средств на {{appName}}. Расскажите свою историю, выберите бенефициаров и поделитесь ссылкой.",
"whyDifferent": {
"eyebrow": "Почему {{appName}}",
"title": "Сделано по-другому.",
"lede": "Bitcoin напрямую от жертвователя к активисту. Никакой платформы посередине, никакого хранителя с мешком денег, никаких разрешений.",
"block1": {
"heading": "В отличие от GoFundMe",
"body": "Ни одна платформа не может заморозить ваши пожертвования, потребовать возврата средств или прекратить вашу кампанию из-за разногласий в политике. Никакого Stripe, никакой Visa, никакого банка посередине, который может отрезать вас в середине кампании.",
"bullet1": "Защищено от заморозки — никакого вето платформы",
"bullet2": "Ни один платёжный процессор не может выдернуть вилку",
"bullet3": "Никаких комиссий платформы"
},
"block2": {
"heading": "В отличие от других «Bitcoin»-платформ",
"body": "Никакого центрального узла Lightning, хранителя или LSP, который может отказать или уйти в офлайн. Средства зачисляются напрямую в блокчейне Bitcoin на кошелёк, который вы контролируете. Если бы {{appName}} исчез завтра, каждая кампания продолжала бы работать.",
"bullet1": "Никакого хранительского кошелька, который можно опустошить или заморозить",
"bullet2": "Зачисляется в блокчейне на кошелёк, которым владеете вы",
"bullet3": "Работает, даже если {{appName}} исчезнет"
},
"block3": {
"heading": "Публично или приватно. Ваш выбор.",
"body": "Активисты выбирают вариант получения, соответствующий их модели угроз. Жертвователи видят один QR-код; кошелёк сам подбирает нужный протокол.",
"publicLabel": "Публично",
"publicSummary": "Работает в любом Bitcoin-кошельке. Быстро и проверяемо в блокчейне.",
"privateLabel": "Приватно",
"privateSummary": "Тихие платежи BIP-352. Пожертвования приходят на несвязываемые выходы."
},
"readMore": "Прочитать полный разбор"
},
"searchPlaceholder": "Поиск кампаний…",
"searchAriaLabel": "Поиск кампаний",
"noMatch": "Ни одна кампания не соответствует «{{query}}»",
"noMatchHint": "Попробуйте другой поисковый запрос или очистите поиск."
},
"all": {
"title": "Все кампании",
"title": "Кампании",
"seoTitle": "Все кампании",
"description": "Просмотрите все кампании, опубликованные на Agora.",
"sectionTagline": "Просмотрите каждое дело в сети.",
"sectionTagline": "Сначала рекомендуемые кампании, затем остальная сеть. Используйте поиск или сортировку, чтобы уточнить результаты.",
"heroKicker": "Кампании",
"heroHeading": "Каждое дело —",
"heroHeadingLine2": "в одном месте.",
@@ -1176,6 +1256,54 @@
"allHiddenHint": "Все кампании в сети скрыты модераторами. Включите «Показать скрытые», чтобы их увидеть.",
"empty": "Пока нет кампаний",
"emptyHint": "Кампаний пока не было опубликовано. Будьте первым."
},
"lists": {
"stripAria": "Кураторские тематические списки кампаний",
"create": "Новый список",
"createDesc": "Создайте новый тематический список. Добавляйте в него кампании с любой страницы кампании.",
"createSubmit": "Создать список",
"createFailed": "Не удалось создать список",
"edit": "Редактировать список",
"editDesc": "Обновите название, описание или иконку списка.",
"editSubmit": "Сохранить изменения",
"updateFailed": "Не удалось обновить список",
"delete": "Удалить список",
"deleteFailed": "Не удалось удалить список",
"deleteConfirmTitle": "Удалить этот список?",
"deleteConfirmDesc": "«{{title}}» будет удалён из тематической ленты. Сами кампании это не затронет.",
"titleField": "Название",
"titlePlaceholder": "например, Свобода прессы",
"descriptionField": "Описание",
"descriptionPlaceholder": "Короткое описание того, что входит в этот список.",
"iconField": "Иконка",
"menuAria": "Параметры списка {{title}}",
"listActions": "Действия со списком",
"memberMenuAria": "Параметры списка кампаний",
"backToCampaigns": "Назад к кампаниям",
"detailTitle": "Список кампаний",
"campaignsCount_one": "{{count}} кампания",
"campaignsCount_other": "{{count}} кампаний",
"addCampaign": "Добавить кампанию",
"addCampaignDesc": "Найдите кампанию в сети и добавьте её в этот список.",
"addFailed": "Не удалось добавить в список",
"addToList": "Добавить",
"alreadyAdded": "Добавлено",
"added": "Добавлено",
"membershipTitle": "Добавить в списки",
"membershipDesc": "Выберите, в каких списках должна отображаться \"{{title}}\".",
"membershipEmpty": "Списков пока нет. Создайте список, чтобы начать курировать.",
"searchPlaceholder": "Поиск кампаний…",
"searchEmpty": "Ни одна кампания не соответствует поиску.",
"removeFromList": "Удалить из списка",
"removeFailed": "Не удалось удалить из списка",
"empty": "Этот список пуст.",
"emptyMod": "Этот список пуст. Добавьте кампании, чтобы начать его курировать.",
"iconPicker": {
"title": "Выберите иконку",
"description": "Выберите любую иконку из библиотеки Lucide.",
"search": "Поиск иконок…",
"empty": "Ни одна иконка не соответствует поиску."
}
}
},
"moderation": {
@@ -1186,21 +1314,27 @@
"ariaPledge": "Модерировать обещание",
"ariaGroup": "Модерировать группу",
"failedAction": "Не удалось выполнить действие: {{action}}",
"approve": "Одобрить",
"unapprove": "Отозвать одобрение",
"approvedState": "Одобрено",
"failedReorder": "Не удалось изменить порядок",
"hide": "Скрыть",
"unhide": "Показать",
"hiddenState": "Скрыто",
"feature": "Выделить",
"unfeature": "Убрать из избранного",
"featuredState": "Избранное",
"toastApproved": "Одобрено для главной страницы",
"toastUnapproved": "Удалено с главной страницы",
"moveToTop": "Переместить наверх",
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз",
"addToList": "Добавить в список…",
"dragHandle": "Перетащите для изменения порядка (позиция {{index}})",
"toastHidden": "Скрыто",
"toastUnhidden": "Показано",
"toastFeatured": "Добавлено в избранное",
"toastUnfeatured": "Удалено из избранного"
"toastUnfeatured": "Удалено из избранного",
"toast": {
"movedToTop": "Перемещено наверх",
"movedUp": "Перемещено вверх",
"movedDown": "Перемещено вниз"
}
}
},
"settings": {
@@ -1556,13 +1690,25 @@
"bitcoinAddress": "Bitcoin-адрес",
"silentPayment": "Адрес тихого платежа",
"toLabel": "Кому",
"clear": "Очистить получателя"
"clear": "Очистить получателя",
"choosePaymentMethod": "Выберите способ оплаты, чтобы продолжить"
},
"feeSpeed": {
"fastest": "~10 мин",
"halfHour": "~30 мин",
"hour": "~1 час",
"economy": "~1 день"
"economy": "~1 день",
"custom": "Другая"
},
"fee": {
"loading": "загрузка…",
"unavailable": "недоступно",
"loadFailed": "Не удалось загрузить ставки комиссий.",
"retry": "Повторить",
"orCustom": "Или введите свою ставку ниже.",
"loadingTiers": "Загрузка ставок комиссий…",
"customPlaceholder": "напр. 5",
"customAriaLabel": "Своя ставка комиссии в sat/vB"
},
"progress": {
"building": "Создание транзакции…",
@@ -1578,7 +1724,9 @@
"enterAmount": "Введите сумму.",
"insufficient": "Недостаточно Bitcoin для этой суммы + комиссии сети.",
"waitingPrice": "Ожидание цены BTC…",
"noneYet": "У вас пока нет Bitcoin."
"noneYet": "У вас пока нет Bitcoin.",
"feesNotLoadedYet": "Ставки комиссий ещё не загружены.",
"feeRateTooLow": "Введите ставку комиссии не менее 1 sat/vB."
},
"scanError": {
"title": "Не удалось прочитать этот QR-код",
@@ -1587,6 +1735,29 @@
"toast": {
"failedTitle": "Транзакция не удалась"
},
"broadcastError": {
"feeTooLowTitle": "Слишком низкая комиссия сети",
"feeTooLowBodyWithMin": "Сеть Bitcoin отклоняет эту комиссию. Минимальная сейчас — около {{min}} sat/vB.",
"feeTooLowBody": "Сеть Bitcoin отклоняет эту комиссию. Выберите более быстрый уровень или повысьте свою ставку.",
"rbfTitle": "Для замены требуется более высокая комиссия",
"rbfBody": "Транзакция-замена должна оплачивать больше, чем исходная. Повысьте комиссию и попробуйте снова.",
"mempoolFullTitle": "Сеть Bitcoin перегружена",
"mempoolFullBody": "Mempool заполнен, и ваша комиссия неконкурентоспособна. Повысьте комиссию, чтобы транзакция прошла.",
"networkTitle": "Не удалось связаться с сетью Bitcoin",
"networkBody": "Проверьте подключение и попробуйте снова.",
"mempoolConflictTitle": "Конфликтующая транзакция",
"mempoolConflictBody": "Один из входов уже был потрачен или тратится другой транзакцией.",
"tooLongChainTitle": "Слишком много неподтверждённых транзакций",
"tooLongChainBody": "У вас длинная цепочка неподтверждённых транзакций. Дождитесь подтверждения одной из них и попробуйте снова.",
"badInputsTitle": "Транзакция отклонена",
"badInputsBody": "Сеть отклонила эту транзакцию. Измените сумму или получателя и попробуйте снова.",
"absurdlyHighFeeTitle": "Необычно высокая комиссия",
"absurdlyHighFeeBody": "Расчётная комиссия подозрительно высока. Перезагрузите ставки комиссий и попробуйте снова.",
"unknownTitle": "Транзакция не удалась",
"useHigherFee": "Использовать более высокую комиссию",
"tryAgain": "Попробовать снова",
"atMaxFeeTier": "Вы уже на самом быстром уровне."
},
"success": {
"title": "Bitcoin отправлен",
"satsAmount": "{{sats}} сатов",
+225 -46
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "Ita seyako",
"subtitle": "Udza vamwe zvishoma pamusoro pako. Zvose zvichisarudzwa, chinja chero nguva.",
"campaignTitle": "Isa chiso pamushandirapamwe wako",
"campaignSubtitle": "Zita nemufananidzo zvinobatsira vanhu kubatana nemushandirapamwe wako.",
"nameLabel": "Zita rinoratidzwa",
"namePlaceholder": "Zita rako",
"aboutLabel": "Nhoroondo",
"aboutPlaceholder": "Zvishoma pamusoro pako…",
"avatarLabel": "Avatar",
"uploadAvatar": "Isa avatar",
"advanced": "Zvimwe",
"finish": "Pedzisa",
"saving": "Kuchengeta…",
"skip": "Pfuura zvinosvika",
@@ -183,10 +186,11 @@
"coverImage": "Mufananidzo wepamusoro",
"description": "Tsananguro",
"timezone": "Nguva yenzvimbo",
"publishing": "Kuburitsa…",
"uploadingCover": "Kuisa mufananidzo wepamusoro…",
"countrySearchPlaceholder": "Tsvaga nyika",
"imageDropzone": "Dzvanya kana kukwevera mufananidzo pano"
"imageDropzone": "Dzvanya kana kukwevera mufananidzo pano",
"countryClearAria": "Bvisa nyika",
"flagOfAria": "Mureza we{{name}}",
"countryHint": "Zvinoburitsa <0>i: iso3166:{{code}}</0> yekuronga nenyika."
},
"organizationContext": {
"attachedToGroup": "Chakabatanidzwa kugroup",
@@ -220,8 +224,8 @@
"myPledgesTagline": "Zvitsidziro zvawakagadzira.",
"featuredPledges": "Zvitsidziro zvakasarudzwa",
"featuredPledgesTagline": "Zvitsidziro zvakasimudzirwa nechikwata che{{appName}}.",
"allPledges": "Zvitsidziro zvose",
"allPledgesTagline": "Tarisa chitsidziro chega chega chiri pamutambo wenetiweki.",
"allPledges": "Zvitsidziro",
"allPledgesTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise chitsidziro chega chega.",
"sectionActive": "Zvitsidziro zviri kushanda",
"sectionUpcoming": "Zvitsidziro zviri kuuya",
"sectionPast": "Zvitsidziro zvapfuura",
@@ -279,11 +283,7 @@
"titlePlaceholder": "Nyora kuchenesa kwemhenderekedzo",
"country": "Nyika",
"countryPlaceholder": "Tsvaga nyika",
"countryClearAria": "Bvisa nyika",
"flagOfAria": "Mureza we{{name}}",
"countryHint": "Zvinoburitsa <0>i: iso3166:{{code}}</0> yekuronga nenyika.",
"tags": "Marebhuro",
"tagsPlaceholder": "kuchenesa-mhenderekedzo, nyore-mhirizhonga, kudzima-internet",
"coverImage": "Mufananidzo wepamusoro",
"description": "Tsanangudzo",
"descriptionPlaceholder": "Tsanangura chiito, humbowo, kana mhedzisiro yauri kuda kukurudzira, zvinofanira kuva muzvirongwa, uye uchazviongorora sei...",
@@ -293,8 +293,6 @@
"timezone": "Nzvimbo yenguva",
"timezoneNote": "Nguva yekutanga neyemugumo zvichaverengerwa munzvimbo yenguva iyi.",
"submit": "Gadzira chitsidziro",
"publishing": "Kuburitsa…",
"uploadingCover": "Kuisa mufananidzo…",
"altText": "Chitsidziro che{{appName}}: {{title}}",
"successToast": "Chitsidziro chagadzirwa",
"errorToast": "Hachina kugadzirika chitsidziro",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "Mari yechitsidziro inofanira kunge iri USD inopfuura zero.",
"errorPriceUnavailable": "Kumirira mutengo weBTC/USD kuti ubatanidze mari yechitsidziro.",
"errorCoverInvalid": "Mufananidzo wepamusoro unofanira kuva URL ye https:// chaiyo.",
"errorDeadlinePast": "Mugumo haungavi munguva yapfuura."
"errorDeadlinePast": "Mugumo haungavi munguva yapfuura.",
"wizard": {
"titleStepTitle": "Tumidza chitsidziro chako",
"titleStepSubtitle": "Chikumbiro chiri pachena nedudziro pfupi yezvauchatsigira.",
"pledgeStepTitle": "Misa chitsidziro chako",
"pledgeStepSubtitle": "Mari yauchabhadhara, muUSD, uye mugumo unosarudzwa.",
"coverStepTitle": "Wedzera mufananidzo wepamusoro",
"coverStepSubtitle": "Mufananidzo mumwe chete unotakura chitsidziro pakadhi rega rega.",
"tagsStepTitle": "Nyika nemapoka",
"tagsStepSubtitle": "Batsira vanhu vakakodzera kuwana chitsidziro chako.",
"launchNow": "Darika Inotevera & Vhura"
}
},
"detail": {
"seoTitle": "{{title}} | Chitsidziro che{{appName}}",
@@ -355,8 +364,8 @@
"myGroupsTagline": "Mapoka awakatanga, aunotungamira, kana aunotevera.",
"featuredGroups": "Mapoka anokurumbira",
"featuredGroupsTagline": "Mapoka anobudirira anokodzera kutariswa nemi.",
"allGroups": "Mapoka ose",
"allGroupsTagline": "Tarisa mapoka e{{appName}}, kana kutsvaga mumapoka ose ari paNostr.",
"allGroups": "Mapoka",
"allGroupsTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise boka rega rega.",
"loginToSeeTitle": "Pinda kuti uone mapoka ako",
"loginToSeeBody": "Mapoka awakatanga kana aunotungamira achaonekwa pano.",
"noGroupsTitle": "Hapana mapoka parizvino",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "Boka iri rakanangana nei?",
"country": "Nyika",
"countryPlaceholder": "Tsvaga nyika",
"countryClearAria": "Bvisa nyika",
"flagOfAria": "Mureza we{{name}}",
"countryHint": "Zvinoburitsa <0>i: iso3166:{{code}}</0> yekuronga nenyika.",
"tags": "Marebhuro",
"tagsPlaceholder": "rubatsiro, nhau-dzomuno, kodzero-dzedhijitari",
"coverImage": "Mufananidzo wepamusoro",
@@ -433,7 +439,18 @@
"errorNameInvalid": "Zita rinofanira kuva nemavara kana manhamba kuti URL yeboka igadzirike.",
"errorEditLatestMissing": "Hatina kuwana shanduro yazvino yeboka iri kuti tirivandudze.",
"errorCoverInvalid": "Mufananidzo wepamusoro unofanira kuva URL ye https:// chaiyo.",
"errorSlugCollision": "Une kare boka rine kupiwa zita kwe«{{slug}}». Sarudza rimwe zita."
"errorSlugCollision": "Une kare boka rine kupiwa zita kwe«{{slug}}». Sarudza rimwe zita.",
"wizard": {
"nameStepTitle": "Tumidza boka rako",
"nameStepSubtitle": "Zita pfupi, riri pachena, ravachaziva nhengo.",
"coverStepTitle": "Wedzera mufananidzo wepamusoro",
"coverStepSubtitle": "Mufananidzo mumwe chete unotakura boka pakadhi rega rega.",
"moderatorsStepTitle": "Kokera vatungamiri",
"moderatorsStepSubtitle": "Zvinosarudzwa — vanogona kutendera zvinyorwa nokubvisa nhengo pamwe newe.",
"tagsStepTitle": "Nyika nemapoka",
"tagsStepSubtitle": "Batsira vanhu vakakodzera kuwana boka rako.",
"launchNow": "Darika Inotevera & Vhura"
}
},
"detail": {
"by": "na",
@@ -493,9 +510,19 @@
"myWalletDefault": "Chikwama changu",
"walletChoose": "Sarudza chikwama",
"walletCustom": "Chenyu",
"walletUseCustom": "Shandisa chimwe chikwama panzvimbo pacho",
"walletDestinationLanding": "Zvipo zvichasvika pano",
"walletDestinationNote": "Chikwama ichi chichaburitswa senzvimbo inoenda zvipo zvemushandirapamwe wako.",
"walletUseMine": "Shandisa chikwama changu cheAgora",
"acceptAll": "Gamuchira mhando dzese dzemubhadharo",
"acceptPublic": "Gamuchira chete mibhadharo yepachena",
"acceptPrivate": "Gamuchira chete mibhadharo yakavanzika",
"acceptAllShort": "Zvose",
"acceptPublicShort": "Zvepachena Chete",
"acceptPrivateShort": "Zvakavanzika Chete",
"acceptAllHint": "Gamuchira mibhadharo yepachena yepa-on-chain neyakavanzika yemubhadharo unyararo.",
"acceptPublicHint": "Gamuchira chete zvipo zvepa-on-chain kukero yepachena.",
"acceptPrivateHint": "Gamuchira chete mubhadharo unyararo — makero evapi anoramba akavanzika.",
"customWalletIntro": "Isa kero yeBitcoin, kodhi yemubhadharo unyararo, kana zvose. Imwechete inodikanwa zvirinani.",
"bitcoinAddress": "Kero yeBitcoin",
"bitcoinAddressPlaceholder": "bc1q… kana bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "Haisi kodhi yeBIP-352 yemubhadharo unyararo inozivikanwa (sp1…).",
"country": "Nyika",
"countryPlaceholder": "Tsvaga nyika",
"countryClearAria": "Bvisa nyika",
"flagOfAria": "Mureza we{{name}}",
"countryHint": "Zvinoburitsa <0>i: iso3166:{{code}}</0> yekuronga nenyika.",
"tags": "Marebhuro",
"tagsPlaceholder": "dziviriro-yekutonga, rubatsiro, nhau-dzomuno",
"categories": {
"humanRights": "Kodzero dzeVanhu",
"democracy": "Demokrasi",
"pressFreedom": "Rusununguko rweNhau",
"politicalPrisoners": "Vasungwa veZvematongerwo eNyika",
"humanitarianAid": "Rubatsiro rweUnhu",
"civilResistance": "Kuramba kweVagari",
"digitalRights": "Kodzero dzeDhijitari",
"antiCorruption": "Kurwisa Huori",
"womenGirls": "Vakadzi neVasikana",
"refugees": "Vapoteri neVakatamiswa",
"legalAid": "Rubatsiro rweMutemo",
"emergencyRelief": "Rubatsiro rweNjodzi",
"animalRights": "Kodzero dzeMhuka",
"education": "Dzidzo",
"medical": "Zveutano",
"community": "Nharaunda"
},
"banner": "Mufananidzo webhana",
"story": "Nyaya",
"storyPlaceholder": "Govera nhoroondo, anobatsirwa, uye kuti mari ichashandiswa sei.",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "Hatina kukwanisa kuwana kero itsva yepa-chain kubva muchikwama chako.",
"errorHdDeriveInvalid": "Kero yakatorwa yatadza chenjedzo. Tapota wedzera kero yenyu.",
"errorWalletRequiredFallback": "Chinangwa chechikwama chinodikanwa.",
"errorPublishedInvalid": "Chiitiko chakaburitswa chatadza chenjedzo. Tapota refresh moedza zvakare."
"errorPublishedInvalid": "Chiitiko chakaburitswa chatadza chenjedzo. Tapota refresh moedza zvakare.",
"wizard": {
"titleStepTitle": "Tumidza campaign yako",
"titleStepSubtitle": "Zita pfupi, riri pachena, ravapi vachaziva.",
"walletStepTitle": "Sarudza ndiani anogamuchira zvipo",
"walletStepSubtitle": "Chikwama chako cheAgora chakagadzirira kugamuchira zvipo zveBitcoin zvemushandirapamwe uyu.",
"bannerStepTitle": "Wedzera bhana",
"bannerStepSubtitle": "Mufananidzo mumwe chete unotapira unotakura campaign pakadhi rega rega.",
"storyStepTitle": "Taura nyaya yako",
"storyStepSubtitle": "Vanobatsirwa ndivanaani uye mari ichashandiswa sei.",
"next": "Inotevera",
"back": "Dzokera",
"skip": "Darika",
"launchNow": "Darika Inotevera & Vhura"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Macampaign e{{appName}}",
@@ -699,31 +755,55 @@
"startCampaign": "Tanga mushandirapamwe",
"howItWorks": "Mashandire awo",
"exploreCampaigns": "Tarisa mishandirapamwe",
"featured": "Yakasarudzwa",
"featuredDesc": "Mishandirapamwe yakasarudzwa neboka re{{appName}}.",
"community": "Mishandirapamwe yeNharaunda",
"communityDesc": "Batsira kupa mari kushanduko dzakakodzera.",
"browseAll": "Tarisa mishandirapamwe yose →",
"pending": "Yakamirira kutenderwa",
"pendingDesc": "Mishandirapamwe iri panetwork isati yatenderwa kana kuvanzwa naani zvake muTeam Soapbox.",
"pendingEmpty": "Hapana chinomirira kutariswa.",
"wlcDesc": "Mishandirapamwe yakasarudzwa neWorld Liberty Congress.",
"allCampaigns": "Mishandirapamwe yose",
"allCampaignsDesc": "Mishandirapamwe yose pamutambo, neumboo wenguva.",
"browseAll": "Tarisa mishandirapamwe yose",
"hidden": "Yakavanzwa",
"hiddenDesc": "Mishandirapamwe yakabviswa papeji rekutanga reveruzhinji. Shandisa menu pakadhi kuti uibvise pakuvanzwa.",
"hiddenEmpty": "Parizvino hapana mushandirapamwe wakavanzwa.",
"yourCampaigns": "Mishandirapamwe yako",
"yourCampaignsDesc": "Mishandirapamwe yako iri panetwork yeNostr uye mipiro inoshanda kuburikidza nemurawu wemushandirapamwe. Inoonekwa papeji rekutanga kana muoni weTeam Soapbox aitenderera.",
"yourCampaignsDesc": "Mishandirapamwe yako iri panetwork yeNostr uye mipiro inoshanda kuburikidza nemurawu wemushandirapamwe. Tarisa mishandirapamwe yose pa/campaigns; boka re{{appName}} rinosarudza yakananga papeji rekutanga.",
"empty": "Hapana mishandirapamwe parizvino",
"emptyHint": "Iva wekutanga kutanga kuunganidza mari pa{{appName}}. Taura nyaya yako, sarudza vanobatsirwa, uchipa rwumwe rwekugovera.",
"whyDifferent": {
"eyebrow": "Sei {{appName}}",
"title": "Yakavakwa zvakasiyana.",
"lede": "Bitcoin yakananga kubva kumupi ichienda kumutungamiri. Hapana puratifomu pakati, hapana muchengeti akabata homwe, hapana mvumo inodiwa.",
"block1": {
"heading": "Kusiyana neGoFundMe",
"body": "Hapana puratifomu inogona kudzivirira zvipo zvako, kukumbira kudzoserwa kwemari, kana kupedza mushandirapamwe wako pamusoro pekusawirirana kwemitemo. Hapana Stripe, hapana Visa, hapana bhanga riri pakati ringakucherekedza pakati pemushandirapamwe.",
"bullet1": "Hazvigoneki kudzivirira — hapana puratifomu inotonga",
"bullet2": "Hapana mutsigiri wokubhadhara anogona kucherekedza",
"bullet3": "Hapana mari yepuratifomu"
},
"block2": {
"heading": "Kusiyana nedzimwe puratifomu dzeBitcoin",
"body": "Hapana Lightning node yepakati, muchengeti, kana LSP inogona kukundikana kana kunge offline. Mari inotsoropodzwa zvakananga paBitcoin kuenda muwallet yauno tonga. Kana {{appName}} ikanyangarika mangwana, mushandirapamwe wose unoramba uchishanda.",
"bullet1": "Hapana wallet yokuchengetedza inogona kupedzwa kana kudzivirirwa",
"bullet2": "Inotsoropodzwa pamuchina kuenda muwallet yauno tora",
"bullet3": "Inoshanda kunyange {{appName}} ikanyangarika"
},
"block3": {
"heading": "Yeruzhinji kana yakavanzika. Kusarudza ndokwako.",
"body": "Vatungamiri vanosarudza nzira yokugamuchira inoenderana nemamiriro avo enjodzi. Vapi vanoona QR imwe chete; wallet ndiyo inosarudza pirotokori yakakodzera.",
"publicLabel": "Yeruzhinji",
"publicSummary": "Inoshanda muwallet yose yeBitcoin. Yokukurumidza uye inoonekwa pamuchina.",
"privateLabel": "Yakavanzika",
"privateSummary": "BIP-352 silent payments. Zvipo zvinosvika pamiganhu isingabatanidziki."
},
"readMore": "Verenga tsananguro yakazara"
},
"searchPlaceholder": "Tsvaga mishandirapamwe…",
"searchAriaLabel": "Tsvaga mishandirapamwe",
"noMatch": "Hapana mushandirapamwe unoenderana ne«{{query}}»",
"noMatchHint": "Edza chimwe chinotsvagwa, kana ubvise kutsvaga."
},
"all": {
"title": "Mishandirapamwe Yose",
"title": "Mishandirapamwe",
"seoTitle": "Mishandirapamwe yose",
"description": "Tarisa mishandirapamwe yose yakaiswa paAgora.",
"sectionTagline": "Tarisa chinangwa chimwe nechimwe panetwork.",
"sectionTagline": "Mishandirapamwe yakasarudzwa pakutanga, yotevedzwa nemamwe ose epanetwork. Tsvaga kana kuronga kuti utsanangure.",
"heroKicker": "Mishandirapamwe",
"heroHeading": "Chinangwa chimwe nechimwe,",
"heroHeadingLine2": "panzvimbo imwe chete.",
@@ -744,6 +824,54 @@
"allHiddenHint": "Mishandirapamwe yose pamutambo yakavanzwa nevatariri. Vhura «Ratidza dzakavanzwa» kuti uione.",
"empty": "Hapana mishandirapamwe parizvino",
"emptyHint": "Hapana mushandirapamwe wakatumirwa parizvino. Iva wokutanga."
},
"lists": {
"stripAria": "Manjuriro emisoro yemishandirapamwe yakasarudzwa",
"create": "Rondedzero itsva",
"createDesc": "Gadzira rondedzero itsva yemisoro. Sarudza mishandirapamwe muiri kubva papeji ipi neipi yemushandirapamwe.",
"createSubmit": "Gadzira rondedzero",
"createFailed": "Kugadzira rondedzero hakuna kubudirira",
"edit": "Gadziridza rondedzero",
"editDesc": "Gadziridza zita, tsananguro, kana chiratidzo cherondedzero.",
"editSubmit": "Chengetedza shanduko",
"updateFailed": "Kugadziridza rondedzero hakuna kubudirira",
"delete": "Bvisa rondedzero",
"deleteFailed": "Kubvisa rondedzero hakuna kubudirira",
"deleteConfirmTitle": "Bvisa rondedzero iyi?",
"deleteConfirmDesc": "\"{{title}}\" ichabviswa pamutsetse wemisoro. Mishandirapamwe pachayo haisi kubatwa.",
"titleField": "Zita",
"titlePlaceholder": "semuyenzaniso, Rusununguko rweManyuzipepa",
"descriptionField": "Tsananguro",
"descriptionPlaceholder": "Mashoko mashoma anotsanangura zviri murondedzero iyi.",
"iconField": "Chiratidzo",
"menuAria": "Sarudzo dzerondedzero {{title}}",
"listActions": "Zviito zverondedzero",
"memberMenuAria": "Sarudzo dzerondedzero yemushandirapamwe",
"backToCampaigns": "Dzokera kumishandirapamwe",
"detailTitle": "Rondedzero yemushandirapamwe",
"campaignsCount_one": "mushandirapamwe {{count}}",
"campaignsCount_other": "mishandirapamwe {{count}}",
"addCampaign": "Wedzera mushandirapamwe",
"addCampaignDesc": "Tsvaga panetwork uye sarudza mushandirapamwe wekuwedzera kurondedzero iyi.",
"addFailed": "Kuwedzera kurondedzero hakuna kubudirira",
"addToList": "Wedzera",
"alreadyAdded": "Yawedzerwa",
"added": "Yawedzerwa",
"membershipTitle": "Wedzera kuzvirondedzero",
"membershipDesc": "Sarudza kuti \"{{title}}\" inofanira kuonekwa muzvirondedzero zvipi.",
"membershipEmpty": "Hapana rondedzero parizvino. Gadzira imwe kuti utange kusarudza.",
"searchPlaceholder": "Tsvaga mishandirapamwe…",
"searchEmpty": "Hapana mushandirapamwe unoenderana nekutsvaga uku.",
"removeFromList": "Bvisa parondedzero",
"removeFailed": "Kubvisa parondedzero hakuna kubudirira",
"empty": "Rondedzero iyi haina chinhu.",
"emptyMod": "Rondedzero iyi haina chinhu. Wedzera mishandirapamwe kuti utange kuisarudza.",
"iconPicker": {
"title": "Sarudza chiratidzo",
"description": "Sarudza chiratidzo chipi nechipi kubva muraibhurari yeLucide.",
"search": "Tsvaga zviratidzo…",
"empty": "Hapana chiratidzo chinoenderana nekutsvaga uku."
}
}
},
"moderation": {
@@ -753,21 +881,27 @@
"ariaPledge": "Tarisa chitsidziro",
"ariaGroup": "Tarisa boka",
"failedAction": "Hazvina kubudirira ku{{action}}",
"approve": "Tendera",
"unapprove": "Bvisa kutenderwa",
"approvedState": "Zvakatenderwa",
"hide": "Vanza",
"unhide": "Bvisa kuvanzwa",
"hiddenState": "Zvakavanzwa",
"feature": "Sarudza",
"unfeature": "Bvisa kusarudzwa",
"featuredState": "Zvakasarudzwa",
"toastApproved": "Zvatenderwa kupeji rekutanga",
"toastUnapproved": "Zvabviswa papeji rekutanga",
"toastHidden": "Zvavanzwa",
"toastUnhidden": "Zvabviswa pakuvanzwa",
"toastFeatured": "Zvasarudzwa",
"toastUnfeatured": "Zvabviswa pakusarudzwa"
"toastUnfeatured": "Zvabviswa pakusarudzwa",
"moveToTop": "Endesa kumusoro",
"moveUp": "Endesa kumusoro",
"moveDown": "Endesa pasi",
"addToList": "Wedzera kurondedzero…",
"dragHandle": "Kweva kuti uchinje chinzvimbo (chinzvimbo {{index}})",
"failedReorder": "Hazvina kubudirira kurongedza patsva",
"toast": {
"movedToTop": "Zvaendeswa kumusoro",
"movedUp": "Zvaendeswa kumusoro",
"movedDown": "Zvaendeswa pasi"
}
}
},
"settings": {
@@ -1123,13 +1257,25 @@
"bitcoinAddress": "Kero yeBitcoin",
"silentPayment": "Kero yemubhadharo wakanyararira",
"toLabel": "Kuna",
"clear": "Bvisa mugamuchiri"
"clear": "Bvisa mugamuchiri",
"choosePaymentMethod": "Sarudza nzira yokubhadhara kuti uenderere"
},
"feeSpeed": {
"fastest": "~10 maminitsi",
"halfHour": "~30 maminitsi",
"hour": "~awa 1",
"economy": "~zuva 1"
"economy": "~zuva 1",
"custom": "Zvakasarudzwa"
},
"fee": {
"loading": "kuloadwa…",
"unavailable": "hazviwanike",
"loadFailed": "Hatina kukwanisa kuloadha mitengo yemiripo.",
"retry": "Edza zvakare",
"orCustom": "Kana isa mutengo wakasarudzwa pazasi.",
"loadingTiers": "Kuloadha mitengo yemiripo…",
"customPlaceholder": "semuenzaniso 5",
"customAriaLabel": "Mutengo wemuripo wakasarudzwa mu-sat/vB"
},
"progress": {
"building": "Kuvaka chinoitwa…",
@@ -1145,11 +1291,36 @@
"enterAmount": "Isa huwandu.",
"insufficient": "Bitcoin haina kukwana pahuwandu uhwu + muripo wenetwork.",
"waitingPrice": "Kumirira mutengo weBTC…",
"noneYet": "Hauna kana Bitcoin parizvino."
"noneYet": "Hauna kana Bitcoin parizvino.",
"feesNotLoadedYet": "Mitengo yemiripo haisati yaloadwa.",
"feeRateTooLow": "Isa mutengo wemuripo usingadarike 1 sat/vB."
},
"toast": {
"failedTitle": "Chinoitwa chakundikana"
},
"broadcastError": {
"feeTooLowTitle": "Muripo wenetwork wakaderera kwazvo",
"feeTooLowBodyWithMin": "Network yeBitcoin iri kuramba muripo uyu. Muripo mudikidiki parizvino unenge {{min}} sat/vB.",
"feeTooLowBody": "Network yeBitcoin iri kuramba muripo uyu. Sarudza mutsara unokurumidza kana usimudze mutengo wako wakasarudzwa.",
"rbfTitle": "Kutsiviwa kunoda muripo wakakwirira",
"rbfBody": "Chinoitwa chinotsiva chinofanira kubhadhara kupfuura chekutanga. Simudza muripo wozoedza zvakare.",
"mempoolFullTitle": "Network yeBitcoin yakazara",
"mempoolFullBody": "Mempool yakazara uye muripo wako haukwanisi kupikisana. Simudza muripo kuti upinde.",
"networkTitle": "Hatina kukwanisa kusvika kunetwork yeBitcoin",
"networkBody": "Tarisa kubatana kwako wozoedza zvakare.",
"mempoolConflictTitle": "Chinoitwa chinopikisana",
"mempoolConflictBody": "Imwe yezvinopinda yatoshandiswa kana iri kushandiswa nechimwe chinoitwa.",
"tooLongChainTitle": "Zvinoitwa zvisina kusimbiswa zvakawanda",
"tooLongChainBody": "Une cheni refu yezvinoitwa zvisina kusimbiswa. Mirira chimwe chisimbiswe wozoedza zvakare.",
"badInputsTitle": "Chinoitwa charambwa",
"badInputsBody": "Network yaramba chinoitwa ichi. Chinja huwandu kana mugamuchiri wozoedza zvakare.",
"absurdlyHighFeeTitle": "Muripo wakakwirira zvisina kujairika",
"absurdlyHighFeeBody": "Muripo wakatariswa wakakwirira nenzira inonetsa. Loadha mitengo yemiripo zvakare wozoedza.",
"unknownTitle": "Chinoitwa chakundikana",
"useHigherFee": "Shandisa muripo wakakwirira",
"tryAgain": "Edza zvakare",
"atMaxFeeTier": "Watove pamutsara unokurumidza kupfuura yose."
},
"success": {
"title": "Bitcoin yatumirwa",
"satsAmount": "{{sats}} sats",
@@ -2151,10 +2322,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "Nezve Agora" },
"payments": { "label": "Zvipo zveBitcoin paAgora" },
"about-nostr": { "label": "Nezve Nostr" },
"legacy": { "label": "Zvekare" }
"getting-started": {
"label": "Nezve Agora"
},
"payments": {
"label": "Zvipo zveBitcoin paAgora"
},
"about-nostr": {
"label": "Nezve Nostr"
},
"legacy": {
"label": "Zvekare"
}
},
"items": {
"what-is-ditto": {
+185 -42
View File
@@ -110,12 +110,15 @@
"profile": {
"title": "Uifanye yako",
"subtitle": "Waambie wengine kidogo kukuhusu. Yote si lazima, ibadilishe wakati wowote.",
"campaignTitle": "Weka uso kwenye kampeni yako",
"campaignSubtitle": "Jina na picha husaidia watu kuungana na kampeni yako.",
"nameLabel": "Jina la kuonyesha",
"namePlaceholder": "Jina lako",
"aboutLabel": "Wasifu mfupi",
"aboutPlaceholder": "Kidogo kukuhusu…",
"avatarLabel": "Avatari",
"uploadAvatar": "Pakia avatari",
"advanced": "Zaidi",
"finish": "Maliza",
"saving": "Inahifadhi…",
"skip": "Ruka kwa sasa",
@@ -614,10 +617,11 @@
"coverImage": "Picha ya jalada",
"description": "Maelezo",
"timezone": "Saa za eneo",
"publishing": "Inachapisha…",
"uploadingCover": "Inapakia jalada…",
"countrySearchPlaceholder": "Tafuta nchi",
"imageDropzone": "Bonyeza au buruta picha hapa"
"imageDropzone": "Bonyeza au buruta picha hapa",
"countryClearAria": "Futa nchi",
"flagOfAria": "Bendera ya {{name}}",
"countryHint": "Inachapisha <0>i: iso3166:{{code}}</0> kwa upangaji wa nchi."
},
"organizationContext": {
"attachedToGroup": "Imeunganishwa na kikundi",
@@ -651,8 +655,8 @@
"myPledgesTagline": "Ahadi ulizounda.",
"featuredPledges": "Ahadi maalum",
"featuredPledgesTagline": "Ahadi zilizoangaziwa na timu ya {{appName}}.",
"allPledges": "Ahadi zote",
"allPledgesTagline": "Vinjari kila ahadi kwenye mtandao.",
"allPledges": "Ahadi",
"allPledgesTagline": "Zimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari kila ahadi.",
"sectionActive": "Ahadi zinazoendelea",
"sectionUpcoming": "Ahadi zijazo",
"sectionPast": "Ahadi zilizopita",
@@ -710,11 +714,7 @@
"titlePlaceholder": "Hifadhi kumbukumbu ya kusafisha pwani",
"country": "Nchi",
"countryPlaceholder": "Tafuta nchi",
"countryClearAria": "Futa nchi",
"flagOfAria": "Bendera ya {{name}}",
"countryHint": "Inachapisha <0>i: iso3166:{{code}}</0> kwa upangaji wa nchi.",
"tags": "Lebo",
"tagsPlaceholder": "kusafisha-pwani, kumbukumbu-maandamano, kuzima-intaneti",
"coverImage": "Picha ya jalada",
"description": "Maelezo",
"descriptionPlaceholder": "Eleza hatua, ushahidi, au matokeo unayotaka kuhamasisha, mawasilisho yanapaswa kujumuisha nini, na jinsi unavyopanga kuyatathmini...",
@@ -724,8 +724,6 @@
"timezone": "Saa za eneo",
"timezoneNote": "Nyakati za kuanza na za mwisho zitafasiriwa katika eneo hili la saa.",
"submit": "Unda ahadi",
"publishing": "Inachapisha…",
"uploadingCover": "Inapakia jalada…",
"altText": "Ahadi ya {{appName}}: {{title}}",
"successToast": "Ahadi imeundwa",
"errorToast": "Haikuweza kuunda ahadi",
@@ -736,7 +734,18 @@
"errorPledgeInvalid": "Kiasi cha ahadi kinapaswa kuwa kiasi chanya cha USD.",
"errorPriceUnavailable": "Inasubiri bei ya BTC/USD ili kuhesabu kiasi cha ahadi.",
"errorCoverInvalid": "Picha ya jalada lazima iwe URL halali ya https://.",
"errorDeadlinePast": "Tarehe ya mwisho haiwezi kuwa iliyopita."
"errorDeadlinePast": "Tarehe ya mwisho haiwezi kuwa iliyopita.",
"wizard": {
"titleStepTitle": "Ipe ahadi yako jina",
"titleStepSubtitle": "Ombi wazi na maelezo mafupi ya kile utakachofadhili.",
"pledgeStepTitle": "Weka ahadi yako",
"pledgeStepSubtitle": "Kiasi utakacholipa, kwa USD, na tarehe ya mwisho si lazima.",
"coverStepTitle": "Ongeza picha ya jalada",
"coverStepSubtitle": "Picha moja hubeba ahadi kwenye kila kadi.",
"tagsStepTitle": "Nchi na kategoria",
"tagsStepSubtitle": "Saidia watu sahihi kupata ahadi yako.",
"launchNow": "Ruka Inayofuata na Uzindue"
}
},
"detail": {
"seoTitle": "{{title}} | Ahadi ya {{appName}}",
@@ -786,8 +795,8 @@
"myGroupsTagline": "Vikundi ulivyounda, unavyosimamia, au unavyofuata.",
"featuredGroups": "Vikundi maarufu",
"featuredGroupsTagline": "Vikundi vinavyojitokeza vinavyostahili usikivu wako.",
"allGroups": "Vikundi vyote",
"allGroupsTagline": "Vinjari vikundi vya {{appName}}, au tafuta kati ya kila kikundi kwenye Nostr.",
"allGroups": "Vikundi",
"allGroupsTagline": "Vimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari kila kikundi.",
"loginToSeeTitle": "Ingia ili kuona vikundi vyako",
"loginToSeeBody": "Vikundi ulivyounda au unavyosimamia vitaonekana hapa.",
"noGroupsTitle": "Hakuna vikundi bado",
@@ -838,9 +847,6 @@
"descriptionPlaceholder": "Kikundi hiki kinahusu nini?",
"country": "Nchi",
"countryPlaceholder": "Tafuta nchi",
"countryClearAria": "Futa nchi",
"flagOfAria": "Bendera ya {{name}}",
"countryHint": "Inachapisha <0>i: iso3166:{{code}}</0> kwa upangaji wa nchi.",
"tags": "Lebo",
"tagsPlaceholder": "msaada-wa-pamoja, habari-za-ndani, haki-za-kidijitali",
"coverImage": "Picha ya jalada",
@@ -864,7 +870,18 @@
"errorNameInvalid": "Jina lazima lijumuishe herufi au nambari ili URL ya kikundi iweze kuundwa.",
"errorEditLatestMissing": "Haikuweza kupata toleo la hivi karibuni la kikundi hiki kusasisha.",
"errorCoverInvalid": "Picha ya jalada lazima iwe URL halali ya https://.",
"errorSlugCollision": "Tayari una kikundi chenye kitambulisho \"{{slug}}\". Chagua jina lingine."
"errorSlugCollision": "Tayari una kikundi chenye kitambulisho \"{{slug}}\". Chagua jina lingine.",
"wizard": {
"nameStepTitle": "Ipe kikundi chako jina",
"nameStepSubtitle": "Jina fupi na wazi ambalo wanachama watalitambua.",
"coverStepTitle": "Ongeza picha ya jalada",
"coverStepSubtitle": "Picha moja hubeba kikundi kwenye kila kadi.",
"moderatorsStepTitle": "Alika wasimamizi",
"moderatorsStepSubtitle": "Si lazima — wanaweza kuidhinisha maudhui na kuondoa wanachama pamoja nawe.",
"tagsStepTitle": "Nchi na kategoria",
"tagsStepSubtitle": "Saidia watu sahihi kupata kikundi chako.",
"launchNow": "Ruka Inayofuata na Uzindue"
}
},
"detail": {
"by": "na",
@@ -924,9 +941,19 @@
"myWalletDefault": "Pochi yangu",
"walletChoose": "Chagua pochi",
"walletCustom": "Maalum",
"walletUseCustom": "Tumia pochi nyingine badala yake",
"walletDestinationLanding": "Michango itafika hapa",
"walletDestinationNote": "Pochi hii itachapishwa kama mahali pa kupokea michango ya kampeni yako.",
"walletUseMine": "Tumia pochi yangu ya Agora",
"acceptAll": "Kubali aina zote za malipo",
"acceptPublic": "Kubali malipo ya umma pekee",
"acceptPrivate": "Kubali malipo ya faragha pekee",
"acceptAllShort": "Zote",
"acceptPublicShort": "Umma Pekee",
"acceptPrivateShort": "Faragha Pekee",
"acceptAllHint": "Kubali malipo ya umma kwenye mnyororo na malipo ya kimya ya faragha.",
"acceptPublicHint": "Kubali tu michango ya kwenye mnyororo kwa anwani ya umma.",
"acceptPrivateHint": "Kubali tu malipo ya kimya — anwani za wachangiaji zinabaki za faragha.",
"customWalletIntro": "Weka anwani ya Bitcoin, msimbo wa malipo ya kimya, au zote mbili. Angalau moja inahitajika.",
"bitcoinAddress": "Anwani ya Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… au bc1p…",
@@ -936,11 +963,26 @@
"spInvalid": "Si msimbo wa malipo ya kimya wa BIP-352 unaotambulika (sp1…).",
"country": "Nchi",
"countryPlaceholder": "Tafuta nchi",
"countryClearAria": "Futa nchi",
"flagOfAria": "Bendera ya {{name}}",
"countryHint": "Inachapisha <0>i: iso3166:{{code}}</0> kwa upangaji wa nchi.",
"tags": "Lebo",
"tagsPlaceholder": "utetezi-wa-kisheria, msaada-wa-pamoja, habari-za-ndani",
"categories": {
"humanRights": "Haki za Binadamu",
"democracy": "Demokrasia",
"pressFreedom": "Uhuru wa Vyombo vya Habari",
"politicalPrisoners": "Wafungwa wa Kisiasa",
"humanitarianAid": "Msaada wa Kibinadamu",
"civilResistance": "Upinzani wa Kiraia",
"digitalRights": "Haki za Kidijitali",
"antiCorruption": "Kupambana na Rushwa",
"womenGirls": "Wanawake na Wasichana",
"refugees": "Wakimbizi na Wahamishwa",
"legalAid": "Msaada wa Kisheria",
"emergencyRelief": "Msaada wa Dharura",
"animalRights": "Haki za Wanyama",
"education": "Elimu",
"medical": "Matibabu",
"community": "Jumuiya"
},
"banner": "Picha ya bango",
"story": "Hadithi",
"storyPlaceholder": "Shiriki historia, ni nani anayenufaika, na jinsi fedha zitakavyotumika.",
@@ -980,7 +1022,21 @@
"errorHdDeriveFailed": "Haikuweza kupata anwani mpya ya katika-mnyororo kutoka kwa pochi yako.",
"errorHdDeriveInvalid": "Anwani ya pochi iliyotolewa imeshindwa uthibitishaji. Tafadhali ongeza anwani maalum badala yake.",
"errorWalletRequiredFallback": "Ncha ya pochi inahitajika.",
"errorPublishedInvalid": "Tukio lililochapishwa limeshindwa uthibitishaji. Tafadhali onyesha upya na ujaribu tena."
"errorPublishedInvalid": "Tukio lililochapishwa limeshindwa uthibitishaji. Tafadhali onyesha upya na ujaribu tena.",
"wizard": {
"titleStepTitle": "Ipe kampeni yako jina",
"titleStepSubtitle": "Jina fupi na wazi ambalo wafadhili watalitambua.",
"walletStepTitle": "Chagua nani anayepokea michango",
"walletStepSubtitle": "Pochi yako ya Agora iko tayari kupokea michango ya Bitcoin kwa kampeni hii.",
"bannerStepTitle": "Ongeza bango",
"bannerStepSubtitle": "Picha moja yenye mvuto hubeba kampeni kwenye kila kadi.",
"storyStepTitle": "Eleza hadithi yako",
"storyStepSubtitle": "Nani atafaidika na jinsi fedha zitakavyotumika.",
"next": "Inayofuata",
"back": "Rudi",
"skip": "Ruka",
"launchNow": "Ruka Inayofuata na Uzindue"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | Kampeni za {{appName}}",
@@ -1130,19 +1186,15 @@
"startCampaign": "Anza kampeni",
"howItWorks": "Jinsi inavyofanya kazi",
"exploreCampaigns": "Chunguza kampeni",
"featured": "Maarufu",
"featuredDesc": "Kampeni zilizochaguliwa kwa mkono kutoka kwa timu ya {{appName}}.",
"community": "Kampeni za Jumuiya",
"communityDesc": "Saidia kufadhili mabadiliko yanayostahili kufanywa.",
"browseAll": "Vinjari kampeni zote →",
"pending": "Inasubiri idhini",
"pendingDesc": "Kampeni kwenye mtandao ambazo hakuna msimamizi wa Team Soapbox aliyezithibitisha au kuzificha bado.",
"pendingEmpty": "Hakuna kinachosubiri ukaguzi.",
"wlcDesc": "Kampeni zilizoteuliwa na World Liberty Congress.",
"allCampaigns": "Kampeni zote",
"allCampaignsDesc": "Kampeni zote kwenye mtandao, kwa mpangilio wa wakati.",
"browseAll": "Vinjari kampeni zote",
"hidden": "Vilivyofichwa",
"hiddenDesc": "Kampeni zilizofichwa kutoka kwenye ukurasa wa mwanzo wa umma. Tumia menyu ya nukta tatu kwenye kadi ili kuonyesha.",
"hiddenEmpty": "Hakuna kampeni zilizofichwa kwa sasa.",
"yourCampaigns": "Kampeni zako",
"yourCampaignsDesc": "Kampeni zako ziko mtandaoni kwenye Nostr na michango hufanya kazi kupitia kiungo cha kampeni. Zinaonekana kwenye ukurasa wa mwanzo mara tu msimamizi wa Team Soapbox anapozithibitisha.",
"yourCampaignsDesc": "Kampeni zako ziko mtandaoni kwenye Nostr na michango hufanya kazi kupitia kiungo cha kampeni. Vinjari kampeni zote kwenye /campaigns; timu ya {{appName}} huangazia uteuzi maalum kwenye ukurasa wa mwanzo.",
"empty": "Hakuna kampeni bado",
"emptyHint": "Kuwa wa kwanza kuanza kampeni ya kukusanya fedha kwenye {{appName}}. Eleza hadithi yako, chagua walengwa wako, na shiriki kiungo.",
"searchPlaceholder": "Tafuta kampeni…",
@@ -1151,10 +1203,10 @@
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji."
},
"all": {
"title": "Kampeni Zote",
"title": "Kampeni",
"seoTitle": "Kampeni zote",
"description": "Vinjari kila kampeni iliyochapishwa kwenye Agora.",
"sectionTagline": "Vinjari kila lengo kwenye mtandao.",
"sectionTagline": "Kampeni zilizoangaziwa kwanza, kisha sehemu nyingine ya mtandao. Tafuta au panga ili kuboresha matokeo.",
"searchAriaLabel": "Tafuta kampeni",
"searchPlaceholder": "Tafuta kampeni…",
"clearSearch": "Futa utafutaji",
@@ -1175,6 +1227,54 @@
"heroBody": "Kila kampeni iliyochapishwa kwenye Nostr, imekusanywa mahali pamoja. Vinjari mtandao mzima, pata lengo linalokuhusu, na liunge mkono moja kwa moja kwa Bitcoin.",
"campaignsCount_one": "kampeni kwenye mtandao",
"campaignsCount_other": "kampeni kwenye mtandao"
},
"lists": {
"stripAria": "Orodha za mada za kampeni zilizoratibiwa",
"create": "Orodha mpya",
"createDesc": "Tengeneza orodha mpya ya mada. Iratibu kampeni ndani yake kutoka ukurasa wowote wa kampeni.",
"createSubmit": "Tengeneza orodha",
"createFailed": "Imeshindikana kutengeneza orodha",
"edit": "Hariri orodha",
"editDesc": "Sasisha kichwa, maelezo, au ikoni ya orodha.",
"editSubmit": "Hifadhi mabadiliko",
"updateFailed": "Imeshindikana kusasisha orodha",
"delete": "Futa orodha",
"deleteFailed": "Imeshindikana kufuta orodha",
"deleteConfirmTitle": "Futa orodha hii?",
"deleteConfirmDesc": "\"{{title}}\" itaondolewa kwenye ukanda wa mada. Kampeni zenyewe haziathiriwi.",
"titleField": "Kichwa",
"titlePlaceholder": "k.m. Uhuru wa Vyombo vya Habari",
"descriptionField": "Maelezo",
"descriptionPlaceholder": "Maelezo mafupi yanayoeleza kinachofaa kwenye orodha hii.",
"iconField": "Ikoni",
"menuAria": "Chaguo za orodha ya {{title}}",
"listActions": "Vitendo vya orodha",
"memberMenuAria": "Chaguo za orodha ya kampeni",
"backToCampaigns": "Rudi kwenye kampeni",
"detailTitle": "Orodha ya kampeni",
"campaignsCount_one": "kampeni {{count}}",
"campaignsCount_other": "kampeni {{count}}",
"addCampaign": "Ongeza kampeni",
"addCampaignDesc": "Tafuta kwenye mtandao na chagua kampeni ya kuongeza kwenye orodha hii.",
"addFailed": "Imeshindikana kuongeza kwenye orodha",
"addToList": "Ongeza",
"alreadyAdded": "Imeongezwa",
"added": "Imeongezwa",
"membershipTitle": "Ongeza kwenye orodha",
"membershipDesc": "Chagua orodha ambazo \"{{title}}\" itaonekana ndani yake.",
"membershipEmpty": "Bado hakuna orodha. Unda moja ili kuanza kuiratibu.",
"searchPlaceholder": "Tafuta kampeni…",
"searchEmpty": "Hakuna kampeni zinazolingana na utafutaji huu.",
"removeFromList": "Ondoa kwenye orodha",
"removeFailed": "Imeshindikana kuondoa kwenye orodha",
"empty": "Orodha hii ni tupu.",
"emptyMod": "Orodha hii ni tupu. Ongeza kampeni ili kuanza kuiratibu.",
"iconPicker": {
"title": "Chagua ikoni",
"description": "Chagua ikoni yoyote kutoka kwa maktaba ya Lucide.",
"search": "Tafuta ikoni…",
"empty": "Hakuna ikoni zinazolingana na utafutaji huu."
}
}
},
"moderation": {
@@ -1185,21 +1285,27 @@
"ariaPledge": "Simamia ahadi",
"ariaGroup": "Simamia kikundi",
"failedAction": "Imeshindikana ku-{{action}}",
"approve": "Idhinisha",
"unapprove": "Ondoa idhini",
"approvedState": "Imeidhinishwa",
"hide": "Ficha",
"unhide": "Onyesha",
"hiddenState": "Imefichwa",
"feature": "Angazia",
"unfeature": "Ondoa kwenye maarufu",
"featuredState": "Imeangaziwa",
"toastApproved": "Imeidhinishwa kwa ukurasa wa mwanzo",
"toastUnapproved": "Imeondolewa kwenye ukurasa wa mwanzo",
"toastHidden": "Imefichwa",
"toastUnhidden": "Imeonyeshwa",
"toastFeatured": "Imeangaziwa",
"toastUnfeatured": "Imeondolewa kwenye maarufu"
"toastUnfeatured": "Imeondolewa kwenye maarufu",
"failedReorder": "Imeshindikana kupanga upya",
"moveToTop": "Hamisha juu kabisa",
"moveUp": "Hamisha juu",
"moveDown": "Hamisha chini",
"addToList": "Ongeza kwenye orodha…",
"dragHandle": "Buruta ili kupanga upya (nafasi {{index}})",
"toast": {
"movedToTop": "Imehamishwa juu kabisa",
"movedUp": "Imehamishwa juu",
"movedDown": "Imehamishwa chini"
}
}
},
"settings": {
@@ -1451,13 +1557,25 @@
"bitcoinAddress": "Anwani ya Bitcoin",
"silentPayment": "Anwani ya malipo ya kimya",
"toLabel": "Kwa",
"clear": "Futa mpokeaji"
"clear": "Futa mpokeaji",
"choosePaymentMethod": "Chagua njia ya malipo ili kuendelea"
},
"feeSpeed": {
"fastest": "~dakika 10",
"halfHour": "~dakika 30",
"hour": "~saa 1",
"economy": "~siku 1"
"economy": "~siku 1",
"custom": "Maalum"
},
"fee": {
"loading": "inapakia…",
"unavailable": "haipatikani",
"loadFailed": "Imeshindwa kupakia viwango vya ada.",
"retry": "Jaribu tena",
"orCustom": "Au weka kiwango maalum hapa chini.",
"loadingTiers": "Inapakia viwango vya ada…",
"customPlaceholder": "mf. 5",
"customAriaLabel": "Kiwango maalum cha ada katika sat/vB"
},
"progress": {
"building": "Inajenga muamala…",
@@ -1473,7 +1591,9 @@
"enterAmount": "Weka kiasi.",
"insufficient": "Hakuna Bitcoin ya kutosha kwa kiasi hiki + ada ya mtandao.",
"waitingPrice": "Inasubiri bei ya BTC…",
"noneYet": "Bado huna Bitcoin yoyote."
"noneYet": "Bado huna Bitcoin yoyote.",
"feesNotLoadedYet": "Viwango vya ada bado havijapakiwa.",
"feeRateTooLow": "Weka kiwango cha ada cha angalau 1 sat/vB."
},
"scanError": {
"title": "Imeshindwa kusoma msimbo huo wa QR",
@@ -1482,6 +1602,29 @@
"toast": {
"failedTitle": "Muamala umeshindikana"
},
"broadcastError": {
"feeTooLowTitle": "Ada ya mtandao ni ndogo sana",
"feeTooLowBodyWithMin": "Mtandao wa Bitcoin unakataa ada hii. Kiwango cha chini sasa hivi ni takriban {{min}} sat/vB.",
"feeTooLowBody": "Mtandao wa Bitcoin unakataa ada hii. Chagua kiwango cha haraka zaidi au panda kiwango chako maalum.",
"rbfTitle": "Ubadilishaji unahitaji ada ya juu zaidi",
"rbfBody": "Muamala wa kubadilisha lazima ulipe zaidi ya wa awali. Panda ada na ujaribu tena.",
"mempoolFullTitle": "Mtandao wa Bitcoin umejaa msongamano",
"mempoolFullBody": "Mempool imejaa na ada yako haishindani. Panda ada ili kupita.",
"networkTitle": "Imeshindwa kufikia mtandao wa Bitcoin",
"networkBody": "Angalia muunganisho wako na ujaribu tena.",
"mempoolConflictTitle": "Muamala unaopingana",
"mempoolConflictBody": "Mojawapo ya pembejeo tayari imekwisha tumika au inatumika na muamala mwingine.",
"tooLongChainTitle": "Miamala mingi ambayo haijathibitishwa",
"tooLongChainBody": "Una mlolongo mrefu wa miamala ambayo haijathibitishwa. Subiri mmoja uthibitishwe kisha ujaribu tena.",
"badInputsTitle": "Muamala umekataliwa",
"badInputsBody": "Mtandao umekataa muamala huu. Rekebisha kiasi au mpokeaji kisha ujaribu tena.",
"absurdlyHighFeeTitle": "Ada iko juu isivyo kawaida",
"absurdlyHighFeeBody": "Ada iliyokadiriwa ni ya juu kwa kushuku. Pakia upya viwango vya ada kisha ujaribu tena.",
"unknownTitle": "Muamala umeshindikana",
"useHigherFee": "Tumia ada ya juu zaidi",
"tryAgain": "Jaribu tena",
"atMaxFeeTier": "Tayari uko kwenye kiwango cha haraka zaidi."
},
"success": {
"title": "Bitcoin imetumwa",
"satsAmount": "sats {{sats}}",
+185 -42
View File
@@ -110,12 +110,15 @@
"profile": {
"title": "Profilinizi kişiselleştirin",
"subtitle": "Başkalarına kendiniz hakkında biraz bilgi verin. Hepsi isteğe bağlı, istediğiniz zaman değiştirebilirsiniz.",
"campaignTitle": "Kampanyanıza bir yüz kazandırın",
"campaignSubtitle": "Bir ad ve fotoğraf, insanların kampanyanızla bağ kurmasına yardımcı olur.",
"nameLabel": "Görünen ad",
"namePlaceholder": "Adınız",
"aboutLabel": "Hakkında",
"aboutPlaceholder": "Kendiniz hakkında birkaç söz…",
"avatarLabel": "Avatar",
"uploadAvatar": "Avatar yükle",
"advanced": "Daha fazla",
"finish": "Bitir",
"saving": "Kaydediliyor…",
"skip": "Şimdilik atla",
@@ -614,10 +617,11 @@
"coverImage": "Kapak resmi",
"description": "Açıklama",
"timezone": "Saat dilimi",
"publishing": "Yayımlanıyor…",
"uploadingCover": "Kapak yükleniyor…",
"countrySearchPlaceholder": "Ülke ara",
"imageDropzone": "Buraya tıklayın veya bir resim sürükleyin"
"imageDropzone": "Buraya tıklayın veya bir resim sürükleyin",
"countryClearAria": "Ülkeyi temizle",
"flagOfAria": "{{name}} bayrağı",
"countryHint": "Ülke sıralaması için <0>i: iso3166:{{code}}</0> yayımlar."
},
"organizationContext": {
"attachedToGroup": "Gruba eklendi",
@@ -651,8 +655,8 @@
"myPledgesTagline": "Oluşturduğunuz taahhütler.",
"featuredPledges": "Öne çıkan taahhütler",
"featuredPledgesTagline": "{{appName}} ekibinin öne çıkardığı taahhütler.",
"allPledges": "Tüm taahhütler",
"allPledgesTagline": "Ağdaki her taahhüde göz atın.",
"allPledges": "Taahhütler",
"allPledgesTagline": "Moderatörler tarafından öne çıkarıldı. Her taahhüde göz atmak için arama yapın veya sıralayın.",
"sectionActive": "Aktif taahhütler",
"sectionUpcoming": "Yaklaşan taahhütler",
"sectionPast": "Geçmiş taahhütler",
@@ -710,11 +714,7 @@
"titlePlaceholder": "Bir sahil temizliğini belgele",
"country": "Ülke",
"countryPlaceholder": "Ülke ara",
"countryClearAria": "Ülkeyi temizle",
"flagOfAria": "{{name}} bayrağı",
"countryHint": "Ülke sıralaması için <0>i: iso3166:{{code}}</0> yayımlar.",
"tags": "Etiketler",
"tagsPlaceholder": "sahil-temizligi, protesto-belgelemesi, internet-karartmasi",
"coverImage": "Kapak resmi",
"description": "Açıklama",
"descriptionPlaceholder": "İlham vermek istediğiniz eylemi, kanıtı veya sonucu, gönderilerin neleri içermesi gerektiğini ve bunları nasıl değerlendirmeyi planladığınızı açıklayın...",
@@ -724,8 +724,6 @@
"timezone": "Saat dilimi",
"timezoneNote": "Başlangıç ve son tarih bu saat diliminde yorumlanacak.",
"submit": "Taahhüt oluştur",
"publishing": "Yayımlanıyor…",
"uploadingCover": "Kapak yükleniyor…",
"altText": "{{appName}} taahhüdü: {{title}}",
"successToast": "Taahhüt oluşturuldu",
"errorToast": "Taahhüt oluşturulamadı",
@@ -736,7 +734,18 @@
"errorPledgeInvalid": "Taahhüt tutarı pozitif bir USD tutarı olmalıdır.",
"errorPriceUnavailable": "Taahhüt tutarını hesaplamak için BTC/USD fiyatı bekleniyor.",
"errorCoverInvalid": "Kapak resmi geçerli bir https:// URL'i olmalıdır.",
"errorDeadlinePast": "Son tarih geçmişte olamaz."
"errorDeadlinePast": "Son tarih geçmişte olamaz.",
"wizard": {
"titleStepTitle": "Taahhüdünüze isim verin",
"titleStepSubtitle": "Net bir talep ve neyi finanse edeceğinizin kısa bir açıklaması.",
"pledgeStepTitle": "Taahhüdünüzü belirleyin",
"pledgeStepSubtitle": "USD cinsinden ne kadar ödeyeceğiniz ve isteğe bağlı bir son tarih.",
"coverStepTitle": "Bir kapak resmi ekleyin",
"coverStepSubtitle": "Tek bir görsel, taahhüdü her kartta öne çıkarır.",
"tagsStepTitle": "Ülke ve kategoriler",
"tagsStepSubtitle": "Doğru insanların taahhüdünüzü bulmasına yardımcı olun.",
"launchNow": "Atla ve Yayınla"
}
},
"detail": {
"seoTitle": "{{title}} | {{appName}} Taahhüdü",
@@ -786,8 +795,8 @@
"myGroupsTagline": "Kurduğunuz, yönettiğiniz veya takip ettiğiniz gruplar.",
"featuredGroups": "Öne çıkan gruplar",
"featuredGroupsTagline": "Dikkatinize değer öne çıkan gruplar.",
"allGroups": "Tüm gruplar",
"allGroupsTagline": "{{appName}} gruplarına göz atın veya Nostr'daki her grup arasında arama yapın.",
"allGroups": "Gruplar",
"allGroupsTagline": "Moderatörler tarafından öne çıkarıldı. Her gruba göz atmak için arama yapın veya sıralayın.",
"loginToSeeTitle": "Gruplarınızı görmek için giriş yapın",
"loginToSeeBody": "Kurduğunuz veya yönettiğiniz gruplar burada görünecek.",
"noGroupsTitle": "Henüz grup yok",
@@ -838,9 +847,6 @@
"descriptionPlaceholder": "Bu grup neyle ilgili?",
"country": "Ülke",
"countryPlaceholder": "Ülke ara",
"countryClearAria": "Ülkeyi temizle",
"flagOfAria": "{{name}} bayrağı",
"countryHint": "Ülke sıralaması için <0>i: iso3166:{{code}}</0> yayımlar.",
"tags": "Etiketler",
"tagsPlaceholder": "dayanisma, yerel-haber, dijital-haklar",
"coverImage": "Kapak resmi",
@@ -864,7 +870,18 @@
"errorNameInvalid": "Grup URL'inin oluşturulabilmesi için adın harf veya rakam içermesi gerekir.",
"errorEditLatestMissing": "Bu grubun güncellemek için en son sürümü bulunamadı.",
"errorCoverInvalid": "Kapak resmi geçerli bir https:// URL'i olmalıdır.",
"errorSlugCollision": "\"{{slug}}\" tanımlayıcısına sahip bir grubunuz zaten var. Başka bir ad seçin."
"errorSlugCollision": "\"{{slug}}\" tanımlayıcısına sahip bir grubunuz zaten var. Başka bir ad seçin.",
"wizard": {
"nameStepTitle": "Grubunuza isim verin",
"nameStepSubtitle": "Üyelerin tanıyacağı kısa ve net bir ad.",
"coverStepTitle": "Bir kapak resmi ekleyin",
"coverStepSubtitle": "Tek bir görsel, grubu her kartta öne çıkarır.",
"moderatorsStepTitle": "Moderatörleri davet edin",
"moderatorsStepSubtitle": "İsteğe bağlı — sizinle birlikte içerikleri onaylayabilir ve üyeleri kaldırabilirler.",
"tagsStepTitle": "Ülke ve kategoriler",
"tagsStepSubtitle": "Doğru insanların grubunuzu bulmasına yardımcı olun.",
"launchNow": "Atla ve Yayınla"
}
},
"detail": {
"by": "tarafından",
@@ -924,9 +941,19 @@
"myWalletDefault": "Cüzdanım",
"walletChoose": "Bir cüzdan seçin",
"walletCustom": "Özel",
"walletUseCustom": "Bunun yerine başka bir cüzdan kullan",
"walletDestinationLanding": "Bağışlar buraya ulaşacak",
"walletDestinationNote": "Bu cüzdan, kampanyanızın bağış adresi olarak yayımlanacak.",
"walletUseMine": "Agora cüzdanımı kullan",
"acceptAll": "Tüm ödeme türlerini kabul et",
"acceptPublic": "Yalnızca açık ödemeleri kabul et",
"acceptPrivate": "Yalnızca gizli ödemeleri kabul et",
"acceptAllShort": "Tümünü Kabul Et",
"acceptPublicShort": "Yalnızca Açık",
"acceptPrivateShort": "Yalnızca Gizli",
"acceptAllHint": "Hem açık zincir üstü hem de gizli sessiz ödemeleri kabul edin.",
"acceptPublicHint": "Yalnızca açık bir adrese yapılan zincir üstü bağışları kabul edin.",
"acceptPrivateHint": "Yalnızca sessiz ödemeleri kabul edin — bağışçı adresleri gizli kalır.",
"customWalletIntro": "Bir Bitcoin adresi, bir sessiz ödeme kodu ya da her ikisini birden girin. En az biri zorunludur.",
"bitcoinAddress": "Bitcoin adresi",
"bitcoinAddressPlaceholder": "bc1q… veya bc1p…",
@@ -936,11 +963,26 @@
"spInvalid": "Tanınan bir BIP-352 sessiz ödeme kodu değil (sp1…).",
"country": "Ülke",
"countryPlaceholder": "Ülke ara",
"countryClearAria": "Ülkeyi temizle",
"flagOfAria": "{{name}} bayrağı",
"countryHint": "Ülke sıralaması için <0>i: iso3166:{{code}}</0> yayımlar.",
"tags": "Etiketler",
"tagsPlaceholder": "yasal-savunma, dayanisma, yerel-haber",
"categories": {
"humanRights": "İnsan Hakları",
"democracy": "Demokrasi",
"pressFreedom": "Basın Özgürlüğü",
"politicalPrisoners": "Siyasi Tutuklular",
"humanitarianAid": "İnsani Yardım",
"civilResistance": "Sivil Direniş",
"digitalRights": "Dijital Haklar",
"antiCorruption": "Yolsuzlukla Mücadele",
"womenGirls": "Kadınlar ve Kız Çocukları",
"refugees": "Mülteciler ve Sürgündekiler",
"legalAid": "Hukuki Yardım",
"emergencyRelief": "Acil Yardım",
"animalRights": "Hayvan Hakları",
"education": "Eğitim",
"medical": "Sağlık",
"community": "Topluluk"
},
"banner": "Pankart resmi",
"story": "Hikaye",
"storyPlaceholder": "Arka planı, kimlerin yararlanacağını ve fonların nasıl kullanılacağını paylaşın.",
@@ -980,7 +1022,21 @@
"errorHdDeriveFailed": "Cüzdanınızdan yeni bir zincir üstü adres türetilemedi.",
"errorHdDeriveInvalid": "Türetilen cüzdan adresi doğrulamadan geçemedi. Lütfen bunun yerine özel bir adres ekleyin.",
"errorWalletRequiredFallback": "Cüzdan uç noktası zorunludur.",
"errorPublishedInvalid": "Yayımlanan event doğrulamadan geçemedi. Lütfen yenileyin ve tekrar deneyin."
"errorPublishedInvalid": "Yayımlanan event doğrulamadan geçemedi. Lütfen yenileyin ve tekrar deneyin.",
"wizard": {
"titleStepTitle": "Kampanyanıza isim verin",
"titleStepSubtitle": "Bağışçıların tanıyacağı kısa ve net bir ad.",
"walletStepTitle": "Bağışları kimin alacağını seçin",
"walletStepSubtitle": "Agora cüzdanınız bu kampanya için Bitcoin bağışları almaya hazır.",
"bannerStepTitle": "Bir pankart ekleyin",
"bannerStepSubtitle": "Çarpıcı tek bir görsel, kampanyayı her kartta öne çıkarır.",
"storyStepTitle": "Hikâyenizi anlatın",
"storyStepSubtitle": "Kimin yararlanacağını ve fonların nasıl kullanılacağını anlatın.",
"next": "İleri",
"back": "Geri",
"skip": "Atla",
"launchNow": "Atla ve Yayınla"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} Fon Toplama",
@@ -1130,19 +1186,15 @@
"startCampaign": "Kampanya başlat",
"howItWorks": "Nasıl çalışır",
"exploreCampaigns": "Kampanyaları keşfet",
"featured": "Öne çıkanlar",
"featuredDesc": "{{appName}} ekibi tarafından özenle seçilmiş kampanyalar.",
"community": "Topluluk Kampanyaları",
"communityDesc": "Yapmaya değer değişiklikleri finanse etmeye yardım edin.",
"browseAll": "Tüm kampanyalara göz at →",
"pending": "Onay bekliyor",
"pendingDesc": "Ağdaki, henüz hiçbir Team Soapbox moderatörünün onaylamadığı veya gizlemediği kampanyalar.",
"pendingEmpty": "İncelemeyi bekleyen bir şey yok.",
"wlcDesc": "World Liberty Congress tarafından özenle seçilmiş kampanyalar.",
"allCampaigns": "Tüm kampanyalar",
"allCampaignsDesc": "Ağdaki tüm kampanyalar, kronolojik sırayla.",
"browseAll": "Tüm kampanyalara göz at",
"hidden": "Gizli",
"hiddenDesc": "Herkese açık ana sayfadan gizlenmiş kampanyalar. Gizlemeyi kaldırmak için karttaki kebap menüsünü kullanın.",
"hiddenEmpty": "Şu anda gizlenmiş kampanya yok.",
"yourCampaigns": "Kampanyalarınız",
"yourCampaignsDesc": "Kampanyalarınız Nostr'da yayında ve bağışlar kampanya bağlantısıyla çalışır. Bir Team Soapbox moderatörü onayladığında ana sayfada görünürler.",
"yourCampaignsDesc": "Kampanyalarınız Nostr'da yayında ve bağışlar kampanya bağlantısıyla çalışır. Tüm kampanyalara /campaigns adresinden göz atın; {{appName}} ekibi ana sayfada özenle seçilmiş bir derlemeyi öne çıkarır.",
"empty": "Henüz kampanya yok",
"emptyHint": "{{appName}}'da fon toplama kampanyası başlatan ilk kişi olun. Hikayenizi anlatın, yararlanıcılarınızı seçin ve bağlantıyı paylaşın.",
"searchPlaceholder": "Kampanya ara…",
@@ -1151,10 +1203,10 @@
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin."
},
"all": {
"title": "Tüm Kampanyalar",
"title": "Kampanyalar",
"seoTitle": "Tüm kampanyalar",
"description": "Agora'da yayımlanmış her kampanyaya göz atın.",
"sectionTagline": "Ağdaki her davaya göz atın.",
"sectionTagline": "Önce öne çıkan kampanyalar, ardından ağın geri kalanı. Daraltmak için arama yapın veya sıralayın.",
"searchAriaLabel": "Kampanyaları ara",
"searchPlaceholder": "Kampanya ara…",
"clearSearch": "Aramayı temizle",
@@ -1175,6 +1227,54 @@
"heroBody": "Nostr'da yayımlanan her bağış kampanyası tek bir yerde toplandı. Ağın tamamına göz atın, sizin için önemli bir dava bulun ve doğrudan Bitcoin ile destekleyin.",
"campaignsCount_one": "ağdaki kampanya",
"campaignsCount_other": "ağdaki kampanya"
},
"lists": {
"stripAria": "Özenle seçilmiş kampanya konu listeleri",
"create": "Yeni liste",
"createDesc": "Yeni bir konu listesi oluşturun. Herhangi bir kampanya sayfasından kampanyaları listeye ekleyin.",
"createSubmit": "Liste oluştur",
"createFailed": "Liste oluşturulamadı",
"edit": "Listeyi düzenle",
"editDesc": "Listenin başlığını, açıklamasını veya simgesini güncelleyin.",
"editSubmit": "Değişiklikleri kaydet",
"updateFailed": "Liste güncellenemedi",
"delete": "Listeyi sil",
"deleteFailed": "Liste silinemedi",
"deleteConfirmTitle": "Bu liste silinsin mi?",
"deleteConfirmDesc": "\"{{title}}\" konu şeridinden kaldırılacak. Kampanyaların kendisi etkilenmez.",
"titleField": "Başlık",
"titlePlaceholder": "ör. Basın Özgürlüğü",
"descriptionField": "Açıklama",
"descriptionPlaceholder": "Bu listeye nelerin gireceğini açıklayan kısa bir tanıtım.",
"iconField": "Simge",
"menuAria": "{{title}} liste seçenekleri",
"listActions": "Liste işlemleri",
"memberMenuAria": "Kampanya listesi seçenekleri",
"backToCampaigns": "Kampanyalara dön",
"detailTitle": "Kampanya listesi",
"campaignsCount_one": "{{count}} kampanya",
"campaignsCount_other": "{{count}} kampanya",
"addCampaign": "Kampanya ekle",
"addCampaignDesc": "Ağda arama yapın ve bu listeye eklemek için bir kampanya seçin.",
"addFailed": "Listeye eklenemedi",
"addToList": "Ekle",
"alreadyAdded": "Eklendi",
"added": "Eklendi",
"membershipTitle": "Listelere ekle",
"membershipDesc": "\"{{title}}\" öğesinin hangi listelerde görüneceğini seçin.",
"membershipEmpty": "Henüz liste yok. Derlemeye başlamak için bir tane oluşturun.",
"searchPlaceholder": "Kampanya ara…",
"searchEmpty": "Bu aramayla eşleşen kampanya yok.",
"removeFromList": "Listeden kaldır",
"removeFailed": "Listeden kaldırılamadı",
"empty": "Bu liste boş.",
"emptyMod": "Bu liste boş. Derlemeye başlamak için kampanya ekleyin.",
"iconPicker": {
"title": "Bir simge seçin",
"description": "Lucide kütüphanesinden istediğiniz simgeyi seçin.",
"search": "Simge ara…",
"empty": "Bu aramayla eşleşen simge yok."
}
}
},
"moderation": {
@@ -1185,21 +1285,27 @@
"ariaPledge": "Taahhüdü modere et",
"ariaGroup": "Grubu modere et",
"failedAction": "{{action}} başarısız oldu",
"approve": "Onayla",
"unapprove": "Onayı kaldır",
"approvedState": "Onaylandı",
"failedReorder": "Yeniden sıralama başarısız oldu",
"moveToTop": "En üste taşı",
"moveUp": "Yukarı taşı",
"moveDown": "Aşağı taşı",
"addToList": "Listeye ekle…",
"dragHandle": "Yeniden sıralamak için sürükleyin (konum {{index}})",
"hide": "Gizle",
"unhide": "Gizlemeyi kaldır",
"hiddenState": "Gizli",
"feature": "Öne çıkar",
"unfeature": "Öne çıkarmayı kaldır",
"featuredState": "Öne çıkarıldı",
"toastApproved": "Ana sayfa için onaylandı",
"toastUnapproved": "Ana sayfadan kaldırıldı",
"toastHidden": "Gizlendi",
"toastUnhidden": "Gizleme kaldırıldı",
"toastFeatured": "Öne çıkarıldı",
"toastUnfeatured": "Öne çıkanlardan kaldırıldı"
"toastUnfeatured": "Öne çıkanlardan kaldırıldı",
"toast": {
"movedToTop": "En üste taşındı",
"movedUp": "Yukarı taşındı",
"movedDown": "Aşağı taşındı"
}
}
},
"settings": {
@@ -1491,13 +1597,25 @@
"bitcoinAddress": "Bitcoin adresi",
"silentPayment": "Sessiz ödeme adresi",
"toLabel": "Alıcı",
"clear": "Alıcıyı temizle"
"clear": "Alıcıyı temizle",
"choosePaymentMethod": "Devam etmek için bir ödeme yöntemi seçin"
},
"feeSpeed": {
"fastest": "~10 dk",
"halfHour": "~30 dk",
"hour": "~1 saat",
"economy": "~1 gün"
"economy": "~1 gün",
"custom": "Özel"
},
"fee": {
"loading": "yükleniyor…",
"unavailable": "kullanılamıyor",
"loadFailed": "Ücret oranları yüklenemedi.",
"retry": "Yeniden dene",
"orCustom": "Ya da aşağıya özel bir oran girin.",
"loadingTiers": "Ücret oranları yükleniyor…",
"customPlaceholder": "örn. 5",
"customAriaLabel": "sat/vB cinsinden özel ücret oranı"
},
"progress": {
"building": "İşlem oluşturuluyor…",
@@ -1513,11 +1631,36 @@
"enterAmount": "Bir tutar girin.",
"insufficient": "Bu tutar + ağ ücreti için yeterli Bitcoin yok.",
"waitingPrice": "BTC fiyatı bekleniyor…",
"noneYet": "Henüz Bitcoin'iniz yok."
"noneYet": "Henüz Bitcoin'iniz yok.",
"feesNotLoadedYet": "Ücret oranları henüz yüklenmedi.",
"feeRateTooLow": "En az 1 sat/vB ücret oranı girin."
},
"toast": {
"failedTitle": "İşlem başarısız"
},
"broadcastError": {
"feeTooLowTitle": "Ağ ücreti çok düşük",
"feeTooLowBodyWithMin": "Bitcoin ağı bu ücreti reddediyor. Şu an için minimum yaklaşık {{min}} sat/vB.",
"feeTooLowBody": "Bitcoin ağı bu ücreti reddediyor. Daha hızlı bir kademe seçin ya da özel oranınızı yükseltin.",
"rbfTitle": "Yerine geçen işlem daha yüksek bir ücret gerektiriyor",
"rbfBody": "Yerine geçen işlemin orijinalinden daha fazla ödemesi gerekir. Ücreti yükseltip tekrar deneyin.",
"mempoolFullTitle": "Bitcoin ağı tıkanmış",
"mempoolFullBody": "Mempool dolu ve ücretiniz rekabetçi değil. Geçebilmek için ücreti yükseltin.",
"networkTitle": "Bitcoin ağına ulaşılamadı",
"networkBody": "Bağlantınızı kontrol edip tekrar deneyin.",
"mempoolConflictTitle": "Çakışan işlem",
"mempoolConflictBody": "Girdilerden biri zaten harcanmış ya da başka bir işlem tarafından harcanıyor.",
"tooLongChainTitle": "Çok fazla onaylanmamış işlem",
"tooLongChainBody": "Uzun bir onaylanmamış işlem zinciriniz var. Birinin onaylanmasını bekleyip tekrar deneyin.",
"badInputsTitle": "İşlem reddedildi",
"badInputsBody": "Ağ bu işlemi reddetti. Tutarı ya da alıcıyı ayarlayıp tekrar deneyin.",
"absurdlyHighFeeTitle": "Ücret olağandışı derecede yüksek",
"absurdlyHighFeeBody": "Tahmini ücret şüpheli ölçüde yüksek. Ücret oranlarını yeniden yükleyip tekrar deneyin.",
"unknownTitle": "İşlem başarısız",
"useHigherFee": "Daha yüksek bir ücret kullan",
"tryAgain": "Tekrar dene",
"atMaxFeeTier": "Zaten en hızlı kademedesiniz."
},
"success": {
"title": "Bitcoin gönderildi",
"satsAmount": "{{sats}} sat",
+184 -41
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "打造你的個人資料",
"subtitle": "向他人簡單介紹一下你自己。全部都是選填,隨時可以修改。",
"campaignTitle": "為你的募款活動賦予面孔",
"campaignSubtitle": "名字和照片能幫助人們與你的募款活動建立連結。",
"nameLabel": "顯示名稱",
"namePlaceholder": "你的名稱",
"aboutLabel": "個人簡介",
"aboutPlaceholder": "簡單介紹一下你自己…",
"avatarLabel": "頭像",
"uploadAvatar": "上傳頭像",
"advanced": "更多",
"finish": "完成",
"saving": "儲存中…",
"skip": "暫時略過",
@@ -183,10 +186,11 @@
"coverImage": "封面圖片",
"description": "描述",
"timezone": "時區",
"publishing": "釋出中…",
"uploadingCover": "正在上傳封面…",
"countrySearchPlaceholder": "搜尋國家/地區",
"imageDropzone": "點選或拖拽圖片到這裡"
"imageDropzone": "點選或拖拽圖片到這裡",
"countryClearAria": "清除國家",
"flagOfAria": "{{name}} 國旗",
"countryHint": "釋出 <0>i: iso3166:{{code}}</0> 用於按國家排序。"
},
"organizationContext": {
"attachedToGroup": "已關聯到群組",
@@ -220,8 +224,8 @@
"myPledgesTagline": "你建立的懸賞。",
"featuredPledges": "精選懸賞",
"featuredPledgesTagline": "{{appName}} 團隊重點推薦的懸賞。",
"allPledges": "全部懸賞",
"allPledgesTagline": "瀏覽網路上的每一個懸賞。",
"allPledges": "懸賞",
"allPledgesTagline": "由版主重點推薦。搜尋或排序以瀏覽所有懸賞。",
"sectionActive": "進行中的懸賞",
"sectionUpcoming": "即將開始的懸賞",
"sectionPast": "已結束的懸賞",
@@ -279,11 +283,7 @@
"titlePlaceholder": "記錄一次海灘清理",
"country": "國家",
"countryPlaceholder": "搜尋國家/地區",
"countryClearAria": "清除國家",
"flagOfAria": "{{name}} 國旗",
"countryHint": "釋出 <0>i: iso3166:{{code}}</0> 用於按國家排序。",
"tags": "標籤",
"tagsPlaceholder": "海灘清理, 抗議記錄, 網路中斷",
"coverImage": "封面圖片",
"description": "描述",
"descriptionPlaceholder": "說明你想激發的行動、證據或成果,提交應該包含什麼,以及你打算如何評估它們……",
@@ -293,8 +293,6 @@
"timezone": "時區",
"timezoneNote": "開始時間和截止時間將以此時區解釋。",
"submit": "建立懸賞",
"publishing": "釋出中……",
"uploadingCover": "上傳封面中……",
"altText": "{{appName}} 懸賞:{{title}}",
"successToast": "已建立懸賞",
"errorToast": "無法建立懸賞",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "懸賞金額必須是正的 USD 金額。",
"errorPriceUnavailable": "正在等待 BTC/USD 價格以計算懸賞金額。",
"errorCoverInvalid": "封面圖片必須是有效的 https:// URL。",
"errorDeadlinePast": "截止日期不能在過去。"
"errorDeadlinePast": "截止日期不能在過去。",
"wizard": {
"titleStepTitle": "為你的懸賞命名",
"titleStepSubtitle": "明確的訴求,以及你將資助什麼的簡短說明。",
"pledgeStepTitle": "設定你的懸賞",
"pledgeStepSubtitle": "你將支付多少美元,以及可選的截止日期。",
"coverStepTitle": "新增封面圖片",
"coverStepSubtitle": "一張圖片,讓你的懸賞在每張卡片上脫穎而出。",
"tagsStepTitle": "國家與分類",
"tagsStepSubtitle": "幫助合適的人找到你的懸賞。",
"launchNow": "跳過後續並啟動"
}
},
"detail": {
"seoTitle": "{{title}} | {{appName}} 懸賞",
@@ -355,8 +364,8 @@
"myGroupsTagline": "你建立、管理或追蹤的群組。",
"featuredGroups": "精選群組",
"featuredGroupsTagline": "值得你關注的出色群組。",
"allGroups": "全部群組",
"allGroupsTagline": "瀏覽 {{appName}} 群組,或在 Nostr 上的每個群組中搜尋。",
"allGroups": "群組",
"allGroupsTagline": "由版主重點推薦。搜尋或排序以瀏覽所有群組。",
"loginToSeeTitle": "登入以檢視你的群組",
"loginToSeeBody": "你建立或管理的群組會顯示在這裡。",
"noGroupsTitle": "還沒有群組",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "這個群組是關於什麼的?",
"country": "國家",
"countryPlaceholder": "搜尋國家/地區",
"countryClearAria": "清除國家",
"flagOfAria": "{{name}} 國旗",
"countryHint": "釋出 <0>i: iso3166:{{code}}</0> 用於按國家排序。",
"tags": "標籤",
"tagsPlaceholder": "互助, 本地新聞, 數字權利",
"coverImage": "封面圖片",
@@ -433,7 +439,18 @@
"errorNameInvalid": "名稱必須包含字母或數字,才能建立群組 URL。",
"errorEditLatestMissing": "找不到此群組的最新版本以更新。",
"errorCoverInvalid": "封面圖片必須是有效的 https:// URL。",
"errorSlugCollision": "你已有一個識別符號為「{{slug}}」的群組。請選擇其他名稱。"
"errorSlugCollision": "你已有一個識別符號為「{{slug}}」的群組。請選擇其他名稱。",
"wizard": {
"nameStepTitle": "為你的群組命名",
"nameStepSubtitle": "簡短、清晰,讓成員一眼就能認出。",
"coverStepTitle": "新增封面圖片",
"coverStepSubtitle": "一張圖片,讓你的群組在每張卡片上脫穎而出。",
"moderatorsStepTitle": "邀請管理員",
"moderatorsStepSubtitle": "可選 — 他們可以與你一起審核內容並移除成員。",
"tagsStepTitle": "國家與分類",
"tagsStepSubtitle": "幫助合適的人找到你的群組。",
"launchNow": "跳過後續並啟動"
}
},
"detail": {
"by": "由",
@@ -493,9 +510,19 @@
"myWalletDefault": "我的錢包",
"walletChoose": "選擇錢包",
"walletCustom": "自定義",
"walletUseCustom": "改用其他錢包",
"walletDestinationLanding": "捐款將會送到這裡",
"walletDestinationNote": "這個錢包將會被發佈為你活動的捐款目的地。",
"walletUseMine": "使用我的 Agora 錢包",
"acceptAll": "接受所有支付型別",
"acceptPublic": "僅接受公開支付",
"acceptPrivate": "僅接受私密支付",
"acceptAllShort": "全部接受",
"acceptPublicShort": "僅公開",
"acceptPrivateShort": "僅私密",
"acceptAllHint": "同時接受公開的鏈上支付與私密的靜默支付。",
"acceptPublicHint": "僅接受發送至公開地址的鏈上捐款。",
"acceptPrivateHint": "僅接受靜默支付——捐款者的地址將保持私密。",
"customWalletIntro": "輸入比特幣地址、靜默支付代碼或兩者皆可。至少需要一個。",
"bitcoinAddress": "比特幣地址",
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "不是已識別的 BIP-352 靜默支付代碼(sp1…)。",
"country": "國家",
"countryPlaceholder": "搜尋國家/地區",
"countryClearAria": "清除國家",
"flagOfAria": "{{name}} 國旗",
"countryHint": "釋出 <0>i: iso3166:{{code}}</0> 用於按國家排序。",
"tags": "標籤",
"tagsPlaceholder": "法律辯護, 互助, 本地新聞",
"categories": {
"humanRights": "人權",
"democracy": "民主",
"pressFreedom": "新聞自由",
"politicalPrisoners": "政治犯",
"humanitarianAid": "人道援助",
"civilResistance": "公民抗爭",
"digitalRights": "數位權利",
"antiCorruption": "反貪腐",
"womenGirls": "婦女與女童",
"refugees": "難民與流亡者",
"legalAid": "法律援助",
"emergencyRelief": "緊急救援",
"animalRights": "動物權利",
"education": "教育",
"medical": "醫療",
"community": "社群"
},
"banner": "橫幅圖片",
"story": "故事",
"storyPlaceholder": "分享背景、受益物件,以及資金的使用方式。",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "無法從你的錢包派生新的鏈上地址。",
"errorHdDeriveInvalid": "派生的錢包地址未通過驗證。請改為新增自定義地址。",
"errorWalletRequiredFallback": "需要錢包端點。",
"errorPublishedInvalid": "已釋出的事件未通過驗證。請重新整理並重試。"
"errorPublishedInvalid": "已釋出的事件未通過驗證。請重新整理並重試。",
"wizard": {
"titleStepTitle": "為你的活動命名",
"titleStepSubtitle": "簡短、清晰,讓捐贈者一眼就能認出。",
"walletStepTitle": "選擇由誰接收捐款",
"walletStepSubtitle": "你的 Agora 錢包已準備好為這個活動接收 Bitcoin 捐款。",
"bannerStepTitle": "新增橫幅",
"bannerStepSubtitle": "一張搶眼的圖片,讓你的活動在每張卡片上脫穎而出。",
"storyStepTitle": "說說你的故事",
"storyStepSubtitle": "誰是受益者,以及這些資金將如何運用。",
"next": "下一步",
"back": "返回",
"skip": "跳過",
"launchNow": "跳過後續並啟動"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
@@ -699,19 +755,15 @@
"startCampaign": "發起活動",
"howItWorks": "運作方式",
"exploreCampaigns": "瀏覽活動",
"featured": "精選",
"featuredDesc": "由 {{appName}} 團隊精心挑選的活動",
"community": "社群活動",
"communityDesc": "為值得做的改變提供資金。",
"browseAll": "瀏覽所有活動 →",
"pending": "等待審批",
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
"pendingEmpty": "沒有等待審查的內容。",
"wlcDesc": "由世界自由大會(World Liberty Congress)精選的活動。",
"allCampaigns": "所有活動",
"allCampaignsDesc": "網絡上的所有活動,按時間順序排列。",
"browseAll": "瀏覽所有活動",
"hidden": "已隱藏",
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
"hiddenEmpty": "當前沒有被隱藏的活動。",
"yourCampaigns": "你的活動",
"yourCampaignsDesc": "你的活動已在 Nostr 上線,通過活動連結可以接收捐款。一旦 Soapbox 團隊版主批准,它們將出現在首頁。",
"yourCampaignsDesc": "你的活動已在 Nostr 上線,並可透過活動連結接收捐款。前往 /campaigns 瀏覽所有活動;{{appName}} 團隊會在首頁精選展示其中一部分。",
"empty": "暫無活動",
"emptyHint": "成為在 {{appName}} 發起眾籌的第一人。講述你的故事、選擇受益人、並分享連結。",
"searchPlaceholder": "搜尋活動…",
@@ -720,10 +772,10 @@
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。"
},
"all": {
"title": "所有活動",
"title": "活動",
"seoTitle": "所有活動",
"description": "瀏覽 Agora 上釋出的所有活動。",
"sectionTagline": "瀏覽網路上的每一項事業。",
"sectionTagline": "精選活動優先,其餘來自整個網路。搜尋或排序以進一步篩選。",
"heroKicker": "活動",
"heroHeading": "每一份心意,",
"heroHeadingLine2": "匯聚於此。",
@@ -744,6 +796,54 @@
"allHiddenHint": "網路上的所有活動都已被版主隱藏。開啟「顯示已隱藏」即可檢視。",
"empty": "暫無活動",
"emptyHint": "尚未釋出任何活動。來當第一個吧。"
},
"lists": {
"stripAria": "精選活動主題清單",
"create": "新清單",
"createDesc": "建立一個新的主題清單。可從任何活動頁面將活動加入其中。",
"createSubmit": "建立清單",
"createFailed": "無法建立清單",
"edit": "編輯清單",
"editDesc": "更新清單的標題、描述或圖示。",
"editSubmit": "儲存變更",
"updateFailed": "無法更新清單",
"delete": "刪除清單",
"deleteFailed": "無法刪除清單",
"deleteConfirmTitle": "要刪除這個清單嗎?",
"deleteConfirmDesc": "「{{title}}」將從主題列中移除。活動本身不會受到影響。",
"titleField": "標題",
"titlePlaceholder": "例如:新聞自由",
"descriptionField": "描述",
"descriptionPlaceholder": "簡短說明這個清單適合收錄哪些活動。",
"iconField": "圖示",
"menuAria": "{{title}} 清單選項",
"listActions": "清單操作",
"memberMenuAria": "活動清單選項",
"backToCampaigns": "返回活動",
"detailTitle": "活動清單",
"campaignsCount_one": "{{count}} 個活動",
"campaignsCount_other": "{{count}} 個活動",
"addCampaign": "新增活動",
"addCampaignDesc": "搜尋網路並挑選一個活動加入此清單。",
"addFailed": "無法新增至清單",
"addToList": "新增",
"alreadyAdded": "已新增",
"added": "已加入",
"membershipTitle": "加入清單",
"membershipDesc": "選擇「{{title}}」要顯示在哪些清單中。",
"membershipEmpty": "目前沒有清單。建立一個以開始整理。",
"searchPlaceholder": "搜尋活動…",
"searchEmpty": "沒有活動符合此搜尋。",
"removeFromList": "從清單中移除",
"removeFailed": "無法從清單中移除",
"empty": "這個清單是空的。",
"emptyMod": "這個清單是空的。新增活動以開始整理。",
"iconPicker": {
"title": "選擇圖示",
"description": "從 Lucide 圖示庫中挑選任一圖示。",
"search": "搜尋圖示…",
"empty": "沒有圖示符合此搜尋。"
}
}
},
"moderation": {
@@ -753,21 +853,27 @@
"ariaPledge": "管理懸賞",
"ariaGroup": "管理群組",
"failedAction": "無法{{action}}",
"approve": "批准",
"unapprove": "取消批准",
"approvedState": "已批准",
"hide": "隱藏",
"unhide": "取消隱藏",
"hiddenState": "已隱藏",
"feature": "推薦精選",
"unfeature": "取消精選",
"featuredState": "已精選",
"toastApproved": "已批准顯示於首頁",
"toastUnapproved": "已自首頁移除",
"toastHidden": "已隱藏",
"toastUnhidden": "已取消隱藏",
"toastFeatured": "已加入精選",
"toastUnfeatured": "已自精選移除"
"toastUnfeatured": "已自精選移除",
"failedReorder": "無法重新排序",
"moveToTop": "移至頂端",
"moveUp": "上移",
"moveDown": "下移",
"addToList": "加入清單…",
"dragHandle": "拖曳以重新排序(位置 {{index}}",
"toast": {
"movedToTop": "已移至頂端",
"movedUp": "已上移",
"movedDown": "已下移"
}
}
},
"settings": {
@@ -1059,13 +1165,25 @@
"bitcoinAddress": "比特幣地址",
"silentPayment": "靜默支付地址",
"toLabel": "收件人",
"clear": "清除收件人"
"clear": "清除收件人",
"choosePaymentMethod": "請選擇付款方式以繼續"
},
"feeSpeed": {
"fastest": "~10 分鐘",
"halfHour": "~30 分鐘",
"hour": "~1 小時",
"economy": "~1 天"
"economy": "~1 天",
"custom": "自訂"
},
"fee": {
"loading": "載入中…",
"unavailable": "無法使用",
"loadFailed": "無法載入費率。",
"retry": "重試",
"orCustom": "或在下方輸入自訂費率。",
"loadingTiers": "正在載入費率…",
"customPlaceholder": "例如 5",
"customAriaLabel": "自訂費率(sat/vB"
},
"progress": {
"building": "正在構建交易…",
@@ -1078,6 +1196,8 @@
"enterRecipient": "請輸入比特幣地址或 sp1… 靜默支付地址。",
"noSpendable": "此錢包中沒有可花費的比特幣。",
"feesNotLoaded": "費率未載入。",
"feesNotLoadedYet": "費率尚未載入。",
"feeRateTooLow": "請輸入至少 1 sat/vB 的費率。",
"enterAmount": "請輸入金額。",
"insufficient": "比特幣不足以支付該金額和網路費用。",
"waitingPrice": "正在等待 BTC 價格…",
@@ -1090,6 +1210,29 @@
"toast": {
"failedTitle": "交易失敗"
},
"broadcastError": {
"feeTooLowTitle": "網路費用過低",
"feeTooLowBodyWithMin": "Bitcoin 網路正在拒絕此費用。目前的最低費率約為 {{min}} sat/vB。",
"feeTooLowBody": "Bitcoin 網路正在拒絕此費用。請選擇更快的費率等級或提高你的自訂費率。",
"rbfTitle": "替換交易需要更高的費用",
"rbfBody": "替換交易支付的費用必須高於原交易。請提高費用後再試一次。",
"mempoolFullTitle": "Bitcoin 網路擁塞",
"mempoolFullBody": "mempool 已滿,你的費用沒有競爭力。請提高費用以順利通過。",
"networkTitle": "無法連線至 Bitcoin 網路",
"networkBody": "請檢查你的網路連線後再試一次。",
"mempoolConflictTitle": "交易衝突",
"mempoolConflictBody": "其中一個輸入已被花費,或正在被另一筆交易花費。",
"tooLongChainTitle": "未確認交易過多",
"tooLongChainBody": "你有一長串未確認的交易。請等待其中一筆確認後再試一次。",
"badInputsTitle": "交易已被拒絕",
"badInputsBody": "網路拒絕了此交易。請調整金額或收件人後再試一次。",
"absurdlyHighFeeTitle": "費用異常偏高",
"absurdlyHighFeeBody": "估算的費用高得可疑。請重新載入費率後再試一次。",
"unknownTitle": "交易失敗",
"useHigherFee": "使用更高費用",
"tryAgain": "再試一次",
"atMaxFeeTier": "你已處於最快的費率等級。"
},
"success": {
"title": "比特幣已傳送",
"satsAmount": "{{sats}} sats",
+213 -42
View File
@@ -111,12 +111,15 @@
"profile": {
"title": "打造属于你的个人资料",
"subtitle": "向他人介绍一下你自己。全部为选填,随时可以修改。",
"campaignTitle": "为你的众筹活动添个面孔",
"campaignSubtitle": "名字和照片能帮助人们与你的众筹活动建立联系。",
"nameLabel": "显示名称",
"namePlaceholder": "你的名字",
"aboutLabel": "简介",
"aboutPlaceholder": "介绍一下你自己……",
"avatarLabel": "头像",
"uploadAvatar": "上传头像",
"advanced": "更多",
"finish": "完成",
"saving": "正在保存……",
"skip": "暂时跳过",
@@ -183,10 +186,11 @@
"coverImage": "封面图片",
"description": "描述",
"timezone": "时区",
"publishing": "发布中…",
"uploadingCover": "正在上传封面…",
"countrySearchPlaceholder": "搜索国家/地区",
"imageDropzone": "点击或拖拽图片到这里"
"imageDropzone": "点击或拖拽图片到这里",
"countryClearAria": "清除国家",
"flagOfAria": "{{name}} 国旗",
"countryHint": "发布 <0>i: iso3166:{{code}}</0> 用于按国家排序。"
},
"organizationContext": {
"attachedToGroup": "已关联到群组",
@@ -220,8 +224,8 @@
"myPledgesTagline": "你创建的悬赏。",
"featuredPledges": "精选悬赏",
"featuredPledgesTagline": "{{appName}} 团队重点推荐的悬赏。",
"allPledges": "全部悬赏",
"allPledgesTagline": "浏览网络上的每一个悬赏。",
"allPledges": "悬赏",
"allPledgesTagline": "由版主精选推荐。可搜索或排序以浏览全部悬赏。",
"sectionActive": "进行中的悬赏",
"sectionUpcoming": "即将开始的悬赏",
"sectionPast": "已结束的悬赏",
@@ -279,11 +283,7 @@
"titlePlaceholder": "记录一次海滩清理",
"country": "国家",
"countryPlaceholder": "搜索国家/地区",
"countryClearAria": "清除国家",
"flagOfAria": "{{name}} 国旗",
"countryHint": "发布 <0>i: iso3166:{{code}}</0> 用于按国家排序。",
"tags": "标签",
"tagsPlaceholder": "海滩清理, 抗议记录, 网络中断",
"coverImage": "封面图片",
"description": "描述",
"descriptionPlaceholder": "说明你想激发的行动、证据或成果,提交应该包含什么,以及你打算如何评估它们……",
@@ -293,8 +293,6 @@
"timezone": "时区",
"timezoneNote": "开始时间和截止时间将以此时区解释。",
"submit": "创建悬赏",
"publishing": "发布中……",
"uploadingCover": "上传封面中……",
"altText": "{{appName}} 悬赏:{{title}}",
"successToast": "已创建悬赏",
"errorToast": "无法创建悬赏",
@@ -305,7 +303,18 @@
"errorPledgeInvalid": "悬赏金额必须是正的 USD 金额。",
"errorPriceUnavailable": "正在等待 BTC/USD 价格以计算悬赏金额。",
"errorCoverInvalid": "封面图片必须是有效的 https:// URL。",
"errorDeadlinePast": "截止日期不能在过去。"
"errorDeadlinePast": "截止日期不能在过去。",
"wizard": {
"titleStepTitle": "为你的悬赏起个名字",
"titleStepSubtitle": "清晰的诉求,加上一段简短说明,介绍你将资助什么。",
"pledgeStepTitle": "设置你的悬赏",
"pledgeStepSubtitle": "你将以 USD 支付多少,以及可选的截止日期。",
"coverStepTitle": "添加封面图片",
"coverStepSubtitle": "一张图片,让悬赏在每张卡片上脱颖而出。",
"tagsStepTitle": "国家和分类",
"tagsStepSubtitle": "帮助合适的人发现你的悬赏。",
"launchNow": "跳过并直接发布"
}
},
"detail": {
"seoTitle": "{{title}} | {{appName}} 悬赏",
@@ -355,8 +364,8 @@
"myGroupsTagline": "你创建、管理或关注的群组。",
"featuredGroups": "精选群组",
"featuredGroupsTagline": "值得你关注的出色群组。",
"allGroups": "全部群组",
"allGroupsTagline": "浏览 {{appName}} 群组,或在 Nostr 上的每个群组中搜索。",
"allGroups": "群组",
"allGroupsTagline": "由版主精选推荐。可搜索或排序以浏览全部群组。",
"loginToSeeTitle": "登录以查看你的群组",
"loginToSeeBody": "你创建或管理的群组会显示在这里。",
"noGroupsTitle": "还没有群组",
@@ -407,9 +416,6 @@
"descriptionPlaceholder": "这个群组是关于什么的?",
"country": "国家",
"countryPlaceholder": "搜索国家/地区",
"countryClearAria": "清除国家",
"flagOfAria": "{{name}} 国旗",
"countryHint": "发布 <0>i: iso3166:{{code}}</0> 用于按国家排序。",
"tags": "标签",
"tagsPlaceholder": "互助, 本地新闻, 数字权利",
"coverImage": "封面图片",
@@ -433,7 +439,18 @@
"errorNameInvalid": "名称必须包含字母或数字,才能创建群组 URL。",
"errorEditLatestMissing": "找不到此群组的最新版本以更新。",
"errorCoverInvalid": "封面图片必须是有效的 https:// URL。",
"errorSlugCollision": "你已有一个标识符为「{{slug}}」的群组。请选择其他名称。"
"errorSlugCollision": "你已有一个标识符为「{{slug}}」的群组。请选择其他名称。",
"wizard": {
"nameStepTitle": "为你的群组起个名字",
"nameStepSubtitle": "一个简短清晰、成员易于辨识的名称。",
"coverStepTitle": "添加封面图片",
"coverStepSubtitle": "一张图片,让群组在每张卡片上脱颖而出。",
"moderatorsStepTitle": "邀请管理员",
"moderatorsStepSubtitle": "可选 — 他们可以与你一同审批内容和移除成员。",
"tagsStepTitle": "国家和分类",
"tagsStepSubtitle": "帮助合适的人发现你的群组。",
"launchNow": "跳过并直接发布"
}
},
"detail": {
"by": "由",
@@ -493,9 +510,19 @@
"myWalletDefault": "我的钱包",
"walletChoose": "选择钱包",
"walletCustom": "自定义",
"walletUseCustom": "改用其他钱包",
"walletDestinationLanding": "捐款将会送到这里",
"walletDestinationNote": "这个钱包将会被发布为你活动的捐款目的地。",
"walletUseMine": "使用我的 Agora 钱包",
"acceptAll": "接受所有支付类型",
"acceptPublic": "仅接受公开支付",
"acceptPrivate": "仅接受私密支付",
"acceptAllShort": "全部接受",
"acceptPublicShort": "仅公开",
"acceptPrivateShort": "仅私密",
"acceptAllHint": "同时接受公开链上支付和私密静默支付。",
"acceptPublicHint": "仅接受发送至公开地址的链上捐款。",
"acceptPrivateHint": "仅接受静默支付——捐赠者地址保持私密。",
"customWalletIntro": "输入比特币地址、静默支付代码或两者皆可。至少需要一个。",
"bitcoinAddress": "比特币地址",
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
@@ -505,11 +532,26 @@
"spInvalid": "不是已识别的 BIP-352 静默支付代码(sp1…)。",
"country": "国家",
"countryPlaceholder": "搜索国家/地区",
"countryClearAria": "清除国家",
"flagOfAria": "{{name}} 国旗",
"countryHint": "发布 <0>i: iso3166:{{code}}</0> 用于按国家排序。",
"tags": "标签",
"tagsPlaceholder": "法律辩护, 互助, 本地新闻",
"categories": {
"humanRights": "人权",
"democracy": "民主",
"pressFreedom": "新闻自由",
"politicalPrisoners": "政治犯",
"humanitarianAid": "人道援助",
"civilResistance": "公民抗争",
"digitalRights": "数字权利",
"antiCorruption": "反腐败",
"womenGirls": "妇女与女童",
"refugees": "难民与流亡者",
"legalAid": "法律援助",
"emergencyRelief": "紧急救援",
"animalRights": "动物权利",
"education": "教育",
"medical": "医疗",
"community": "社区"
},
"banner": "横幅图片",
"story": "故事",
"storyPlaceholder": "分享背景、受益对象,以及资金的使用方式。",
@@ -549,7 +591,21 @@
"errorHdDeriveFailed": "无法从你的钱包派生新的链上地址。",
"errorHdDeriveInvalid": "派生的钱包地址未通过验证。请改为添加自定义地址。",
"errorWalletRequiredFallback": "需要钱包端点。",
"errorPublishedInvalid": "已发布的事件未通过验证。请刷新并重试。"
"errorPublishedInvalid": "已发布的事件未通过验证。请刷新并重试。",
"wizard": {
"titleStepTitle": "为你的活动起个名字",
"titleStepSubtitle": "一个简短清晰、捐赠者易于辨识的名称。",
"walletStepTitle": "选择由谁接收捐款",
"walletStepSubtitle": "你的 Agora 钱包已准备好为这个活动接收 Bitcoin 捐款。",
"bannerStepTitle": "添加横幅",
"bannerStepSubtitle": "一张醒目的图片,让活动在每张卡片上脱颖而出。",
"storyStepTitle": "讲述你的故事",
"storyStepSubtitle": "谁将从中受益,资金将如何使用。",
"next": "下一步",
"back": "返回",
"skip": "跳过",
"launchNow": "跳过并直接发布"
}
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
@@ -699,31 +755,55 @@
"startCampaign": "发起活动",
"howItWorks": "运作方式",
"exploreCampaigns": "浏览活动",
"featured": "精选",
"featuredDesc": "由 {{appName}} 团队精心挑选的活动",
"community": "社区活动",
"communityDesc": "为值得做的改变提供资金。",
"browseAll": "浏览所有活动 →",
"pending": "等待审批",
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
"pendingEmpty": "没有等待审查的内容。",
"wlcDesc": "由世界自由大会(World Liberty Congress)精选的活动。",
"allCampaigns": "所有活动",
"allCampaignsDesc": "网络上的所有活动,按时间顺序排列。",
"browseAll": "浏览所有活动",
"hidden": "已隐藏",
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
"hiddenEmpty": "当前没有被隐藏的活动。",
"yourCampaigns": "你的活动",
"yourCampaignsDesc": "你的活动已在 Nostr 上线,通过活动链接可接收捐款。一旦 Soapbox 团队版主批准,它们将出现在首页。",
"yourCampaignsDesc": "你的活动已在 Nostr 上线,通过活动链接可接收捐款。在 /campaigns 浏览所有活动;{{appName}} 团队会在首页展示精选活动。",
"empty": "暂无活动",
"emptyHint": "成为在 {{appName}} 发起众筹的第一人。讲述你的故事、选择受益人、并分享链接。",
"whyDifferent": {
"eyebrow": "为什么选 {{appName}}",
"title": "我们与众不同。",
"lede": "Bitcoin 从捐赠者直达活动人士。没有平台横在中间,没有托管方握着资金,无需任何许可。",
"block1": {
"heading": "不像 GoFundMe",
"body": "没有平台可以冻结你的捐款、要求退款或因政策分歧而终止你的活动。没有 Stripe、没有 Visa、没有银行夹在中间,可以在活动中途切断你。",
"bullet1": "防冻结 — 平台无否决权",
"bullet2": "没有支付处理商可以拔掉插头",
"bullet3": "零平台费用"
},
"block2": {
"heading": "不像其他「比特币」平台",
"body": "没有中央 Lightning 节点、托管方或 LSP 可能出现故障或下线。资金直接在 Bitcoin 上结算到你控制的钱包。如果 {{appName}} 明天消失,每个活动都会继续运作。",
"bullet1": "没有可被掏空或冻结的托管钱包",
"bullet2": "直接在链上结算到你拥有的钱包",
"bullet3": "即使 {{appName}} 消失也能运作"
},
"block3": {
"heading": "公开或私密。由你选择。",
"body": "活动人士选择与自身威胁模型相匹配的收款方式。捐赠者只看到一个 QR;钱包会自动选择正确的协议。",
"publicLabel": "公开",
"publicSummary": "适用于每一个 Bitcoin 钱包。链上快速且可验证。",
"privateLabel": "私密",
"privateSummary": "BIP-352 静默支付。捐款到达不可链接的输出。"
},
"readMore": "阅读完整说明"
},
"searchPlaceholder": "搜索活动…",
"searchAriaLabel": "搜索活动",
"noMatch": "没有活动匹配「{{query}}」",
"noMatchHint": "尝试其他搜索词,或清除搜索。"
},
"all": {
"title": "所有活动",
"title": "活动",
"seoTitle": "所有活动",
"description": "浏览 Agora 上发布的所有活动。",
"sectionTagline": "浏览网络上的每一项事业。",
"sectionTagline": "精选活动优先展示,其余来自整个网络。可搜索或排序以进一步筛选。",
"heroKicker": "活动",
"heroHeading": "每一个理念,",
"heroHeadingLine2": "汇聚于此。",
@@ -744,6 +824,54 @@
"allHiddenHint": "网络上的所有活动都已被版主隐藏。打开「显示已隐藏」即可查看。",
"empty": "暂无活动",
"emptyHint": "尚未发布任何活动。来当第一个吧。"
},
"lists": {
"stripAria": "精选活动主题列表",
"create": "新建列表",
"createDesc": "创建一个新的主题列表。可以从任意活动页面将活动收录其中。",
"createSubmit": "创建列表",
"createFailed": "创建列表失败",
"edit": "编辑列表",
"editDesc": "更新列表的标题、描述或图标。",
"editSubmit": "保存更改",
"updateFailed": "更新列表失败",
"delete": "删除列表",
"deleteFailed": "删除列表失败",
"deleteConfirmTitle": "删除此列表?",
"deleteConfirmDesc": "「{{title}}」将从主题栏中移除。活动本身不会受到影响。",
"titleField": "标题",
"titlePlaceholder": "例如:新闻自由",
"descriptionField": "描述",
"descriptionPlaceholder": "用一段简短的说明阐明此列表收录的内容。",
"iconField": "图标",
"menuAria": "{{title}} 列表选项",
"listActions": "列表操作",
"memberMenuAria": "活动列表选项",
"backToCampaigns": "返回活动",
"detailTitle": "活动列表",
"campaignsCount_one": "{{count}} 个活动",
"campaignsCount_other": "{{count}} 个活动",
"addCampaign": "添加活动",
"addCampaignDesc": "搜索网络,挑选一个活动加入此列表。",
"addFailed": "添加到列表失败",
"addToList": "添加",
"alreadyAdded": "已添加",
"added": "已添加",
"membershipTitle": "添加到列表",
"membershipDesc": "选择 \"{{title}}\" 应出现在哪些列表中。",
"membershipEmpty": "尚无列表。创建一个以开始整理。",
"searchPlaceholder": "搜索活动…",
"searchEmpty": "没有活动匹配此搜索。",
"removeFromList": "从列表中移除",
"removeFailed": "从列表中移除失败",
"empty": "此列表为空。",
"emptyMod": "此列表为空。添加活动以开始整理。",
"iconPicker": {
"title": "选择一个图标",
"description": "从 Lucide 图标库中任选一个图标。",
"search": "搜索图标…",
"empty": "没有图标匹配此搜索。"
}
}
},
"moderation": {
@@ -753,21 +881,27 @@
"ariaPledge": "管理悬赏",
"ariaGroup": "管理群组",
"failedAction": "操作失败:{{action}}",
"approve": "批准",
"unapprove": "取消批准",
"approvedState": "已批准",
"failedReorder": "重新排序失败",
"moveToTop": "移到顶部",
"moveUp": "上移",
"moveDown": "下移",
"addToList": "添加到列表…",
"dragHandle": "拖动以重新排序(位置 {{index}}",
"hide": "隐藏",
"unhide": "取消隐藏",
"hiddenState": "已隐藏",
"feature": "精选",
"unfeature": "取消精选",
"featuredState": "已精选",
"toastApproved": "已批准至首页",
"toastUnapproved": "已从首页移除",
"toastHidden": "已隐藏",
"toastUnhidden": "已取消隐藏",
"toastFeatured": "已精选",
"toastUnfeatured": "已从精选移除"
"toastUnfeatured": "已从精选移除",
"toast": {
"movedToTop": "已移到顶部",
"movedUp": "已上移",
"movedDown": "已下移"
}
}
},
"settings": {
@@ -1123,13 +1257,25 @@
"bitcoinAddress": "比特币地址",
"silentPayment": "静默支付地址",
"toLabel": "收件人",
"clear": "清除收件人"
"clear": "清除收件人",
"choosePaymentMethod": "请选择支付方式以继续"
},
"feeSpeed": {
"fastest": "~10 分钟",
"halfHour": "~30 分钟",
"hour": "~1 小时",
"economy": "~1 天"
"economy": "~1 天",
"custom": "自定义"
},
"fee": {
"loading": "加载中…",
"unavailable": "不可用",
"loadFailed": "无法加载费率。",
"retry": "重试",
"orCustom": "或在下方输入自定义费率。",
"loadingTiers": "正在加载费率…",
"customPlaceholder": "例如 5",
"customAriaLabel": "自定义费率(sat/vB"
},
"progress": {
"building": "正在构建交易…",
@@ -1145,7 +1291,9 @@
"enterAmount": "请输入金额。",
"insufficient": "比特币不足以支付该金额和网络费用。",
"waitingPrice": "正在等待 BTC 价格…",
"noneYet": "你还没有任何比特币。"
"noneYet": "你还没有任何比特币。",
"feesNotLoadedYet": "费率尚未加载。",
"feeRateTooLow": "请输入至少 1 sat/vB 的费率。"
},
"scanError": {
"title": "无法读取该二维码",
@@ -1154,6 +1302,29 @@
"toast": {
"failedTitle": "交易失败"
},
"broadcastError": {
"feeTooLowTitle": "网络费用过低",
"feeTooLowBodyWithMin": "Bitcoin 网络拒绝了该费用。当前最低费率约为 {{min}} sat/vB。",
"feeTooLowBody": "Bitcoin 网络拒绝了该费用。请选择更快的档位,或提高你的自定义费率。",
"rbfTitle": "替换交易需要更高的费用",
"rbfBody": "替换交易支付的费用必须高于原始交易。请提高费用后再试。",
"mempoolFullTitle": "Bitcoin 网络拥堵",
"mempoolFullBody": "mempool 已满,你的费用没有竞争力。请提高费用以便通过。",
"networkTitle": "无法连接到 Bitcoin 网络",
"networkBody": "请检查你的网络连接后再试。",
"mempoolConflictTitle": "交易冲突",
"mempoolConflictBody": "其中一个输入已被花费,或正在被另一笔交易花费。",
"tooLongChainTitle": "未确认交易过多",
"tooLongChainBody": "你有一长串未确认的交易。请等待其中一笔确认后再试。",
"badInputsTitle": "交易被拒绝",
"badInputsBody": "网络拒绝了此交易。请调整金额或收款人后再试。",
"absurdlyHighFeeTitle": "费用异常偏高",
"absurdlyHighFeeBody": "估算的费用高得可疑。请重新加载费率后再试。",
"unknownTitle": "交易失败",
"useHigherFee": "使用更高的费用",
"tryAgain": "重试",
"atMaxFeeTier": "你已选择最快的档位。"
},
"success": {
"title": "比特币已发送",
"satsAmount": "{{sats}} sats",
+117 -504
View File
@@ -1,211 +1,50 @@
import { useEffect, useState, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { nip19 } from 'nostr-tools';
import {
ChevronDown,
ChevronUp,
EyeOff,
Megaphone,
PlusCircle,
} from 'lucide-react';
import { parseAction, useActions, type Action } from '@/hooks/useActions';
import { ActionShareMenu } from '@/components/ActionShareMenu';
import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
import { useActions, type Action } from '@/hooks/useActions';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useDebounce } from '@/hooks/useDebounce';
import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { getPledgeCoord } from '@/lib/pledges';
import { cn } from '@/lib/utils';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { ModerationMenuItems, ModerationOverlay, ModeratorCollapsibleSection } from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
import { Card } from '@/components/ui/card';
import {
ModerationOverlay,
ModeratorCollapsibleSection,
} from '@/components/moderation';
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
HandHeart, PlusCircle, ChevronDown, ChevronUp, Loader2,
Link as LinkIcon, Check, MoreHorizontal, Trash2,
Megaphone, Sparkles, EyeOff,
} from 'lucide-react';
// ─────────────────────────────────────────────────────────────────────────────
// Skeletons / Cards
// ─────────────────────────────────────────────────────────────────────────────
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
}
function ActionSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
function ActionShareMenu({ action, displayTitle }: { action: Action; displayTitle: string }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { mutateAsync: createEvent } = useNostrPublish();
const { toast } = useToast();
const shareOrigin = useShareOrigin();
const queryClient = useQueryClient();
const [copied, setCopied] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isOwner = user?.pubkey === action.pubkey;
// Moderator gate is identical to the one in `ModerationMenuItems`,
// duplicated here so we can decide whether to render the trailing
// separator that introduces the moderator section. `ModerationMenuItems`
// returns `null` for non-mods, so without this check we'd render an
// orphaned separator at the bottom of the dropdown.
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const actionUrl = `${shareOrigin}/${naddr}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(actionUrl);
setCopied(true);
toast({ title: t('pledges.card.linkCopied') });
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy link:', error);
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user || !isOwner) return;
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
if (!confirmed) return;
setIsDeleting(true);
try {
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
// honour a-tag-only deletions for addressable events.
await createEvent({
kind: 5,
content: t('pledges.card.deletedContent'),
tags: [
['e', action.event.id],
['a', getPledgeCoord(action)],
],
});
// Extract any organization `A` tag the pledge was associated with so
// the org's activity shelf and community feeds refresh too.
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
...(orgATag
? [
queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] }),
queryClient.invalidateQueries({ queryKey: ['community-actions', orgATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(orgATag);
},
}),
]
: []),
]);
toast({ title: t('pledges.card.deleted') });
} catch (error) {
console.error('Failed to delete pledge:', error);
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
} finally {
setIsDeleting(false);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t('pledges.card.actionsAriaLabel')}
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{isOwner && (
<>
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t('pledges.card.deletePledge')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleCopyLink}>
{copied ? (
<Check className="h-4 w-4 mr-2 text-primary" />
) : (
<LinkIcon className="h-4 w-4 mr-2" />
)}
{t('pledges.card.copyLink')}
</DropdownMenuItem>
{/* Moderator actions appear under a separator when the viewer
is a Team Soapbox moderator. `ModerationMenuItems` returns
null for non-mods, so we gate the trailing separator on the
same `isMod` check to avoid an orphan separator at the
bottom of non-mod dropdowns. */}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems
coord={getPledgeCoord(action)}
entityTitle={displayTitle}
surface="pledge"
axes={['hide', 'featured']}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Page
// ─────────────────────────────────────────────────────────────────────────────
/**
* Dedicated `/pledges` page.
*
* Thin shell around the shared {@link PledgesDiscoverySection}:
* hero, optional "My pledges" shelf, the unified search-and-discover
* section, and a moderator-only Hidden collapsible.
*
* URL state (`?q=&sort=&country=`) lives inside the section's
* `useDiscoveryFilters` hook so search results stay shareable. The
* page reads `?country=` independently to thread it into the
* create-pledge href so "Create pledge" preserves the active country
* filter into the form.
*/
export default function ActionsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
@@ -213,138 +52,44 @@ export default function ActionsPage() {
const { data: btcPrice } = useBtcPrice();
const navigate = useNavigate();
const [selectedCountry, setSelectedCountry] = useState<string | undefined>(undefined);
// Mirror the section's `?country=` so the create-pledge href can
// carry it forward into the form pre-fill (matches the old modal's
// `countryCode` prop behaviour). The section's filters hook is the
// source of truth; we only read here.
const [searchParams] = useSearchParams();
const selectedCountry = searchParams.get('country') ?? undefined;
// On-page NIP-50 search + sort + show-hidden toolbar state.
//
// Default sort, empty query → curated active / upcoming / past
// sections below.
// Default sort, with query → relay search for kind 36639, results
// post-filtered against title/content client-side.
// Top / New → always active. Top sends `sort:top`;
// New sends a raw chronological feed of the kind.
//
// The country filter is threaded through to the search as a NIP-73
// `#i` tag filter (`iso3166:XX` + legacy `geo:XX`). Picking a country
// with an empty query still activates the search view — narrowing a
// kind by external identifier produces a useful filtered grid even
// without a typed term.
const [searchInput, setSearchInput] = useState('');
const [sortMode, setSortMode] = useState<Nip50Sort>('default');
const [showHidden, setShowHidden] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
const trimmedSearch = debouncedSearch.trim();
const iTags = useMemo<string[] | undefined>(() => {
if (!selectedCountry) return undefined;
const code = selectedCountry.toUpperCase();
return [`iso3166:${code}`, `geo:${code}`];
}, [selectedCountry]);
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<Action>({
kind: 36639,
query: debouncedSearch,
sort: sortMode,
parse: parseAction,
iTags,
// Pledge titles live in a `title` tag, not `content`. Most NIP-50
// implementations only match content; widen the net client-side.
getKeywordHaystack: (event) => {
const title = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
return [title, event.content];
},
});
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox) —
// the pledge moderation namespace rides the same signer set as the
// campaign and group surfaces.
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox)
// — the pledge moderation namespace rides the same signer set as
// the campaign and group surfaces.
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const canShowHidden = isMod && showHidden;
const [showHidden, setShowHidden] = useState(false);
const { data: rawActions, isLoading: actionsLoading } = useActions({
countryCode: selectedCountry,
limit: 300,
});
const { data: myPledges, isLoading: myPledgesLoading } = useActions({
const { data: myPledges } = useActions({
authors: user ? [user.pubkey] : undefined,
limit: 100,
enabled: !!user,
});
// Moderator-only feed of every pledge on the network — drives the
// Hidden collapsible and the toolbar's hidden-count badge.
const { data: allPledgesForMods, isLoading: allPledgesLoading } = useActions({
limit: 300,
enabled: isMod,
});
const { data: pledgeModeration, isReady: pledgeModerationReady } =
usePledgeModeration();
const { data: pledgeModeration, isReady: pledgeModerationReady } = usePledgeModeration();
const hiddenPledges = useMemo<Action[]>(() => {
if (!isMod || !pledgeModerationReady) return [];
return (allPledgesForMods ?? []).filter((pledge) =>
pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)),
);
}, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
const featuredPledgeCoords = useMemo(() => {
if (!pledgeModerationReady) return [] as string[];
return Array.from(pledgeModeration.featuredCoords)
.filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
.sort((a, b) => (pledgeModeration.featuredOrder.get(b) ?? 0) - (pledgeModeration.featuredOrder.get(a) ?? 0));
}, [pledgeModeration, pledgeModerationReady]);
const { data: featuredPledges, isLoading: featuredPledgesLoading } = useActions({
coordinates: featuredPledgeCoords,
limit: featuredPledgeCoords.length || 1,
enabled: pledgeModerationReady,
});
const orderedFeaturedPledges = useMemo(() => {
if (!featuredPledges) return [] as Action[];
const order = pledgeModeration.featuredOrder;
return [...featuredPledges].sort((a, b) => {
const aCoord = getPledgeCoord(a);
const bCoord = getPledgeCoord(b);
return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
});
}, [featuredPledges, pledgeModeration]);
const featuredPledgeCoordSet = useMemo(() => new Set(featuredPledgeCoords), [featuredPledgeCoords]);
const { searchHits, searchHiddenCount } = useMemo(() => {
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
let hidden = 0;
const visible: Action[] = [];
for (const a of searchHitsRaw) {
const coord = getPledgeCoord(a);
if (hiddenCoords.has(coord)) {
hidden += 1;
if (canShowHidden) visible.push(a);
} else {
visible.push(a);
}
}
return { searchHits: visible, searchHiddenCount: hidden };
}, [searchHitsRaw, pledgeModeration, canShowHidden]);
const { actions, listHiddenCount } = useMemo(() => {
if (!rawActions) return { actions: undefined, listHiddenCount: 0 };
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
let hidden = 0;
const visible: Action[] = [];
for (const action of rawActions) {
const coord = getPledgeCoord(action);
if (hiddenCoords.has(coord)) {
hidden += 1;
if (canShowHidden) visible.push(action);
} else {
visible.push(action);
}
}
return { actions: visible, listHiddenCount: hidden };
}, [rawActions, pledgeModeration, canShowHidden]);
// Route entry points for "Create pledge" all pass the currently-selected
// country via ?country= so the dedicated page can pre-fill it, matching
// the old modal's `countryCode` prop.
// Route entry points for "Create pledge" all pass the currently
// selected country via ?country= so the dedicated page can
// pre-fill it, matching the old modal's `countryCode` prop.
const createActionHref = selectedCountry
? `/pledges/new?country=${encodeURIComponent(selectedCountry)}`
: '/pledges/new';
@@ -354,216 +99,62 @@ export default function ActionsPage() {
: t('pledges.list.global');
useSeoMeta({
title: `${selectedCountry
? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName })
: t('pledges.list.seoTitle')} | ${config.appName}`,
title: `${
selectedCountry
? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName })
: t('pledges.list.seoTitle')
} | ${config.appName}`,
description: t('pledges.list.seoDescription'),
});
const isLoading = actionsLoading || !pledgeModerationReady;
const isSearchLoading = isSearchFetching || !pledgeModerationReady;
const DEFAULT_VISIBLE = 4;
const [showAllMine, setShowAllMine] = useState(false);
const [showAllFeatured, setShowAllFeatured] = useState(false);
const [showAllPledges, setShowAllPledges] = useState(false);
const allPledges = useMemo(
() => (actions ?? []).filter((action) => !featuredPledgeCoordSet.has(getPledgeCoord(action))),
[actions, featuredPledgeCoordSet],
);
const visibleMine = showAllMine ? (myPledges ?? []) : (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
const visibleFeatured = showAllFeatured ? orderedFeaturedPledges : orderedFeaturedPledges.slice(0, DEFAULT_VISIBLE);
const visibleAllPledges = showAllPledges ? allPledges : allPledges.slice(0, DEFAULT_VISIBLE);
const hiddenPledges = useMemo<Action[]>(() => {
if (!isMod || !pledgeModerationReady) return [];
return (allPledgesForMods ?? []).filter((pledge) => pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)));
}, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
const headerControls = (
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
searchPlaceholderKey="pledges.list.searchPlaceholder"
searchAriaLabelKey="pledges.list.searchAriaLabel"
showHidden={isMod ? {
value: canShowHidden,
onChange: setShowHidden,
count: isSearching ? searchHiddenCount : listHiddenCount,
} : undefined}
country={selectedCountry}
onCountryChange={setSelectedCountry}
/>
);
const visibleMine = showAllMine
? (myPledges ?? [])
: (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
return (
<main className="pb-16 sidebar:pb-0">
<ActionsHero
actionCount={actions?.length ?? 0}
actionCount={allPledgesForMods?.length ?? myPledges?.length ?? 0}
canCreate={!!user}
onCreateAction={() => navigate(createActionHref)}
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12">
{user && (myPledgesLoading || (myPledges && myPledges.length > 0)) && (
{user && myPledges && myPledges.length > 0 && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('pledges.list.myPledges')}</h2>
<p className="text-sm text-muted-foreground mt-1">{t('pledges.list.myPledgesTagline')}</p>
</div>
{myPledgesLoading && !myPledges ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : (
<ActionSection
items={visibleMine}
total={myPledges?.length ?? 0}
visible={DEFAULT_VISIBLE}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
btcPrice={btcPrice}
/>
)}
</section>
)}
{(featuredPledgesLoading || orderedFeaturedPledges.length > 0) && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
<Sparkles className="size-6 text-primary" />
{t('pledges.list.featuredPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">{t('pledges.list.featuredPledgesTagline')}</p>
</div>
{featuredPledgesLoading && orderedFeaturedPledges.length === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : (
<ActionSection
items={visibleFeatured}
total={orderedFeaturedPledges.length}
visible={DEFAULT_VISIBLE}
showAll={showAllFeatured}
onToggle={() => setShowAllFeatured(!showAllFeatured)}
btcPrice={btcPrice}
/>
)}
</section>
)}
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch
? t('common.search')
: isSearching && sortMode === 'top'
? t('common.sortTop')
: isSearching && sortMode === 'new'
? t('common.sortNew')
: t('pledges.list.allPledges')}
{t('pledges.list.myPledges')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('pledges.list.allPledgesTagline')}
{t('pledges.list.myPledgesTagline')}
</p>
</div>
{headerControls}
</div>
{isSearching ? (
<>
{isSearchLoading && !searchHits ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : searchHits && searchHits.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{searchHits.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={getPledgeCoord(action)}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('pledges.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('pledges.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
)}
</div>
</Card>
)}
</>
) : isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : allPledges.length > 0 ? (
<ActionSection
items={visibleAllPledges}
total={allPledges.length}
items={visibleMine}
total={myPledges.length}
visible={DEFAULT_VISIBLE}
showAll={showAllPledges}
onToggle={() => setShowAllPledges(!showAllPledges)}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
btcPrice={btcPrice}
/>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div>
<h3 className="text-lg font-semibold">{t('pledges.list.emptyTitle')}</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{selectedCountry
? t('pledges.list.emptyHintCountry', { country: selectedCountryName })
: t('pledges.list.emptyHint')}
</p>
</div>
{user && (
<Button onClick={() => navigate(createActionHref)}>
<PlusCircle className="size-4 mr-2" />
{t('pledges.list.createPledge')}
</Button>
)}
</div>
</Card>
)}
</section>
</section>
)}
<PledgesDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenPledges.length,
}
: undefined
}
/>
{isMod && (
<ModeratorCollapsibleSection
@@ -575,7 +166,9 @@ export default function ActionsPage() {
emptyText={t('pledges.list.hiddenEmpty')}
skeleton={
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <ActionSkeleton key={i} />)}
{Array.from({ length: 4 }).map((_, i) => (
<PledgeCardSkeleton key={i} />
))}
</div>
}
>
@@ -597,7 +190,10 @@ export default function ActionsPage() {
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
<ActionShareMenu
action={action}
displayTitle={action.title}
/>
</>
}
/>
@@ -638,10 +234,10 @@ interface ActionsHeroProps {
/**
* Photo-led hero for the Pledges index. Same structural recipe as the
* Organize hero (rotating banner + atmospheric tint + scrims + overlay
* copy + glassy CTA), but tuned for the pledge page's "dawn / golden hour" vibe:
* uses {@link HOPE_PALETTE} instead of the cool palette so the warm
* hues land on top of the protest photography rather than competing
* with it.
* copy + glassy CTA), but tuned for the pledge page's "dawn / golden
* hour" vibe: uses {@link HOPE_PALETTE} instead of the cool palette
* so the warm hues land on top of the protest photography rather
* than competing with it.
*/
function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProps) {
const { t } = useTranslation();
@@ -707,7 +303,10 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
aria-live="polite"
>
<div className="flex items-center justify-center gap-3">
<Megaphone className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
<Megaphone
className="size-5 text-amber-200 shrink-0 drop-shadow"
aria-hidden
/>
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{actionCount.toLocaleString()}
</span>
@@ -733,7 +332,11 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
'motion-safe:transition-colors motion-safe:duration-200',
'disabled:opacity-60 disabled:cursor-not-allowed',
)}
aria-label={canCreate ? t('pledges.list.createPledge') : t('pledges.list.loginToCreate')}
aria-label={
canCreate
? t('pledges.list.createPledge')
: t('pledges.list.loginToCreate')
}
>
<PlusCircle className="mr-2" />
{t('pledges.list.createPledge')}
@@ -745,9 +348,19 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
}
function ActionSection({
items, total, visible, showAll, onToggle, btcPrice,
items,
total,
visible,
showAll,
onToggle,
btcPrice,
}: {
items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; btcPrice: number | undefined;
items: Action[];
total: number;
visible: number;
showAll: boolean;
onToggle: () => void;
btcPrice: number | undefined;
}) {
const { t } = useTranslation();
return (
+79 -345
View File
@@ -1,356 +1,136 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronUp, EyeOff, HandHeart, PlusCircle } from 'lucide-react';
import { EyeOff, HandHeart, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
import { CampaignListsStrip } from '@/components/campaign-lists/CampaignListsStrip';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
import { useAllCampaigns, toQuerySort } from '@/hooks/useAllCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useAppContext } from '@/hooks/useAppContext';
import { useDebounce } from '@/hooks/useDebounce';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { parseSort } from '@/hooks/useDiscoveryFilters';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import type { Nip50Sort } from '@/hooks/useNip50Search';
import type { ParsedCampaign } from '@/lib/campaign';
/** Type-guard for the `?sort=` URL param. Default is `top` (most-zapped). */
function parseSort(value: string | null): CampaignSort {
return value === 'none' ? 'none' : 'top';
}
/**
* Map between the shared toolbar's sort vocabulary (`default` / `top` /
* `new`) and the `useAllCampaigns` hook's vocabulary (`top` / `none`).
* Lists every campaign found on relays.
*
* AllCampaignsPage doesn't have a curated/default layout — it's the
* "show me everything" page so the toolbar's 'default' option falls
* through to 'top' here, the page's canonical ranked view. The legacy
* `none` value is preserved on the URL so existing share links keep
* working.
*/
const toToolbarSort = (s: CampaignSort): Nip50Sort => (s === 'none' ? 'new' : 'top');
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'new' ? 'none' : 'top');
/**
* Lists every campaign found on relays. Two sort modes:
* The page itself is a thin shell: hero, the moderator-curated topic-list
* strip ({@link CampaignListsStrip}), the shared
* {@link CampaignsDiscoverySection} (which owns search / sort / country
* + idle / active grids), and a moderator-only Hidden collapsible.
*
* - **Top** (default): ranked by total sats raised (kind 8333 donation receipts).
* - **New**: chronological by `created_at`.
* URL state (`?q=&sort=&country=`) lives inside the section's
* `useDiscoveryFilters` hook so search results stay shareable. The
* page reads the same params independently to compute the Hidden
* collapsible's contents TanStack Query dedupes the underlying
* `useAllCampaigns` call, so there's no extra network round-trip.
*
* Both modes share a free-text search bar that filters across title,
* summary, story, location, and category tags client-side.
*
* Hidden campaigns are excluded by default flip the "Show hidden"
* toggle (inside the toolbar's filter popover) to include them.
*
* URL state: `?sort=none&q=<search>`. Default values are stripped so the
* canonical URL stays clean. Useful for sharing search results.
* **Censorship-resistance:** the section's Show-hidden toggle is
* available to every viewer here, not just moderators. The campaigns
* page is the canonical browseable index, and the moderation labels
* sit on public relays anyway, so anyone can flip the toggle to see
* what mods have suppressed. The Hidden collapsible below the
* section is still mod-only because it's a review workflow for
* moderators (one-click hide/unhide affordances), not a discovery
* surface.
*/
export function AllCampaignsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
// URL state — sort, query, and country live in the URL so results are
// shareable.
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const sort = parseSort(searchParams.get('sort'));
const urlQuery = searchParams.get('q') ?? '';
const urlCountry = searchParams.get('country') ?? undefined;
// Search input is local-state so typing is responsive; we debounce to
// the URL + the query.
const [searchInput, setSearchInput] = useState(urlQuery);
const debouncedSearch = useDebounce(searchInput, 300);
const [showHidden, setShowHidden] = useState(false);
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const [showHidden, setShowHidden] = useState(false);
// Sync the debounced search → URL. Empty / default values are stripped
// so the canonical URL is `/campaigns/all` (not
// `/campaigns/all?sort=none&q=`).
useEffect(() => {
const next = new URLSearchParams(searchParams);
const trimmed = debouncedSearch.trim();
if (trimmed) next.set('q', trimmed);
else next.delete('q');
// Only replace history when the params actually change, to avoid
// looping when the URL is already in sync.
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
}, [debouncedSearch, searchParams, setSearchParams]);
// Sync URL → input (e.g. browser back/forward or a deep link).
useEffect(() => {
if (urlQuery !== debouncedSearch) {
setSearchInput(urlQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlQuery]);
const setSortFromToolbar = (value: Nip50Sort) => {
const next = new URLSearchParams(searchParams);
const queryValue = toQuerySort(value);
if (queryValue === 'none') next.set('sort', 'none');
else next.delete('sort');
setSearchParams(next, { replace: true });
};
// The country picker also rides the URL so country-scoped views are
// shareable / linkable.
const setCountry = (next: string | undefined) => {
const params = new URLSearchParams(searchParams);
if (next) params.set('country', next);
else params.delete('country');
setSearchParams(params, { replace: true });
};
const { data: campaigns, isLoading } = useAllCampaigns({
sort,
search: debouncedSearch.trim(),
// Mirror the section's underlying query so the Hidden collapsible
// can list the exact set of hidden items matching the current
// search / sort / country. TanStack dedupes; this is a cache read
// on the same key the section uses.
const { data: campaigns } = useAllCampaigns({
sort: toQuerySort(sort),
search: urlQuery,
countryCode: urlCountry,
limit: 200,
});
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
const { data: myCampaigns, isLoading: myCampaignsLoading } = useCampaigns({
authors: user ? [user.pubkey] : undefined,
limit: 100,
enabled: !!user,
});
const featuredCoords = useMemo(() => {
if (!moderationReady) return [] as string[];
return Array.from(moderation.featuredCoords)
.filter((coord) => !moderation.hiddenCoords.has(coord))
.sort((a, b) => (moderation.featuredOrder.get(b) ?? 0) - (moderation.featuredOrder.get(a) ?? 0));
}, [moderation, moderationReady]);
const { data: moderation } = useCampaignModeration();
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns({
coordinates: featuredCoords,
limit: featuredCoords.length || 1,
enabled: moderationReady,
});
const { hiddenCount, hiddenCampaigns } = useMemo(() => {
const all = campaigns ?? [];
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
let count = 0;
const list: ParsedCampaign[] = [];
for (const c of all) {
if (hiddenCoords.has(c.aTag)) {
count += 1;
list.push(c);
}
}
return { hiddenCount: count, hiddenCampaigns: list };
}, [campaigns, moderation]);
useSeoMeta({
title: `${t('campaigns.all.seoTitle')} | ${config.appName}`,
description: t('campaigns.all.description'),
});
const { visible, hiddenCount, hiddenCampaigns } = useMemo(() => {
const all = campaigns ?? [];
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
const featuredCoordSet = new Set(featuredCoords);
let hiddenCount = 0;
const visible: ParsedCampaign[] = [];
const hiddenCampaigns: ParsedCampaign[] = [];
for (const c of all) {
if (hiddenCoords.has(c.aTag)) {
hiddenCount += 1;
hiddenCampaigns.push(c);
if (isMod && showHidden) visible.push(c);
} else if (!featuredCoordSet.has(c.aTag)) {
visible.push(c);
}
}
return { visible, hiddenCount, hiddenCampaigns };
}, [campaigns, featuredCoords, isMod, moderation, showHidden]);
const orderedFeaturedCampaigns = useMemo(() => {
if (!featuredCampaigns) return [] as ParsedCampaign[];
return [...featuredCampaigns].sort(
(a, b) => (moderation.featuredOrder.get(b.aTag) ?? 0) - (moderation.featuredOrder.get(a.aTag) ?? 0),
);
}, [featuredCampaigns, moderation]);
const DEFAULT_VISIBLE = 4;
const [showAllMine, setShowAllMine] = useState(false);
const [showAllFeatured, setShowAllFeatured] = useState(false);
const visibleMine = showAllMine ? (myCampaigns ?? []) : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
const visibleFeatured = showAllFeatured ? orderedFeaturedCampaigns : orderedFeaturedCampaigns.slice(0, DEFAULT_VISIBLE);
const showSkeleton = isLoading || !moderationReady;
const activeQuery = debouncedSearch.trim();
const totalCampaigns = campaigns?.length ?? 0;
return (
<main className="min-h-screen pb-16">
<AllCampaignsHero campaignCount={totalCampaigns} />
<AllCampaignsHero campaignCount={campaigns?.length ?? 0} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-8">
{user && (myCampaignsLoading || (myCampaigns && myCampaigns.length > 0)) && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('campaigns.home.yourCampaigns')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.yourCampaignsDesc')}
</p>
</div>
{myCampaignsLoading && !myCampaigns ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <CampaignCardSkeleton key={i} />)}
</div>
) : (
<CampaignSection
campaigns={visibleMine}
total={myCampaigns?.length ?? 0}
visible={DEFAULT_VISIBLE}
showAll={showAllMine}
onToggle={() => setShowAllMine(!showAllMine)}
/>
)}
</section>
)}
{/* Curated topic-list strip. Moderators can create/edit/reorder
lists here; non-moderators see only the published lists (or
nothing, if none exist yet). Replaces the previous "Your
campaigns" shelf campaign authors can still find their own
campaigns via their profile page. */}
<CampaignListsStrip />
{(featuredLoading || orderedFeaturedCampaigns.length > 0) && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('campaigns.home.featured')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.featuredDesc', { appName: config.appName })}
</p>
</div>
{featuredLoading && orderedFeaturedCampaigns.length === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <CampaignCardSkeleton key={i} />)}
</div>
) : (
<CampaignSection
campaigns={visibleFeatured}
total={orderedFeaturedCampaigns.length}
visible={DEFAULT_VISIBLE}
showAll={showAllFeatured}
onToggle={() => setShowAllFeatured(!showAllFeatured)}
/>
)}
</section>
)}
<CampaignsDiscoverySection
filterPersistence="url"
showHidden={{
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
}}
/>
{/* Section heading matches the `/pledges` and `/groups` pages
so the discovery surfaces all share the same large-bold
section header pattern. Title switches between Search / Top /
New based on toolbar state; tagline stays constant.
Search input + filter button cluster on the right, paired
with the heading on the left in a single row. */}
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{activeQuery
? t('common.search')
: t('campaigns.all.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{activeQuery
? t('common.searchResultsCount', { count: visible.length })
: t('campaigns.all.sectionTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={toToolbarSort(sort)}
onSortChange={setSortFromToolbar}
sortOptions={['top', 'new']}
searchPlaceholderKey="campaigns.all.searchPlaceholder"
searchAriaLabelKey="campaigns.all.searchAriaLabel"
showHidden={isMod ? {
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
} : undefined}
country={urlCountry}
onCountryChange={setCountry}
/>
</div>
{/* Grid widens to 3 columns at lg and 4 at xl so desktop users
can scan more campaigns at once, matching the Pledge index's
card density. Mobile and small tablets stay single / double
column so the cards keep their tappable size. */}
{showSkeleton ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
) : visible.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div className="space-y-1.5">
{activeQuery ? (
<>
<h2 className="text-lg font-semibold">
{t('campaigns.all.noMatch', { query: activeQuery })}
</h2>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.noMatchHint')}
</p>
</>
) : hiddenCount > 0 && !showHidden ? (
<>
<h2 className="text-lg font-semibold">{t('campaigns.all.allHidden')}</h2>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.allHiddenHint')}
</p>
</>
) : (
<>
<h2 className="text-lg font-semibold">{t('campaigns.all.empty')}</h2>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.all.emptyHint')}
</p>
</>
)}
</div>
<Button asChild>
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.all.startCampaign')}
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{visible.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
{/* Moderator-only: every hidden campaign on the network. Mirrors
the section on `/campaigns` so moderators see the same
"Hidden" affordance whether they're browsing the curated
home or the full index. */}
{/* Moderator-only: every hidden campaign on the network matching
the current section filters. The section drops hidden items
from its main grid unless the toolbar's Show-hidden switch
is on; this collapsible always exposes them so a moderator
can act on hidden coords without flipping the visibility
mode. */}
{isMod && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
isLoading={showSkeleton}
isLoading={!moderation}
emptyText={t('campaigns.home.hiddenEmpty')}
skeleton={
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <CampaignCardSkeleton key={i} />)}
{Array.from({ length: 4 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
}
>
@@ -361,7 +141,6 @@ export function AllCampaignsPage() {
</div>
</ModeratorCollapsibleSection>
)}
</div>
</main>
);
@@ -369,54 +148,6 @@ export function AllCampaignsPage() {
export default AllCampaignsPage;
function CampaignSection({
campaigns,
total,
visible,
showAll,
onToggle,
}: {
campaigns: ParsedCampaign[];
total: number;
visible: number;
showAll: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{campaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
{total > visible && (
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={onToggle}
className="rounded-full text-sm"
aria-expanded={showAll}
>
{showAll ? (
<>
<ChevronUp className="size-4 mr-1.5" />
{t('common.showLess')}
</>
) : (
<>
<ChevronDown className="size-4 mr-1.5" />
{t('groups.list.showMore', { count: total - visible })}
</>
)}
</Button>
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
@@ -491,7 +222,10 @@ function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) {
aria-live="polite"
>
<div className="flex items-center justify-center gap-3">
<HandHeart className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
<HandHeart
className="size-5 text-amber-200 shrink-0 drop-shadow"
aria-hidden
/>
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{campaignCount.toLocaleString()}
</span>
@@ -515,10 +249,10 @@ function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) {
'motion-safe:transition-colors motion-safe:duration-200',
)}
>
<Link to="/campaigns/new">
<StartCampaignLink>
<PlusCircle className="mr-2" />
{t('campaigns.all.startCampaign')}
</Link>
</StartCampaignLink>
</Button>
</div>
</div>
+555
View File
@@ -0,0 +1,555 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Loader2, MoreVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { ListFormDialog } from '@/components/campaign-lists/ListFormDialog';
import { AddCampaignToListDialog } from '@/components/campaign-lists/AddCampaignToListDialog';
import { LucideIcon } from '@/components/LucideIcon';
import { useCampaignList } from '@/hooks/useCampaignLists';
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAppContext } from '@/hooks/useAppContext';
import { toast } from '@/hooks/useToast';
import { Link } from 'react-router-dom';
import type { ParsedCampaign } from '@/lib/campaign';
import { cn } from '@/lib/utils';
import NotFound from './NotFound';
const DRAG_MIME = 'text/x-agora-campaign-list-member';
/**
* Detail page for a single campaign list. Shows the list's title and
* description as a header, the list's campaigns in moderator-defined
* order, and (for moderators) controls to edit the list metadata,
* delete the list, add campaigns to it, remove campaigns from it, and
* reorder the membership via drag-and-drop on desktop or the kebab menu
* on mobile.
*
* Hidden campaigns (those carrying a `hidden` label per
* `useCampaignModeration`) are filtered out for non-moderator viewers
* the same way they are everywhere else. Moderators still see them so
* they can decide to unhide or remove from the list.
*/
export function CampaignListDetailPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { config } = useAppContext();
const { list, isLoading } = useCampaignList(slug);
const actions = useCampaignListActions();
const isMobile = useIsMobile();
const { data: moderation } = useCampaignModeration();
const coords = useMemo(() => list?.coords ?? [], [list]);
const { data: campaigns, isLoading: campaignsLoading } = useCampaigns({
coordinates: coords,
enabled: !!list,
});
// Build a coord -> campaign map and emit the list in MEMBERSHIP order
// (the list's `coords` array is authoritative for display order;
// `useCampaigns` returns them in `created_at` order which we override).
const ordered = useMemo<ParsedCampaign[]>(() => {
if (!campaigns || campaigns.length === 0) return [];
const byCoord = new Map(campaigns.map((c) => [c.aTag, c]));
const out: ParsedCampaign[] = [];
const hiddenSet = moderation?.hiddenCoords ?? new Set<string>();
for (const coord of coords) {
const found = byCoord.get(coord);
if (!found) continue;
// Non-moderators: drop hidden campaigns. Moderators see everything.
if (!actions.isMod && hiddenSet.has(coord)) continue;
out.push(found);
}
return out;
}, [campaigns, coords, moderation, actions.isMod]);
const [editOpen, setEditOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [optimisticOrder, setOptimisticOrder] = useState<readonly string[] | null>(null);
const displayedCoords = useMemo(() => {
if (optimisticOrder) {
const known = new Set(coords);
const filtered = optimisticOrder.filter((c) => known.has(c));
if (filtered.length === coords.length) return filtered;
}
return coords;
}, [optimisticOrder, coords]);
// Apply optimistic order to the displayed campaigns.
const displayedCampaigns = useMemo<ParsedCampaign[]>(() => {
if (!optimisticOrder) return ordered;
const byCoord = new Map(ordered.map((c) => [c.aTag, c]));
const out: ParsedCampaign[] = [];
for (const coord of displayedCoords) {
const found = byCoord.get(coord);
if (found) out.push(found);
}
return out;
}, [ordered, optimisticOrder, displayedCoords]);
// Drop the optimistic override once authoritative matches.
if (
optimisticOrder &&
coords.length === optimisticOrder.length &&
coords.every((c, i) => c === optimisticOrder[i])
) {
queueMicrotask(() => setOptimisticOrder(null));
}
const reorderWithOptimism = useCallback(
async (newOrder: string[]) => {
if (!slug) return;
const prev = optimisticOrder;
setOptimisticOrder(newOrder);
try {
await actions.reorderCampaignsInList(slug, newOrder);
} catch (err) {
setOptimisticOrder(prev);
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('moderation.menu.failedReorder'),
description: msg,
variant: 'destructive',
});
}
},
[slug, actions, optimisticOrder, t],
);
const moveTo = useCallback(
(coord: string, toIndex: number) => {
const current = displayedCoords;
const fromIndex = current.indexOf(coord);
if (fromIndex < 0 || fromIndex === toIndex) return;
const next = [...current];
next.splice(fromIndex, 1);
next.splice(toIndex, 0, coord);
void reorderWithOptimism(next);
},
[displayedCoords, reorderWithOptimism],
);
const handleRemove = useCallback(
async (coord: string) => {
if (!slug) return;
try {
await actions.removeCampaignFromList(slug, coord);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('campaigns.lists.removeFailed'),
description: msg,
variant: 'destructive',
});
}
},
[slug, actions, t],
);
const handleEditSubmit = useCallback(
async (values: { title: string; description?: string; icon: string }) => {
if (!slug) return;
await actions.updateListMeta({
slug,
title: values.title,
description: values.description,
icon: values.icon,
});
},
[slug, actions],
);
const handleDeleteConfirm = async () => {
if (!slug) return;
try {
await actions.deleteList(slug);
setDeleteOpen(false);
navigate('/campaigns');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({
title: t('campaigns.lists.deleteFailed'),
description: msg,
variant: 'destructive',
});
}
};
useSeoMeta({
title: list
? `${list.title} | ${config.appName}`
: `${t('campaigns.lists.detailTitle')} | ${config.appName}`,
description: list?.description,
});
if (!slug) return <NotFound />;
if (isLoading) {
return (
<main className="min-h-screen pb-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14">
<div className="flex items-center justify-center py-24">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
</div>
</main>
);
}
if (!list) return <NotFound />;
const visibleCount = displayedCampaigns.length;
const isLoadingCampaigns = campaignsLoading && coords.length > 0;
return (
<main className="min-h-screen pb-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8 lg:py-12 space-y-8">
<header className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to="/campaigns">
<ArrowLeft className="size-4 mr-1.5" />
{t('campaigns.lists.backToCampaigns')}
</Link>
</Button>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1 min-w-0">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight flex items-center gap-3">
<span className="inline-flex size-10 sm:size-12 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<LucideIcon name={list.icon} className="size-5 sm:size-6" />
</span>
<span className="break-words">{list.title}</span>
</h1>
{list.description && (
<p className="text-sm sm:text-base text-muted-foreground max-w-2xl">
{list.description}
</p>
)}
<p className="text-xs text-muted-foreground pt-1">
{t('campaigns.lists.campaignsCount', { count: visibleCount })}
</p>
</div>
{actions.isMod && (
<div className="flex items-center gap-2">
<Button
onClick={() => setAddOpen(true)}
variant="outline"
size="sm"
>
<Plus className="size-4 mr-1.5" />
{t('campaigns.lists.addCampaign')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('campaigns.lists.listActions')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<Pencil className="size-4 mr-2" />
{t('campaigns.lists.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="size-4 mr-2" />
{t('campaigns.lists.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</header>
{isLoadingCampaigns && displayedCampaigns.length === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: Math.min(4, coords.length) }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
) : displayedCampaigns.length === 0 ? (
<EmptyState
isMod={actions.isMod}
onAddClick={() => setAddOpen(true)}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{displayedCampaigns.map((campaign, idx) => (
<ListMemberCard
key={campaign.aTag}
campaign={campaign}
index={idx}
isMod={actions.isMod}
isMobile={isMobile}
onDropAt={(coord) => moveTo(coord, idx)}
onMoveToTop={() => moveTo(campaign.aTag, 0)}
onMoveUp={() => moveTo(campaign.aTag, Math.max(0, idx - 1))}
onMoveDown={() => moveTo(campaign.aTag, idx + 1)}
onRemove={() => handleRemove(campaign.aTag)}
canMoveUp={idx > 0}
canMoveDown={idx < displayedCampaigns.length - 1}
/>
))}
</div>
)}
</div>
<ListFormDialog
open={editOpen}
onOpenChange={setEditOpen}
mode="edit"
initial={{
title: list.title,
description: list.description,
icon: list.icon,
}}
onSubmit={handleEditSubmit}
/>
<AddCampaignToListDialog
open={addOpen}
onOpenChange={setAddOpen}
slug={slug}
existingCoords={coords}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('campaigns.lists.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('campaigns.lists.deleteConfirmDesc', { title: list.title })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void handleDeleteConfirm();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
export default CampaignListDetailPage;
function EmptyState({
isMod,
onAddClick,
}: {
isMod: boolean;
onAddClick: () => void;
}) {
const { t } = useTranslation();
return (
<div className="rounded-2xl border border-dashed py-12 px-8 text-center space-y-3">
<p className="text-muted-foreground max-w-sm mx-auto">
{isMod
? t('campaigns.lists.emptyMod')
: t('campaigns.lists.empty')}
</p>
{isMod && (
<Button onClick={onAddClick} variant="outline" size="sm">
<Plus className="size-4 mr-1.5" />
{t('campaigns.lists.addCampaign')}
</Button>
)}
</div>
);
}
interface ListMemberCardProps {
campaign: ParsedCampaign;
index: number;
isMod: boolean;
isMobile: boolean;
onDropAt: (sourceCoord: string) => void;
onMoveToTop: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}
/**
* Wraps a `CampaignCard` with the moderator-only DnD + kebab overlay
* for in-list reordering and removal. The DnD MIME type is distinct
* from the Featured-row MIME so a drag started in the Featured grid
* can't accidentally drop on a list-member card and vice versa.
*/
function ListMemberCard({
campaign,
index,
isMod,
isMobile,
onDropAt,
onMoveToTop,
onMoveUp,
onMoveDown,
onRemove,
canMoveUp,
canMoveDown,
}: ListMemberCardProps) {
const { t } = useTranslation();
const [isOver, setIsOver] = useState(false);
if (!isMod) {
return <CampaignCard campaign={campaign} />;
}
const desktopDropHandlers = isMobile
? {}
: {
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(DRAG_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!isOver) setIsOver(true);
},
onDragLeave: () => setIsOver(false),
onDrop: (e: React.DragEvent) => {
const sourceCoord = e.dataTransfer.getData(DRAG_MIME);
setIsOver(false);
if (!sourceCoord || sourceCoord === campaign.aTag) return;
e.preventDefault();
onDropAt(sourceCoord);
},
};
return (
<div
className={cn(
'relative group/list-member motion-safe:transition-shadow',
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background rounded-xl shadow-lg',
)}
{...desktopDropHandlers}
>
{!isMobile && (
<DragHandle
coord={campaign.aTag}
index={index}
mimeType={DRAG_MIME}
ariaLabel={t('moderation.menu.dragHandle', { index: index + 1 })}
/>
)}
<div className="absolute top-3 right-3 z-20 opacity-0 group-hover/list-member:opacity-100 focus-within:opacity-100 motion-safe:transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('campaigns.lists.memberMenuAria')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={!canMoveUp} onSelect={() => onMoveToTop()}>
{t('moderation.menu.moveToTop')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!canMoveUp} onSelect={() => onMoveUp()}>
{t('moderation.menu.moveUp')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!canMoveDown} onSelect={() => onMoveDown()}>
{t('moderation.menu.moveDown')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRemove()}
className="text-destructive focus:text-destructive"
>
{t('campaigns.lists.removeFromList')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CampaignCard campaign={campaign} />
</div>
);
}
interface DragHandleProps {
coord: string;
index: number;
mimeType: string;
ariaLabel: string;
}
function DragHandle({ coord, index: _index, mimeType, ariaLabel }: DragHandleProps): ReactNode {
return (
<div
role="button"
tabIndex={0}
draggable
aria-label={ariaLabel}
title={ariaLabel}
onDragStart={(e) => {
e.dataTransfer.setData(mimeType, coord);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
}
}}
className="absolute top-3 left-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-md bg-background/80 backdrop-blur text-muted-foreground opacity-0 group-hover/list-member:opacity-100 focus-visible:opacity-100 hover:text-foreground cursor-grab active:cursor-grabbing motion-safe:transition-opacity"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
<circle cx="5" cy="3" r="1.4" />
<circle cx="11" cy="3" r="1.4" />
<circle cx="5" cy="8" r="1.4" />
<circle cx="11" cy="8" r="1.4" />
<circle cx="5" cy="13" r="1.4" />
<circle cx="11" cy="13" r="1.4" />
</svg>
</div>
);
}
File diff suppressed because it is too large Load Diff
+91 -301
View File
@@ -7,32 +7,41 @@ import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, PlusCircle, Users }
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import {
CommunityMiniCard,
CommunityMiniCardSkeleton,
} from '@/components/discovery/CommunityMiniCard';
import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { COOL_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDebounce } from '@/hooks/useDebounce';
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { useToast } from '@/hooks/useToast';
import { useUserOrganizations } from '@/hooks/useUserOrganizations';
import { hasAgoraTag } from '@/lib/agoraNoteTags';
import { formatSatsShort } from '@/lib/formatCampaignAmount';
import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, type ParsedCommunity } from '@/lib/communityUtils';
// ─── Page ──────────────────────────────────────────────────────────────────────
import type { ParsedCommunity } from '@/lib/communityUtils';
/**
* Dedicated `/groups` page.
*
* Thin shell around the shared {@link GroupsDiscoverySection}: hero,
* optional "My groups" shelf, the unified search-and-discover
* section, and a moderator-only Hidden collapsible.
*
* URL state (`?q=&sort=`) lives inside the section's
* `useDiscoveryFilters` hook so search results stay shareable. The
* page only owns the Show-hidden flag and the moderator-only data
* needed for the Hidden collapsible.
*/
export function CommunitiesPage() {
const { t } = useTranslation();
const { config } = useAppContext();
@@ -63,198 +72,46 @@ export function CommunitiesPage() {
navigate('/groups/new');
};
// On-page NIP-50 search + sort + show-hidden toolbar state.
//
// Default sort, empty query → curated "My groups" / "Featured" /
// moderator shelves below.
// Default sort, with query → relay search for kind 34550, results
// post-filtered against name/description/content client-side.
// Top / New → always active. Top sends `sort:top`;
// New sends a raw chronological feed of the kind.
//
// Groups aren't country-scoped on the discovery surface (a community
// is its own scope), so the country picker is intentionally omitted
// from the toolbar here even though Campaigns and Pledges expose it.
const [searchInput, setSearchInput] = useState('');
const [sortMode, setSortMode] = useState<Nip50Sort>('default');
const [showHidden, setShowHidden] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
const trimmedSearch = debouncedSearch.trim();
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<ParsedCommunity>({
kind: COMMUNITY_DEFINITION_KIND,
query: debouncedSearch,
sort: sortMode,
parse: parseCommunityEvent,
// Group names and descriptions live in tags, not `content`. Relay
// NIP-50 implementations that only match content silently miss
// obvious title hits — widen client-side by also checking these
// tag values.
getKeywordHaystack: (event) => {
const name = event.tags.find(([n]) => n === 'name')?.[1] ?? '';
const description = event.tags.find(([n]) => n === 'description')?.[1] ?? '';
return [name, description, event.content];
},
// Moderator-only: fetch the full kind-34550 universe so we can list
// hidden groups and surface a hidden-count badge on the toolbar.
// Non-moderators don't need this query — the section drives the
// public idle/active grids straight from featured + search.
const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({
limit: 200,
enabled: isMod,
});
// Lift org moderation to the page so search results can drop hidden
// groups (or include them when the Show-hidden switch is on). The
// Hidden ModeratorCollapsibleSection below derives its data from the
// same `allOrgs` fetch, so no additional query round-trip is needed.
const { data: orgModeration } = useOrganizationModeration();
const { searchHits, searchHiddenCount } = useMemo(() => {
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
const { hiddenGroups, hiddenCount } = useMemo(() => {
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
let hidden = 0;
const visible: ParsedCommunity[] = [];
for (const c of searchHitsRaw) {
if (hiddenCoords.has(c.aTag)) {
hidden += 1;
if (showHidden) visible.push(c);
} else {
visible.push(c);
}
}
return { searchHits: visible, searchHiddenCount: hidden };
}, [searchHitsRaw, orgModeration, showHidden]);
const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({ limit: 200 });
const { allGroups, allHiddenCount, hiddenGroups } = useMemo(() => {
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
const featuredCoords = orgModeration?.featuredCoords ?? new Set<string>();
let hidden = 0;
const visible: ParsedCommunity[] = [];
const hiddenList: ParsedCommunity[] = [];
const list: ParsedCommunity[] = [];
for (const org of allOrgs ?? []) {
if (hiddenCoords.has(org.aTag)) {
hidden += 1;
hiddenList.push(org);
if (isMod && showHidden) visible.push(org);
} else if (hasAgoraTag(org.tags) && !featuredCoords.has(org.aTag)) {
visible.push(org);
}
if (hiddenCoords.has(org.aTag)) list.push(org);
}
return { allGroups: visible, allHiddenCount: hidden, hiddenGroups: hiddenList };
}, [allOrgs, isMod, orgModeration, showHidden]);
// Search + sort + show-hidden cluster for the All section.
const searchToolbar = (
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
searchPlaceholderKey="groups.list.searchPlaceholder"
searchAriaLabelKey="groups.list.searchAriaLabel"
showHidden={isMod ? {
value: showHidden,
onChange: setShowHidden,
count: isSearching ? searchHiddenCount : allHiddenCount,
} : undefined}
/>
);
return { hiddenGroups: list, hiddenCount: list.length };
}, [allOrgs, orgModeration]);
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<CommunitiesHero onCreateCommunity={handleCreateCommunity} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 space-y-10 sm:space-y-12 pb-8 pt-10 lg:pt-14">
<MyCommunitiesShelf
userOrganizations={userOrganizations}
<MyCommunitiesShelf userOrganizations={userOrganizations} />
<GroupsDiscoverySection
filterPersistence="url"
showHidden={
isMod
? {
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
}
: undefined
}
/>
<FeaturedOrganizationsShelf />
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch
? t('common.search')
: isSearching && sortMode === 'top'
? t('common.sortTop')
: isSearching && sortMode === 'new'
? t('common.sortNew')
: t('groups.list.allGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{isSearching && searchHits
? t('common.searchResultsCount', { count: searchHits.length })
: t('groups.list.allGroupsTagline')}
</p>
</div>
{searchToolbar}
</div>
{isSearching ? (
<>
{isSearchFetching && !searchHits ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : searchHits && searchHits.length > 0 ? (
<CommunityGrid>
{searchHits.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('groups.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('groups.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
)}
</CardContent>
</Card>
)}
</>
) : allOrgsLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : allGroups.length > 0 ? (
<CommunityGrid>
{allGroups.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
</CardContent>
</Card>
)}
</section>
{isMod && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
@@ -460,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
}
// ═══════════════════════════════════════════════════════════════════════════════
// Community shelves
// "My groups" shelf
// ═══════════════════════════════════════════════════════════════════════════════
type UserOrganizationsResult = ReturnType<typeof useUserOrganizations>;
@@ -472,8 +329,23 @@ function MyCommunitiesShelf({
}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
// "My organizations" = orgs the user founded, moderates, or follows.
// Sorting is founder first, moderator second, followed-only last,
// with newest community definition revisions first inside each
// bucket.
const { data: organizations } = userOrganizations;
const [expanded, setExpanded] = useState(false);
if (!user) return null;
// Suppress the entire section (header + tagline included) until at
// least one group is known. Rendering the header while the query is
// still pending causes a flash when the result resolves to an empty
// list.
if (!organizations || organizations.length === 0) return null;
const COLLAPSED_COUNT = 4;
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
const canExpand = organizations.length > COLLAPSED_COUNT;
return (
<section className="space-y-5">
@@ -485,122 +357,40 @@ function MyCommunitiesShelf({
{t('groups.list.myGroupsTagline')}
</p>
</div>
<MyCommunitiesShelfContent userOrganizations={userOrganizations} />
</section>
);
}
function MyCommunitiesShelfContent({
userOrganizations,
}: {
userOrganizations: UserOrganizationsResult;
}) {
const { t } = useTranslation();
// "My organizations" = orgs the user founded, moderates, or follows.
// Sorting is founder first, moderator second, followed-only last, with
// newest community definition revisions first inside each bucket.
const { data: organizations, isLoading } = userOrganizations;
const [expanded, setExpanded] = useState(false);
if (isLoading) {
return (
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
);
}
if (!organizations || organizations.length === 0) return null;
const COLLAPSED_COUNT = 4;
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
const canExpand = organizations.length > COLLAPSED_COUNT;
return (
<div className="space-y-4">
<CommunityGrid>
{visible.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
{canExpand && (
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={() => setExpanded((v) => !v)}
className="rounded-full text-sm"
aria-expanded={expanded}
>
{expanded ? (
<>
<ChevronUp className="size-4 mr-1.5" />
{t('groups.list.showLess')}
</>
) : (
<>
<ChevronDown className="size-4 mr-1.5" />
{t('groups.list.showMore', { count: organizations.length - COLLAPSED_COUNT })}
</>
)}
</Button>
</div>
)}
</div>
);
}
function FeaturedOrganizationsShelf() {
const { data: featured, isLoading, isPending } = useFeaturedOrganizations();
const hasFeatured = !!featured && featured.length > 0;
if ((isPending || isLoading) && !hasFeatured) {
return (
<section className="space-y-5">
<FeaturedOrganizationsHeading />
<div className="space-y-4">
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
{visible.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
</section>
);
}
if (!hasFeatured) return null;
return (
<section className="space-y-5">
<FeaturedOrganizationsHeading />
<CommunityGrid>
{featured.map((entry) => (
<CommunityMiniCard
key={entry.community.aTag}
community={entry.community}
className="w-full"
/>
))}
</CommunityGrid>
{canExpand && (
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={() => setExpanded((v) => !v)}
className="rounded-full text-sm"
aria-expanded={expanded}
>
{expanded ? (
<>
<ChevronUp className="size-4 mr-1.5" />
{t('groups.list.showLess')}
</>
) : (
<>
<ChevronDown className="size-4 mr-1.5" />
{t('groups.list.showMore', { count: organizations.length - COLLAPSED_COUNT })}
</>
)}
</Button>
</div>
)}
</div>
</section>
);
}
function FeaturedOrganizationsHeading() {
const { t } = useTranslation();
return (
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('groups.list.featuredGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('groups.list.featuredGroupsTagline')}
</p>
</div>
);
}
+269 -332
View File
@@ -1,27 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation, Trans } from 'react-i18next';
import {
AlertTriangle,
ArrowLeft,
Clock,
Loader2,
MapPin,
Megaphone,
Plus,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Clock, Loader2, Megaphone, Plus } from 'lucide-react';
import { CategoryPicker } from '@/components/CategoryPicker';
import { CountrySelect } from '@/components/CountrySelect';
import { CoverImageField } from '@/components/CoverImageField';
import { CountryFlag } from '@/components/CountryFlag';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { Wizard } from '@/components/Wizard';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -32,14 +24,13 @@ import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { usdToSats } from '@/lib/bitcoin';
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
import { parseContentTagInput } from '@/lib/contentTags';
import { CAMPAIGN_CATEGORIES } from '@/lib/campaignCategories';
import { getCountryInfo } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { getTodayDateInput } from '@/lib/dateInput';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { cn } from '@/lib/utils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
export function CreateActionPage() {
@@ -81,14 +72,22 @@ export function CreateActionPage() {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tagInput, setTagInput] = useState('');
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(
() => new Set(),
);
const [pledgeUsd, setPledgeUsd] = useState('');
const [deadline, setDeadline] = useState('');
const [deadlineTime, setDeadlineTime] = useState('');
const [coverImage, setCoverImage] = useState<string>('');
const [coverUploading, setCoverUploading] = useState(false);
const [countryCode, setCountryCode] = useState(pageCountryCode);
const [countryQuery, setCountryQuery] = useState(pageCountryCode ? (getCountryInfo(pageCountryCode)?.subdivisionName ?? getCountryInfo(pageCountryCode)?.name ?? pageCountryCode) : '');
const [countryQuery, setCountryQuery] = useState(
pageCountryCode
? getCountryInfo(pageCountryCode)?.subdivisionName ??
getCountryInfo(pageCountryCode)?.name ??
pageCountryCode
: '',
);
// Effective org coordinate to attach on publish. Sourced only from the
// URL — never editable inside the form. Drops to '' when the user
// isn't authorized for the param's org.
@@ -102,6 +101,18 @@ export function CreateActionPage() {
const minDeadline = useMemo(() => getTodayDateInput(), []);
const toggleCategory = useCallback((slug: string) => {
setSelectedCategories((prev) => {
const next = new Set(prev);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
return next;
});
}, []);
useSeoMeta({
title: `${t('pledges.create.seoTitle')} | ${config.appName}`,
description: t('pledges.create.seoDescription', { appName: config.appName }),
@@ -139,7 +150,14 @@ export function CreateActionPage() {
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const dTag = `${slug || 'pledge'}-${now}`;
const pledgeTags = parseContentTagInput(tagInput);
// Emit categories in CAMPAIGN_CATEGORIES order — the curated
// list is the canonical ordering, easier to reason about in
// cross-client renderers than insertion order. Same posture
// campaigns and groups adopted when their tag inputs were
// swapped for the picker.
const pledgeTags = CAMPAIGN_CATEGORIES
.map((c) => c.slug)
.filter((s) => selectedCategories.has(s));
const tags: string[][] = [
['d', dTag],
@@ -234,325 +252,244 @@ export function CreateActionPage() {
);
}
const canSubmit =
title.trim().length > 0 &&
description.trim().length > 0 &&
pledgeUsd.trim().length > 0 &&
pledgeSatsPreview > 0 &&
!coverUploading &&
!submitMutation.isPending;
// ─── Wizard step bodies ──────────────────────────────────────────────────
return (
<main className="min-h-screen pb-16">
<form
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-5"
onSubmit={(e) => {
e.preventDefault();
setFormError('');
submitMutation.mutate();
}}
>
<div>
<div className="flex items-center gap-2 -ml-2">
<button
type="button"
onClick={() => navigate(-1)}
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
aria-label={t('common.goBack')}
>
<ArrowLeft className="size-5 rtl:rotate-180" />
</button>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('pledges.create.heading')}
</h1>
</div>
<OrganizationContextChip
aTag={organizationATag}
authorizedOrg={authorizedOrgFromParam}
param={orgParam}
paramDecoded={orgFromParam}
manageableLoading={manageableOrgsLoading}
/>
</div>
<div className="rounded-2xl bg-card/50 p-2">
{/* Title */}
<FormSection title={t('forms.title')} requirement="Required">
<Input
placeholder={t('pledges.create.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
required
/>
</FormSection>
{/* Country */}
<FormSection title={t('forms.country')} requirement="Recommended">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined;
const selectedName = selectedCountry?.subdivisionName ?? selectedCountry?.name;
if (selectedCountry && value !== selectedName && value.toUpperCase() !== countryCode) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</FormSection>
{/* Tags */}
<FormSection title={t('forms.tags')} requirement="Recommended">
<Input
id="pledge-tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder={t('pledges.create.tagsPlaceholder')}
/>
</FormSection>
{/* Cover image */}
<FormSection title={t('forms.coverImage')} requirement="Optional">
<CoverImageField
value={coverImage}
onChange={setCoverImage}
onUploadingChange={setCoverUploading}
/>
</FormSection>
{/* Description */}
<FormSection title={t('forms.description')} requirement="Required">
<Textarea
placeholder={t('pledges.create.descriptionPlaceholder')}
rows={7}
className="font-mono text-base md:text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</FormSection>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{/* Pledge amount */}
<FormSection title={t('pledges.create.pledge')} requirement="Required">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
type="text"
inputMode="decimal"
placeholder={t('pledges.create.pledgeAmountPlaceholder')}
value={pledgeUsd}
onChange={(e) => setPledgeUsd(e.target.value)}
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>
</FormSection>
{/* Deadline */}
<FormSection title={t('pledges.create.deadline')} requirement="Optional">
<Input
type="date"
min={minDeadline}
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
/>
{deadline && (
<Input
type="time"
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={deadlineTime}
onChange={(e) => setDeadlineTime(e.target.value)}
/>
)}
</FormSection>
</div>
{deadline && (
<FormSection title={t('forms.timezone')} requirement="Required">
<div className="bg-muted/30 p-3 rounded-lg border border-border/50 space-y-2 animate-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-2 text-sm font-medium">
<Clock className="h-4 w-4" /> {t('forms.timezone')}
</div>
<TimezoneSwitcher value={timezone} onChange={setTimezone} />
<p className="text-xs text-muted-foreground">
{t('pledges.create.timezoneNote')}
</p>
</div>
</FormSection>
)}
</div>
{formError && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<div className="pt-1">
<Button
type="submit"
disabled={!canSubmit}
className="w-full"
>
{submitMutation.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('forms.publishing')}
</>
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('forms.uploadingCover')}
</>
) : (
<>
<Plus className="size-4 mr-2" />
{t('pledges.create.submit')}
</>
)}
</Button>
</div>
</form>
</main>
);
}
function CountrySelect({
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
}: {
query: string;
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined;
const results = useMemo(() => searchCountries(query), [query]);
const showResults = open && results.length > 0;
const selectCountry = (country: CountryEntry) => {
onSelect(country);
setOpen(false);
setSelectedIndex(0);
};
return (
<div className="space-y-2">
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
const titleDescriptionSection = (
<>
<FormSection title={t('forms.title')} requirement="Required">
<Input
id="pledge-country"
value={query}
onChange={(e) => {
onQueryChange(e.target.value);
setOpen(true);
setSelectedIndex(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (!showResults) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
selectCountry(results[selectedIndex]);
} else if (e.key === 'Escape') {
setOpen(false);
placeholder={t('pledges.create.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
required
/>
</FormSection>
<FormSection title={t('forms.description')} requirement="Required">
<Textarea
placeholder={t('pledges.create.descriptionPlaceholder')}
rows={6}
className="font-mono text-base md:text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</FormSection>
</>
);
const pledgeAmountSection = (
<>
<FormSection title={t('pledges.create.pledge')} requirement="Required">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
type="text"
inputMode="decimal"
placeholder={t('pledges.create.pledgeAmountPlaceholder')}
value={pledgeUsd}
onChange={(e) => setPledgeUsd(e.target.value)}
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>
</FormSection>
{/* Deadline sits on the same step as the pledge amount
they answer the same question ("how much, and by when?"),
and a dedicated deadline step felt like padding given how
rarely it's filled in. The timezone subsection still
reveals only once a date is chosen. */}
<FormSection title={t('pledges.create.deadline')} requirement="Optional">
<Input
type="date"
min={minDeadline}
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
/>
{deadline && (
<Input
type="time"
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={deadlineTime}
onChange={(e) => setDeadlineTime(e.target.value)}
/>
)}
</FormSection>
{deadline && (
<FormSection title={t('forms.timezone')} requirement="Required">
<div className="bg-muted/30 p-3 rounded-lg border border-border/50 space-y-2 animate-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-2 text-sm font-medium">
<Clock className="h-4 w-4" /> {t('forms.timezone')}
</div>
<TimezoneSwitcher value={timezone} onChange={setTimezone} />
<p className="text-xs text-muted-foreground">
{t('pledges.create.timezoneNote')}
</p>
</div>
</FormSection>
)}
</>
);
const coverSection = (
<FormSection title={t('forms.coverImage')} requirement="Optional">
<CoverImageField
value={coverImage}
onChange={setCoverImage}
onUploadingChange={setCoverUploading}
/>
</FormSection>
);
const countryTagsSection = (
<>
<FormSection title={t('forms.country')} requirement="Optional">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined;
const selectedName =
selectedCountry?.subdivisionName ?? selectedCountry?.name;
if (
selectedCountry &&
value !== selectedName &&
value.toUpperCase() !== countryCode
) {
setCountryCode('');
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
aria-expanded={showResults}
aria-controls="pledge-country-results"
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
{(query || selectedCode) && (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
aria-label={t('pledges.create.countryClearAria')}
>
<X className="size-4" />
</button>
)}
</FormSection>
{showResults && (
<div
id="pledge-country-results"
role="listbox"
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
>
{results.map((country, index) => (
<button
key={country.code}
type="button"
role="option"
aria-selected={index === selectedIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectCountry(country)}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-secondary/60',
index === selectedIndex && 'bg-secondary/60',
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary leading-none">
<CountryFlag
code={country.code}
emoji={country.flag}
label={t('pledges.create.flagOfAria', { name: country.name })}
className="text-lg"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{country.name}</span>
<span className="block text-xs text-muted-foreground">{country.code}</span>
</span>
</button>
))}
</div>
)}
</div>
<FormSection title={t('forms.tags')} requirement="Optional">
<CategoryPicker selected={selectedCategories} onToggle={toggleCategory} />
</FormSection>
</>
);
{selectedCountry && (
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="pledges.create.countryHint"
values={{ code: selectedCode }}
components={{ 0: <span className="font-mono text-foreground" /> }}
/>
</p>
)}
</div>
// ─── Submit + error chrome ───────────────────────────────────────────────
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setFormError('');
submitMutation.mutate();
};
// Required-field gates for the wizard's Next buttons. Title + description
// sit together on step 1, the pledge amount on step 2. The amount field
// also has to resolve to a positive sats value — without a BTC/USD price
// we can't compute the bounty and the publish will throw.
const titleProvided = title.trim().length > 0;
const descriptionProvided = description.trim().length > 0;
const pledgeProvided = pledgeUsd.trim().length > 0 && pledgeSatsPreview > 0;
const submitting = submitMutation.isPending || coverUploading;
const submitButtonContent = submitMutation.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('forms.publishing')}
</>
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('forms.uploadingCover')}
</>
) : (
<>
<Plus className="size-4 mr-2" />
{t('pledges.create.submit')}
</>
);
// The captive overlay swallows the page chrome, so the org context chip
// needs to ride along inside step 1. Same treatment the campaign wizard
// uses for its "publishing under <org>" affordance.
const orgChip = (
<OrganizationContextChip
aTag={organizationATag}
authorizedOrg={authorizedOrgFromParam}
param={orgParam}
paramDecoded={orgFromParam}
manageableLoading={manageableOrgsLoading}
/>
);
const errorAlert = formError ? (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
) : null;
return (
<Wizard
headingAriaLabel={t('pledges.create.heading')}
step1Lead={orgChip}
steps={[
{
title: t('pledges.create.wizard.titleStepTitle'),
subtitle: t('pledges.create.wizard.titleStepSubtitle'),
body: titleDescriptionSection,
},
{
title: t('pledges.create.wizard.pledgeStepTitle'),
subtitle: t('pledges.create.wizard.pledgeStepSubtitle'),
body: pledgeAmountSection,
},
{
title: t('pledges.create.wizard.coverStepTitle'),
subtitle: t('pledges.create.wizard.coverStepSubtitle'),
body: coverSection,
},
{
title: t('pledges.create.wizard.tagsStepTitle'),
subtitle: t('pledges.create.wizard.tagsStepSubtitle'),
body: countryTagsSection,
},
]}
// Step 1 gates on title + description (both required), step 2
// gates on the pledge amount (required, and must resolve to a
// positive sats value once the BTC/USD price is known). The
// deadline lives on step 2 alongside the amount but isn't
// gated — it's optional. Every step after that is opt-in.
canAdvanceFromStep={(s) => {
if (s === 1) return titleProvided && descriptionProvided;
if (s === 2) return pledgeProvided;
return true;
}}
// The shortcut appears from step 2 onward. On step 2 it shares
// its disabled state with Next via canAdvanceFromStep — the
// button stays grayed out until the pledge amount resolves to
// a positive sats value, then lights up as the user's escape
// hatch out of the remaining optional steps. Step 1 hides it
// because publishing without a title or description would
// trip server-side validation.
launchAvailableFromStep={2}
launchNowLabel={t('pledges.create.wizard.launchNow')}
errorAlert={errorAlert}
submitButtonContent={submitButtonContent}
submitting={submitting}
onSubmit={handleSubmit}
onClose={() => navigate(-1)}
/>
);
}
File diff suppressed because it is too large Load Diff
+307 -303
View File
@@ -1,24 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent,
} from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useTranslation, Trans } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import {
AlertTriangle,
ArrowLeft,
Loader2,
MapPin,
Users,
X,
} from 'lucide-react';
import { AlertTriangle, ArrowLeft, Loader2, Users, X } from 'lucide-react';
import { PersonSearch } from '@/components/PersonSearch';
import { CategoryPicker } from '@/components/CategoryPicker';
import { CountrySelect } from '@/components/CountrySelect';
import { CoverImageField } from '@/components/CoverImageField';
import { CountryFlag } from '@/components/CountryFlag';
import { FormSection } from '@/components/FormSection';
import { PersonSearch } from '@/components/PersonSearch';
import { Wizard } from '@/components/Wizard';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -39,12 +40,15 @@ import {
type ParsedCommunity,
} from '@/lib/communityUtils';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries';
import { parseContentTagInput } from '@/lib/contentTags';
import { getCountryInfo } from '@/lib/countries';
import { getEditableContentTags } from '@/lib/contentTags';
import {
CAMPAIGN_CATEGORIES,
CAMPAIGN_CATEGORY_SLUGS,
} from '@/lib/campaignCategories';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
/**
@@ -141,7 +145,9 @@ export function CreateCommunityPage() {
const [imageUrl, setImageUrl] = useState('');
const [countryCode, setCountryCode] = useState('');
const [countryQuery, setCountryQuery] = useState('');
const [tagInput, setTagInput] = useState('');
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(
() => new Set(),
);
const [coverUploading, setCoverUploading] = useState(false);
// Additional moderators on top of the founder. The founder is implicit —
// they're always pubkey #0 in the published moderator list and are not
@@ -152,8 +158,15 @@ export function CreateCommunityPage() {
// Fetch the existing community when editing.
const editCommunityQuery = useQuery({
queryKey: ['community', editTarget?.pubkey ?? '', editTarget?.identifier ?? '', editTarget?.relays ?? []],
queryFn: async ({ signal }): Promise<{ event: NostrEvent; community: ParsedCommunity } | null> => {
queryKey: [
'community',
editTarget?.pubkey ?? '',
editTarget?.identifier ?? '',
editTarget?.relays ?? [],
],
queryFn: async ({
signal,
}): Promise<{ event: NostrEvent; community: ParsedCommunity } | null> => {
if (!editTarget) return null;
const relayPool = editTarget.relays?.length ? nostr.group(editTarget.relays) : nostr;
const events = await relayPool.query(
@@ -195,10 +208,10 @@ export function CreateCommunityPage() {
const missingPubkeys: string[] = [];
for (const pubkey of editModeratorPubkeys) {
const cachedAuthor = queryClient.getQueryData<{ event?: NostrEvent; metadata?: NostrMetadata }>([
'author',
pubkey,
]);
const cachedAuthor = queryClient.getQueryData<{
event?: NostrEvent;
metadata?: NostrMetadata;
}>(['author', pubkey]);
if (cachedAuthor?.event) {
cachedProfiles.set(pubkey, makeProfileFromAuthor(pubkey, cachedAuthor));
} else {
@@ -244,7 +257,9 @@ export function CreateCommunityPage() {
const activeSlug = editCommunity?.community.dTag ?? derivedSlug;
useSeoMeta({
title: `${isEditMode ? t('groups.create.seoTitleEdit') : t('groups.create.seoTitleCreate')} | ${config.appName}`,
title: `${
isEditMode ? t('groups.create.seoTitleEdit') : t('groups.create.seoTitleCreate')
} | ${config.appName}`,
description: isEditMode
? t('groups.create.seoDescriptionEdit', { appName: config.appName })
: t('groups.create.seoDescriptionCreate', { appName: config.appName }),
@@ -258,8 +273,23 @@ export function CreateCommunityPage() {
setImageUrl(editCommunity.community.image ?? '');
const editCountryCode = editCommunity.community.countryCode ?? '';
setCountryCode(editCountryCode);
setCountryQuery(editCountryCode ? (getCountryInfo(editCountryCode)?.subdivisionName ?? getCountryInfo(editCountryCode)?.name ?? editCountryCode) : '');
setTagInput(editCommunity.community.topicTags.join(', '));
setCountryQuery(
editCountryCode
? getCountryInfo(editCountryCode)?.subdivisionName ??
getCountryInfo(editCountryCode)?.name ??
editCountryCode
: '',
);
// Only pre-select categories that exist in the curated set. Any other
// `t` tags the old free-form input may have published (e.g.
// "mutual-aid") are intentionally dropped from the picker — the user
// would have no way to re-select them, and saving the edit would
// silently re-publish stale tags they can't see. Same posture the
// campaign wizard adopted when its tag input was replaced.
const existingContentTags = getEditableContentTags(editCommunity.event.tags);
setSelectedCategories(
new Set(existingContentTags.filter((tag) => CAMPAIGN_CATEGORY_SLUGS.has(tag))),
);
setModerators(editCommunity.community.moderatorPubkeys.map(makeProfileFromPubkey));
setPrepopulatedEventId(editCommunity.event.id);
}, [editCommunity, prepopulatedEventId]);
@@ -299,6 +329,18 @@ export function CreateCommunityPage() {
setModerators((prev) => prev.filter((m) => m.pubkey !== pubkey));
}, []);
const toggleCategory = useCallback((slug: string) => {
setSelectedCategories((prev) => {
const next = new Set(prev);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
return next;
});
}, []);
const submitMutation = useMutation({
mutationFn: async () => {
if (!user) throw new Error(t('groups.create.errorLoginRequired'));
@@ -326,7 +368,14 @@ export function CreateCommunityPage() {
if (trimmedImageUrl && !sanitizedImage) {
throw new Error(t('groups.create.errorCoverInvalid'));
}
const contentTags = parseContentTagInput(tagInput);
// Emit categories in CAMPAIGN_CATEGORIES order — the curated list
// is the canonical ordering, easier to reason about in
// cross-client renderers than an alphabetized/insertion-order
// dump.
const contentTags = CAMPAIGN_CATEGORIES
.map((c) => c.slug)
.filter((slug) => selectedCategories.has(slug));
// ── Edit branch ────────────────────────────────────────────────────
if (isEditMode && editCommunity) {
@@ -450,7 +499,11 @@ export function CreateCommunityPage() {
queryKey: ['community-activity-feed'],
exact: false,
});
toast({ title: edited ? t('groups.create.successEdit') : t('groups.create.successCreate') });
toast({
title: edited
? t('groups.create.successEdit')
: t('groups.create.successCreate'),
});
navigate(`/${naddr}`);
},
onError: (error: unknown) => {
@@ -464,6 +517,15 @@ export function CreateCommunityPage() {
},
});
const submitting = submitMutation.isPending || coverUploading;
const nameProvided = name.trim().length > 0;
// ─── Pre-wizard guards ─────────────────────────────────────────────────
// The login gate, invalid-edit guard, loading state, and non-owner
// guard render their own page chrome — they're not wizard steps. The
// wizard only mounts once the user is signed in and (in edit mode) we
// have a community they actually own.
if (!user) {
return (
<main className="min-h-screen pb-16">
@@ -495,9 +557,7 @@ export function CreateCommunityPage() {
<CardContent className="py-12 px-8 text-center space-y-4">
<AlertTriangle className="size-10 text-muted-foreground mx-auto" />
<h2 className="text-xl font-semibold">{t('groups.create.invalidEditTitle')}</h2>
<p className="text-muted-foreground">
{t('groups.create.invalidEditBody')}
</p>
<p className="text-muted-foreground">{t('groups.create.invalidEditBody')}</p>
<Button type="button" onClick={() => navigate('/groups/new')}>
{t('groups.create.startNewGroup')}
</Button>
@@ -534,9 +594,7 @@ export function CreateCommunityPage() {
<CardContent className="py-12 px-8 text-center space-y-4">
<AlertTriangle className="size-10 text-muted-foreground mx-auto" />
<h2 className="text-xl font-semibold">{t('groups.create.cannotEditTitle')}</h2>
<p className="text-muted-foreground">
{t('groups.create.cannotEditBody')}
</p>
<p className="text-muted-foreground">{t('groups.create.cannotEditBody')}</p>
<Button type="button" onClick={() => navigate(-1)}>
{t('common.goBack')}
</Button>
@@ -547,17 +605,162 @@ export function CreateCommunityPage() {
);
}
return (
<main className="min-h-screen pb-16">
<form
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-5"
onSubmit={(e) => {
e.preventDefault();
setFormError('');
submitMutation.mutate();
}}
>
<div>
// ─── Wizard step bodies ────────────────────────────────────────────────
// Each section is constructed once and slotted into the wizard's step
// body below. Keeping the JSX up here (rather than inline in the
// `steps` array) makes the wizard call read like a table of contents.
const nameDescriptionSection = (
<>
<FormSection title={t('groups.create.name')} requirement="Required">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('groups.create.namePlaceholder')}
maxLength={100}
required
/>
<p className="text-xs text-muted-foreground">
{t('groups.create.urlPreview')}{' '}
<span className="font-mono text-foreground">
/{activeSlug || t('groups.create.urlPlaceholder')}
</span>
{isEditMode && ` ${t('groups.create.urlKeptOriginal')}`}
</p>
</FormSection>
<FormSection title={t('groups.create.description')} requirement="Recommended">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('groups.create.descriptionPlaceholder')}
rows={4}
/>
</FormSection>
</>
);
const coverSection = (
<FormSection title={t('groups.create.coverImage')} requirement="Recommended">
<CoverImageField
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setCoverUploading}
/>
</FormSection>
);
const moderatorsSection = (
<FormSection title={t('groups.create.moderators')} requirement="Optional">
<div className="space-y-3">
<PersonSearch
onAdd={addModerator}
onAddMany={addModerators}
// Hide the founder and anyone already queued from search
// results so they can't be added twice. The founder isn't
// shown as a chip — they're always implicit.
excludePubkeys={[user.pubkey, ...moderators.map((m) => m.pubkey)]}
/>
{moderators.length > 0 && (
<>
<Label className="text-xs text-muted-foreground">
{t('groups.create.moderatorsCount', { count: moderators.length })}
</Label>
<div className="space-y-1.5">
{moderators.map((moderator) => (
<ModeratorRow
key={moderator.pubkey}
profile={moderator}
onRemove={() => removeModerator(moderator.pubkey)}
/>
))}
</div>
</>
)}
</div>
</FormSection>
);
const countryCategoriesSection = (
<>
<FormSection title={t('groups.create.country')} requirement="Optional">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined;
const selectedName =
selectedCountry?.subdivisionName ?? selectedCountry?.name;
if (
selectedCountry &&
value !== selectedName &&
value.toUpperCase() !== countryCode
) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</FormSection>
<FormSection title={t('groups.create.tags')} requirement="Optional">
<CategoryPicker selected={selectedCategories} onToggle={toggleCategory} />
</FormSection>
</>
);
// ─── Submit + error chrome ─────────────────────────────────────────────
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setFormError('');
submitMutation.mutate();
};
const submitButtonContent = submitMutation.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{isEditMode ? t('groups.create.updating') : t('groups.create.creating')}
</>
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('groups.create.uploadingCover')}
</>
) : (
<>
<Users className="size-4 mr-2" />
{isEditMode ? t('groups.create.submitEdit') : t('groups.create.submitCreate')}
</>
);
const errorAlert = formError ? (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
) : null;
// Edit mode keeps the original single-page form — pre-populated fields
// need to be visible and editable in one place, and the multi-step
// wizard is optimized for a linear first-time flow. Mirrors the same
// create-vs-edit split the campaign flow uses.
if (isEditMode) {
return (
<main className="min-h-screen pb-16">
<form
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-5"
onSubmit={handleSubmit}
>
<div className="flex items-center gap-2 -ml-2">
<button
type="button"
@@ -568,276 +771,77 @@ export function CreateCommunityPage() {
<ArrowLeft className="size-5 rtl:rotate-180" />
</button>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
{isEditMode ? t('groups.create.headingEdit') : t('groups.create.headingCreate')}
{t('groups.create.headingEdit')}
</h1>
</div>
</div>
<div className="rounded-2xl bg-card/50 p-2">
{/* Name */}
<FormSection title={t('groups.create.name')} requirement="Required">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('groups.create.namePlaceholder')}
maxLength={100}
required
/>
<p className="text-xs text-muted-foreground">
{t('groups.create.urlPreview')}{' '}
<span className="font-mono text-foreground">
/{activeSlug || t('groups.create.urlPlaceholder')}
</span>
{isEditMode && ` ${t('groups.create.urlKeptOriginal')}`}
</p>
</FormSection>
<div className="rounded-2xl bg-card/50 p-2">
{nameDescriptionSection}
{countryCategoriesSection}
{coverSection}
{moderatorsSection}
</div>
{/* Description */}
<FormSection title={t('groups.create.description')} requirement="Recommended">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('groups.create.descriptionPlaceholder')}
rows={3}
/>
</FormSection>
{errorAlert}
{/* Country */}
<FormSection title={t('groups.create.country')} requirement="Optional">
<CountrySelect
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined;
const selectedName = selectedCountry?.subdivisionName ?? selectedCountry?.name;
if (selectedCountry && value !== selectedName && value.toUpperCase() !== countryCode) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</FormSection>
<div className="pt-1">
<Button type="submit" disabled={submitting || !nameProvided} className="w-full">
{submitButtonContent}
</Button>
</div>
</form>
</main>
);
}
{/* Tags */}
<FormSection title={t('groups.create.tags')} requirement="Optional">
<Input
id="group-tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder={t('groups.create.tagsPlaceholder')}
/>
</FormSection>
{/* Cover image */}
<FormSection title={t('groups.create.coverImage')} requirement="Recommended">
<CoverImageField
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setCoverUploading}
/>
</FormSection>
{/* Moderators */}
<FormSection title={t('groups.create.moderators')} requirement="Optional">
<div className="space-y-3">
<PersonSearch
onAdd={addModerator}
onAddMany={addModerators}
// Hide the founder and anyone already queued from search
// results so they can't be added twice. The founder isn't
// shown as a chip — they're always implicit.
excludePubkeys={[user.pubkey, ...moderators.map((m) => m.pubkey)]}
/>
{moderators.length > 0 && (
<>
<Label className="text-xs text-muted-foreground">
{t('groups.create.moderatorsCount', { count: moderators.length })}
</Label>
<div className="space-y-1.5">
{moderators.map((moderator) => (
<ModeratorRow
key={moderator.pubkey}
profile={moderator}
onRemove={() => removeModerator(moderator.pubkey)}
/>
))}
</div>
</>
)}
</div>
</FormSection>
</div>
{formError && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<div className="pt-1">
<Button
type="submit"
disabled={submitMutation.isPending || coverUploading || !name.trim() || !activeSlug}
className="w-full"
>
{submitMutation.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{isEditMode ? t('groups.create.updating') : t('groups.create.creating')}
</>
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
{t('groups.create.uploadingCover')}
</>
) : (
<>
<Users className="size-4 mr-2" />
{isEditMode ? t('groups.create.submitEdit') : t('groups.create.submitCreate')}
</>
)}
</Button>
</div>
</form>
</main>
return (
<Wizard
headingAriaLabel={t('groups.create.headingCreate')}
steps={[
{
title: t('groups.create.wizard.nameStepTitle'),
subtitle: t('groups.create.wizard.nameStepSubtitle'),
body: nameDescriptionSection,
},
{
title: t('groups.create.wizard.coverStepTitle'),
subtitle: t('groups.create.wizard.coverStepSubtitle'),
body: coverSection,
},
{
title: t('groups.create.wizard.moderatorsStepTitle'),
subtitle: t('groups.create.wizard.moderatorsStepSubtitle'),
body: moderatorsSection,
},
{
title: t('groups.create.wizard.tagsStepTitle'),
subtitle: t('groups.create.wizard.tagsStepSubtitle'),
body: countryCategoriesSection,
},
]}
// The name field on step 1 is the only required gate — the slug
// is derived from it, and we can't submit without a non-empty
// d-tag. Every other step is optional and advances freely.
canAdvanceFromStep={(s) => (s === 1 ? nameProvided : true)}
// Once name is provided (step 1 cleared) the user has everything
// we need to publish. Surface a "Skip Next & Launch" shortcut on
// step 1 itself so the remaining three steps — cover, moderators,
// country/categories — are explicitly opt-in. The shortcut shares
// its disabled state with Next via `canAdvanceFromStep`, so it
// only lights up once the name field is non-empty.
launchAvailableFromStep={1}
launchNowLabel={t('groups.create.wizard.launchNow')}
errorAlert={errorAlert}
submitButtonContent={submitButtonContent}
submitting={submitting}
onSubmit={handleSubmit}
onClose={() => navigate(-1)}
/>
);
}
// ─── Layout helpers ──────────────────────────────────────────────────────────
function CountrySelect({
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
}: {
query: string;
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined;
const results = useMemo(() => searchCountries(query), [query]);
const showResults = open && results.length > 0;
const selectCountry = (country: CountryEntry) => {
onSelect(country);
setOpen(false);
setSelectedIndex(0);
};
return (
<div className="space-y-2">
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="group-country"
value={query}
onChange={(e) => {
onQueryChange(e.target.value);
setOpen(true);
setSelectedIndex(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (!showResults) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
selectCountry(results[selectedIndex]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
aria-expanded={showResults}
aria-controls="group-country-results"
/>
{(query || selectedCode) && (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
aria-label={t('groups.create.countryClearAria')}
>
<X className="size-4" />
</button>
)}
{showResults && (
<div
id="group-country-results"
role="listbox"
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
>
{results.map((country, index) => (
<button
key={country.code}
type="button"
role="option"
aria-selected={index === selectedIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectCountry(country)}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-secondary/60',
index === selectedIndex && 'bg-secondary/60',
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary leading-none">
<CountryFlag
code={country.code}
emoji={country.flag}
label={t('groups.create.flagOfAria', { name: country.name })}
className="text-lg"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{country.name}</span>
<span className="block text-xs text-muted-foreground">{country.code}</span>
</span>
</button>
))}
</div>
)}
</div>
{selectedCountry && (
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="groups.create.countryHint"
values={{ code: selectedCode }}
components={{ 0: <span className="font-mono text-foreground" /> }}
/>
</p>
)}
</div>
);
}
function ModeratorRow({
profile,
onRemove,
+15 -8
View File
@@ -19,6 +19,7 @@ import {
import { LoginArea } from '@/components/auth/LoginArea';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import {
CommunityMiniCard,
CommunityMiniCardSkeleton,
@@ -32,10 +33,10 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
import { useHdWallet } from '@/hooks/useHdWallet';
import { useNotificationPreview } from '@/hooks/useNotificationPreview';
import { useUserOrganizations, type UserOrganization } from '@/hooks/useUserOrganizations';
@@ -55,7 +56,7 @@ import type { ParsedCampaign } from '@/lib/campaign';
*
* 1. **Personal hero** avatar, greeting, three stat tiles derived from
* already-loaded section data (zero extra queries).
* 2. **Utility strip** wallet balance snapshot (`useHdWallet` + `useBtcPrice`,
* 2. **Utility strip** wallet balance snapshot (`useHdWallet` + `useHdBtcPrice`,
* Blockbook-backed, nsec-only; graceful fallback for other login types) +
* notification preview (`useNotificationPreview`, limit 3 one-shot query,
* no persistent subscription).
@@ -279,14 +280,16 @@ function StatTile({
// ─── Zone 2: Wallet summary ─────────────────────────────────────────────────
/**
* Compact wallet balance card backed by `useHdWallet` (Blockbook) +
* `useBtcPrice` (Esplora). The HD wallet requires an nsec login; for
* extension / bunker logins the card shows a simple "View wallet" prompt
* instead of a balance. The card always links to `/wallet`.
* Compact wallet balance card backed by `useHdWallet` +
* `useHdBtcPrice` (both Blockbook-sourced, matching `/wallet` and the
* top-nav balance pill so all three surfaces show the same USD figure).
* The HD wallet requires an nsec login; for extension / bunker logins the
* card shows a simple "View wallet" prompt instead of a balance. The card
* always links to `/wallet`.
*/
function WalletSummaryCard() {
const { availability, totalBalance, isLoading, error } = useHdWallet();
const { data: btcPrice } = useBtcPrice();
const { data: btcPrice } = useHdBtcPrice();
const walletAvailable = availability.status === 'available';
return (
@@ -674,7 +677,11 @@ function EmptyShelf({
</div>
{ctaLabel && ctaTo && (
<Button asChild className="rounded-full mt-1">
<Link to={ctaTo}>{ctaLabel}</Link>
{ctaTo === '/campaigns/new' ? (
<StartCampaignLink>{ctaLabel}</StartCampaignLink>
) : (
<Link to={ctaTo}>{ctaLabel}</Link>
)}
</Button>
)}
</CardContent>
+246 -27
View File
@@ -1,10 +1,12 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronUp,
Loader2,
Search,
Wallet as WalletIcon,
@@ -13,10 +15,18 @@ import {
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useHdWallet } from '@/hooks/useHdWallet';
@@ -35,6 +45,62 @@ type Step = 'idle' | 'sweeping' | 'success' | 'error';
/** sat/vB — conservative default for the recovery sweep. */
const SWEEP_FEE_RATE = 5;
// ---------------------------------------------------------------------------
// "Since" presets — same pattern as HDSilentPaymentScanDialog
// ---------------------------------------------------------------------------
const PRESETS = {
lastHour: { seconds: 60 * 60 },
last3h: { seconds: 3 * 60 * 60 },
last24h: { seconds: 24 * 60 * 60 },
lastWeek: { seconds: 7 * 24 * 60 * 60 },
lastMonth: { seconds: 30 * 24 * 60 * 60 },
} as const;
type PresetId = keyof typeof PRESETS;
const CUSTOM_SINCE = 'custom' as const;
type SinceId = PresetId | typeof CUSTOM_SINCE;
const PRESET_ORDER: PresetId[] = ['lastHour', 'last3h', 'last24h', 'lastWeek', 'lastMonth'];
const SINCE_ORDER: SinceId[] = [...PRESET_ORDER, CUSTOM_SINCE];
const DEFAULT_SINCE: SinceId = 'lastMonth';
/**
* BIP-113 median-time-past safety margin same 11-block rewind used
* by the regular SP scan dialog to account for out-of-order timestamps.
*/
const TIME_RESOLUTION_SAFETY_BLOCKS = 11;
const MEMPOOL_TIMESTAMP_BLOCK_URL = 'https://mempool.space/api/v1/mining/blocks/timestamp';
interface MempoolTimestampBlockResponse {
height?: unknown;
}
async function fetchMempoolTimestampBlockHeight(cutoffTime: number): Promise<number> {
const response = await fetch(`${MEMPOOL_TIMESTAMP_BLOCK_URL}/${cutoffTime}`);
if (!response.ok) {
throw new Error(`mempool.space timestamp lookup returned ${response.status}`);
}
const data = (await response.json()) as MempoolTimestampBlockResponse;
if (typeof data.height !== 'number' || !Number.isInteger(data.height) || data.height < 0) {
throw new Error('mempool.space timestamp lookup missing valid block height');
}
return data.height;
}
async function resolveWindowFromHeight(
windowSeconds: number,
tipHeight: number,
): Promise<number> {
const cutoffTime = Math.floor(Date.now() / 1000) - windowSeconds;
let boundary = await fetchMempoolTimestampBlockHeight(cutoffTime);
boundary = Math.min(boundary, tipHeight);
return Math.max(0, boundary - TIME_RESOLUTION_SAFETY_BLOCKS);
}
// ---------------------------------------------------------------------------
/**
* Recovery page at `/wallet/double-tweak-fix`.
*
@@ -58,7 +124,13 @@ export function WalletDoubleTweakFixPage() {
const blockbookUrl = (config.blockbookBaseUrl ?? '').trim();
const destinationAddress = wallet.currentReceiveAddress?.address;
const [fromHeight, setFromHeight] = useState(String(recovery.defaultFromHeight));
const [since, setSince] = useState<SinceId>(DEFAULT_SINCE);
const [customHours, setCustomHours] = useState('');
// Pre-populate with the known recovery-era start block so the first scan
// covers every possible stranded output without depending on mempool.space.
const [fromOverride, setFromOverride] = useState(String(recovery.defaultFromHeight));
const [advancedOpen, setAdvancedOpen] = useState(true);
const [isResolvingSince, setIsResolvingSince] = useState(false);
const [step, setStep] = useState<Step>('idle');
const [error, setError] = useState<string | null>(null);
const [txid, setTxid] = useState<string | null>(null);
@@ -69,20 +141,81 @@ export function WalletDoubleTweakFixPage() {
description: t('walletDoubleTweak.seoDescription'),
});
const fromHeightNum = useMemo(() => {
const n = parseInt(fromHeight, 10);
return Number.isInteger(n) && n >= 0 ? n : undefined;
}, [fromHeight]);
// Parse Advanced → From block override.
const overrideTrimmed = fromOverride.trim();
const overrideParsed = overrideTrimmed === '' ? undefined : Number(overrideTrimmed);
const overrideValid =
overrideTrimmed === '' ||
(Number.isInteger(overrideParsed) && (overrideParsed as number) >= 0);
const effectiveFrom = overrideTrimmed !== '' ? overrideParsed : undefined;
// Parse Custom hours input.
const customTrimmed = customHours.trim();
const customParsed = customTrimmed === '' ? undefined : Number(customTrimmed);
const customValid =
customTrimmed === '' ||
(typeof customParsed === 'number' &&
Number.isFinite(customParsed) &&
(customParsed as number) > 0);
const customSeconds =
typeof customParsed === 'number' && customValid && customParsed > 0
? Math.round(customParsed * 60 * 60)
: undefined;
const tipHeight = recovery.tipHeight;
// If the manual override exceeds the tip, there's nothing to scan.
const isManualUpToDate =
tipHeight !== undefined && effectiveFrom !== undefined && effectiveFrom > tipHeight;
const sinceReady = since === CUSTOM_SINCE ? customSeconds !== undefined : true;
const canStart =
overrideValid &&
customValid &&
(overrideTrimmed !== '' ? effectiveFrom !== undefined : tipHeight !== undefined) &&
sinceReady &&
!isManualUpToDate &&
!recovery.isScanning &&
!isResolvingSince;
async function runScan() {
if (fromHeightNum === undefined) return;
if (!canStart) return;
setStep('idle');
setError(null);
setTxid(null);
setSweptSats(null);
// If the user filled in a manual block height override, use it directly.
if (overrideTrimmed !== '') {
if (effectiveFrom === undefined) return;
try {
await recovery.scan({ fromHeight: effectiveFrom });
} catch (err) {
logger.error('[DoubleTweakFix] scan failed', err);
}
return;
}
if (tipHeight === undefined) return;
// Resolve the Since preset / custom hours to a window in seconds.
const windowSeconds =
since === CUSTOM_SINCE ? customSeconds : PRESETS[since].seconds;
if (windowSeconds === undefined) return;
setIsResolvingSince(true);
try {
await recovery.scan({ fromHeight: fromHeightNum });
} catch (err) {
logger.error('[DoubleTweakFix] scan failed', err);
const fromHeight = await resolveWindowFromHeight(windowSeconds, tipHeight);
await recovery.scan({ fromHeight });
} catch {
toast({
title: t('walletDoubleTweak.resolveFailed.title'),
description: t('walletDoubleTweak.resolveFailed.description'),
variant: 'destructive',
});
setAdvancedOpen(true);
} finally {
setIsResolvingSince(false);
}
}
@@ -202,29 +335,114 @@ export function WalletDoubleTweakFixPage() {
<CardDescription>{t('walletDoubleTweak.scan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Primary control: relative time window.
Disabled when the From block override is filled the override
takes priority and this dropdown would be ignored. */}
<div className="space-y-1.5">
<Label htmlFor="dt-from-height" className="text-xs">
{t('walletDoubleTweak.scan.fromHeightLabel')}
<Label htmlFor="dt-scan-since" className="text-xs">
{t('walletDoubleTweak.scan.since')}
</Label>
<Input
id="dt-from-height"
inputMode="numeric"
value={fromHeight}
onChange={(e) => setFromHeight(e.target.value.replace(/[^0-9]/g, ''))}
placeholder={
recovery.defaultFromHeight !== undefined
? String(recovery.defaultFromHeight)
: '—'
}
disabled={recovery.isScanning}
/>
{recovery.tipHeight !== undefined && (
<Select
value={since}
onValueChange={(v) => setSince(v as SinceId)}
disabled={recovery.isScanning || isResolvingSince || overrideTrimmed !== ''}
>
<SelectTrigger id="dt-scan-since">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_ORDER.map((id) => (
<SelectItem key={id} value={id}>
{t(`spScan.preset.${id}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{overrideTrimmed !== '' && (
<p className="text-[11px] text-muted-foreground">
{t('walletDoubleTweak.scan.tipHint', { tip: recovery.tipHeight.toLocaleString() })}
{t('walletDoubleTweak.scan.overrideActive')}
</p>
)}
{since === CUSTOM_SINCE && overrideTrimmed === '' && (
<div className="pt-1.5 space-y-1.5">
<Label htmlFor="dt-scan-custom-hours" className="text-xs">
{t('spScan.customHours')}
</Label>
<Input
id="dt-scan-custom-hours"
type="number"
inputMode="decimal"
min={0}
step="any"
placeholder={t('spScan.customHoursPlaceholder')}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
disabled={recovery.isScanning || isResolvingSince}
aria-invalid={!customValid}
/>
</div>
)}
</div>
{/* Advanced disclosure — From block override for power users. */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer"
>
{advancedOpen ? (
<ChevronUp className="size-3" />
) : (
<ChevronDown className="size-3" />
)}
{t('walletDoubleTweak.scan.advanced')}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label htmlFor="dt-from-block" className="text-xs">
{t('walletDoubleTweak.scan.fromBlock')}
</Label>
<Input
id="dt-from-block"
type="number"
inputMode="numeric"
min={0}
value={fromOverride}
onChange={(e) => setFromOverride(e.target.value)}
disabled={recovery.isScanning || isResolvingSince}
aria-invalid={!overrideValid}
/>
</div>
{tipHeight !== undefined && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.tipHint', { tip: tipHeight.toLocaleString() })}
</p>
)}
{overrideTrimmed !== '' && overrideValid && !isManualUpToDate && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.recoveryWindowHint')}
</p>
)}
{isManualUpToDate && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.upToDate')}
</p>
)}
</CollapsibleContent>
</Collapsible>
{/* Disabled-state hints. */}
{!recovery.isScanning && tipHeight === undefined && overrideTrimmed === '' && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.connectingIndexer')}
</p>
)}
{recovery.isScanning ? (
<div className="space-y-2">
<Button variant="outline" className="w-full" onClick={recovery.cancel}>
@@ -245,8 +463,9 @@ export function WalletDoubleTweakFixPage() {
<Button
className="w-full"
onClick={runScan}
disabled={fromHeightNum === undefined}
disabled={!canStart}
>
{isResolvingSince && <Loader2 className="size-4 animate-spin mr-1.5" />}
<Search className="size-4 mr-1.5" />
{t('walletDoubleTweak.scan.start')}
</Button>
+1
View File
@@ -123,6 +123,7 @@ export function TestApp({ children }: TestAppProps) {
aiApiKey: '',
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
translateWorkerUrl: '',
};
return (