Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71ca2778fd | |||
| 545f288aee | |||
| 0895b763c6 | |||
| 670ef9a3e9 | |||
| be3c6fd3eb | |||
| 1996d960b8 | |||
| 5c9d332d21 | |||
| 86d132ed73 | |||
| c1ace8422b | |||
| e02a008069 | |||
| a4d8bf50e3 |
Generated
+24
-24
@@ -52,8 +52,8 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.52.1",
|
||||
"@nostrify/react": "^0.6.1",
|
||||
"@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",
|
||||
@@ -2506,9 +2506,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify": {
|
||||
"version": "0.52.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.1.tgz",
|
||||
"integrity": "sha512-tnzl7PTXyiZfYd3sTlPzxrZsTs9MxguJqh0ZG6vguUJEUwgHacvFeHXCWWok5CLsbpedYVrO/MpeCV8BqwDVpg==",
|
||||
"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",
|
||||
@@ -2547,11 +2547,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.1.tgz",
|
||||
"integrity": "sha512-+fI4WyWYRLc5YhfGD6HCYmWXe3im35av1+sdaNqToxOZDfs5le/7QoyFQIVAdfLggmM+8ycEZcZfmFoTknbqhg==",
|
||||
"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.1",
|
||||
"@nostrify/nostrify": "0.52.2",
|
||||
"@nostrify/types": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5871,13 +5871,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.24.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
|
||||
"integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==",
|
||||
"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.2",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5897,9 +5897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
|
||||
"integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
|
||||
"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"
|
||||
@@ -5909,12 +5909,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-base64": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.5.tgz",
|
||||
"integrity": "sha512-2J8l+DoX3IIiP75X5SYkJ3mIgOkxW29MxOs7oPjbXLuInQ7UL6zLw2IJHbQ44+eKDBBhTjvt+GgwsTTNBGt8zA==",
|
||||
"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/core": "^3.24.5",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5935,12 +5935,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-hex-encoding": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.3.5.tgz",
|
||||
"integrity": "sha512-+ip3QrXGjDOzV/ciNWPTm6bhJuXjmzugMR19ouXgA26QqhEo0zuXM7pvYE9S4VfX13YmPgSYDPkF4+2bPqIwAg==",
|
||||
"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.5",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+2
-2
@@ -59,8 +59,8 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.52.1",
|
||||
"@nostrify/react": "^0.6.1",
|
||||
"@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",
|
||||
|
||||
@@ -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
|
||||
@@ -151,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]);
|
||||
@@ -287,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">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import {
|
||||
CAMPAIGN_LIST_KIND,
|
||||
CAMPAIGN_LIST_HASHTAG,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
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';
|
||||
@@ -22,15 +22,19 @@ interface UseCampaignListsResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads moderator-curated campaign lists (kind 30003 with the
|
||||
* 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.** The query gates `authors:` on
|
||||
* {@link useCampaignModerators}'s allowlist (Team Soapbox follow pack
|
||||
* members). Without that gate, any pubkey could publish a kind 30003
|
||||
* with our hashtag and appear in the strip — same self-appointment hole
|
||||
* we avoid in `useCampaignModeration`.
|
||||
* **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
|
||||
@@ -38,20 +42,10 @@ interface UseCampaignListsResult {
|
||||
*/
|
||||
export function useCampaignLists() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
|
||||
|
||||
const moderatorsKey = useMemo(
|
||||
() => (moderators ? [...moderators].sort().join(',') : ''),
|
||||
[moderators],
|
||||
);
|
||||
|
||||
const query = useQuery<UseCampaignListsResult>({
|
||||
queryKey: ['campaign-lists', moderatorsKey],
|
||||
enabled: !!moderators && moderators.length > 0,
|
||||
queryKey: ['campaign-lists', LIST_CURATOR_PUBKEY],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { lists: [], indexEvent: undefined };
|
||||
}
|
||||
// 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
|
||||
@@ -61,23 +55,19 @@ export function useCampaignLists() {
|
||||
[
|
||||
{
|
||||
kinds: [CAMPAIGN_LIST_KIND],
|
||||
authors: moderators,
|
||||
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,
|
||||
isLoading: query.isLoading || moderatorsLoading,
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Lookup a single list by slug from the cached collection. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-4
@@ -498,7 +498,9 @@
|
||||
"myWalletDefault": "محفظتي",
|
||||
"walletChoose": "اختر محفظة",
|
||||
"walletCustom": "مخصصة",
|
||||
"walletUseCustom": "استخدم محفظة مخصصة بدلاً من ذلك",
|
||||
"walletUseCustom": "استخدم محفظة أخرى بدلاً من ذلك",
|
||||
"walletDestinationLanding": "ستصل التبرعات هنا",
|
||||
"walletDestinationNote": "سيتم نشر هذه المحفظة كوجهة التبرعات لحملتك.",
|
||||
"walletUseMine": "استخدم محفظة Agora الخاصة بي",
|
||||
"acceptAll": "قبول جميع أنواع الدفع",
|
||||
"acceptPublic": "قبول الدفعات العامة فقط",
|
||||
@@ -581,8 +583,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "سمِّ حملتك",
|
||||
"titleStepSubtitle": "اسم قصير وواضح يتعرّف عليه المتبرعون.",
|
||||
"walletStepTitle": "أين تذهب التبرعات؟",
|
||||
"walletStepSubtitle": "اختر محفظة Agora أو ألصق عنوانك الخاص.",
|
||||
"walletStepTitle": "اختر مَن يتلقى التبرعات",
|
||||
"walletStepSubtitle": "محفظة Agora الخاصة بك جاهزة لتلقّي تبرعات Bitcoin لهذه الحملة.",
|
||||
"bannerStepTitle": "أضف صورة بانر",
|
||||
"bannerStepSubtitle": "صورة واحدة لافتة تُمثّل الحملة في كل بطاقة.",
|
||||
"storyStepTitle": "احكِ قصتك",
|
||||
@@ -744,7 +746,7 @@
|
||||
"wlcDesc": "حملات منتقاة من World Liberty Congress.",
|
||||
"allCampaigns": "كل الحملات",
|
||||
"allCampaignsDesc": "كل الحملات على الشبكة، بالترتيب الزمني.",
|
||||
"browseAll": "تصفّح كل الحملات ←",
|
||||
"browseAll": "تصفّح كل الحملات",
|
||||
"hidden": "مخفية",
|
||||
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
|
||||
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
|
||||
|
||||
+20
-8
@@ -936,7 +936,9 @@
|
||||
"myWalletDefault": "My wallet",
|
||||
"walletChoose": "Choose a wallet",
|
||||
"walletCustom": "Custom wallet",
|
||||
"walletUseCustom": "Use a custom wallet instead",
|
||||
"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",
|
||||
@@ -1019,8 +1021,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Name your campaign",
|
||||
"titleStepSubtitle": "A short, clear name donors will recognize.",
|
||||
"walletStepTitle": "Where do donations go?",
|
||||
"walletStepSubtitle": "Pick your Agora wallet or paste your own address.",
|
||||
"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",
|
||||
@@ -1186,7 +1188,7 @@
|
||||
"wlcDesc": "Campaigns curated by World Liberty Congress.",
|
||||
"allCampaigns": "All campaigns",
|
||||
"allCampaignsDesc": "Every campaign on the network, in chronological order.",
|
||||
"browseAll": "Browse all campaigns →",
|
||||
"browseAll": "Browse all campaigns",
|
||||
"searchPlaceholder": "Search campaigns\u2026",
|
||||
"searchAriaLabel": "Search campaigns",
|
||||
"noMatch": "No campaigns match \u201c{{query}}\u201d",
|
||||
@@ -1654,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",
|
||||
|
||||
+6
-4
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "Mi cartera",
|
||||
"walletChoose": "Elige una cartera",
|
||||
"walletCustom": "Personalizada",
|
||||
"walletUseCustom": "Usar una cartera 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",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Nombra tu campaña",
|
||||
"titleStepSubtitle": "Un nombre corto y claro que los donantes reconocerán.",
|
||||
"walletStepTitle": "¿Adónde van las donaciones?",
|
||||
"walletStepSubtitle": "Elige tu cartera de Agora o pega tu propia direcció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",
|
||||
@@ -756,7 +758,7 @@
|
||||
"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 →",
|
||||
"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.",
|
||||
|
||||
+6
-4
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "کیف پول من",
|
||||
"walletChoose": "یک کیف پول انتخاب کن",
|
||||
"walletCustom": "سفارشی",
|
||||
"walletUseCustom": "به جای آن از کیف پول سفارشی استفاده کن",
|
||||
"walletUseCustom": "به جای آن از کیف پول دیگری استفاده کن",
|
||||
"walletDestinationLanding": "اهداها اینجا میرسند",
|
||||
"walletDestinationNote": "این کیف پول به عنوان مقصد اهداهای کمپین تو منتشر خواهد شد.",
|
||||
"walletUseMine": "از کیف پول Agora من استفاده کن",
|
||||
"acceptAll": "پذیرش همهٔ نوعهای پرداخت",
|
||||
"acceptPublic": "پذیرش فقط پرداختهای عمومی",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "به کمپینت نام بده",
|
||||
"titleStepSubtitle": "نامی کوتاه و روشن که اهداکنندگان بهیاد بسپارند.",
|
||||
"walletStepTitle": "اهداها کجا بروند؟",
|
||||
"walletStepSubtitle": "کیف پول آگورای خودت را انتخاب کن یا نشانی دلخواهت را وارد کن.",
|
||||
"walletStepTitle": "انتخاب کن چه کسی اهداها را دریافت کند",
|
||||
"walletStepSubtitle": "کیف پول آگورای تو آماده دریافت اهداهای Bitcoin برای این کمپین است.",
|
||||
"bannerStepTitle": "یک بنر اضافه کن",
|
||||
"bannerStepSubtitle": "یک تصویر گیرا، کمپین را روی هر کارت همراهی میکند.",
|
||||
"storyStepTitle": "داستانت را تعریف کن",
|
||||
@@ -756,7 +758,7 @@
|
||||
"wlcDesc": "کمپینهای گزینششده توسط کنگرهٔ آزادی جهانی (World Liberty Congress).",
|
||||
"allCampaigns": "همه کمپینها",
|
||||
"allCampaignsDesc": "همه کمپینهای شبکه، به ترتیب زمانی.",
|
||||
"browseAll": "← مرور همه کمپینها",
|
||||
"browseAll": "مرور همه کمپینها",
|
||||
"hidden": "پنهانشده",
|
||||
"hiddenDesc": "کمپینهایی که از صفحه اصلی عمومی حذف شدهاند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
|
||||
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
|
||||
|
||||
+6
-4
@@ -941,7 +941,9 @@
|
||||
"myWalletDefault": "Mon portefeuille",
|
||||
"walletChoose": "Choisir un portefeuille",
|
||||
"walletCustom": "Personnalisé",
|
||||
"walletUseCustom": "Utiliser un portefeuille 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",
|
||||
@@ -1024,8 +1026,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Nommez votre campagne",
|
||||
"titleStepSubtitle": "Un nom court et clair que les donateurs reconnaîtront.",
|
||||
"walletStepTitle": "Où vont les dons ?",
|
||||
"walletStepSubtitle": "Choisissez votre portefeuille Agora ou collez votre propre adresse.",
|
||||
"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",
|
||||
@@ -1187,7 +1189,7 @@
|
||||
"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 →",
|
||||
"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.",
|
||||
|
||||
+6
-4
@@ -942,7 +942,9 @@
|
||||
"myWalletDefault": "मेरा वॉलेट",
|
||||
"walletChoose": "वॉलेट चुनें",
|
||||
"walletCustom": "कस्टम",
|
||||
"walletUseCustom": "इसके बजाय कस्टम वॉलेट का उपयोग करें",
|
||||
"walletUseCustom": "इसके बजाय कोई दूसरा वॉलेट उपयोग करें",
|
||||
"walletDestinationLanding": "डोनेशन यहाँ आएँगे",
|
||||
"walletDestinationNote": "यह वॉलेट आपके कैंपेन के डोनेशन डेस्टिनेशन के रूप में प्रकाशित किया जाएगा।",
|
||||
"walletUseMine": "मेरे Agora वॉलेट का उपयोग करें",
|
||||
"acceptAll": "सभी भुगतान प्रकार स्वीकार करें",
|
||||
"acceptPublic": "केवल सार्वजनिक भुगतान स्वीकार करें",
|
||||
@@ -1025,8 +1027,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "अपने कैंपेन को नाम दें",
|
||||
"titleStepSubtitle": "एक छोटा, स्पष्ट नाम जिसे डोनर पहचान सकें।",
|
||||
"walletStepTitle": "डोनेशन कहाँ जाएँगे?",
|
||||
"walletStepSubtitle": "अपना Agora वॉलेट चुनें या अपना एड्रेस पेस्ट करें।",
|
||||
"walletStepTitle": "चुनें कि डोनेशन कौन प्राप्त करेगा",
|
||||
"walletStepSubtitle": "आपका Agora वॉलेट इस कैंपेन के लिए Bitcoin डोनेशन प्राप्त करने को तैयार है।",
|
||||
"bannerStepTitle": "एक बैनर जोड़ें",
|
||||
"bannerStepSubtitle": "एक प्रभावशाली इमेज हर कार्ड पर कैंपेन को आगे बढ़ाती है।",
|
||||
"storyStepTitle": "अपनी कहानी बताएँ",
|
||||
@@ -1188,7 +1190,7 @@
|
||||
"wlcDesc": "World Liberty Congress द्वारा चुने गए कैंपेन।",
|
||||
"allCampaigns": "सभी कैंपेन",
|
||||
"allCampaignsDesc": "नेटवर्क के सभी कैंपेन, कालक्रम के अनुसार।",
|
||||
"browseAll": "सभी कैंपेन देखें →",
|
||||
"browseAll": "सभी कैंपेन देखें",
|
||||
"hidden": "छुपा हुआ",
|
||||
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
|
||||
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
|
||||
|
||||
+6
-4
@@ -942,7 +942,9 @@
|
||||
"myWalletDefault": "Dompet saya",
|
||||
"walletChoose": "Pilih dompet",
|
||||
"walletCustom": "Kustom",
|
||||
"walletUseCustom": "Gunakan dompet 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",
|
||||
@@ -1025,8 +1027,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Beri nama kampanye Anda",
|
||||
"titleStepSubtitle": "Nama singkat dan jelas yang mudah dikenali donatur.",
|
||||
"walletStepTitle": "Ke mana donasi akan masuk?",
|
||||
"walletStepSubtitle": "Pilih dompet Agora Anda atau tempel alamat Anda sendiri.",
|
||||
"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",
|
||||
@@ -1188,7 +1190,7 @@
|
||||
"wlcDesc": "Kampanye yang dikurasi oleh World Liberty Congress.",
|
||||
"allCampaigns": "Semua kampanye",
|
||||
"allCampaignsDesc": "Semua kampanye di jaringan, dalam urutan kronologis.",
|
||||
"browseAll": "Telusuri semua kampanye →",
|
||||
"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.",
|
||||
|
||||
+6
-4
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "កាបូបរបស់ខ្ញុំ",
|
||||
"walletChoose": "ជ្រើសរើសកាបូប",
|
||||
"walletCustom": "ផ្ទាល់ខ្លួន",
|
||||
"walletUseCustom": "ប្រើកាបូបផ្ទាល់ខ្លួនជំនួសវិញ",
|
||||
"walletUseCustom": "ប្រើកាបូបផ្សេងជំនួសវិញ",
|
||||
"walletDestinationLanding": "ការបរិច្ចាគនឹងមកដល់ទីនេះ",
|
||||
"walletDestinationNote": "កាបូបនេះនឹងត្រូវបានផ្សាយជាគោលដៅនៃការបរិច្ចាគសម្រាប់យុទ្ធនាការរបស់អ្នក។",
|
||||
"walletUseMine": "ប្រើកាបូប Agora របស់ខ្ញុំ",
|
||||
"acceptAll": "ទទួលយកការទូទាត់គ្រប់ប្រភេទ",
|
||||
"acceptPublic": "ទទួលយកការទូទាត់សាធារណៈតែប៉ុណ្ណោះ",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "ដាក់ឈ្មោះយុទ្ធនាការរបស់អ្នក",
|
||||
"titleStepSubtitle": "ឈ្មោះខ្លី ច្បាស់លាស់ ដែលអ្នកបរិច្ចាគនឹងស្គាល់។",
|
||||
"walletStepTitle": "តើការបរិច្ចាគទៅទីណា?",
|
||||
"walletStepSubtitle": "ជ្រើសរើសកាបូប Agora របស់អ្នក ឬបិទភ្ជាប់អាសយដ្ឋានផ្ទាល់ខ្លួន។",
|
||||
"walletStepTitle": "ជ្រើសរើសអ្នកដែលទទួលការបរិច្ចាគ",
|
||||
"walletStepSubtitle": "កាបូប Agora របស់អ្នកត្រៀមរួចរាល់ដើម្បីទទួលការបរិច្ចាគ Bitcoin សម្រាប់យុទ្ធនាការនេះ។",
|
||||
"bannerStepTitle": "បន្ថែមបដា",
|
||||
"bannerStepSubtitle": "រូបភាពគួរឱ្យចាប់អារម្មណ៍មួយ នាំយុទ្ធនាការនៅលើកាតគ្រប់ទីកន្លែង។",
|
||||
"storyStepTitle": "ប្រាប់រឿងរបស់អ្នក",
|
||||
@@ -756,7 +758,7 @@
|
||||
"wlcDesc": "យុទ្ធនាការដែលបានសម្រិតសម្រាំងដោយសភាសេរីភាពពិភពលោក (World Liberty Congress)។",
|
||||
"allCampaigns": "យុទ្ធនាការទាំងអស់",
|
||||
"allCampaignsDesc": "យុទ្ធនាការទាំងអស់នៅលើបណ្តាញ តាមលំដាប់កាលប្បវត្តិ។",
|
||||
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
|
||||
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
|
||||
"hidden": "បានលាក់",
|
||||
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
|
||||
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
|
||||
|
||||
+6
-4
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "زما پاکټ",
|
||||
"walletChoose": "پاکټ وټاکئ",
|
||||
"walletCustom": "ګمرکي",
|
||||
"walletUseCustom": "ګمرکي پاکټ وکاروئ",
|
||||
"walletUseCustom": "پرځای يې بل پاکټ وکاروئ",
|
||||
"walletDestinationLanding": "بسپنې به دلته راشي",
|
||||
"walletDestinationNote": "دا پاکټ به ستاسو د کمپاین د بسپنو د منزل په توګه خپور شي.",
|
||||
"walletUseMine": "زما د اګورا پاکټ وکاروئ",
|
||||
"acceptAll": "د ټولو پیسو ډولونو منل",
|
||||
"acceptPublic": "یوازې د عامه پیسو منل",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "خپل کمپاین ته نوم ورکړئ",
|
||||
"titleStepSubtitle": "لنډ او روښانه نوم چې بسپنه ورکوونکي یې وپېژني.",
|
||||
"walletStepTitle": "بسپنې چېرته ورځي؟",
|
||||
"walletStepSubtitle": "خپل د اګورا پاکټ وټاکئ یا خپله پته ورپېسټ کړئ.",
|
||||
"walletStepTitle": "وټاکئ چې بسپنې به څوک ترلاسه کوي",
|
||||
"walletStepSubtitle": "ستاسو د اګورا پاکټ د دې کمپاین لپاره د Bitcoin بسپنو ترلاسه کولو ته چمتو دی.",
|
||||
"bannerStepTitle": "بنر اضافه کړئ",
|
||||
"bannerStepSubtitle": "یو زړهراښکونکی انځور په هر کارت کې کمپاین ښیي.",
|
||||
"storyStepTitle": "خپله کیسه ووایاست",
|
||||
@@ -756,7 +758,7 @@
|
||||
"wlcDesc": "د World Liberty Congress لخوا غوره شوي کمپاینونه.",
|
||||
"allCampaigns": "ټول کمپاینونه",
|
||||
"allCampaignsDesc": "د شبکې ټول کمپاینونه، د وخت په ترتیب.",
|
||||
"browseAll": "← ټول کمپاینونه وګورئ",
|
||||
"browseAll": "ټول کمپاینونه وګورئ",
|
||||
"hidden": "پټ شوي",
|
||||
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
|
||||
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
|
||||
|
||||
+6
-4
@@ -942,7 +942,9 @@
|
||||
"myWalletDefault": "Minha carteira",
|
||||
"walletChoose": "Escolher uma carteira",
|
||||
"walletCustom": "Personalizada",
|
||||
"walletUseCustom": "Usar uma carteira 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",
|
||||
@@ -1025,8 +1027,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Dê um nome à sua campanha",
|
||||
"titleStepSubtitle": "Um nome curto e claro que os doadores reconhecerão.",
|
||||
"walletStepTitle": "Para onde vão as doações?",
|
||||
"walletStepSubtitle": "Escolha sua carteira Agora ou cole seu próprio endereç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",
|
||||
@@ -1188,7 +1190,7 @@
|
||||
"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 →",
|
||||
"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.",
|
||||
|
||||
+6
-4
@@ -942,7 +942,9 @@
|
||||
"myWalletDefault": "Мой кошелёк",
|
||||
"walletChoose": "Выбрать кошелёк",
|
||||
"walletCustom": "Пользовательский",
|
||||
"walletUseCustom": "Использовать пользовательский кошелёк",
|
||||
"walletUseCustom": "Использовать другой кошелёк",
|
||||
"walletDestinationLanding": "Пожертвования будут поступать сюда",
|
||||
"walletDestinationNote": "Этот кошелёк будет опубликован как адрес для пожертвований вашей кампании.",
|
||||
"walletUseMine": "Использовать мой кошелёк Agora",
|
||||
"acceptAll": "Принимать все типы платежей",
|
||||
"acceptPublic": "Принимать только публичные платежи",
|
||||
@@ -1025,8 +1027,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Назовите свою кампанию",
|
||||
"titleStepSubtitle": "Короткое и понятное название, которое запомнят доноры.",
|
||||
"walletStepTitle": "Куда пойдут пожертвования?",
|
||||
"walletStepSubtitle": "Выберите кошелёк Agora или вставьте свой адрес.",
|
||||
"walletStepTitle": "Выберите, кто получает пожертвования",
|
||||
"walletStepSubtitle": "Ваш кошелёк Agora готов принимать пожертвования в Bitcoin для этой кампании.",
|
||||
"bannerStepTitle": "Добавьте баннер",
|
||||
"bannerStepSubtitle": "Одно яркое изображение представит кампанию на каждой карточке.",
|
||||
"storyStepTitle": "Расскажите свою историю",
|
||||
@@ -1188,7 +1190,7 @@
|
||||
"wlcDesc": "Кампании, отобранные World Liberty Congress.",
|
||||
"allCampaigns": "Все кампании",
|
||||
"allCampaignsDesc": "Все кампании в сети, в хронологическом порядке.",
|
||||
"browseAll": "Просмотреть все кампании →",
|
||||
"browseAll": "Просмотреть все кампании",
|
||||
"hidden": "Скрытые",
|
||||
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
|
||||
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
|
||||
|
||||
+6
-4
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "Chikwama changu",
|
||||
"walletChoose": "Sarudza chikwama",
|
||||
"walletCustom": "Chenyu",
|
||||
"walletUseCustom": "Shandisa chikwama chako pachako",
|
||||
"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",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Tumidza campaign yako",
|
||||
"titleStepSubtitle": "Zita pfupi, riri pachena, ravapi vachaziva.",
|
||||
"walletStepTitle": "Zvipo zvinoenda kupi?",
|
||||
"walletStepSubtitle": "Sarudza chikwama chako cheAgora kana unamatire kero yako.",
|
||||
"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",
|
||||
@@ -756,7 +758,7 @@
|
||||
"wlcDesc": "Mishandirapamwe yakasarudzwa neWorld Liberty Congress.",
|
||||
"allCampaigns": "Mishandirapamwe yose",
|
||||
"allCampaignsDesc": "Mishandirapamwe yose pamutambo, neumboo wenguva.",
|
||||
"browseAll": "Tarisa mishandirapamwe yose →",
|
||||
"browseAll": "Tarisa mishandirapamwe yose",
|
||||
"hidden": "Yakavanzwa",
|
||||
"hiddenDesc": "Mishandirapamwe yakabviswa papeji rekutanga reveruzhinji. Shandisa menu pakadhi kuti uibvise pakuvanzwa.",
|
||||
"hiddenEmpty": "Parizvino hapana mushandirapamwe wakavanzwa.",
|
||||
|
||||
+6
-4
@@ -941,7 +941,9 @@
|
||||
"myWalletDefault": "Pochi yangu",
|
||||
"walletChoose": "Chagua pochi",
|
||||
"walletCustom": "Maalum",
|
||||
"walletUseCustom": "Tumia pochi maalum badala yake",
|
||||
"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",
|
||||
@@ -1024,8 +1026,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Ipe kampeni yako jina",
|
||||
"titleStepSubtitle": "Jina fupi na wazi ambalo wafadhili watalitambua.",
|
||||
"walletStepTitle": "Michango itaenda wapi?",
|
||||
"walletStepSubtitle": "Chagua pochi yako ya Agora au bandika anwani yako mwenyewe.",
|
||||
"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",
|
||||
@@ -1187,7 +1189,7 @@
|
||||
"wlcDesc": "Kampeni zilizoteuliwa na World Liberty Congress.",
|
||||
"allCampaigns": "Kampeni zote",
|
||||
"allCampaignsDesc": "Kampeni zote kwenye mtandao, kwa mpangilio wa wakati.",
|
||||
"browseAll": "Vinjari kampeni zote →",
|
||||
"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.",
|
||||
|
||||
+6
-4
@@ -941,7 +941,9 @@
|
||||
"myWalletDefault": "Cüzdanım",
|
||||
"walletChoose": "Bir cüzdan seçin",
|
||||
"walletCustom": "Özel",
|
||||
"walletUseCustom": "Bunun yerine özel bir cüzdan kullan",
|
||||
"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",
|
||||
@@ -1024,8 +1026,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "Kampanyanıza isim verin",
|
||||
"titleStepSubtitle": "Bağışçıların tanıyacağı kısa ve net bir ad.",
|
||||
"walletStepTitle": "Bağışlar nereye gidecek?",
|
||||
"walletStepSubtitle": "Agora cüzdanınızı seçin ya da kendi adresinizi yapıştırın.",
|
||||
"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",
|
||||
@@ -1187,7 +1189,7 @@
|
||||
"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 →",
|
||||
"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.",
|
||||
|
||||
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "我的錢包",
|
||||
"walletChoose": "選擇錢包",
|
||||
"walletCustom": "自定義",
|
||||
"walletUseCustom": "改用自定義錢包",
|
||||
"walletUseCustom": "改用其他錢包",
|
||||
"walletDestinationLanding": "捐款將會送到這裡",
|
||||
"walletDestinationNote": "這個錢包將會被發佈為你活動的捐款目的地。",
|
||||
"walletUseMine": "使用我的 Agora 錢包",
|
||||
"acceptAll": "接受所有支付型別",
|
||||
"acceptPublic": "僅接受公開支付",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "為你的活動命名",
|
||||
"titleStepSubtitle": "簡短、清晰,讓捐贈者一眼就能認出。",
|
||||
"walletStepTitle": "捐款要送到哪裡?",
|
||||
"walletStepSubtitle": "選擇你的 Agora 錢包,或貼上自己的地址。",
|
||||
"walletStepTitle": "選擇由誰接收捐款",
|
||||
"walletStepSubtitle": "你的 Agora 錢包已準備好為這個活動接收 Bitcoin 捐款。",
|
||||
"bannerStepTitle": "新增橫幅",
|
||||
"bannerStepSubtitle": "一張搶眼的圖片,讓你的活動在每張卡片上脫穎而出。",
|
||||
"storyStepTitle": "說說你的故事",
|
||||
@@ -756,7 +758,7 @@
|
||||
"wlcDesc": "由世界自由大會(World Liberty Congress)精選的活動。",
|
||||
"allCampaigns": "所有活動",
|
||||
"allCampaignsDesc": "網絡上的所有活動,按時間順序排列。",
|
||||
"browseAll": "瀏覽所有活動 →",
|
||||
"browseAll": "瀏覽所有活動",
|
||||
"hidden": "已隱藏",
|
||||
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
|
||||
"hiddenEmpty": "當前沒有被隱藏的活動。",
|
||||
|
||||
+6
-4
@@ -510,7 +510,9 @@
|
||||
"myWalletDefault": "我的钱包",
|
||||
"walletChoose": "选择钱包",
|
||||
"walletCustom": "自定义",
|
||||
"walletUseCustom": "改用自定义钱包",
|
||||
"walletUseCustom": "改用其他钱包",
|
||||
"walletDestinationLanding": "捐款将会送到这里",
|
||||
"walletDestinationNote": "这个钱包将会被发布为你活动的捐款目的地。",
|
||||
"walletUseMine": "使用我的 Agora 钱包",
|
||||
"acceptAll": "接受所有支付类型",
|
||||
"acceptPublic": "仅接受公开支付",
|
||||
@@ -593,8 +595,8 @@
|
||||
"wizard": {
|
||||
"titleStepTitle": "为你的活动起个名字",
|
||||
"titleStepSubtitle": "一个简短清晰、捐赠者易于辨识的名称。",
|
||||
"walletStepTitle": "捐款流向何处?",
|
||||
"walletStepSubtitle": "选择你的 Agora 钱包,或粘贴你自己的地址。",
|
||||
"walletStepTitle": "选择由谁接收捐款",
|
||||
"walletStepSubtitle": "你的 Agora 钱包已准备好为这个活动接收 Bitcoin 捐款。",
|
||||
"bannerStepTitle": "添加横幅",
|
||||
"bannerStepSubtitle": "一张醒目的图片,让活动在每张卡片上脱颖而出。",
|
||||
"storyStepTitle": "讲述你的故事",
|
||||
@@ -756,7 +758,7 @@
|
||||
"wlcDesc": "由世界自由大会(World Liberty Congress)精选的活动。",
|
||||
"allCampaigns": "所有活动",
|
||||
"allCampaignsDesc": "网络上的所有活动,按时间顺序排列。",
|
||||
"browseAll": "浏览所有活动 →",
|
||||
"browseAll": "浏览所有活动",
|
||||
"hidden": "已隐藏",
|
||||
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
|
||||
"hiddenEmpty": "当前没有被隐藏的活动。",
|
||||
|
||||
@@ -22,7 +22,6 @@ import { StartCampaignLink } from '@/components/StartCampaignLink';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCampaignList } from '@/hooks/useCampaignLists';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -127,23 +126,19 @@ export function CampaignsPage() {
|
||||
: { coordinates: [] },
|
||||
);
|
||||
|
||||
// Filter out hidden campaigns and reorder to match the list's
|
||||
// declared order. `useCampaigns` returns events in network order
|
||||
// which we override here so the hero row always reflects the
|
||||
// moderator's intent.
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
// Reorder to match the list's declared order. `useCampaigns` returns
|
||||
// events in network order which we override here so the hero row always
|
||||
// reflects the moderator's intent.
|
||||
const orderedCampaigns = useMemo<ParsedCampaign[]>(() => {
|
||||
if (!heroCampaigns || cappedCoords.length === 0) return [];
|
||||
const hidden = moderation?.hiddenCoords ?? new Set<string>();
|
||||
const byCoord = new Map(heroCampaigns.map((c) => [c.aTag, c]));
|
||||
const out: ParsedCampaign[] = [];
|
||||
for (const coord of cappedCoords) {
|
||||
if (hidden.has(coord)) continue;
|
||||
const found = byCoord.get(coord);
|
||||
if (found) out.push(found);
|
||||
}
|
||||
return out;
|
||||
}, [heroCampaigns, cappedCoords, moderation]);
|
||||
}, [heroCampaigns, cappedCoords]);
|
||||
|
||||
useSeoMeta({
|
||||
title: `${t('campaigns.home.seoTitle')} | ${config.appName}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bitcoin,
|
||||
ChevronDown,
|
||||
HandHeart,
|
||||
HelpCircle,
|
||||
@@ -889,26 +890,34 @@ export function CreateCampaignPage() {
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
const walletPicker = (
|
||||
<WalletPicker
|
||||
hdWalletAvailable={hdWalletAvailable}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
displayName={userDisplayName}
|
||||
picture={userMetadata?.picture}
|
||||
totalBalance={hdWallet.totalBalance}
|
||||
balanceLoading={hdWalletAvailable && hdWallet.isLoading}
|
||||
walletSource={walletSource}
|
||||
onWalletSourceChange={setWalletSource}
|
||||
mineAccept={mineAccept}
|
||||
onMineAcceptChange={setMineAccept}
|
||||
customOnchain={customOnchain}
|
||||
onCustomOnchainChange={setCustomOnchain}
|
||||
parsedCustomOnchain={parsedCustomOnchain}
|
||||
customSp={customSp}
|
||||
onCustomSpChange={setCustomSp}
|
||||
parsedCustomSp={parsedCustomSp}
|
||||
/>
|
||||
);
|
||||
|
||||
// Edit mode keeps the picker inside a titled FormSection so it lines up
|
||||
// with the other single-page sections. The wizard step (below) renders
|
||||
// the picker bare — its centered title/subtitle already frame the step,
|
||||
// so a second "Bitcoin wallet · Required" header would only compete.
|
||||
const walletSection = (
|
||||
<FormSection title={t('campaignsCreate.wallet')} requirement="Required">
|
||||
<WalletPicker
|
||||
hdWalletAvailable={hdWalletAvailable}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
displayName={userDisplayName}
|
||||
picture={userMetadata?.picture}
|
||||
totalBalance={hdWallet.totalBalance}
|
||||
balanceLoading={hdWalletAvailable && hdWallet.isLoading}
|
||||
walletSource={walletSource}
|
||||
onWalletSourceChange={setWalletSource}
|
||||
mineAccept={mineAccept}
|
||||
onMineAcceptChange={setMineAccept}
|
||||
customOnchain={customOnchain}
|
||||
onCustomOnchainChange={setCustomOnchain}
|
||||
parsedCustomOnchain={parsedCustomOnchain}
|
||||
customSp={customSp}
|
||||
onCustomSpChange={setCustomSp}
|
||||
parsedCustomSp={parsedCustomSp}
|
||||
/>
|
||||
{walletPicker}
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
@@ -1166,7 +1175,7 @@ export function CreateCampaignPage() {
|
||||
{
|
||||
title: t('campaignsCreate.wizard.walletStepTitle'),
|
||||
subtitle: t('campaignsCreate.wizard.walletStepSubtitle'),
|
||||
body: walletSection,
|
||||
body: walletPicker,
|
||||
},
|
||||
{
|
||||
title: t('campaignsCreate.wizard.bannerStepTitle'),
|
||||
@@ -1336,54 +1345,77 @@ function WalletPicker({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{walletSource === 'mine' ? (
|
||||
<>
|
||||
{/* Identity + balance row. Pure visual chrome — the swap to
|
||||
custom mode is handled by the "Use a custom wallet"
|
||||
sub-link below so the row reads as confirmation of the
|
||||
destination, not as a tappable target. */}
|
||||
<div className="flex items-center gap-3 px-1 py-2">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{myWalletLabel}</p>
|
||||
{balanceLoading ? (
|
||||
<Skeleton className="mt-1 h-4 w-24" />
|
||||
) : btcPrice ? (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
<span className="font-medium text-foreground">
|
||||
{satsToUSD(totalBalance, btcPrice)}
|
||||
</span>
|
||||
<span className="mx-1.5 opacity-60">·</span>
|
||||
<span>{formatBTC(totalBalance)} BTC</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatBTC(totalBalance)} BTC
|
||||
</p>
|
||||
)}
|
||||
/* One calm card carries the whole decision — identity, the
|
||||
accept-mode choice, a reassuring note, and the soft swap to a
|
||||
custom wallet — so the step reads as a single guided choice
|
||||
rather than a stack of form sections. Mirrors the onboarding
|
||||
"save your key" card's warm primary-tinted treatment so the
|
||||
wallet decision feels just as carefully guided. */
|
||||
<Card className="rounded-xl border-2 border-primary/30 bg-primary/10 shadow-md ring-1 ring-primary/10">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
{/* Identity + balance row. Pure visual chrome — the swap to
|
||||
custom mode is handled by the "Use another wallet"
|
||||
sub-link below so the row reads as confirmation of the
|
||||
destination, not as a tappable target. */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-12 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{myWalletLabel}</p>
|
||||
{balanceLoading ? (
|
||||
<Skeleton className="mt-1 h-4 w-24" />
|
||||
) : btcPrice ? (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
<span className="font-medium text-foreground">
|
||||
{satsToUSD(totalBalance, btcPrice)}
|
||||
</span>
|
||||
<span className="mx-1.5 opacity-60">·</span>
|
||||
<span>{formatBTC(totalBalance)} BTC</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatBTC(totalBalance)} BTC
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary ring-2 ring-primary/20">
|
||||
<Bitcoin className="size-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "Use a custom wallet" sub-link — the only affordance for
|
||||
swapping to custom mode. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWalletSourceChange('custom')}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{t('campaignsCreate.walletUseCustom')}
|
||||
</button>
|
||||
{/* Destination preview inset, in the spirit of the onboarding
|
||||
masked-key panel: a lighter inset on the warm card that
|
||||
states plainly where donations land. */}
|
||||
<div className="rounded-lg border border-primary/20 bg-background/60 p-3 text-center dark:bg-black/20">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('campaignsCreate.walletDestinationLanding')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('campaignsCreate.walletDestinationNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Accept-mode segmented picker. Default 'all' (HD + SP); the
|
||||
non-SP options are only relevant if SP is unsupported. */}
|
||||
<AcceptModePicker
|
||||
value={mineAccept}
|
||||
onChange={onMineAcceptChange}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
/>
|
||||
</>
|
||||
{/* Accept-mode segmented picker. Default 'all' (HD + SP); the
|
||||
non-SP options are only relevant if SP is unsupported. */}
|
||||
<AcceptModePicker
|
||||
value={mineAccept}
|
||||
onChange={onMineAcceptChange}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
/>
|
||||
|
||||
{/* "Use another wallet" — soft secondary action, the only
|
||||
affordance for swapping to custom mode. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWalletSourceChange('custom')}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{t('campaignsCreate.walletUseCustom')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Header — name the current mode, then offer the swap back
|
||||
@@ -1453,6 +1485,13 @@ function AcceptModePicker({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Pills sit on the warm primary-tinted wallet card, so the unselected
|
||||
// state gets an inset surface to stay legible, and the selected state
|
||||
// is a solid primary fill (a faint tint would vanish on the tinted
|
||||
// card). Behavior/labels are unchanged.
|
||||
const acceptItemClass =
|
||||
'h-auto justify-center rounded-full border-primary/20 bg-background/60 px-3 py-2 text-xs font-medium data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground';
|
||||
|
||||
const caption = {
|
||||
all: t('campaignsCreate.acceptAllHint'),
|
||||
public: t('campaignsCreate.acceptPublicHint'),
|
||||
@@ -1477,20 +1516,20 @@ function AcceptModePicker({
|
||||
<ToggleGroupItem
|
||||
value="all"
|
||||
disabled={!silentPaymentSupported}
|
||||
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
|
||||
className={acceptItemClass}
|
||||
>
|
||||
{t('campaignsCreate.acceptAllShort')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="public"
|
||||
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
|
||||
className={acceptItemClass}
|
||||
>
|
||||
{t('campaignsCreate.acceptPublicShort')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="private"
|
||||
disabled={!silentPaymentSupported}
|
||||
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
|
||||
className={acceptItemClass}
|
||||
>
|
||||
{t('campaignsCreate.acceptPrivateShort')}
|
||||
</ToggleGroupItem>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user