Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c06e070cd | |||
| f0c3ff1a80 | |||
| 13a0bb3e3a | |||
| 646ed9777f | |||
| 437613641a | |||
| d0836328a4 | |||
| 123f53e7a6 | |||
| 977fd000ea | |||
| 5132141aa2 | |||
| b6dc57eb85 | |||
| 016a7b4a7d | |||
| 7ae63883e9 | |||
| d4cf4ba0d8 | |||
| 399dc53395 | |||
| 699e505fb5 | |||
| 20839f4de3 | |||
| 4e9da2d168 | |||
| 32b477bd01 | |||
| 564459e12d | |||
| c97d0723a6 | |||
| 53da626461 | |||
| c79699ca71 | |||
| e58c031a85 | |||
| bc80dba826 | |||
| 611f97488e | |||
| a948725245 | |||
| dde9865284 | |||
| 3d825aef04 | |||
| 575603554b | |||
| dfb0a52603 |
@@ -169,6 +169,11 @@ build-apk:
|
||||
|
||||
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
|
||||
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
|
||||
# Pass the alias key password explicitly via -srckeypass / -destkeypass.
|
||||
# The upload key inside the JKS has its own password ($KEY_PASSWORD) that
|
||||
# differs from the store password ($KEYSTORE_PASSWORD); without these flags
|
||||
# keytool prompts for it on a non-interactive runner and dies with
|
||||
# "Too many failures - try later".
|
||||
- keytool -importkeystore
|
||||
-srckeystore android/app/my-upload-key.jks
|
||||
-destkeystore android/app/my-upload-key.keystore
|
||||
@@ -177,6 +182,8 @@ build-apk:
|
||||
-deststorepass "$KEYSTORE_PASSWORD"
|
||||
-srcalias upload
|
||||
-destalias upload
|
||||
-srckeypass "$KEY_PASSWORD"
|
||||
-destkeypass "$KEY_PASSWORD"
|
||||
-noprompt
|
||||
- rm android/app/my-upload-key.jks
|
||||
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.3] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.2] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.1] - 2026-06-02
|
||||
|
||||
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
|
||||
|
||||
### Added
|
||||
|
||||
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
|
||||
- Organizations with their own events, pledges, members, and moderation tools.
|
||||
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
|
||||
- One-tap support: zap posts, profiles, campaigns, and organizations.
|
||||
- AI agent chat with a model selector, tool-calling, and slash commands.
|
||||
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
|
||||
- Comments and reactions on campaigns, and donation receipts shown inline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed Agora branding, navigation, and app icons throughout.
|
||||
- Streamlined onboarding with country and people follows.
|
||||
- Polished campaign, organization, and donation flows end to end.
|
||||
|
||||
### Removed
|
||||
|
||||
- Direct messaging and ephemeral geo chat.
|
||||
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -282,11 +282,11 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
|
||||
|
||||
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
|
||||
|
||||
The kind is addressable so the creator can edit the story, banner, goal, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
The kind is addressable so the creator can edit the story, banner, goal, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
|
||||
### Event Structure
|
||||
|
||||
@@ -315,7 +315,6 @@ The kind is addressable so the creator can edit the story, banner, goal, deadlin
|
||||
["w", "sp1qq...verylongsilentpaymentcode..."],
|
||||
|
||||
["goal", "25000"],
|
||||
["deadline", "1735689600"],
|
||||
|
||||
["i", "iso3166:US"],
|
||||
["k", "iso3166"],
|
||||
@@ -352,7 +351,6 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH
|
||||
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
|
||||
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
|
||||
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
|
||||
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
|
||||
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
|
||||
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.14.4"
|
||||
versionName "2.8.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
MARKETING_VERSION = 2.8.3;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -347,7 +347,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
MARKETING_VERSION = 2.8.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
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": {
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"private": true,
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.3] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.2] - 2026-06-02
|
||||
|
||||
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
|
||||
|
||||
## [2.8.1] - 2026-06-02
|
||||
|
||||
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
|
||||
|
||||
### Added
|
||||
|
||||
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
|
||||
- Organizations with their own events, pledges, members, and moderation tools.
|
||||
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
|
||||
- One-tap support: zap posts, profiles, campaigns, and organizations.
|
||||
- AI agent chat with a model selector, tool-calling, and slash commands.
|
||||
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
|
||||
- Comments and reactions on campaigns, and donation receipts shown inline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed Agora branding, navigation, and app icons throughout.
|
||||
- Streamlined onboarding with country and people follows.
|
||||
- Polished campaign, organization, and donation flows end to end.
|
||||
|
||||
### Removed
|
||||
|
||||
- Direct messaging and ephemeral geo chat.
|
||||
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -17,17 +17,6 @@ import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = unixSeconds - now;
|
||||
if (diff <= 0) return { label: 'Ended', isPast: true };
|
||||
const days = Math.ceil(diff / 86_400);
|
||||
if (days <= 1) return { label: 'Ends today', isPast: false };
|
||||
if (days < 30) return { label: `${days} days left`, isPast: false };
|
||||
const months = Math.round(days / 30);
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
function InlineShell({
|
||||
image,
|
||||
fallbackIcon,
|
||||
@@ -76,7 +65,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
?? sanitizeUrl(authorMetadata?.picture);
|
||||
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier });
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined;
|
||||
const isSilentPayment = !campaign.wallets.onchain;
|
||||
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
@@ -113,16 +101,9 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
|
||||
<CalendarClock className="size-3.5" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,10 @@ interface BitcoinAmountPickerProps {
|
||||
usdAmount: number | string;
|
||||
onUsdAmountChange: (amount: number | string) => void;
|
||||
presets: readonly number[];
|
||||
maxLabel?: string;
|
||||
maxSelected?: boolean;
|
||||
maxDisabled?: boolean;
|
||||
onMaxSelect?: () => void;
|
||||
insufficient?: boolean;
|
||||
satsLabel?: string;
|
||||
onAmountChangeStart?: () => void;
|
||||
@@ -16,6 +20,10 @@ export function BitcoinAmountPicker({
|
||||
usdAmount,
|
||||
onUsdAmountChange,
|
||||
presets,
|
||||
maxLabel = 'MAX',
|
||||
maxSelected = false,
|
||||
maxDisabled = false,
|
||||
onMaxSelect,
|
||||
insufficient = false,
|
||||
satsLabel,
|
||||
onAmountChangeStart,
|
||||
@@ -74,14 +82,25 @@ export function BitcoinAmountPicker({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmount(true)}
|
||||
onClick={() => {
|
||||
onAmountChangeStart?.();
|
||||
setEditingAmount(true);
|
||||
}}
|
||||
aria-label="Edit amount"
|
||||
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||
>
|
||||
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
|
||||
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
|
||||
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
|
||||
</span>
|
||||
{maxSelected ? (
|
||||
<span className={cn('text-4xl font-semibold tracking-tight', insufficient && 'text-destructive')}>
|
||||
{maxLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
|
||||
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
|
||||
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{satsLabel && (
|
||||
@@ -93,10 +112,15 @@ export function BitcoinAmountPicker({
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
value={maxSelected ? 'max' : presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
onAmountChangeStart?.();
|
||||
if (value === 'max') {
|
||||
onMaxSelect?.();
|
||||
setEditingAmount(false);
|
||||
return;
|
||||
}
|
||||
onUsdAmountChange(Number(value));
|
||||
setEditingAmount(false);
|
||||
}
|
||||
@@ -112,6 +136,13 @@ export function BitcoinAmountPicker({
|
||||
${preset}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
<ToggleGroupItem
|
||||
value="max"
|
||||
disabled={maxDisabled}
|
||||
className="h-8 min-w-0 text-xs font-semibold px-1"
|
||||
>
|
||||
{maxLabel}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
|
||||
import { HandHeart, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { AuthorByline } from '@/components/AuthorByline';
|
||||
import { Card } from '@/components/ui/card';
|
||||
@@ -23,17 +23,6 @@ import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCamp
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = unixSeconds - now;
|
||||
if (diff <= 0) return { label: 'Ended', isPast: true };
|
||||
const days = Math.ceil(diff / 86_400);
|
||||
if (days <= 1) return { label: 'Ends today', isPast: false };
|
||||
if (days < 30) return { label: `${days} days left`, isPast: false };
|
||||
const months = Math.round(days / 30);
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Short helper rendered both inline (cards) and in the detail page.
|
||||
*
|
||||
@@ -47,13 +36,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 +160,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]);
|
||||
@@ -159,7 +168,6 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
const cover = sanitizeUrl(displayCampaign.banner)
|
||||
?? sanitizeUrl(authorMetadata?.banner)
|
||||
?? sanitizeUrl(authorMetadata?.picture);
|
||||
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
// SP-only campaigns hide aggregate totals; dual-endpoint campaigns
|
||||
@@ -184,7 +192,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
|
||||
)}
|
||||
>
|
||||
{/* Cover image. Optional metadata (country, deadline) is
|
||||
{/* Cover image. Optional metadata (country) is
|
||||
overlaid on the banner as glass chips so the body below can
|
||||
stay structurally deterministic. A bottom gradient keeps
|
||||
the chips legible against any photo; a top scrim does the
|
||||
@@ -211,7 +219,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
{/* Bottom gradient — only present when there are bottom chips
|
||||
to display, so a banner with no overlays stays visually
|
||||
clean. */}
|
||||
{(countryLabel || deadline) && (
|
||||
{(countryLabel) && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 via-black/30 to-transparent"
|
||||
@@ -223,26 +231,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/30 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Bottom-left meta chips — country + deadline. */}
|
||||
{(countryLabel || deadline) && (
|
||||
{/* Bottom-left meta chips — country. */}
|
||||
{(countryLabel) && (
|
||||
<div className="absolute bottom-3 left-3 z-10 flex flex-wrap items-center gap-1.5 [text-shadow:0_1px_2px_rgba(0,0,0,0.6)]">
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white',
|
||||
deadline.isPast && 'bg-destructive/60',
|
||||
)}
|
||||
>
|
||||
<CalendarClock className="size-3.5" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -287,7 +283,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">
|
||||
|
||||
@@ -85,17 +85,22 @@ export function CampaignWalletDonatePanel({
|
||||
Error-correction level H tolerates the centered occlusion
|
||||
(~30% of modules can be missing and the code still scans). */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={qrPayload} size={280} level="H" />
|
||||
<div className="relative w-full max-w-[280px] rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas
|
||||
value={qrPayload}
|
||||
size={280}
|
||||
level="H"
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
|
||||
<div className="flex aspect-square w-[28%] items-center justify-center rounded-full bg-primary ring-[6px] ring-white">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="size-16 object-contain brightness-0 invert"
|
||||
className="aspect-square w-3/5 object-contain brightness-0 invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -48,17 +48,19 @@ import {
|
||||
classifyBroadcastError,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
import { isLargeAmount, satsToUSD } from '@/lib/bitcoin';
|
||||
import { formatSats, isLargeAmount, satsToUSD } from '@/lib/bitcoin';
|
||||
import {
|
||||
broadcastBlockbookTx,
|
||||
fetchFeeRates,
|
||||
} from '@/lib/hdwallet/blockbook';
|
||||
import {
|
||||
buildHdSpendPsbt,
|
||||
buildHdMaxSpendPsbt,
|
||||
finalizeHdPsbt,
|
||||
type HdInput,
|
||||
type HdSpendableSpUtxo,
|
||||
type HdSpendableUtxo,
|
||||
previewHdMaxSpend,
|
||||
previewHdFee,
|
||||
signHdPsbt,
|
||||
} from '@/lib/hdwallet/transaction';
|
||||
@@ -68,7 +70,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
const USD_PRESETS = [5, 10, 25, 100];
|
||||
|
||||
type FeeSpeed = BitcoinFeeSpeed;
|
||||
|
||||
@@ -154,6 +156,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
// recipient (or null) to us. We only see the final picked destination.
|
||||
const [recipient, setRecipient] = useState<ResolvedRecipient | null>(null);
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [sendMax, setSendMax] = useState(false);
|
||||
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
|
||||
/** Raw text for the custom sat/vB rate input (only used when feeSpeed === 'custom'). */
|
||||
const [customFeeRate, setCustomFeeRate] = useState('');
|
||||
@@ -228,6 +231,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
return Math.round((usd / btcPrice) * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
const maxSpend = useMemo(
|
||||
() => (currentFeeRate ? previewHdMaxSpend(ownedInputs, currentFeeRate) : null),
|
||||
[ownedInputs, currentFeeRate],
|
||||
);
|
||||
|
||||
// ── Fee estimate (matches the actual coin selection) ────────
|
||||
//
|
||||
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
|
||||
@@ -240,17 +248,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
|
||||
}, [ownedInputs, currentFeeRate, amountSats]);
|
||||
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
const effectiveAmountSats = sendMax ? (maxSpend?.amountSats ?? 0) : amountSats;
|
||||
const effectiveFeeSats = sendMax ? (maxSpend?.fee ?? 0) : estimatedFeeSats;
|
||||
const totalSats = effectiveAmountSats + effectiveFeeSats;
|
||||
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
|
||||
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
|
||||
const selectionFailed =
|
||||
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
|
||||
const selectionFailed = sendMax
|
||||
? !!currentFeeRate && ownedInputs.length > 0 && !maxSpend
|
||||
: amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
|
||||
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
|
||||
|
||||
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
|
||||
// user has manually overridden.
|
||||
useEffect(() => {
|
||||
if (feeSpeedUserChanged.current) return;
|
||||
if (sendMax) return;
|
||||
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
|
||||
|
||||
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
|
||||
@@ -263,7 +275,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
if (fee > 0 && fee <= threshold) { target = speed; break; }
|
||||
}
|
||||
setFeeSpeed((prev) => (prev === target ? prev : target));
|
||||
}, [amountSats, feeRates, ownedInputs, totalBalance]);
|
||||
}, [amountSats, feeRates, ownedInputs, sendMax, totalBalance]);
|
||||
|
||||
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
@@ -284,7 +296,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
}, [effectiveAmountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
|
||||
// Track open transitions so we can re-key the picker on each
|
||||
// closed → open transition. Re-keying remounts the picker with a fresh
|
||||
@@ -312,31 +324,56 @@ 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 (feeSpeed !== 'custom' && !feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
|
||||
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
|
||||
if (effectiveAmountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
|
||||
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
|
||||
|
||||
const rate = resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate);
|
||||
if (!rate || rate < 1) throw new Error(t('walletSend.errors.feeRateTooLow'));
|
||||
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
|
||||
const resolvedRecipient = recipient.kind === 'sp'
|
||||
? { kind: 'sp' as const, spAddress: recipient.address }
|
||||
: { kind: 'address' as const, address: recipient.address };
|
||||
|
||||
setProgress('building');
|
||||
const built = buildHdSpendPsbt({
|
||||
account: availability.account,
|
||||
inputs: ownedInputs,
|
||||
recipient:
|
||||
recipient.kind === 'sp'
|
||||
? { kind: 'sp', spAddress: recipient.address }
|
||||
: { kind: 'address', address: recipient.address },
|
||||
amountSats,
|
||||
feeRate: rate,
|
||||
nextChangeIndex,
|
||||
seed: availability.seed,
|
||||
});
|
||||
let psbtHex: string;
|
||||
let fee: number;
|
||||
let sentAmountSats = effectiveAmountSats;
|
||||
let inputDescriptors: Parameters<typeof signHdPsbt>[1];
|
||||
let consumedSpUtxos: Array<{ txid: string; vout: number }>;
|
||||
|
||||
if (sendMax) {
|
||||
const built = buildHdMaxSpendPsbt({
|
||||
account: availability.account,
|
||||
inputs: ownedInputs,
|
||||
recipient: resolvedRecipient,
|
||||
feeRate: rate,
|
||||
seed: availability.seed,
|
||||
});
|
||||
psbtHex = built.psbtHex;
|
||||
fee = built.fee;
|
||||
sentAmountSats = built.amountSats;
|
||||
inputDescriptors = built.inputDescriptors;
|
||||
consumedSpUtxos = built.consumedSpUtxos;
|
||||
} else {
|
||||
const built = buildHdSpendPsbt({
|
||||
account: availability.account,
|
||||
inputs: ownedInputs,
|
||||
recipient: resolvedRecipient,
|
||||
amountSats: effectiveAmountSats,
|
||||
feeRate: rate,
|
||||
nextChangeIndex,
|
||||
seed: availability.seed,
|
||||
});
|
||||
psbtHex = built.psbtHex;
|
||||
fee = built.fee;
|
||||
inputDescriptors = built.inputDescriptors;
|
||||
consumedSpUtxos = built.consumedSpUtxos;
|
||||
}
|
||||
|
||||
setProgress('signing');
|
||||
const signedHex = signHdPsbt(
|
||||
built.psbtHex,
|
||||
built.inputDescriptors,
|
||||
psbtHex,
|
||||
inputDescriptors,
|
||||
availability.account,
|
||||
availability.seed,
|
||||
);
|
||||
@@ -345,12 +382,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
setProgress('broadcasting');
|
||||
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
|
||||
|
||||
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
|
||||
return { txid, amountSats: sentAmountSats, fee, consumedSpUtxos };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
notificationSuccess();
|
||||
setSuccess(result);
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
// Remove the SP UTXOs we just spent from local storage and
|
||||
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
|
||||
// outputs, so without this the spent UTXOs would linger forever:
|
||||
@@ -362,6 +398,9 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
if (result.consumedSpUtxos.length > 0) {
|
||||
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
|
||||
}
|
||||
// Refresh after pruning so transaction history can classify mixed
|
||||
// BIP-86 + SP sends with the spent SP outpoints already archived.
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
void refetchWallet();
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -393,7 +432,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
// because that's just a passive refresh.
|
||||
useEffect(() => {
|
||||
setBroadcastError(null);
|
||||
}, [recipient?.address, amountSats, feeSpeed, customFeeRate]);
|
||||
}, [recipient?.address, effectiveAmountSats, feeSpeed, customFeeRate]);
|
||||
|
||||
/**
|
||||
* Recovery action for fee-related broadcast failures.
|
||||
@@ -466,7 +505,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
}
|
||||
if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; }
|
||||
if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; }
|
||||
if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
|
||||
if (effectiveAmountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
|
||||
if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; }
|
||||
if (!currentFeeRate || currentFeeRate < 1) {
|
||||
setError(
|
||||
@@ -484,7 +523,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
availability,
|
||||
recipient,
|
||||
btcPrice,
|
||||
amountSats,
|
||||
effectiveAmountSats,
|
||||
ownedInputs.length,
|
||||
currentFeeRate,
|
||||
feeSpeed,
|
||||
@@ -502,6 +541,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
setTimeout(() => {
|
||||
setRecipient(null);
|
||||
setUsdAmount(5);
|
||||
setSendMax(false);
|
||||
setError('');
|
||||
setFeeSpeed('halfHour');
|
||||
setCustomFeeRate('');
|
||||
@@ -531,12 +571,16 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
sendMutation.isPending ||
|
||||
!recipient ||
|
||||
!btcPrice ||
|
||||
amountSats <= 0 ||
|
||||
effectiveAmountSats <= 0 ||
|
||||
insufficient ||
|
||||
!ownedInputs.length ||
|
||||
!currentFeeRate ||
|
||||
currentFeeRate < 1;
|
||||
|
||||
const maxAmountLabel = sendMax && effectiveAmountSats > 0 && btcPrice
|
||||
? `${satsToUSD(effectiveAmountSats, btcPrice)} · ${t('walletSend.success.satsAmount', { sats: formatSats(effectiveAmountSats) })}`
|
||||
: undefined;
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
@@ -570,10 +614,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
<div className="grid gap-4 px-4 py-4 w-full overflow-y-auto">
|
||||
<BitcoinAmountPicker
|
||||
usdAmount={usdAmount}
|
||||
onUsdAmountChange={setUsdAmount}
|
||||
onUsdAmountChange={(amount) => {
|
||||
setSendMax(false);
|
||||
setUsdAmount(amount);
|
||||
}}
|
||||
presets={USD_PRESETS}
|
||||
maxLabel={t('walletSend.max')}
|
||||
maxSelected={sendMax}
|
||||
maxDisabled={!ownedInputs.length || !currentFeeRate || !maxSpend}
|
||||
onMaxSelect={() => {
|
||||
setError('');
|
||||
setSendMax(true);
|
||||
}}
|
||||
insufficient={insufficient}
|
||||
onAmountChangeStart={() => setError('')}
|
||||
satsLabel={maxAmountLabel}
|
||||
onAmountChangeStart={() => {
|
||||
setError('');
|
||||
setSendMax(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recipient — text input + Popover dropdown surfacing the
|
||||
@@ -648,8 +706,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
|
||||
>
|
||||
{estimatedFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(estimatedFeeSats, btcPrice)}</>
|
||||
{effectiveFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(effectiveFeeSats, btcPrice)}</>
|
||||
) : currentFeeRate ? (
|
||||
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
|
||||
) : feeRatesLoading && feeSpeed !== 'custom' ? (
|
||||
|
||||
@@ -44,9 +44,11 @@ import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { CommunityReportDialog } from '@/components/CommunityReportDialog';
|
||||
import { AddToListDialog } from '@/components/AddToListDialog';
|
||||
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useBookmarks } from '@/hooks/useBookmarks';
|
||||
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
||||
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
@@ -211,6 +213,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [banContentOpen, setBanContentOpen] = useState(false);
|
||||
const [addToListOpen, setAddToListOpen] = useState(false);
|
||||
const [addToCampaignListOpen, setAddToCampaignListOpen] = useState(false);
|
||||
const [eventJsonOpen, setEventJsonOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
@@ -233,6 +236,16 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
|
||||
const nip19Id = encodeEventNip19(event);
|
||||
|
||||
// Campaign-specific membership-dialog inputs. Only meaningful when
|
||||
// `event.kind === CAMPAIGN_KIND`; the dialog row that uses them is
|
||||
// gated inside the menu content the same way.
|
||||
const isCampaign = event.kind === CAMPAIGN_KIND;
|
||||
const campaignDTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const campaignCoord = isCampaign
|
||||
? `${CAMPAIGN_KIND}:${event.pubkey}:${campaignDTag}`
|
||||
: '';
|
||||
const campaignTitle = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
|
||||
|
||||
const handleDelete = () => {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
deleteEvent(
|
||||
@@ -269,6 +282,10 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToListOpen(true), 150);
|
||||
}}
|
||||
onAddToCampaignList={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToCampaignListOpen(true), 150);
|
||||
}}
|
||||
onViewEventJson={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setEventJsonOpen(true), 150);
|
||||
@@ -307,6 +324,15 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange={setAddToListOpen}
|
||||
/>
|
||||
|
||||
{isCampaign && (
|
||||
<CampaignListMembershipDialog
|
||||
open={addToCampaignListOpen}
|
||||
onOpenChange={setAddToCampaignListOpen}
|
||||
campaignCoord={campaignCoord}
|
||||
campaignTitle={campaignTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventJsonDialog
|
||||
event={event}
|
||||
nip19Id={nip19Id}
|
||||
@@ -347,11 +373,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
|
||||
onReport: () => void;
|
||||
onBanContent: () => void;
|
||||
onAddToList: () => void;
|
||||
onAddToCampaignList: () => void;
|
||||
onViewEventJson: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onAddToCampaignList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -365,6 +392,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
// (kind 33863 — addressable, with their own dedicated UI). Hide them there.
|
||||
const isCampaign = event.kind === CAMPAIGN_KIND;
|
||||
|
||||
// Campaign moderators get a dedicated "Add to list" row that toggles
|
||||
// the campaign's membership in the curated topic lists. `isMod` is a
|
||||
// synchronous boolean — no loading state to handle.
|
||||
const campaignListActions = useCampaignListActions();
|
||||
const canManageCampaignLists = isCampaign && campaignListActions.isMod;
|
||||
|
||||
// Country-feed pin/unpin context (organizer/admin action). `useCountryFeed`
|
||||
// returns null outside of a country page; we only enable usePinnedPosts when
|
||||
// the viewer is actually authorized to pin so we avoid extra relay traffic
|
||||
@@ -551,6 +584,13 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
onClick={() => { onAddToList(); }}
|
||||
/>
|
||||
)}
|
||||
{canManageCampaignLists && (
|
||||
<MenuItem
|
||||
icon={<ListPlus className="size-5" />}
|
||||
label={t('campaigns.lists.membershipTitle')}
|
||||
onClick={() => { onAddToCampaignList(); }}
|
||||
/>
|
||||
)}
|
||||
{!isCampaign && (
|
||||
<MenuItem
|
||||
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -39,6 +41,9 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const DRAG_MIME = 'text/x-agora-campaign-list-coord';
|
||||
|
||||
/** How many pills to show before collapsing the rest behind a "Show more". */
|
||||
const COLLAPSED_COUNT = 5;
|
||||
|
||||
/**
|
||||
* Horizontal scrollable strip of moderator-curated campaign list pills.
|
||||
*
|
||||
@@ -69,6 +74,7 @@ export function CampaignListsStrip() {
|
||||
const [editTarget, setEditTarget] = useState<ParsedCampaignList | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ParsedCampaignList | null>(null);
|
||||
const [optimisticOrder, setOptimisticOrder] = useState<readonly string[] | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lists = useMemo(() => data?.lists ?? [], [data]);
|
||||
const authoritativeCoords = useMemo(() => lists.map((l) => l.aTag), [lists]);
|
||||
@@ -180,6 +186,28 @@ export function CampaignListsStrip() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visible = displayed.slice(0, COLLAPSED_COUNT);
|
||||
const overflow = displayed.slice(COLLAPSED_COUNT);
|
||||
const canExpand = overflow.length > 0;
|
||||
|
||||
const renderPill = (list: ParsedCampaignList, idx: number) => (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
@@ -187,23 +215,35 @@ export function CampaignListsStrip() {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
{visible.map((list, i) => renderPill(list, i))}
|
||||
{expanded &&
|
||||
overflow.map((list, i) => renderPill(list, i + COLLAPSED_COUNT))}
|
||||
{canExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm whitespace-nowrap shrink-0',
|
||||
'border-border bg-background hover:border-primary/40 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',
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-4 shrink-0" aria-hidden />
|
||||
<span>{t('campaigns.lists.showLess')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t('campaigns.lists.showMore', { count: overflow.length })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{actions.isMod && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -14,8 +14,10 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
canvas,
|
||||
value,
|
||||
{
|
||||
width: size,
|
||||
@@ -26,6 +28,13 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
|
||||
if (error) console.error('QR Code generation error:', error);
|
||||
}
|
||||
);
|
||||
|
||||
// The qrcode library hard-codes inline `width`/`height` pixel styles on
|
||||
// the canvas, which override Tailwind sizing classes and cause the QR to
|
||||
// overflow its container on narrow viewports. Clear them so the caller's
|
||||
// className (e.g. `h-auto w-full`) controls the rendered size responsively.
|
||||
canvas.style.removeProperty('width');
|
||||
canvas.style.removeProperty('height');
|
||||
}, [value, size, level]);
|
||||
|
||||
return <canvas ref={canvasRef} className={className} />;
|
||||
|
||||
@@ -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,7 +9,7 @@ import {
|
||||
type ParsedCampaignList,
|
||||
foldCampaignLists,
|
||||
} from '@/lib/campaignLists';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -22,15 +21,32 @@ 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.
|
||||
*
|
||||
* **Relay fan-out.** This used to query `relay.ditto.pub` directly (a
|
||||
* single-relay `nostr.relay(...)` call) to avoid a fast empty EOSE from a
|
||||
* less-populated relay racing the surface to "no lists." But this query
|
||||
* sits at the *head* of the home-page waterfall — every hero campaign is
|
||||
* gated on its result (see `CampaignsPage`/`useCampaigns`) — so a slow
|
||||
* `relay.ditto.pub` stalled the entire first paint. We now fan out to the
|
||||
* whole read pool via `nostr.query`. The `authors: [LIST_CURATOR_PUBKEY]`
|
||||
* filter is what enforces the trust model; correctness no longer depends
|
||||
* on hitting one specific relay, and the curated relay is still in the
|
||||
* fan-out so its events are found. The pool accumulates events across
|
||||
* relays until first EOSE (+ the pool's eoseTimeout), so a late event from
|
||||
* the curated relay still folds in on the next tick.
|
||||
*
|
||||
* Lists *and* the index are pulled in a single filter via
|
||||
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
|
||||
@@ -38,46 +54,27 @@ 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
|
||||
// "no lists" while the curated relay still holds them.
|
||||
const relay = nostr.relay(DITTO_RELAY);
|
||||
const events = await relay.query(
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
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)]) },
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return foldCampaignLists(events);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
isLoading: query.isLoading || moderatorsLoading,
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Lookup a single list by slug from the cached collection. */
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import {
|
||||
AGORA_MODERATION_NAMESPACE,
|
||||
@@ -62,8 +61,12 @@ export function useCampaignModeration() {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
||||
}
|
||||
const relay = nostr.relay(DITTO_RELAY);
|
||||
const events = await relay.query(
|
||||
// Fan out to the whole read pool rather than pinning a single relay.
|
||||
// The `authors: moderators` filter enforces the trust model, so
|
||||
// querying more relays only improves coverage — and it keeps this
|
||||
// moderation surface off the single-relay critical path that was
|
||||
// serializing the home page behind relay.ditto.pub.
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [LABEL_KIND],
|
||||
@@ -76,7 +79,7 @@ export function useCampaignModeration() {
|
||||
limit: 2000,
|
||||
},
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
{ signal },
|
||||
);
|
||||
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
@@ -38,13 +37,15 @@ interface UseDiscoverCommunitiesOptions {
|
||||
export function useDiscoverCommunities(options: UseDiscoverCommunitiesOptions = {}) {
|
||||
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(
|
||||
// Global discovery (no `authors:` filter), so fan out to the whole
|
||||
// read pool: more relays means broader community coverage, and it
|
||||
// keeps Discover off the single-relay critical path.
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
@@ -50,7 +49,6 @@ function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
|
||||
*/
|
||||
export function useFeaturedOrganizations() {
|
||||
const { nostr } = useNostr();
|
||||
const relay = nostr.relay(DITTO_RELAY);
|
||||
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
|
||||
|
||||
// Derive the curated coord set: featured minus hidden, sorted by the
|
||||
@@ -102,8 +100,11 @@ export function useFeaturedOrganizations() {
|
||||
}),
|
||||
);
|
||||
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const events = await relay.query(filters, { signal: combinedSignal });
|
||||
// Fan out to the whole read pool. Each filter pins `authors:`, so the
|
||||
// curation is still enforced by the moderation labels (the `featured`
|
||||
// labels are themselves moderator-authored) — querying more relays only
|
||||
// improves coverage and keeps this off the single-relay critical path.
|
||||
const events = await nostr.query(filters, { signal });
|
||||
|
||||
// Latest-wins dedupe of addressable revisions, then index by coord so
|
||||
// we can return them in the moderator-controlled `featuredOrder`.
|
||||
|
||||
@@ -591,10 +591,13 @@ export function useHdWalletSp(): UseHdWalletSpResult {
|
||||
}
|
||||
|
||||
const opt = optimisticRef.current!;
|
||||
const spentKeys = new Set(freshArchive.map((u) => `${u.txid}:${u.vout}`));
|
||||
optimisticRef.current = {
|
||||
version: SP_STORAGE_VERSION,
|
||||
scanHeight: opt.scanHeight,
|
||||
utxos: mergeUtxos(opt.utxos, freshActive),
|
||||
utxos: mergeUtxos(opt.utxos, freshActive).filter(
|
||||
(u) => !spentKeys.has(`${u.txid}:${u.vout}`),
|
||||
),
|
||||
spent: mergeUtxos(opt.spent ?? [], freshArchive),
|
||||
};
|
||||
matchesFound += blockMatches.length;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,8 +90,6 @@ export interface ParsedCampaign {
|
||||
wallets: CampaignWallets;
|
||||
/** Fundraising goal in **integer US Dollars**, or `undefined` if not set. */
|
||||
goalUsd?: number;
|
||||
/** Deadline (Unix seconds), or `undefined` if not set. */
|
||||
deadline?: number;
|
||||
/** ISO 3166-1 alpha-2 country code parsed from a NIP-73 `i` tag. */
|
||||
countryCode?: string;
|
||||
/** Created-at from the event. */
|
||||
@@ -259,7 +257,6 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
|
||||
bannerImeta,
|
||||
wallets,
|
||||
goalUsd: parsePositiveInt(getTag(event, 'goal')),
|
||||
deadline: parsePositiveInt(getTag(event, 'deadline')),
|
||||
countryCode: getCountryCode(event),
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
|
||||
@@ -454,6 +454,16 @@ export function buildHdTransactions(
|
||||
|
||||
const out: HdTransaction[] = [];
|
||||
for (const tx of result.rawTransactions) {
|
||||
const spValuesByTxid = new Map<string, number[]>();
|
||||
for (const [outpoint, value] of spOutpoints ?? []) {
|
||||
const sep = outpoint.lastIndexOf(':');
|
||||
if (sep <= 0) continue;
|
||||
const txid = outpoint.slice(0, sep);
|
||||
const values = spValuesByTxid.get(txid);
|
||||
if (values) values.push(value);
|
||||
else spValuesByTxid.set(txid, [value]);
|
||||
}
|
||||
|
||||
let inflowsBip86 = 0;
|
||||
let outflowsBip86 = 0;
|
||||
let outflowsSp = 0;
|
||||
@@ -483,17 +493,26 @@ export function buildHdTransactions(
|
||||
}
|
||||
}
|
||||
if (attributed) continue;
|
||||
if (
|
||||
spOutpoints &&
|
||||
typeof v.txid === 'string' &&
|
||||
typeof v.vout === 'number'
|
||||
) {
|
||||
if (spOutpoints && typeof v.txid === 'string' && typeof v.vout === 'number') {
|
||||
const spValue = spOutpoints.get(`${v.txid}:${v.vout}`);
|
||||
if (spValue !== undefined) {
|
||||
// Trust the wallet's own stored value for SP inputs — Blockbook
|
||||
// doesn't always populate `vin.value` for taproot inputs.
|
||||
outflowsSp += spValue || value;
|
||||
}
|
||||
} else if (typeof v.txid === 'string') {
|
||||
// Blockbook's tx rows often omit the previous output index on inputs
|
||||
// (they expose `n`, the input index, instead). Fall back to matching
|
||||
// archived SP outpoints by prev txid + value so historical mixed
|
||||
// BIP-86/SP sends can still be attributed after an include-spent scan.
|
||||
const candidates = spValuesByTxid.get(v.txid);
|
||||
if (candidates?.length) {
|
||||
const idx = value > 0 ? candidates.findIndex((candidate) => candidate === value) : 0;
|
||||
if (idx >= 0) {
|
||||
outflowsSp += candidates[idx] || value;
|
||||
candidates.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ function inputId(input: HdInput): string {
|
||||
}
|
||||
|
||||
/** Recipient parsing result. */
|
||||
type HdRecipient =
|
||||
export type HdRecipient =
|
||||
| { kind: 'address'; address: string }
|
||||
| { kind: 'sp'; spAddress: string };
|
||||
|
||||
@@ -667,6 +667,217 @@ export function finalizeHdPsbt(psbtHex: string): string {
|
||||
// Max-sendable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Preview of the maximum spendable amount at a fee rate. */
|
||||
export interface HdMaxSpendPreview {
|
||||
/** Sats actually sent to the recipient after subtracting fee. */
|
||||
amountSats: number;
|
||||
/** Network fee in satoshis. */
|
||||
fee: number;
|
||||
/** Total sats across all consumed inputs. */
|
||||
totalInput: number;
|
||||
}
|
||||
|
||||
/** Arguments accepted by {@link buildHdMaxSpendPsbt}. */
|
||||
export interface BuildHdMaxSpendArgs {
|
||||
/** HD account whose keys can sign all `inputs`. */
|
||||
account: HdAccount;
|
||||
/** Every UTXO the wallet should drain. */
|
||||
inputs: readonly HdInput[];
|
||||
/** Where to send the max amount. */
|
||||
recipient: HdRecipient;
|
||||
/** Fee rate in sat/vB. */
|
||||
feeRate: number;
|
||||
/** Required iff any input is a silent-payment UTXO. */
|
||||
seed?: Uint8Array;
|
||||
}
|
||||
|
||||
/** Result of {@link buildHdMaxSpendPsbt}. */
|
||||
export interface HdMaxSpendPsbt extends HdMaxSpendPreview {
|
||||
/** Hex-encoded unsigned PSBT, ready for `signHdPsbt`. */
|
||||
psbtHex: string;
|
||||
/** Per-input descriptor, aligned 1:1 with PSBT inputs. */
|
||||
inputDescriptors: HdInputDescriptor[];
|
||||
/** Resolved recipient address. SP sends resolve to the derived P2TR address. */
|
||||
resolvedRecipientAddress: string;
|
||||
/** SP UTXOs consumed by this max spend, for post-broadcast bookkeeping. */
|
||||
consumedSpUtxos: Array<{ txid: string; vout: number }>;
|
||||
}
|
||||
|
||||
function dedupeInputs(inputs: readonly HdInput[]): HdInput[] {
|
||||
const seen = new Set<string>();
|
||||
const dedup: HdInput[] = [];
|
||||
for (const i of inputs) {
|
||||
const id = inputId(i);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
dedup.push(i);
|
||||
}
|
||||
return dedup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a wallet-draining send: all inputs, one recipient output, no change.
|
||||
* Returns `null` when the wallet cannot produce a non-dust recipient output.
|
||||
*/
|
||||
export function previewHdMaxSpend(
|
||||
inputs: readonly HdInput[],
|
||||
feeRate: number,
|
||||
): HdMaxSpendPreview | null {
|
||||
if (!Number.isFinite(feeRate) || feeRate <= 0) return null;
|
||||
if (!inputs.length) return null;
|
||||
|
||||
const dedup = dedupeInputs(inputs);
|
||||
if (!dedup.length) return null;
|
||||
|
||||
const totalInput = dedup.reduce((s, i) => s + inputValue(i), 0);
|
||||
const fee = estimateFee(dedup.length, 1, feeRate);
|
||||
const amountSats = totalInput - fee;
|
||||
if (amountSats < BITCOIN_DUST_LIMIT) return null;
|
||||
|
||||
return { amountSats, fee, totalInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a one-output PSBT that sends the maximum possible amount to a normal
|
||||
* Bitcoin address or silent-payment address. The fee is deducted from the
|
||||
* wallet balance and no change output is created.
|
||||
*/
|
||||
export function buildHdMaxSpendPsbt(args: BuildHdMaxSpendArgs): HdMaxSpendPsbt {
|
||||
const { account, inputs, recipient, feeRate, seed } = args;
|
||||
|
||||
if (!inputs.length) throw new Error('Max spend requires at least one input.');
|
||||
if (!Number.isFinite(feeRate) || feeRate <= 0) {
|
||||
throw new Error('Fee rate must be positive.');
|
||||
}
|
||||
if (recipient.kind === 'address' && !validateBitcoinAddress(recipient.address)) {
|
||||
throw new Error(`Invalid Bitcoin address: ${recipient.address}`);
|
||||
}
|
||||
|
||||
const dedup = dedupeInputs(inputs);
|
||||
const preview = previewHdMaxSpend(dedup, feeRate);
|
||||
if (!preview) {
|
||||
const totalInput = dedup.reduce((s, i) => s + inputValue(i), 0);
|
||||
const fee = dedup.length ? estimateFee(dedup.length, 1, feeRate) : 0;
|
||||
throw new Error(
|
||||
`Max spend amount below dust limit after fee. Total: ${totalInput}, fee: ${fee}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const hasSp = dedup.some((i) => i.kind === 'sp');
|
||||
if (hasSp && !seed) {
|
||||
throw new Error('Max spend with SP inputs requires the source wallet seed.');
|
||||
}
|
||||
|
||||
const bSpend = hasSp && seed ? deriveSilentPaymentSpendKey(seed) : undefined;
|
||||
const wipeAfterBuild: Uint8Array[] = [];
|
||||
if (bSpend) wipeAfterBuild.push(bSpend);
|
||||
|
||||
try {
|
||||
const tx = new btc.Transaction();
|
||||
const inputDescriptors: HdInputDescriptor[] = [];
|
||||
const consumedSpUtxos: Array<{ txid: string; vout: number }> = [];
|
||||
const spSenderInputs: SpSenderInput[] = [];
|
||||
|
||||
for (const input of dedup) {
|
||||
if (input.kind === 'bip86') {
|
||||
const utxo = input.utxo;
|
||||
const derived = deriveAddress(
|
||||
utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
|
||||
utxo.chain,
|
||||
utxo.index,
|
||||
);
|
||||
if (derived.address !== utxo.address) {
|
||||
throw new Error(
|
||||
`UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
|
||||
`expected ${derived.address}, got ${utxo.address}`,
|
||||
);
|
||||
}
|
||||
const internalPubkey = hex.decode(derived.internalPubkeyHex);
|
||||
const payment = btc.p2tr(internalPubkey, undefined, HD_WALLET_NETWORK);
|
||||
tx.addInput({
|
||||
txid: utxo.txid,
|
||||
index: utxo.vout,
|
||||
witnessUtxo: { script: payment.script, amount: BigInt(utxo.value) },
|
||||
tapInternalKey: internalPubkey,
|
||||
});
|
||||
inputDescriptors.push({ kind: 'bip86', chain: utxo.chain, index: utxo.index });
|
||||
|
||||
if (recipient.kind === 'sp') {
|
||||
const leaf = deriveLeafPrivateKey(account, utxo.chain, utxo.index);
|
||||
const tweaked = bip86TweakedPrivateKey(leaf);
|
||||
leaf.fill(0);
|
||||
wipeAfterBuild.push(tweaked);
|
||||
spSenderInputs.push({
|
||||
txid: utxo.txid,
|
||||
vout: utxo.vout,
|
||||
privateKey: tweaked,
|
||||
isTaproot: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!bSpend) {
|
||||
throw new Error('SP input requires b_spend (unreachable).');
|
||||
}
|
||||
const utxo = input.utxo;
|
||||
const tweak = hexToBytes(utxo.tweakHex);
|
||||
const xonly = deriveSpUtxoXOnly(bSpend, tweak);
|
||||
const script = spP2trScriptPubKey(xonly);
|
||||
tx.addInput({
|
||||
txid: utxo.txid,
|
||||
index: utxo.vout,
|
||||
witnessUtxo: { script, amount: BigInt(utxo.value) },
|
||||
});
|
||||
inputDescriptors.push({ kind: 'sp', tweakHex: utxo.tweakHex });
|
||||
consumedSpUtxos.push({ txid: utxo.txid, vout: utxo.vout });
|
||||
|
||||
if (recipient.kind === 'sp') {
|
||||
const dk = deriveSpUtxoSigningKey(bSpend, tweak);
|
||||
wipeAfterBuild.push(dk);
|
||||
spSenderInputs.push({
|
||||
txid: utxo.txid,
|
||||
vout: utxo.vout,
|
||||
privateKey: dk,
|
||||
isTaproot: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedRecipientAddress: string;
|
||||
if (recipient.kind === 'address') {
|
||||
resolvedRecipientAddress = recipient.address;
|
||||
tx.addOutputAddress(recipient.address, BigInt(preview.amountSats), HD_WALLET_NETWORK);
|
||||
} else {
|
||||
if (spSenderInputs.length === 0) {
|
||||
throw new Error('Silent-payment max spend needs at least one input.');
|
||||
}
|
||||
const outputs = deriveSilentPaymentOutputs(
|
||||
spSenderInputs,
|
||||
[{ address: decodeSilentPaymentAddress(recipient.spAddress), raw: recipient.spAddress }],
|
||||
{ network: 'mainnet' },
|
||||
);
|
||||
if (outputs.length !== 1) {
|
||||
throw new Error('Silent-payment derivation returned unexpected number of outputs.');
|
||||
}
|
||||
const out: SpSenderOutput = outputs[0];
|
||||
tx.addOutput({ script: spP2trScriptPubKey(out.xOnlyPubKey), amount: BigInt(preview.amountSats) });
|
||||
resolvedRecipientAddress = out.address;
|
||||
}
|
||||
|
||||
return {
|
||||
psbtHex: txToPsbtHex(tx),
|
||||
fee: preview.fee,
|
||||
amountSats: preview.amountSats,
|
||||
totalInput: preview.totalInput,
|
||||
inputDescriptors,
|
||||
resolvedRecipientAddress,
|
||||
consumedSpUtxos,
|
||||
};
|
||||
} finally {
|
||||
for (const buf of wipeAfterBuild) buf.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sweep — drain every input into one output
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -822,4 +1033,3 @@ export function buildHdSweepPsbt(args: BuildHdSweepArgs): HdSweepPsbt {
|
||||
if (bSpend) bSpend.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-20
@@ -496,20 +496,23 @@
|
||||
"wallet": "محفظة بيتكوين",
|
||||
"myWalletLabel": "محفظة {{name}}",
|
||||
"myWalletDefault": "محفظتي",
|
||||
"walletHeroNote": "تتدفّق التبرّعات مباشرةً إلى محفظة Agora الخاصة بك. لا وسيط، ولا إعداد للدفعات، ولا انتظار.",
|
||||
"walletHeroReassurance": "أنت تملك المفتاح، إذًا أنت تملك الأموال. اسحبها في أي وقت من تبويب المحفظة.",
|
||||
"walletChoose": "اختر محفظة",
|
||||
"walletCustom": "مخصصة",
|
||||
"walletUseCustom": "استخدم محفظة مخصصة بدلاً من ذلك",
|
||||
"walletUseMine": "استخدم محفظة Agora الخاصة بي",
|
||||
"acceptAll": "قبول جميع أنواع الدفع",
|
||||
"acceptPublic": "قبول الدفعات العامة فقط",
|
||||
"acceptPrivate": "قبول الدفعات الخاصة فقط",
|
||||
"acceptAllShort": "قبول الكل",
|
||||
"acceptPublicShort": "عامة فقط",
|
||||
"acceptPrivateShort": "خاصة فقط",
|
||||
"acceptAllHint": "قبول الدفعات العامة على السلسلة والدفعات الصامتة الخاصة.",
|
||||
"acceptPublicHint": "قبول التبرعات على السلسلة إلى عنوان عام فقط.",
|
||||
"acceptPrivateHint": "قبول الدفعات الصامتة فقط — تبقى عناوين المتبرعين خاصة.",
|
||||
"customWalletIntro": "أدخل عنوان بيتكوين، رمز دفع صامت، أو كليهما. يلزم واحد على الأقل.",
|
||||
"acceptHeading": "ما نوع التبرعات التي ستقبلها؟",
|
||||
"acceptUnavailable": "غير متاح مع تسجيل الدخول هذا.",
|
||||
"acceptAllTitle": "أي تبرع",
|
||||
"acceptPublicTitle": "التبرعات العامة فقط",
|
||||
"acceptPrivateTitle": "التبرعات الخاصة فقط",
|
||||
"acceptAllHint": "استقبل التبرعات العامة والخاصة معًا.",
|
||||
"acceptPublicHint": "يتبرع المانحون إلى عنوان Bitcoin عادي. هذه التبرعات مرئية للجميع.",
|
||||
"acceptPrivateHint": "يتبرع المانحون بشكل خاص، لذا تبقى هويتهم مخفية عن الجميع.",
|
||||
"customWalletIntro": "املأ أي تبرعات ترغب في قبولها: عنوان عام، أو رمز خاص، أو كليهما. يلزم واحد على الأقل.",
|
||||
"customOnchainMeaning": "عام. يمكن لأي شخص رؤية هذه التبرعات.",
|
||||
"customSpMeaning": "خاص. تبقى هوية المتبرع مخفية.",
|
||||
"bitcoinAddress": "عنوان بيتكوين",
|
||||
"bitcoinAddressPlaceholder": "bc1q… أو bc1p…",
|
||||
"silentPaymentCode": "رمز الدفع الصامت",
|
||||
@@ -544,7 +547,6 @@
|
||||
"goal": "الهدف",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "دولارات أمريكية صحيحة. المتبرعون يدفعون بالبيتكوين؛ يقدّر العملاء المعادل بالدولار وقت العرض.",
|
||||
"deadline": "الموعد النهائي",
|
||||
"submitCreate": "إطلاق الحملة",
|
||||
"submitEdit": "تحديث الحملة",
|
||||
"publishing": "جارٍ النشر…",
|
||||
@@ -569,8 +571,6 @@
|
||||
"errorSpInvalid": "رمز الدفع الصامت ليس رمز BIP-352 معروفًا (sp1…).",
|
||||
"errorWalletRequired": "أدخل نقطة محفظة واحدة على الأقل — عنوان بيتكوين شبكة رئيسية (bc1q… / bc1p…) أو رمز دفع صامت (sp1…).",
|
||||
"errorGoalInvalid": "يجب أن يكون الهدف مبلغًا موجبًا بالدولار الصحيح.",
|
||||
"errorDeadlinePast": "لا يمكن أن يكون الموعد النهائي في الماضي.",
|
||||
"errorDeadlineInvalid": "الموعد النهائي ليس تاريخًا صالحًا.",
|
||||
"errorEditLatestMissing": "تعذّر العثور على أحدث نسخة لهذه الحملة لتحديثها.",
|
||||
"errorSlugCollision": "لديك بالفعل حملة بالمعرّف «{{slug}}». اختر معرّفًا آخر.",
|
||||
"errorBannerInvalid": "يجب أن يكون البانر رابط https:// صالحًا.",
|
||||
@@ -587,6 +587,8 @@
|
||||
"bannerStepSubtitle": "صورة واحدة لافتة تُمثّل الحملة في كل بطاقة.",
|
||||
"storyStepTitle": "احكِ قصتك",
|
||||
"storyStepSubtitle": "من المستفيد وكيف ستُستخدم الأموال.",
|
||||
"goalStepTitle": "الهدف",
|
||||
"goalStepSubtitle": "اختياري — اتركه فارغًا لحملة مفتوحة دون موعد نهائي.",
|
||||
"next": "التالي",
|
||||
"back": "رجوع",
|
||||
"skip": "تخطٍّ",
|
||||
@@ -596,11 +598,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | حملات {{appName}}",
|
||||
"seoDescriptionFallback": "ادعم {{title}} على {{appName}}.",
|
||||
"deadlineEndedOn": "انتهى في {{date}}",
|
||||
"deadlineEndsToday": "ينتهي اليوم",
|
||||
"deadlineDaysLeft_one": "بقي {{count}} يوم",
|
||||
"deadlineDaysLeft_other": "بقي {{count}} يومًا",
|
||||
"deadlineEndsOn": "ينتهي في {{date}}",
|
||||
"back": "رجوع",
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
@@ -643,7 +640,6 @@
|
||||
"deleteDialogTitle": "حذف هذه الحملة؟",
|
||||
"deleteDialogBody": "هذا ينشر طلب حذف NIP-09. ستزيل المرحّلات المتعاونة الحملة من الخلاصات والروابط المباشرة. تبقى إيصالات التبرعات السابقة على السلسلة بغض النظر. لا يمكن التراجع عن هذا الإجراء — لمواصلة قبول التبرعات، عدّل الحملة بدلًا من ذلك.",
|
||||
"storyHeading": "القصة",
|
||||
"campaignEnded": "انتهت الحملة",
|
||||
"donate": "تبرّع",
|
||||
"share": "مشاركة",
|
||||
"target": "الهدف: {{amount}}",
|
||||
@@ -744,7 +740,7 @@
|
||||
"wlcDesc": "حملات منتقاة من World Liberty Congress.",
|
||||
"allCampaigns": "كل الحملات",
|
||||
"allCampaignsDesc": "كل الحملات على الشبكة، بالترتيب الزمني.",
|
||||
"browseAll": "تصفّح كل الحملات ←",
|
||||
"browseAll": "تصفّح كل الحملات",
|
||||
"hidden": "مخفية",
|
||||
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
|
||||
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
|
||||
@@ -810,6 +806,8 @@
|
||||
"lists": {
|
||||
"stripAria": "قوائم مواضيع منتقاة للحملات",
|
||||
"create": "قائمة جديدة",
|
||||
"showMore": "إظهار {{count}} أخرى",
|
||||
"showLess": "إظهار أقل",
|
||||
"createDesc": "أنشئ قائمة مواضيع جديدة. ثم انتقِ إليها حملات من أي صفحة حملة.",
|
||||
"createSubmit": "إنشاء القائمة",
|
||||
"createFailed": "فشل إنشاء القائمة",
|
||||
@@ -1228,6 +1226,7 @@
|
||||
"walletSend": {
|
||||
"title": "إرسال البيتكوين",
|
||||
"send": "إرسال البيتكوين",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "انقر مرة أخرى للتأكيد",
|
||||
"satPerVB": "{{rate}} ساتوشي/vB",
|
||||
"notEnoughBitcoin": "لا يوجد بيتكوين كافٍ",
|
||||
|
||||
+19
-22
@@ -934,20 +934,23 @@
|
||||
"wallet": "Bitcoin wallet",
|
||||
"myWalletLabel": "{{name}}'s wallet",
|
||||
"myWalletDefault": "My wallet",
|
||||
"walletHeroNote": "Donations flow straight into your own Agora wallet. No middleman, no payout setup, no waiting.",
|
||||
"walletHeroReassurance": "You hold the key, so you hold the funds. Withdraw any time from the wallet tab.",
|
||||
"walletChoose": "Choose a wallet",
|
||||
"walletCustom": "Custom wallet",
|
||||
"walletUseCustom": "Use a custom wallet instead",
|
||||
"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.",
|
||||
"acceptHeading": "What donations will you accept?",
|
||||
"acceptUnavailable": "Not available with this login.",
|
||||
"acceptAllTitle": "Any donation",
|
||||
"acceptPublicTitle": "Public donations only",
|
||||
"acceptPrivateTitle": "Private donations only",
|
||||
"acceptAllHint": "Take both public and private donations.",
|
||||
"acceptPublicHint": "Donors give to a regular Bitcoin address. These donations are visible to anyone.",
|
||||
"acceptPrivateHint": "Donors give privately, so their identity stays hidden from the public.",
|
||||
"customWalletIntro": "Fill in whichever donations you want to accept: a public address, a private code, or both. At least one is required.",
|
||||
"customOnchainMeaning": "Public. Anyone can see these donations.",
|
||||
"customSpMeaning": "Private. The donor's identity stays hidden.",
|
||||
"bitcoinAddress": "Bitcoin address",
|
||||
"bitcoinAddressPlaceholder": "bc1q… or bc1p…",
|
||||
"silentPaymentCode": "Silent-payment code",
|
||||
@@ -982,7 +985,6 @@
|
||||
"goal": "Goal",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "Whole US Dollars. Donors pay in Bitcoin; clients estimate the USD-equivalent at view time.",
|
||||
"deadline": "Deadline",
|
||||
"submitCreate": "Launch campaign",
|
||||
"submitEdit": "Update campaign",
|
||||
"publishing": "Publishing…",
|
||||
@@ -1007,8 +1009,6 @@
|
||||
"errorSpInvalid": "The silent-payment code is not a recognized BIP-352 code (sp1…).",
|
||||
"errorWalletRequired": "Provide at least one wallet endpoint — a Bitcoin mainnet address (bc1q… / bc1p…) or a silent-payment code (sp1…).",
|
||||
"errorGoalInvalid": "Goal must be a positive whole-dollar amount.",
|
||||
"errorDeadlinePast": "Deadline cannot be in the past.",
|
||||
"errorDeadlineInvalid": "Deadline is not a valid date.",
|
||||
"errorEditLatestMissing": "Could not find the latest version of this campaign to update.",
|
||||
"errorSlugCollision": "You already have a campaign with the identifier \"{{slug}}\". Choose another.",
|
||||
"errorBannerInvalid": "Banner must be a valid https:// URL.",
|
||||
@@ -1025,8 +1025,8 @@
|
||||
"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.",
|
||||
"goalStepTitle": "Goal",
|
||||
"goalStepSubtitle": "Optional — leave blank for an open-ended campaign.",
|
||||
"tagsStepTitle": "Country and categories",
|
||||
"tagsStepSubtitle": "Help the right people find your campaign.",
|
||||
"next": "Next",
|
||||
@@ -1038,11 +1038,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | {{appName}} Fundraisers",
|
||||
"seoDescriptionFallback": "Support {{title}} on {{appName}}.",
|
||||
"deadlineEndedOn": "Ended {{date}}",
|
||||
"deadlineEndsToday": "Ends today",
|
||||
"deadlineDaysLeft_one": "{{count}} day left",
|
||||
"deadlineDaysLeft_other": "{{count}} days left",
|
||||
"deadlineEndsOn": "Ends {{date}}",
|
||||
"back": "Back",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -1085,7 +1080,6 @@
|
||||
"deleteDialogTitle": "Delete this campaign?",
|
||||
"deleteDialogBody": "This publishes a NIP-09 deletion request. Well-behaved relays will drop the campaign from feeds and direct links. Past donation receipts stay on-chain regardless. This action cannot be undone — to keep accepting donations, edit the campaign instead.",
|
||||
"storyHeading": "The story",
|
||||
"campaignEnded": "Campaign ended",
|
||||
"donate": "Donate",
|
||||
"share": "Share",
|
||||
"target": "Target: {{amount}}",
|
||||
@@ -1186,7 +1180,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",
|
||||
@@ -1256,6 +1250,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Curated campaign topic lists",
|
||||
"create": "New list",
|
||||
"showMore": "Show {{count}} more",
|
||||
"showLess": "Show less",
|
||||
"createDesc": "Create a new topic list. Curate campaigns into it from any campaign page.",
|
||||
"createSubmit": "Create list",
|
||||
"createFailed": "Failed to create list",
|
||||
@@ -1742,6 +1738,7 @@
|
||||
"walletSend": {
|
||||
"title": "Send Bitcoin",
|
||||
"send": "Send Bitcoin",
|
||||
"max": "MAX",
|
||||
"notEnoughBitcoin": "Not enough Bitcoin",
|
||||
"tapAgainToConfirm": "Tap again to confirm",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
|
||||
+17
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "Cartera Bitcoin",
|
||||
"myWalletLabel": "Cartera de {{name}}",
|
||||
"myWalletDefault": "Mi cartera",
|
||||
"walletHeroNote": "Las donaciones llegan directamente a tu propia cartera de Agora. Sin intermediarios, sin configurar pagos, sin esperas.",
|
||||
"walletHeroReassurance": "Tú tienes la clave, así que tú tienes los fondos. Retíralos en cualquier momento desde la pestaña de la cartera.",
|
||||
"walletChoose": "Elige una cartera",
|
||||
"walletCustom": "Personalizada",
|
||||
"walletUseCustom": "Usar una cartera personalizada",
|
||||
"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.",
|
||||
"acceptHeading": "¿Qué donaciones aceptarás?",
|
||||
"acceptUnavailable": "No disponible con este inicio de sesión.",
|
||||
"acceptAllTitle": "Cualquier donación",
|
||||
"acceptPublicTitle": "Solo donaciones públicas",
|
||||
"acceptPrivateTitle": "Solo donaciones privadas",
|
||||
"acceptAllHint": "Recibe donaciones tanto públicas como privadas.",
|
||||
"acceptPublicHint": "Los donantes envían a una dirección de Bitcoin normal. Estas donaciones son visibles para cualquier persona.",
|
||||
"acceptPrivateHint": "Los donantes dan de forma privada, así su identidad permanece oculta del público.",
|
||||
"customWalletIntro": "Completa las donaciones que quieras aceptar: una dirección pública, un código privado o ambas. Se requiere al menos una.",
|
||||
"customOnchainMeaning": "Pública. Cualquier persona puede ver estas donaciones.",
|
||||
"customSpMeaning": "Privada. La identidad del donante permanece oculta.",
|
||||
"bitcoinAddress": "Dirección de Bitcoin",
|
||||
"bitcoinAddressPlaceholder": "bc1q… o bc1p…",
|
||||
"silentPaymentCode": "Código de pago silencioso",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "Meta",
|
||||
"goalPlaceholder": "25.000",
|
||||
"goalNote": "Dólares estadounidenses enteros. Las personas donan en Bitcoin; los clientes calculan el equivalente en USD al momento de ver.",
|
||||
"deadline": "Fecha límite",
|
||||
"submitCreate": "Lanzar campaña",
|
||||
"submitEdit": "Actualizar campaña",
|
||||
"publishing": "Publicando…",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "El código de pago silencioso no es un código BIP-352 reconocido (sp1…).",
|
||||
"errorWalletRequired": "Proporciona al menos un punto de cartera: una dirección Bitcoin mainnet (bc1q… / bc1p…) o un código de pago silencioso (sp1…).",
|
||||
"errorGoalInvalid": "La meta debe ser una cantidad positiva en dólares enteros.",
|
||||
"errorDeadlinePast": "La fecha límite no puede estar en el pasado.",
|
||||
"errorDeadlineInvalid": "La fecha límite no es una fecha válida.",
|
||||
"errorEditLatestMissing": "No se pudo encontrar la última versión de esta campaña para actualizarla.",
|
||||
"errorSlugCollision": "Ya tienes una campaña con el identificador «{{slug}}». Elige otro.",
|
||||
"errorBannerInvalid": "La portada debe ser una URL https:// válida.",
|
||||
@@ -608,11 +608,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Recaudaciones de {{appName}}",
|
||||
"seoDescriptionFallback": "Apoya {{title}} en {{appName}}.",
|
||||
"deadlineEndedOn": "Finalizó el {{date}}",
|
||||
"deadlineEndsToday": "Finaliza hoy",
|
||||
"deadlineDaysLeft_one": "Queda {{count}} día",
|
||||
"deadlineDaysLeft_other": "Quedan {{count}} días",
|
||||
"deadlineEndsOn": "Finaliza el {{date}}",
|
||||
"back": "Atrás",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
@@ -655,7 +650,6 @@
|
||||
"deleteDialogTitle": "¿Eliminar esta campaña?",
|
||||
"deleteDialogBody": "Esto publica una solicitud de eliminación NIP-09. Los relés que se comportan bien quitarán la campaña de los feeds y los enlaces directos. Los recibos de donaciones pasadas quedan en cadena de todos modos. Esta acción no se puede deshacer — para seguir aceptando donaciones, edita la campaña en su lugar.",
|
||||
"storyHeading": "La historia",
|
||||
"campaignEnded": "Campaña finalizada",
|
||||
"donate": "Donar",
|
||||
"share": "Compartir",
|
||||
"target": "Meta: {{amount}}",
|
||||
@@ -756,7 +750,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.",
|
||||
@@ -826,6 +820,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Listas temáticas de campañas curadas",
|
||||
"create": "Nueva lista",
|
||||
"showMore": "Mostrar {{count}} más",
|
||||
"showLess": "Mostrar menos",
|
||||
"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",
|
||||
@@ -1244,6 +1240,7 @@
|
||||
"walletSend": {
|
||||
"title": "Enviar Bitcoin",
|
||||
"send": "Enviar Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Toca de nuevo para confirmar",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "No hay suficiente Bitcoin",
|
||||
|
||||
+17
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "کیف پول بیتکوین",
|
||||
"myWalletLabel": "کیف پول {{name}}",
|
||||
"myWalletDefault": "کیف پول من",
|
||||
"walletHeroNote": "کمکهای مالی مستقیماً به کیف پول Agora خودت سرازیر میشوند. بدون واسطه، بدون راهاندازی پرداخت، بدون انتظار.",
|
||||
"walletHeroReassurance": "کلید در دست توست، پس پول هم در دست توست. هر زمان که خواستی از بخش کیف پول برداشت کن.",
|
||||
"walletChoose": "یک کیف پول انتخاب کن",
|
||||
"walletCustom": "سفارشی",
|
||||
"walletUseCustom": "به جای آن از کیف پول سفارشی استفاده کن",
|
||||
"walletUseMine": "از کیف پول Agora من استفاده کن",
|
||||
"acceptAll": "پذیرش همهٔ نوعهای پرداخت",
|
||||
"acceptPublic": "پذیرش فقط پرداختهای عمومی",
|
||||
"acceptPrivate": "پذیرش فقط پرداختهای خصوصی",
|
||||
"acceptAllShort": "همه",
|
||||
"acceptPublicShort": "فقط عمومی",
|
||||
"acceptPrivateShort": "فقط خصوصی",
|
||||
"acceptAllHint": "هم پرداختهای عمومی روی زنجیره و هم پرداختهای بیصدای خصوصی پذیرفته میشوند.",
|
||||
"acceptPublicHint": "فقط اهداهای روی زنجیره به یک نشانی عمومی پذیرفته میشوند.",
|
||||
"acceptPrivateHint": "فقط پرداختهای بیصدا — نشانی اهداکنندگان خصوصی میماند.",
|
||||
"customWalletIntro": "یک نشانی بیتکوین، یک کد پرداخت بیصدا یا هر دو را وارد کن. حداقل یکی الزامی است.",
|
||||
"acceptHeading": "چه نوع کمکهای مالی را میپذیری؟",
|
||||
"acceptUnavailable": "با این ورود در دسترس نیست.",
|
||||
"acceptAllTitle": "هر نوع کمک مالی",
|
||||
"acceptPublicTitle": "فقط کمکهای مالی عمومی",
|
||||
"acceptPrivateTitle": "فقط کمکهای مالی خصوصی",
|
||||
"acceptAllHint": "هم کمکهای مالی عمومی و هم خصوصی را بپذیر.",
|
||||
"acceptPublicHint": "اهداکنندگان به یک نشانی معمولی Bitcoin پرداخت میکنند. این کمکهای مالی برای همه قابل مشاهدهاند.",
|
||||
"acceptPrivateHint": "اهداکنندگان بهصورت خصوصی پرداخت میکنند، بنابراین هویتشان از دید عموم پنهان میماند.",
|
||||
"customWalletIntro": "هر نوع کمک مالی را که میخواهی بپذیری وارد کن: یک نشانی عمومی، یک کد خصوصی، یا هر دو. دستکم یکی لازم است.",
|
||||
"customOnchainMeaning": "عمومی. همه میتوانند این کمکهای مالی را ببینند.",
|
||||
"customSpMeaning": "خصوصی. هویت اهداکننده پنهان میماند.",
|
||||
"bitcoinAddress": "نشانی بیتکوین",
|
||||
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
|
||||
"silentPaymentCode": "کد پرداخت بیصدا",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "هدف",
|
||||
"goalPlaceholder": "۲۵٬۰۰۰",
|
||||
"goalNote": "دلار آمریکای صحیح. اهداکنندگان با بیتکوین میپردازند؛ کلاینتها معادل دلاری را در زمان نمایش تخمین میزنند.",
|
||||
"deadline": "مهلت",
|
||||
"submitCreate": "راهاندازی کمپین",
|
||||
"submitEdit": "بهروزرسانی کمپین",
|
||||
"publishing": "در حال انتشار…",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "کد پرداخت بیصدا یک کد BIP-352 شناختهشده نیست (sp1…).",
|
||||
"errorWalletRequired": "حداقل یک نقطهٔ کیف پول وارد کن — یک نشانی بیتکوین شبکهٔ اصلی (bc1q… / bc1p…) یا یک کد پرداخت بیصدا (sp1…).",
|
||||
"errorGoalInvalid": "هدف باید یک مقدار مثبت به دلار صحیح باشد.",
|
||||
"errorDeadlinePast": "مهلت نمیتواند در گذشته باشد.",
|
||||
"errorDeadlineInvalid": "مهلت یک تاریخ معتبر نیست.",
|
||||
"errorEditLatestMissing": "آخرین نسخهٔ این کمپین برای بهروزرسانی یافت نشد.",
|
||||
"errorSlugCollision": "از قبل کمپینی با شناسهٔ «{{slug}}» داری. شناسهٔ دیگری انتخاب کن.",
|
||||
"errorBannerInvalid": "بنر باید یک نشانی https:// معتبر باشد.",
|
||||
@@ -608,11 +608,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | کمپینهای {{appName}}",
|
||||
"seoDescriptionFallback": "از {{title}} در {{appName}} حمایت کن.",
|
||||
"deadlineEndedOn": "در {{date}} پایان یافت",
|
||||
"deadlineEndsToday": "امروز پایان مییابد",
|
||||
"deadlineDaysLeft_one": "{{count}} روز باقی",
|
||||
"deadlineDaysLeft_other": "{{count}} روز باقی",
|
||||
"deadlineEndsOn": "در {{date}} پایان مییابد",
|
||||
"back": "بازگشت",
|
||||
"edit": "ویرایش",
|
||||
"delete": "حذف",
|
||||
@@ -655,7 +650,6 @@
|
||||
"deleteDialogTitle": "این کمپین حذف شود؟",
|
||||
"deleteDialogBody": "این یک درخواست حذف NIP-09 منتشر میکند. رلههای همکار کمپین را از فیدها و پیوندهای مستقیم حذف خواهند کرد. رسیدهای کمکهای گذشته در زنجیره باقی میمانند. این کار قابل بازگشت نیست — برای ادامهٔ دریافت کمک، بهجای حذف کمپین را ویرایش کن.",
|
||||
"storyHeading": "داستان",
|
||||
"campaignEnded": "کمپین پایان یافت",
|
||||
"donate": "کمک کنید",
|
||||
"share": "همرسانی",
|
||||
"target": "هدف: {{amount}}",
|
||||
@@ -756,7 +750,7 @@
|
||||
"wlcDesc": "کمپینهای گزینششده توسط کنگرهٔ آزادی جهانی (World Liberty Congress).",
|
||||
"allCampaigns": "همه کمپینها",
|
||||
"allCampaignsDesc": "همه کمپینهای شبکه، به ترتیب زمانی.",
|
||||
"browseAll": "← مرور همه کمپینها",
|
||||
"browseAll": "مرور همه کمپینها",
|
||||
"hidden": "پنهانشده",
|
||||
"hiddenDesc": "کمپینهایی که از صفحه اصلی عمومی حذف شدهاند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
|
||||
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
|
||||
@@ -826,6 +820,8 @@
|
||||
"lists": {
|
||||
"stripAria": "فهرستهای منتخب موضوعی کمپینها",
|
||||
"create": "فهرست جدید",
|
||||
"showMore": "نمایش {{count}} مورد بیشتر",
|
||||
"showLess": "نمایش کمتر",
|
||||
"createDesc": "یک فهرست موضوعی جدید بسازید. از هر صفحهٔ کمپین، کمپینها را به آن اضافه کنید.",
|
||||
"createSubmit": "ساخت فهرست",
|
||||
"createFailed": "ساخت فهرست ناموفق بود",
|
||||
@@ -1244,6 +1240,7 @@
|
||||
"walletSend": {
|
||||
"title": "ارسال بیتکوین",
|
||||
"send": "ارسال بیتکوین",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "برای تأیید دوباره ضربه بزنید",
|
||||
"satPerVB": "{{rate}} ساتوشی/vB",
|
||||
"notEnoughBitcoin": "بیتکوین کافی نیست",
|
||||
|
||||
+14
-19
@@ -939,19 +939,20 @@
|
||||
"wallet": "Portefeuille Bitcoin",
|
||||
"myWalletLabel": "Portefeuille de {{name}}",
|
||||
"myWalletDefault": "Mon portefeuille",
|
||||
"walletHeroNote": "Les dons arrivent directement dans votre propre portefeuille Agora. Pas d'intermédiaire, pas de configuration de versement, pas d'attente.",
|
||||
"walletHeroReassurance": "Vous détenez la clé, donc vous détenez les fonds. Retirez à tout moment depuis l'onglet portefeuille.",
|
||||
"walletChoose": "Choisir un portefeuille",
|
||||
"walletCustom": "Personnalisé",
|
||||
"walletUseCustom": "Utiliser un portefeuille personnalisé",
|
||||
"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.",
|
||||
"acceptHeading": "Quels dons souhaitez-vous accepter ?",
|
||||
"acceptUnavailable": "Non disponible avec cette connexion.",
|
||||
"acceptAllTitle": "Tout don",
|
||||
"acceptPublicTitle": "Dons publics uniquement",
|
||||
"acceptPrivateTitle": "Dons privés uniquement",
|
||||
"acceptAllHint": "Recevez à la fois les dons publics et les dons privés.",
|
||||
"acceptPublicHint": "Les donateurs versent sur une adresse Bitcoin classique. Ces dons sont visibles par tout le monde.",
|
||||
"acceptPrivateHint": "Les donateurs versent en privé, afin que leur identité reste cachée du public.",
|
||||
"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…",
|
||||
@@ -987,7 +988,6 @@
|
||||
"goal": "Objectif",
|
||||
"goalPlaceholder": "25 000",
|
||||
"goalNote": "Dollars américains entiers. Les donateurs paient en Bitcoin ; les clients estiment l'équivalent en USD au moment de la consultation.",
|
||||
"deadline": "Échéance",
|
||||
"submitCreate": "Lancer la campagne",
|
||||
"submitEdit": "Mettre à jour la campagne",
|
||||
"publishing": "Publication…",
|
||||
@@ -1012,8 +1012,6 @@
|
||||
"errorSpInvalid": "Le code de paiement silencieux n'est pas un code BIP-352 reconnu (sp1…).",
|
||||
"errorWalletRequired": "Fournissez au moins un point de terminaison de portefeuille — une adresse mainnet Bitcoin (bc1q… / bc1p…) ou un code de paiement silencieux (sp1…).",
|
||||
"errorGoalInvalid": "L'objectif doit être un montant entier positif en dollars.",
|
||||
"errorDeadlinePast": "L'échéance ne peut pas être dans le passé.",
|
||||
"errorDeadlineInvalid": "L'échéance n'est pas une date valide.",
|
||||
"errorEditLatestMissing": "Impossible de trouver la dernière version de cette campagne à mettre à jour.",
|
||||
"errorSlugCollision": "Vous avez déjà une campagne avec l'identifiant « {{slug}} ». Choisissez-en une autre.",
|
||||
"errorBannerInvalid": "La bannière doit être une URL https:// valide.",
|
||||
@@ -1039,11 +1037,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Collectes de fonds {{appName}}",
|
||||
"seoDescriptionFallback": "Soutenez {{title}} sur {{appName}}.",
|
||||
"deadlineEndedOn": "Terminée le {{date}}",
|
||||
"deadlineEndsToday": "Se termine aujourd'hui",
|
||||
"deadlineDaysLeft_one": "{{count}} jour restant",
|
||||
"deadlineDaysLeft_other": "{{count}} jours restants",
|
||||
"deadlineEndsOn": "Se termine le {{date}}",
|
||||
"back": "Retour",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
@@ -1086,7 +1079,6 @@
|
||||
"deleteDialogTitle": "Supprimer cette campagne ?",
|
||||
"deleteDialogBody": "Cela publie une demande de suppression NIP-09. Les relais bien intentionnés retireront la campagne des fils et des liens directs. Les reçus de dons passés restent sur la chaîne quoi qu'il arrive. Cette action est irréversible — pour continuer à accepter les dons, modifiez la campagne à la place.",
|
||||
"storyHeading": "L'histoire",
|
||||
"campaignEnded": "Campagne terminée",
|
||||
"donate": "Faire un don",
|
||||
"share": "Partager",
|
||||
"target": "Objectif : {{amount}}",
|
||||
@@ -1187,7 +1179,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.",
|
||||
@@ -1257,6 +1249,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Listes thématiques de campagnes",
|
||||
"create": "Nouvelle liste",
|
||||
"showMore": "Afficher {{count}} de plus",
|
||||
"showLess": "Afficher moins",
|
||||
"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",
|
||||
@@ -1676,6 +1670,7 @@
|
||||
"walletSend": {
|
||||
"title": "Envoyer du Bitcoin",
|
||||
"send": "Envoyer du Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Appuyez à nouveau pour confirmer",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "Bitcoin insuffisant",
|
||||
|
||||
+17
-20
@@ -940,20 +940,23 @@
|
||||
"wallet": "Bitcoin वॉलेट",
|
||||
"myWalletLabel": "{{name}} का वॉलेट",
|
||||
"myWalletDefault": "मेरा वॉलेट",
|
||||
"walletHeroNote": "दान सीधे आपके अपने Agora वॉलेट में आता है। कोई बिचौलिया नहीं, कोई पेआउट सेटअप नहीं, कोई इंतज़ार नहीं।",
|
||||
"walletHeroReassurance": "चाबी आपके पास रहती है, इसलिए पैसा भी आपके पास रहता है। वॉलेट टैब से किसी भी समय निकासी करें।",
|
||||
"walletChoose": "वॉलेट चुनें",
|
||||
"walletCustom": "कस्टम",
|
||||
"walletUseCustom": "इसके बजाय कस्टम वॉलेट का उपयोग करें",
|
||||
"walletUseMine": "मेरे Agora वॉलेट का उपयोग करें",
|
||||
"acceptAll": "सभी भुगतान प्रकार स्वीकार करें",
|
||||
"acceptPublic": "केवल सार्वजनिक भुगतान स्वीकार करें",
|
||||
"acceptPrivate": "केवल निजी भुगतान स्वीकार करें",
|
||||
"acceptAllShort": "सभी स्वीकारें",
|
||||
"acceptPublicShort": "केवल सार्वजनिक",
|
||||
"acceptPrivateShort": "केवल निजी",
|
||||
"acceptAllHint": "सार्वजनिक ऑन-चेन और निजी साइलेंट पेमेंट दोनों स्वीकार करें।",
|
||||
"acceptPublicHint": "केवल सार्वजनिक एड्रेस पर ऑन-चेन दान स्वीकार करें।",
|
||||
"acceptPrivateHint": "केवल साइलेंट पेमेंट स्वीकार करें — दानदाता के एड्रेस निजी रहते हैं।",
|
||||
"customWalletIntro": "एक Bitcoin एड्रेस, एक साइलेंट-पेमेंट कोड, या दोनों दर्ज करें। कम से कम एक ज़रूरी है।",
|
||||
"acceptHeading": "आप किस तरह के दान स्वीकार करेंगे?",
|
||||
"acceptUnavailable": "इस लॉगिन के साथ उपलब्ध नहीं है।",
|
||||
"acceptAllTitle": "कोई भी दान",
|
||||
"acceptPublicTitle": "केवल सार्वजनिक दान",
|
||||
"acceptPrivateTitle": "केवल निजी दान",
|
||||
"acceptAllHint": "सार्वजनिक और निजी, दोनों तरह के दान लें।",
|
||||
"acceptPublicHint": "दानदाता एक सामान्य Bitcoin एड्रेस पर देते हैं। ये दान किसी को भी दिखाई देते हैं।",
|
||||
"acceptPrivateHint": "दानदाता निजी तौर पर देते हैं, ताकि उनकी पहचान सबसे छिपी रहे।",
|
||||
"customWalletIntro": "जो भी दान आप स्वीकार करना चाहते हैं, उसे भरें: एक सार्वजनिक पता, एक निजी कोड, या दोनों। कम से कम एक ज़रूरी है।",
|
||||
"customOnchainMeaning": "सार्वजनिक। ये दान कोई भी देख सकता है।",
|
||||
"customSpMeaning": "निजी। दानदाता की पहचान छिपी रहती है।",
|
||||
"bitcoinAddress": "Bitcoin एड्रेस",
|
||||
"bitcoinAddressPlaceholder": "bc1q… या bc1p…",
|
||||
"silentPaymentCode": "साइलेंट-पेमेंट कोड",
|
||||
@@ -988,7 +991,6 @@
|
||||
"goal": "लक्ष्य",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "पूरे US डॉलर। डोनर Bitcoin में भुगतान करते हैं; क्लाइंट देखने के समय USD-समकक्ष का अनुमान लगाते हैं।",
|
||||
"deadline": "अंतिम तारीख़",
|
||||
"submitCreate": "कैंपेन लॉन्च करें",
|
||||
"submitEdit": "कैंपेन अपडेट करें",
|
||||
"publishing": "पब्लिश हो रहा है…",
|
||||
@@ -1013,8 +1015,6 @@
|
||||
"errorSpInvalid": "साइलेंट-पेमेंट कोड कोई पहचाना BIP-352 कोड नहीं है (sp1…)।",
|
||||
"errorWalletRequired": "कम से कम एक वॉलेट endpoint दें — एक Bitcoin mainnet एड्रेस (bc1q… / bc1p…) या एक साइलेंट-पेमेंट कोड (sp1…)।",
|
||||
"errorGoalInvalid": "लक्ष्य एक धनात्मक पूर्ण-डॉलर राशि होनी चाहिए।",
|
||||
"errorDeadlinePast": "अंतिम तारीख़ बीते समय की नहीं हो सकती।",
|
||||
"errorDeadlineInvalid": "अंतिम तारीख़ मान्य तारीख़ नहीं है।",
|
||||
"errorEditLatestMissing": "इस कैंपेन का सबसे नया संस्करण अपडेट करने के लिए नहीं मिला।",
|
||||
"errorSlugCollision": "आपके पास पहले से \"{{slug}}\" पहचानकर्ता वाला कैंपेन है। दूसरा चुनें।",
|
||||
"errorBannerInvalid": "बैनर एक मान्य https:// URL होना चाहिए।",
|
||||
@@ -1040,11 +1040,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | {{appName}} फंडरेज़र",
|
||||
"seoDescriptionFallback": "{{appName}} पर {{title}} का समर्थन करें।",
|
||||
"deadlineEndedOn": "{{date}} को समाप्त",
|
||||
"deadlineEndsToday": "आज समाप्त",
|
||||
"deadlineDaysLeft_one": "{{count}} दिन बाक़ी",
|
||||
"deadlineDaysLeft_other": "{{count}} दिन बाक़ी",
|
||||
"deadlineEndsOn": "{{date}} को समाप्त",
|
||||
"back": "वापस",
|
||||
"edit": "एडिट करें",
|
||||
"delete": "डिलीट करें",
|
||||
@@ -1087,7 +1082,6 @@
|
||||
"deleteDialogTitle": "इस कैंपेन को डिलीट करें?",
|
||||
"deleteDialogBody": "इससे एक NIP-09 डिलीशन रिक्वेस्ट पब्लिश होती है। सही तरीक़े से चलने वाले रिले कैंपेन को फ़ीड और सीधे लिंक से हटा देंगे। पिछली डोनेशन रसीदें ऑन-चेन वैसे भी रहेंगी। यह क्रिया वापस नहीं ली जा सकती — डोनेशन लेना जारी रखने के लिए इसके बजाय कैंपेन एडिट करें।",
|
||||
"storyHeading": "कहानी",
|
||||
"campaignEnded": "कैंपेन समाप्त",
|
||||
"donate": "डोनेट करें",
|
||||
"share": "शेयर करें",
|
||||
"target": "लक्ष्य: {{amount}}",
|
||||
@@ -1188,7 +1182,7 @@
|
||||
"wlcDesc": "World Liberty Congress द्वारा चुने गए कैंपेन।",
|
||||
"allCampaigns": "सभी कैंपेन",
|
||||
"allCampaignsDesc": "नेटवर्क के सभी कैंपेन, कालक्रम के अनुसार।",
|
||||
"browseAll": "सभी कैंपेन देखें →",
|
||||
"browseAll": "सभी कैंपेन देखें",
|
||||
"hidden": "छुपा हुआ",
|
||||
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
|
||||
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
|
||||
@@ -1230,6 +1224,8 @@
|
||||
"lists": {
|
||||
"stripAria": "क्यूरेटेड कैंपेन टॉपिक सूचियाँ",
|
||||
"create": "नई सूची",
|
||||
"showMore": "{{count}} और दिखाएँ",
|
||||
"showLess": "कम दिखाएँ",
|
||||
"createDesc": "एक नई टॉपिक सूची बनाएँ। किसी भी कैंपेन पेज से कैंपेन उसमें जोड़कर क्यूरेट करें।",
|
||||
"createSubmit": "सूची बनाएँ",
|
||||
"createFailed": "सूची नहीं बनाई जा सकी",
|
||||
@@ -1585,6 +1581,7 @@
|
||||
"walletSend": {
|
||||
"title": "Bitcoin भेजें",
|
||||
"send": "Bitcoin भेजें",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "पुष्टि के लिए फिर टैप करें",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "पर्याप्त बिटकॉइन नहीं",
|
||||
|
||||
+17
-20
@@ -940,20 +940,23 @@
|
||||
"wallet": "Dompet Bitcoin",
|
||||
"myWalletLabel": "Dompet {{name}}",
|
||||
"myWalletDefault": "Dompet saya",
|
||||
"walletHeroNote": "Donasi langsung masuk ke dompet Agora Anda sendiri. Tanpa perantara, tanpa pengaturan pembayaran, tanpa menunggu.",
|
||||
"walletHeroReassurance": "Anda yang memegang kuncinya, jadi Anda yang memegang dananya. Tarik kapan saja dari tab dompet.",
|
||||
"walletChoose": "Pilih dompet",
|
||||
"walletCustom": "Kustom",
|
||||
"walletUseCustom": "Gunakan dompet kustom",
|
||||
"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.",
|
||||
"acceptHeading": "Donasi apa yang akan Anda terima?",
|
||||
"acceptUnavailable": "Tidak tersedia dengan login ini.",
|
||||
"acceptAllTitle": "Donasi apa pun",
|
||||
"acceptPublicTitle": "Hanya donasi publik",
|
||||
"acceptPrivateTitle": "Hanya donasi privat",
|
||||
"acceptAllHint": "Terima donasi publik maupun privat.",
|
||||
"acceptPublicHint": "Donatur memberi ke alamat Bitcoin biasa. Donasi ini terlihat oleh siapa saja.",
|
||||
"acceptPrivateHint": "Donatur memberi secara privat, sehingga identitas mereka tetap tersembunyi dari publik.",
|
||||
"customWalletIntro": "Isi donasi mana saja yang ingin Anda terima: alamat publik, kode privat, atau keduanya. Setidaknya satu wajib diisi.",
|
||||
"customOnchainMeaning": "Publik. Siapa saja dapat melihat donasi ini.",
|
||||
"customSpMeaning": "Privat. Identitas donatur tetap tersembunyi.",
|
||||
"bitcoinAddress": "Alamat Bitcoin",
|
||||
"bitcoinAddressPlaceholder": "bc1q… atau bc1p…",
|
||||
"silentPaymentCode": "Kode silent-payment",
|
||||
@@ -988,7 +991,6 @@
|
||||
"goal": "Target",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "Dalam USD utuh. Donatur membayar dalam Bitcoin; klien memperkirakan nilai setara USD saat dilihat.",
|
||||
"deadline": "Tenggat",
|
||||
"submitCreate": "Luncurkan kampanye",
|
||||
"submitEdit": "Perbarui kampanye",
|
||||
"publishing": "Memublikasikan…",
|
||||
@@ -1013,8 +1015,6 @@
|
||||
"errorSpInvalid": "Kode silent-payment bukan kode BIP-352 yang dikenal (sp1…).",
|
||||
"errorWalletRequired": "Sediakan setidaknya satu titik dompet — alamat Bitcoin mainnet (bc1q… / bc1p…) atau kode silent-payment (sp1…).",
|
||||
"errorGoalInvalid": "Target harus berupa nilai dolar utuh yang positif.",
|
||||
"errorDeadlinePast": "Tenggat tidak boleh di masa lalu.",
|
||||
"errorDeadlineInvalid": "Tenggat bukan tanggal yang valid.",
|
||||
"errorEditLatestMissing": "Tidak dapat menemukan versi terbaru kampanye ini untuk diperbarui.",
|
||||
"errorSlugCollision": "Anda sudah memiliki kampanye dengan pengenal \"{{slug}}\". Pilih yang lain.",
|
||||
"errorBannerInvalid": "Banner harus berupa URL https:// yang valid.",
|
||||
@@ -1040,11 +1040,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Penggalangan Dana {{appName}}",
|
||||
"seoDescriptionFallback": "Dukung {{title}} di {{appName}}.",
|
||||
"deadlineEndedOn": "Berakhir {{date}}",
|
||||
"deadlineEndsToday": "Berakhir hari ini",
|
||||
"deadlineDaysLeft_one": "{{count}} hari lagi",
|
||||
"deadlineDaysLeft_other": "{{count}} hari lagi",
|
||||
"deadlineEndsOn": "Berakhir {{date}}",
|
||||
"back": "Kembali",
|
||||
"edit": "Ubah",
|
||||
"delete": "Hapus",
|
||||
@@ -1087,7 +1082,6 @@
|
||||
"deleteDialogTitle": "Hapus kampanye ini?",
|
||||
"deleteDialogBody": "Ini akan memublikasikan permintaan penghapusan NIP-09. Relay yang berperilaku baik akan menghapus kampanye dari feed dan tautan langsung. Tanda terima donasi sebelumnya tetap on-chain apa pun yang terjadi. Tindakan ini tidak dapat dibatalkan — untuk tetap menerima donasi, ubah kampanye sebagai gantinya.",
|
||||
"storyHeading": "Ceritanya",
|
||||
"campaignEnded": "Kampanye berakhir",
|
||||
"donate": "Donasi",
|
||||
"share": "Bagikan",
|
||||
"target": "Target: {{amount}}",
|
||||
@@ -1188,7 +1182,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.",
|
||||
@@ -1230,6 +1224,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Daftar topik kampanye terkurasi",
|
||||
"create": "Daftar baru",
|
||||
"showMore": "Tampilkan {{count}} lagi",
|
||||
"showLess": "Tampilkan lebih sedikit",
|
||||
"createDesc": "Buat daftar topik baru. Kurasi kampanye ke dalamnya dari halaman kampanye mana pun.",
|
||||
"createSubmit": "Buat daftar",
|
||||
"createFailed": "Gagal membuat daftar",
|
||||
@@ -1585,6 +1581,7 @@
|
||||
"walletSend": {
|
||||
"title": "Kirim Bitcoin",
|
||||
"send": "Kirim Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Ketuk lagi untuk konfirmasi",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "Bitcoin tidak mencukupi",
|
||||
|
||||
+17
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "កាបូបប៊ីតខញ",
|
||||
"myWalletLabel": "កាបូបរបស់ {{name}}",
|
||||
"myWalletDefault": "កាបូបរបស់ខ្ញុំ",
|
||||
"walletHeroNote": "ការបរិច្ចាគហូរចូលដោយផ្ទាល់ទៅក្នុងកាបូប Agora ផ្ទាល់ខ្លួនរបស់អ្នក។ គ្មានអ្នកកណ្ដាល គ្មានការរៀបចំការទូទាត់ គ្មានការរង់ចាំ។",
|
||||
"walletHeroReassurance": "អ្នកកាន់កូនសោ ដូច្នេះអ្នកកាន់មូលនិធិ។ ដកប្រាក់បានគ្រប់ពេលពីផ្ទាំងកាបូប។",
|
||||
"walletChoose": "ជ្រើសរើសកាបូប",
|
||||
"walletCustom": "ផ្ទាល់ខ្លួន",
|
||||
"walletUseCustom": "ប្រើកាបូបផ្ទាល់ខ្លួនជំនួសវិញ",
|
||||
"walletUseMine": "ប្រើកាបូប Agora របស់ខ្ញុំ",
|
||||
"acceptAll": "ទទួលយកការទូទាត់គ្រប់ប្រភេទ",
|
||||
"acceptPublic": "ទទួលយកការទូទាត់សាធារណៈតែប៉ុណ្ណោះ",
|
||||
"acceptPrivate": "ទទួលយកការទូទាត់ឯកជនតែប៉ុណ្ណោះ",
|
||||
"acceptAllShort": "ទាំងអស់",
|
||||
"acceptPublicShort": "សាធារណៈតែប៉ុណ្ណោះ",
|
||||
"acceptPrivateShort": "ឯកជនតែប៉ុណ្ណោះ",
|
||||
"acceptAllHint": "ទទួលយកទាំងការទូទាត់ on-chain សាធារណៈ និងការបង់ប្រាក់ស្ងាត់ឯកជន។",
|
||||
"acceptPublicHint": "ទទួលយកតែការបរិច្ចាគ on-chain ទៅកាន់អាសយដ្ឋានសាធារណៈប៉ុណ្ណោះ។",
|
||||
"acceptPrivateHint": "ទទួលយកតែការបង់ប្រាក់ស្ងាត់ប៉ុណ្ណោះ — អាសយដ្ឋានរបស់អ្នកបរិច្ចាគនៅតែឯកជន។",
|
||||
"customWalletIntro": "បញ្ចូលអាសយដ្ឋានប៊ីតខញ លេខកូដបង់ប្រាក់ស្ងាត់ ឬទាំងពីរ។ ត្រូវការយ៉ាងហោចណាស់មួយ។",
|
||||
"acceptHeading": "តើអ្នកនឹងទទួលការបរិច្ចាគបែបណាខ្លះ?",
|
||||
"acceptUnavailable": "មិនអាចប្រើបានជាមួយការចូលគណនីនេះទេ។",
|
||||
"acceptAllTitle": "ការបរិច្ចាគគ្រប់ប្រភេទ",
|
||||
"acceptPublicTitle": "ការបរិច្ចាគសាធារណៈតែប៉ុណ្ណោះ",
|
||||
"acceptPrivateTitle": "ការបរិច្ចាគឯកជនតែប៉ុណ្ណោះ",
|
||||
"acceptAllHint": "ទទួលយកការបរិច្ចាគទាំងសាធារណៈ និងឯកជន។",
|
||||
"acceptPublicHint": "អ្នកបរិច្ចាគផ្ញើទៅកាន់អាសយដ្ឋាន Bitcoin ធម្មតា។ ការបរិច្ចាគទាំងនេះអ្នករាល់គ្នាអាចមើលឃើញ។",
|
||||
"acceptPrivateHint": "អ្នកបរិច្ចាគផ្ញើដោយឯកជន ដូច្នេះអត្តសញ្ញាណរបស់ពួកគេនៅតែលាក់បាំងពីសាធារណៈ។",
|
||||
"customWalletIntro": "បំពេញការបរិច្ចាគណាមួយដែលអ្នកចង់ទទួល៖ អាសយដ្ឋានសាធារណៈ លេខកូដឯកជន ឬទាំងពីរ។ ត្រូវការយ៉ាងហោចណាស់មួយ។",
|
||||
"customOnchainMeaning": "សាធារណៈ។ អ្នករាល់គ្នាអាចមើលឃើញការបរិច្ចាគទាំងនេះ។",
|
||||
"customSpMeaning": "ឯកជន។ អត្តសញ្ញាណរបស់អ្នកបរិច្ចាគនៅតែលាក់បាំង។",
|
||||
"bitcoinAddress": "អាសយដ្ឋានប៊ីតខញ",
|
||||
"bitcoinAddressPlaceholder": "bc1q… ឬ bc1p…",
|
||||
"silentPaymentCode": "លេខកូដបង់ប្រាក់ស្ងាត់",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "គោលដៅ",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "ដុល្លារអាមេរិកពេញ។ អ្នកបរិច្ចាគបង់ប្រាក់ជាប៊ីតខញ; អតិថិជនប៉ាន់ប្រមាណសមមូល USD នៅពេលមើល។",
|
||||
"deadline": "ពេលកំណត់",
|
||||
"submitCreate": "បើកដំណើរយុទ្ធនាការ",
|
||||
"submitEdit": "ធ្វើបច្ចុប្បន្នភាពយុទ្ធនាការ",
|
||||
"publishing": "កំពុងផ្សព្វផ្សាយ…",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "លេខកូដបង់ប្រាក់ស្ងាត់មិនមែនជាលេខកូដ BIP-352 ដែលត្រូវបានទទួលស្គាល់ទេ (sp1…)។",
|
||||
"errorWalletRequired": "ផ្តល់យ៉ាងហោចណាស់ចំណុចកាបូបមួយ — អាសយដ្ឋានប៊ីតខញ mainnet (bc1q… / bc1p…) ឬលេខកូដបង់ប្រាក់ស្ងាត់ (sp1…)។",
|
||||
"errorGoalInvalid": "គោលដៅត្រូវតែជាចំនួនវិជ្ជមានជា USD ពេញ។",
|
||||
"errorDeadlinePast": "ពេលកំណត់មិនអាចស្ថិតក្នុងអតីតកាល។",
|
||||
"errorDeadlineInvalid": "ពេលកំណត់មិនមែនជាកាលបរិច្ឆេទត្រឹមត្រូវ។",
|
||||
"errorEditLatestMissing": "មិនអាចស្វែងរកកំណែចុងក្រោយរបស់យុទ្ធនាការនេះដើម្បីធ្វើបច្ចុប្បន្នភាព។",
|
||||
"errorSlugCollision": "អ្នកមានយុទ្ធនាការដែលមានកំណត់អត្តសញ្ញាណ «{{slug}}» រួចហើយ។ ជ្រើសរើសផ្សេង។",
|
||||
"errorBannerInvalid": "បដាត្រូវតែជា URL https:// ត្រឹមត្រូវ។",
|
||||
@@ -608,11 +608,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | យុទ្ធនាការរបស់ {{appName}}",
|
||||
"seoDescriptionFallback": "គាំទ្រ {{title}} នៅលើ {{appName}}។",
|
||||
"deadlineEndedOn": "បានបញ្ចប់នៅ {{date}}",
|
||||
"deadlineEndsToday": "បញ្ចប់នៅថ្ងៃនេះ",
|
||||
"deadlineDaysLeft_one": "នៅសល់ {{count}} ថ្ងៃ",
|
||||
"deadlineDaysLeft_other": "នៅសល់ {{count}} ថ្ងៃ",
|
||||
"deadlineEndsOn": "បញ្ចប់នៅ {{date}}",
|
||||
"back": "ត្រឡប់",
|
||||
"edit": "កែសម្រួល",
|
||||
"delete": "លុប",
|
||||
@@ -655,7 +650,6 @@
|
||||
"deleteDialogTitle": "លុបយុទ្ធនាការនេះមែនទេ?",
|
||||
"deleteDialogBody": "នេះផ្សព្វផ្សាយសំណើលុប NIP-09។ Relay ដែលអនុលោមនឹងលុបយុទ្ធនាការចេញពី feed និងតំណផ្ទាល់។ បង្កាន់ដៃនៃការបរិច្ចាគកន្លងមកនៅតែស្ថិតលើខ្សែសង្វាក់។ សកម្មភាពនេះមិនអាចត្រឡប់វិញបានទេ — ដើម្បីបន្តទទួលការបរិច្ចាគ កែសម្រួលយុទ្ធនាការជំនួស។",
|
||||
"storyHeading": "រឿង",
|
||||
"campaignEnded": "យុទ្ធនាការបានបញ្ចប់",
|
||||
"donate": "បរិច្ចាគ",
|
||||
"share": "ចែករំលែក",
|
||||
"target": "គោលដៅ៖ {{amount}}",
|
||||
@@ -756,7 +750,7 @@
|
||||
"wlcDesc": "យុទ្ធនាការដែលបានសម្រិតសម្រាំងដោយសភាសេរីភាពពិភពលោក (World Liberty Congress)។",
|
||||
"allCampaigns": "យុទ្ធនាការទាំងអស់",
|
||||
"allCampaignsDesc": "យុទ្ធនាការទាំងអស់នៅលើបណ្តាញ តាមលំដាប់កាលប្បវត្តិ។",
|
||||
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
|
||||
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
|
||||
"hidden": "បានលាក់",
|
||||
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
|
||||
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
|
||||
@@ -826,6 +820,8 @@
|
||||
"lists": {
|
||||
"stripAria": "បញ្ជីប្រធានបទយុទ្ធនាការដែលបានសម្រិតសម្រាំង",
|
||||
"create": "បញ្ជីថ្មី",
|
||||
"showMore": "បង្ហាញ {{count}} ទៀត",
|
||||
"showLess": "បង្ហាញតិច",
|
||||
"createDesc": "បង្កើតបញ្ជីប្រធានបទថ្មី។ សម្រិតសម្រាំងយុទ្ធនាការទៅក្នុងវាពីទំព័រយុទ្ធនាការណាមួយ។",
|
||||
"createSubmit": "បង្កើតបញ្ជី",
|
||||
"createFailed": "បរាជ័យក្នុងការបង្កើតបញ្ជី",
|
||||
@@ -1244,6 +1240,7 @@
|
||||
"walletSend": {
|
||||
"title": "ផ្ញើ Bitcoin",
|
||||
"send": "ផ្ញើ Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "ប៉ះម្ដងទៀតដើម្បីបញ្ជាក់",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "មិនមាន Bitcoin គ្រប់គ្រាន់",
|
||||
|
||||
+19
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "د بټکوین پاکټ",
|
||||
"myWalletLabel": "د {{name}} پاکټ",
|
||||
"myWalletDefault": "زما پاکټ",
|
||||
"walletHeroNote": "بسپنې مستقیماً ستاسو په خپل اګورا (Agora) پاکټ کې راځي. نه منځګړی، نه د تادیې جوړونه، نه انتظار.",
|
||||
"walletHeroReassurance": "تاسو کلی لرئ، نو پیسې هم تاسو لرئ. کله هم چې وغواړئ د پاکټ له ټوپ څخه یې وباسئ.",
|
||||
"walletChoose": "پاکټ وټاکئ",
|
||||
"walletCustom": "ګمرکي",
|
||||
"walletUseCustom": "ګمرکي پاکټ وکاروئ",
|
||||
"walletUseMine": "زما د اګورا پاکټ وکاروئ",
|
||||
"acceptAll": "د ټولو پیسو ډولونو منل",
|
||||
"acceptPublic": "یوازې د عامه پیسو منل",
|
||||
"acceptPrivate": "یوازې د خصوصي پیسو منل",
|
||||
"acceptAllShort": "ټول ومنه",
|
||||
"acceptPublicShort": "یوازې عامه",
|
||||
"acceptPrivateShort": "یوازې خصوصي",
|
||||
"acceptAllHint": "د عامه آنچین او خصوصي چپ پیسو دواړه ومنه.",
|
||||
"acceptPublicHint": "یوازې عامه پته ته آنچین مرستې ومنه.",
|
||||
"acceptPrivateHint": "یوازې چپ پیسې ومنه — د مرستهکوونکو پتې پټې پاتې کیږي.",
|
||||
"customWalletIntro": "د بټکوین پته، د چپ پیسو کوډ، یا دواړه دننه کړئ. لږ تر لږه یو ته اړتیا ده.",
|
||||
"acceptHeading": "کومې بسپنې به ومنئ؟",
|
||||
"acceptUnavailable": "د دې لاگین سره شتون نه لري.",
|
||||
"acceptAllTitle": "هره بسپنه",
|
||||
"acceptPublicTitle": "یوازې عامه بسپنې",
|
||||
"acceptPrivateTitle": "یوازې پټې بسپنې",
|
||||
"acceptAllHint": "عامه او پټې بسپنې دواړه ومنئ.",
|
||||
"acceptPublicHint": "بسپنهورکوونکي یوې عادي Bitcoin پتې ته ورکوي. دا بسپنې هر چا ته ښکاري.",
|
||||
"acceptPrivateHint": "بسپنهورکوونکي په پټه توګه ورکوي، نو د دوی پېژندنه له عامو خلکو پټه پاتې کیږي.",
|
||||
"customWalletIntro": "هره بسپنه چې غواړئ ومنئ ډکه کړئ: یوه عامه پته، یو پټ کوډ، یا دواړه. لږ تر لږه یو ته اړتیا ده.",
|
||||
"customOnchainMeaning": "عامه. هر څوک کولی شي دا بسپنې وویني.",
|
||||
"customSpMeaning": "پټه. د بسپنهورکوونکي پېژندنه پټه پاتې کیږي.",
|
||||
"bitcoinAddress": "د بټکوین پته",
|
||||
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
|
||||
"silentPaymentCode": "د چپ پیسو کوډ",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "هدف",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "ټول امریکايي ډالر. ډونرز په بټکوین پیسې ورکوي؛ کلائنټونه د لیدلو په وخت کې د ډالر معادل اټکل کوي.",
|
||||
"deadline": "وروستۍ نېټه",
|
||||
"submitCreate": "کمپاین پیلول",
|
||||
"submitEdit": "د کمپاین تازه کول",
|
||||
"publishing": "خپرول…",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "د چپ پیسو کوډ د پېژندل شوي BIP-352 کوډ نه دی (sp1…).",
|
||||
"errorWalletRequired": "لږ تر لږه یوه د پاکټ نقطه ورکړئ — د بټکوین mainnet پته (bc1q… / bc1p…) یا د چپ پیسو کوډ (sp1…).",
|
||||
"errorGoalInvalid": "هدف باید د ډالر مثبته بشپړه اندازه وي.",
|
||||
"errorDeadlinePast": "وروستۍ نېټه نشي کولی په تېر وخت کې وي.",
|
||||
"errorDeadlineInvalid": "وروستۍ نېټه سمه نېټه نه ده.",
|
||||
"errorEditLatestMissing": "د دې کمپاین وروستۍ نسخه د تازه کولو لپاره ونه موندل شوه.",
|
||||
"errorSlugCollision": "تاسو لا دمخه «{{slug}}» پېژندونکی کمپاین لرئ. بل وټاکئ.",
|
||||
"errorBannerInvalid": "بنر باید د https:// سمه نښه وي.",
|
||||
@@ -599,6 +599,8 @@
|
||||
"bannerStepSubtitle": "یو زړهراښکونکی انځور په هر کارت کې کمپاین ښیي.",
|
||||
"storyStepTitle": "خپله کیسه ووایاست",
|
||||
"storyStepSubtitle": "څوک ګټه اخلي او بسپنې به څنګه ولګول شي.",
|
||||
"goalStepTitle": "هدف",
|
||||
"goalStepSubtitle": "اختیاري — د بېمهاله کمپاین لپاره یې تش پرېږدئ.",
|
||||
"next": "بل",
|
||||
"back": "شاته",
|
||||
"skip": "تېرول",
|
||||
@@ -608,11 +610,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | د {{appName}} کمپاینونه",
|
||||
"seoDescriptionFallback": "په {{appName}} کې د {{title}} ملاتړ وکړئ.",
|
||||
"deadlineEndedOn": "په {{date}} پای ته ورسیده",
|
||||
"deadlineEndsToday": "نن پای ته رسي",
|
||||
"deadlineDaysLeft_one": "{{count}} ورځ پاتې",
|
||||
"deadlineDaysLeft_other": "{{count}} ورځې پاتې",
|
||||
"deadlineEndsOn": "په {{date}} پای ته رسي",
|
||||
"back": "شاته",
|
||||
"edit": "سمول",
|
||||
"delete": "ړنګول",
|
||||
@@ -655,7 +652,6 @@
|
||||
"deleteDialogTitle": "دا کمپاین ړنګ کړئ؟",
|
||||
"deleteDialogBody": "دا د NIP-09 د ړنګولو غوښتنه خپروي. ښه چلند کوونکي ریلې به کمپاین له فیدونو او مستقیمو لینکونو څخه لرې کړي. د تېرو مرستو رسیدونه پر چین پاتې کیږي. دا کار بیرته نه راګرځول کیږي — د مرستو د منلو لپاره، کمپاین سم کړئ.",
|
||||
"storyHeading": "کیسه",
|
||||
"campaignEnded": "کمپاین پای ته ورسید",
|
||||
"donate": "مرسته",
|
||||
"share": "شریکول",
|
||||
"target": "هدف: {{amount}}",
|
||||
@@ -756,7 +752,7 @@
|
||||
"wlcDesc": "د World Liberty Congress لخوا غوره شوي کمپاینونه.",
|
||||
"allCampaigns": "ټول کمپاینونه",
|
||||
"allCampaignsDesc": "د شبکې ټول کمپاینونه، د وخت په ترتیب.",
|
||||
"browseAll": "← ټول کمپاینونه وګورئ",
|
||||
"browseAll": "ټول کمپاینونه وګورئ",
|
||||
"hidden": "پټ شوي",
|
||||
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
|
||||
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
|
||||
@@ -826,6 +822,8 @@
|
||||
"lists": {
|
||||
"stripAria": "د کمپاین موضوعاتو ترتیب شوي لیستونه",
|
||||
"create": "نوی لیست",
|
||||
"showMore": "{{count}} نور وښایه",
|
||||
"showLess": "لږ وښایه",
|
||||
"createDesc": "د موضوع یو نوی لیست جوړ کړئ. د هر کمپاین له پاڼې څخه کمپاینونه پکې ترتیب کړئ.",
|
||||
"createSubmit": "لیست جوړ کړئ",
|
||||
"createFailed": "د لیست جوړول ناکام شول",
|
||||
@@ -1244,6 +1242,7 @@
|
||||
"walletSend": {
|
||||
"title": "بټکوین لیږل",
|
||||
"send": "بټکوین لیږل",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "د تایید لپاره بیا ټک ووهئ",
|
||||
"satPerVB": "{{rate}} ساتوشي/vB",
|
||||
"notEnoughBitcoin": "کافی بټکوین نشته",
|
||||
|
||||
+19
-20
@@ -940,20 +940,23 @@
|
||||
"wallet": "Carteira Bitcoin",
|
||||
"myWalletLabel": "Carteira de {{name}}",
|
||||
"myWalletDefault": "Minha carteira",
|
||||
"walletHeroNote": "As doações vão direto para a sua própria carteira Agora. Sem intermediários, sem configuração de pagamento, sem espera.",
|
||||
"walletHeroReassurance": "Você guarda a chave, então você guarda os fundos. Saque a qualquer momento na aba da carteira.",
|
||||
"walletChoose": "Escolher uma carteira",
|
||||
"walletCustom": "Personalizada",
|
||||
"walletUseCustom": "Usar uma carteira personalizada",
|
||||
"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.",
|
||||
"acceptHeading": "Quais doações você vai aceitar?",
|
||||
"acceptUnavailable": "Não disponível com este login.",
|
||||
"acceptAllTitle": "Qualquer doação",
|
||||
"acceptPublicTitle": "Somente doações públicas",
|
||||
"acceptPrivateTitle": "Somente doações privadas",
|
||||
"acceptAllHint": "Receber doações tanto públicas quanto privadas.",
|
||||
"acceptPublicHint": "Os doadores enviam para um endereço Bitcoin comum. Essas doações ficam visíveis para qualquer pessoa.",
|
||||
"acceptPrivateHint": "Os doadores enviam de forma privada, então a identidade deles fica oculta do público.",
|
||||
"customWalletIntro": "Preencha as doações que você quiser aceitar: um endereço público, um código privado, ou ambos. Pelo menos um é obrigatório.",
|
||||
"customOnchainMeaning": "Público. Qualquer pessoa pode ver essas doações.",
|
||||
"customSpMeaning": "Privado. A identidade do doador fica oculta.",
|
||||
"bitcoinAddress": "Endereço Bitcoin",
|
||||
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
|
||||
"silentPaymentCode": "Código de pagamento silencioso",
|
||||
@@ -988,7 +991,6 @@
|
||||
"goal": "Meta",
|
||||
"goalPlaceholder": "25.000",
|
||||
"goalNote": "Dólares americanos inteiros. Doadores pagam em Bitcoin; clientes estimam o equivalente em USD na hora da visualização.",
|
||||
"deadline": "Prazo",
|
||||
"submitCreate": "Lançar campanha",
|
||||
"submitEdit": "Atualizar campanha",
|
||||
"publishing": "Publicando…",
|
||||
@@ -1013,8 +1015,6 @@
|
||||
"errorSpInvalid": "O código de pagamento silencioso não é um código BIP-352 reconhecido (sp1…).",
|
||||
"errorWalletRequired": "Forneça pelo menos um endpoint de carteira — um endereço Bitcoin mainnet (bc1q… / bc1p…) ou um código de pagamento silencioso (sp1…).",
|
||||
"errorGoalInvalid": "A meta deve ser um valor inteiro positivo em dólares.",
|
||||
"errorDeadlinePast": "O prazo não pode estar no passado.",
|
||||
"errorDeadlineInvalid": "O prazo não é uma data válida.",
|
||||
"errorEditLatestMissing": "Não foi possível encontrar a versão mais recente desta campanha para atualizar.",
|
||||
"errorSlugCollision": "Você já tem uma campanha com o identificador \"{{slug}}\". Escolha outro.",
|
||||
"errorBannerInvalid": "O banner deve ser uma URL https:// válida.",
|
||||
@@ -1031,6 +1031,8 @@
|
||||
"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.",
|
||||
"goalStepTitle": "Meta",
|
||||
"goalStepSubtitle": "Opcional — deixe em branco para uma campanha sem prazo definido.",
|
||||
"next": "Próximo",
|
||||
"back": "Voltar",
|
||||
"skip": "Pular",
|
||||
@@ -1040,11 +1042,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Arrecadações {{appName}}",
|
||||
"seoDescriptionFallback": "Apoie {{title}} no {{appName}}.",
|
||||
"deadlineEndedOn": "Encerrada em {{date}}",
|
||||
"deadlineEndsToday": "Encerra hoje",
|
||||
"deadlineDaysLeft_one": "{{count}} dia restante",
|
||||
"deadlineDaysLeft_other": "{{count}} dias restantes",
|
||||
"deadlineEndsOn": "Encerra em {{date}}",
|
||||
"back": "Voltar",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir",
|
||||
@@ -1087,7 +1084,6 @@
|
||||
"deleteDialogTitle": "Excluir esta campanha?",
|
||||
"deleteDialogBody": "Isso publica uma solicitação de exclusão NIP-09. Relays bem-comportados retirarão a campanha dos feeds e links diretos. Recibos de doações passadas permanecem on-chain de qualquer forma. Esta ação não pode ser desfeita — para continuar aceitando doações, edite a campanha.",
|
||||
"storyHeading": "A história",
|
||||
"campaignEnded": "Campanha encerrada",
|
||||
"donate": "Doar",
|
||||
"share": "Compartilhar",
|
||||
"target": "Meta: {{amount}}",
|
||||
@@ -1188,7 +1184,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.",
|
||||
@@ -1258,6 +1254,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Listas de tópicos de campanhas curadas",
|
||||
"create": "Nova lista",
|
||||
"showMore": "Mostrar mais {{count}}",
|
||||
"showLess": "Mostrar menos",
|
||||
"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",
|
||||
@@ -1677,6 +1675,7 @@
|
||||
"walletSend": {
|
||||
"title": "Enviar Bitcoin",
|
||||
"send": "Enviar Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Toque novamente para confirmar",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "Bitcoin insuficiente",
|
||||
|
||||
+16
-19
@@ -940,19 +940,20 @@
|
||||
"wallet": "Bitcoin-кошелёк",
|
||||
"myWalletLabel": "Кошелёк {{name}}",
|
||||
"myWalletDefault": "Мой кошелёк",
|
||||
"walletHeroNote": "Пожертвования поступают напрямую в ваш собственный кошелёк Agora. Без посредников, без настройки выплат, без ожидания.",
|
||||
"walletHeroReassurance": "Ключ у вас, а значит, и средства у вас. Выводите их в любой момент на вкладке кошелька.",
|
||||
"walletChoose": "Выбрать кошелёк",
|
||||
"walletCustom": "Пользовательский",
|
||||
"walletUseCustom": "Использовать пользовательский кошелёк",
|
||||
"walletUseMine": "Использовать мой кошелёк Agora",
|
||||
"acceptAll": "Принимать все типы платежей",
|
||||
"acceptPublic": "Принимать только публичные платежи",
|
||||
"acceptPrivate": "Принимать только приватные платежи",
|
||||
"acceptAllShort": "Принимать все",
|
||||
"acceptPublicShort": "Только публичные",
|
||||
"acceptPrivateShort": "Только приватные",
|
||||
"acceptAllHint": "Принимать как публичные ончейн-платежи, так и приватные тихие платежи.",
|
||||
"acceptPublicHint": "Принимать только ончейн-пожертвования на публичный адрес.",
|
||||
"acceptPrivateHint": "Принимать только тихие платежи — адреса донаторов остаются приватными.",
|
||||
"acceptHeading": "Какие пожертвования вы готовы принимать?",
|
||||
"acceptUnavailable": "Недоступно при этом способе входа.",
|
||||
"acceptAllTitle": "Любые пожертвования",
|
||||
"acceptPublicTitle": "Только публичные пожертвования",
|
||||
"acceptPrivateTitle": "Только приватные пожертвования",
|
||||
"acceptAllHint": "Принимать как публичные, так и приватные пожертвования.",
|
||||
"acceptPublicHint": "Жертвователи отправляют средства на обычный Bitcoin-адрес. Такие пожертвования видны всем.",
|
||||
"acceptPrivateHint": "Жертвователи отправляют средства приватно, поэтому их личность остаётся скрытой от посторонних.",
|
||||
"customWalletIntro": "Введите Bitcoin-адрес, код тихого платежа или оба. Требуется хотя бы один.",
|
||||
"bitcoinAddress": "Bitcoin-адрес",
|
||||
"bitcoinAddressPlaceholder": "bc1q… или bc1p…",
|
||||
@@ -988,7 +989,6 @@
|
||||
"goal": "Цель",
|
||||
"goalPlaceholder": "25 000",
|
||||
"goalNote": "Целые доллары США. Жертвователи платят в Bitcoin; клиенты оценивают эквивалент в USD во время просмотра.",
|
||||
"deadline": "Срок",
|
||||
"submitCreate": "Запустить кампанию",
|
||||
"submitEdit": "Обновить кампанию",
|
||||
"publishing": "Публикация…",
|
||||
@@ -1013,8 +1013,6 @@
|
||||
"errorSpInvalid": "Код тихого платежа не является распознанным кодом BIP-352 (sp1…).",
|
||||
"errorWalletRequired": "Укажите хотя бы один эндпойнт кошелька — Bitcoin-адрес mainnet (bc1q… / bc1p…) или код тихого платежа (sp1…).",
|
||||
"errorGoalInvalid": "Цель должна быть положительной целой суммой в долларах.",
|
||||
"errorDeadlinePast": "Срок не может быть в прошлом.",
|
||||
"errorDeadlineInvalid": "Срок не является действительной датой.",
|
||||
"errorEditLatestMissing": "Не удалось найти последнюю версию этой кампании для обновления.",
|
||||
"errorSlugCollision": "У вас уже есть кампания с идентификатором «{{slug}}». Выберите другой.",
|
||||
"errorBannerInvalid": "Баннер должен быть валидной URL https://.",
|
||||
@@ -1031,6 +1029,8 @@
|
||||
"bannerStepSubtitle": "Одно яркое изображение представит кампанию на каждой карточке.",
|
||||
"storyStepTitle": "Расскажите свою историю",
|
||||
"storyStepSubtitle": "Кому это поможет и как будут использованы средства.",
|
||||
"goalStepTitle": "Цель",
|
||||
"goalStepSubtitle": "Необязательно — оставьте пустым для бессрочной кампании.",
|
||||
"next": "Далее",
|
||||
"back": "Назад",
|
||||
"skip": "Пропустить",
|
||||
@@ -1040,11 +1040,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Сборы средств {{appName}}",
|
||||
"seoDescriptionFallback": "Поддержите {{title}} на {{appName}}.",
|
||||
"deadlineEndedOn": "Завершено {{date}}",
|
||||
"deadlineEndsToday": "Завершается сегодня",
|
||||
"deadlineDaysLeft_one": "Остался {{count}} день",
|
||||
"deadlineDaysLeft_other": "Осталось {{count}} дней",
|
||||
"deadlineEndsOn": "Завершается {{date}}",
|
||||
"back": "Назад",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
@@ -1087,7 +1082,6 @@
|
||||
"deleteDialogTitle": "Удалить эту кампанию?",
|
||||
"deleteDialogBody": "Это публикует запрос на удаление NIP-09. Корректно работающие реле уберут кампанию из лент и прямых ссылок. Прошлые квитанции о пожертвованиях остаются в блокчейне независимо. Это действие нельзя отменить — чтобы продолжать принимать пожертвования, отредактируйте кампанию.",
|
||||
"storyHeading": "История",
|
||||
"campaignEnded": "Кампания завершена",
|
||||
"donate": "Пожертвовать",
|
||||
"share": "Поделиться",
|
||||
"target": "Цель: {{amount}}",
|
||||
@@ -1188,7 +1182,7 @@
|
||||
"wlcDesc": "Кампании, отобранные World Liberty Congress.",
|
||||
"allCampaigns": "Все кампании",
|
||||
"allCampaignsDesc": "Все кампании в сети, в хронологическом порядке.",
|
||||
"browseAll": "Просмотреть все кампании →",
|
||||
"browseAll": "Просмотреть все кампании",
|
||||
"hidden": "Скрытые",
|
||||
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
|
||||
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
|
||||
@@ -1258,6 +1252,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Кураторские тематические списки кампаний",
|
||||
"create": "Новый список",
|
||||
"showMore": "Показать ещё {{count}}",
|
||||
"showLess": "Скрыть",
|
||||
"createDesc": "Создайте новый тематический список. Добавляйте в него кампании с любой страницы кампании.",
|
||||
"createSubmit": "Создать список",
|
||||
"createFailed": "Не удалось создать список",
|
||||
@@ -1677,6 +1673,7 @@
|
||||
"walletSend": {
|
||||
"title": "Отправить Bitcoin",
|
||||
"send": "Отправить Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Нажмите ещё раз для подтверждения",
|
||||
"satPerVB": "{{rate}} сат/vB",
|
||||
"notEnoughBitcoin": "Недостаточно биткоинов",
|
||||
|
||||
+19
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "Chikwama cheBitcoin",
|
||||
"myWalletLabel": "Chikwama cha{{name}}",
|
||||
"myWalletDefault": "Chikwama changu",
|
||||
"walletHeroNote": "Zvipo zvinopinda zvakananga muchikwama chako cheAgora. Hapana munhu ari pakati, hapana kugadzirira kubhadharwa, hapana kumirira.",
|
||||
"walletHeroReassurance": "Iwe ndiwe une kiyi, saka ndiwe une mari. Bvisa mari nguva ipi zvayo kubva mutabhu yechikwama.",
|
||||
"walletChoose": "Sarudza chikwama",
|
||||
"walletCustom": "Chenyu",
|
||||
"walletUseCustom": "Shandisa chikwama chako pachako",
|
||||
"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.",
|
||||
"acceptHeading": "Ndezvipi zvipo zvauchagamuchira?",
|
||||
"acceptUnavailable": "Hazviwanikwi nekupinda uku.",
|
||||
"acceptAllTitle": "Chipo chero chipi zvacho",
|
||||
"acceptPublicTitle": "Zvipo zvepachena chete",
|
||||
"acceptPrivateTitle": "Zvipo zvakavanzika chete",
|
||||
"acceptAllHint": "Gamuchira zvipo zvepachena nezvakavanzika.",
|
||||
"acceptPublicHint": "Vanopa vanopa kukero yeBitcoin yenguva dzose. Zvipo izvi zvinoonekwa nemunhu wese.",
|
||||
"acceptPrivateHint": "Vanopa vanopa muchivande, saka zita ravo rinoramba rakavanzika kuruzhinji.",
|
||||
"customWalletIntro": "Zadza chero zvipo zvauinazvo zvaunoda kugamuchira: kero yeruzhinji, kodhi yakavanzika, kana zvose zviri zviviri. Pakuita zvirinani imwechete inodikanwa.",
|
||||
"customOnchainMeaning": "Zveruzhinji. Munhu wese anogona kuona zvipo izvi.",
|
||||
"customSpMeaning": "Zvakavanzika. Zita reanopa rinoramba rakavanzika.",
|
||||
"bitcoinAddress": "Kero yeBitcoin",
|
||||
"bitcoinAddressPlaceholder": "bc1q… kana bc1p…",
|
||||
"silentPaymentCode": "Kodhi yemubhadharo unyararo",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "Chinangwa",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "Madhora eUS chete. Vapi vebhadhara muBitcoin; vatengi vanofungidzira mucherechedzo weUSD panguva yokutarisa.",
|
||||
"deadline": "Mugumo",
|
||||
"submitCreate": "Burisa campaign",
|
||||
"submitEdit": "Vandudza campaign",
|
||||
"publishing": "Kuburitsa…",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "Kodhi yemubhadharo unyararo haisi kodhi yeBIP-352 inozivikanwa (sp1…).",
|
||||
"errorWalletRequired": "Ipa zvirinani imwechete chinangwa chechikwama — kero yeBitcoin mainnet (bc1q… / bc1p…) kana kodhi yemubhadharo unyararo (sp1…).",
|
||||
"errorGoalInvalid": "Chinangwa chinofanira kunge chiri madhora akakwana anopfuura zero.",
|
||||
"errorDeadlinePast": "Mugumo haungavi munguva yapfuura.",
|
||||
"errorDeadlineInvalid": "Mugumo hausi zuva rakanaka.",
|
||||
"errorEditLatestMissing": "Hatina kuwana shanduro yazvino yecampaign iyi kuti tirivandudze.",
|
||||
"errorSlugCollision": "Une kare campaign ine kupiwa zita kwe«{{slug}}». Sarudza rimwe.",
|
||||
"errorBannerInvalid": "Bhana inofanira kuva URL ye https:// chaiyo.",
|
||||
@@ -599,6 +599,8 @@
|
||||
"bannerStepSubtitle": "Mufananidzo mumwe chete unotapira unotakura campaign pakadhi rega rega.",
|
||||
"storyStepTitle": "Taura nyaya yako",
|
||||
"storyStepSubtitle": "Vanobatsirwa ndivanaani uye mari ichashandiswa sei.",
|
||||
"goalStepTitle": "Chinangwa",
|
||||
"goalStepSubtitle": "Zvisina kumanikidzwa — siya pasina kuti uite campaign isina mugumo.",
|
||||
"next": "Inotevera",
|
||||
"back": "Dzokera",
|
||||
"skip": "Darika",
|
||||
@@ -608,11 +610,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Macampaign e{{appName}}",
|
||||
"seoDescriptionFallback": "Tsigira {{title}} pa{{appName}}.",
|
||||
"deadlineEndedOn": "Zvakapera pa{{date}}",
|
||||
"deadlineEndsToday": "Zvinopera nhasi",
|
||||
"deadlineDaysLeft_one": "{{count}} zuva rasara",
|
||||
"deadlineDaysLeft_other": "Mazuva {{count}} asara",
|
||||
"deadlineEndsOn": "Zvinopera pa{{date}}",
|
||||
"back": "Dzokera",
|
||||
"edit": "Gadzirisa",
|
||||
"delete": "Bvisa",
|
||||
@@ -655,7 +652,6 @@
|
||||
"deleteDialogTitle": "Bvisa campaign iyi?",
|
||||
"deleteDialogBody": "Izvi zvinoburitsa chikumbiro chekubvisa cheNIP-09. Marelay anozvibata zvakanaka achabvisa campaign mufeeds nezvirink zvakananga. Rezvi dzezvipo dzapfuura dzinoramba dzakachengetwa pachain. Izvi hazvigoni kudzosererwa — kuti urambe uchigamuchira zvipo, gadzirisa campaign panzvimbo yokuti uibvise.",
|
||||
"storyHeading": "Nyaya",
|
||||
"campaignEnded": "Campaign yapera",
|
||||
"donate": "Ipa",
|
||||
"share": "Govera",
|
||||
"target": "Chinangwa: {{amount}}",
|
||||
@@ -756,7 +752,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.",
|
||||
@@ -826,6 +822,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Manjuriro emisoro yemishandirapamwe yakasarudzwa",
|
||||
"create": "Rondedzero itsva",
|
||||
"showMore": "Ratidza {{count}} dzimwe",
|
||||
"showLess": "Ratidza zvishoma",
|
||||
"createDesc": "Gadzira rondedzero itsva yemisoro. Sarudza mishandirapamwe muiri kubva papeji ipi neipi yemushandirapamwe.",
|
||||
"createSubmit": "Gadzira rondedzero",
|
||||
"createFailed": "Kugadzira rondedzero hakuna kubudirira",
|
||||
@@ -1244,6 +1242,7 @@
|
||||
"walletSend": {
|
||||
"title": "Tumira Bitcoin",
|
||||
"send": "Tumira Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Dzvanya zvakare kuti usimbise",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "Bitcoin haina kukwana",
|
||||
|
||||
+17
-20
@@ -939,20 +939,23 @@
|
||||
"wallet": "Pochi ya Bitcoin",
|
||||
"myWalletLabel": "Pochi ya {{name}}",
|
||||
"myWalletDefault": "Pochi yangu",
|
||||
"walletHeroNote": "Michango huingia moja kwa moja kwenye pochi yako mwenyewe ya Agora. Hakuna mtu wa kati, hakuna usanidi wa malipo, hakuna kusubiri.",
|
||||
"walletHeroReassurance": "Wewe ndiye unayeshikilia ufunguo, kwa hivyo wewe ndiye unayeshikilia pesa. Toa wakati wowote kupitia kichupo cha pochi.",
|
||||
"walletChoose": "Chagua pochi",
|
||||
"walletCustom": "Maalum",
|
||||
"walletUseCustom": "Tumia pochi maalum badala yake",
|
||||
"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.",
|
||||
"acceptHeading": "Utakubali michango ya aina gani?",
|
||||
"acceptUnavailable": "Haipatikani kwa kuingia huku.",
|
||||
"acceptAllTitle": "Mchango wowote",
|
||||
"acceptPublicTitle": "Michango ya umma pekee",
|
||||
"acceptPrivateTitle": "Michango ya faragha pekee",
|
||||
"acceptAllHint": "Pokea michango ya umma na ya faragha vyote.",
|
||||
"acceptPublicHint": "Wachangiaji hutoa kwa anwani ya kawaida ya Bitcoin. Michango hii inaonekana kwa mtu yeyote.",
|
||||
"acceptPrivateHint": "Wachangiaji hutoa kwa faragha, kwa hivyo utambulisho wao unabaki umefichwa kutoka kwa umma.",
|
||||
"customWalletIntro": "Jaza michango yoyote unayotaka kupokea: anwani ya umma, msimbo wa siri, au zote mbili. Angalau moja inahitajika.",
|
||||
"customOnchainMeaning": "Ya umma. Mtu yeyote anaweza kuona michango hii.",
|
||||
"customSpMeaning": "Ya faragha. Utambulisho wa mchangiaji unabaki umefichwa.",
|
||||
"bitcoinAddress": "Anwani ya Bitcoin",
|
||||
"bitcoinAddressPlaceholder": "bc1q… au bc1p…",
|
||||
"silentPaymentCode": "Msimbo wa malipo ya kimya",
|
||||
@@ -987,7 +990,6 @@
|
||||
"goal": "Lengo",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "Dola za Marekani kamili. Wafadhili hulipa kwa Bitcoin; wateja hukadiria kiwango sawa cha USD wakati wa kutazama.",
|
||||
"deadline": "Tarehe ya mwisho",
|
||||
"submitCreate": "Zindua kampeni",
|
||||
"submitEdit": "Sasisha kampeni",
|
||||
"publishing": "Inachapisha…",
|
||||
@@ -1012,8 +1014,6 @@
|
||||
"errorSpInvalid": "Msimbo wa malipo ya kimya si msimbo wa BIP-352 unaotambulika (sp1…).",
|
||||
"errorWalletRequired": "Toa angalau ncha moja ya pochi — anwani ya mtandao mkuu wa Bitcoin (bc1q… / bc1p…) au msimbo wa malipo ya kimya (sp1…).",
|
||||
"errorGoalInvalid": "Lengo lazima liwe kiasi chanya cha dola kamili.",
|
||||
"errorDeadlinePast": "Tarehe ya mwisho haiwezi kuwa iliyopita.",
|
||||
"errorDeadlineInvalid": "Tarehe ya mwisho si tarehe halali.",
|
||||
"errorEditLatestMissing": "Haikuweza kupata toleo la hivi karibuni la kampeni hii kusasisha.",
|
||||
"errorSlugCollision": "Tayari una kampeni yenye kitambulisho \"{{slug}}\". Chagua nyingine.",
|
||||
"errorBannerInvalid": "Bango lazima liwe URL halali ya https://.",
|
||||
@@ -1039,11 +1039,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | Kampeni za {{appName}}",
|
||||
"seoDescriptionFallback": "Unga mkono {{title}} kwenye {{appName}}.",
|
||||
"deadlineEndedOn": "Imeisha {{date}}",
|
||||
"deadlineEndsToday": "Inaisha leo",
|
||||
"deadlineDaysLeft_one": "siku {{count}} imebaki",
|
||||
"deadlineDaysLeft_other": "siku {{count}} zimebaki",
|
||||
"deadlineEndsOn": "Inaisha {{date}}",
|
||||
"back": "Rudi",
|
||||
"edit": "Hariri",
|
||||
"delete": "Futa",
|
||||
@@ -1086,7 +1081,6 @@
|
||||
"deleteDialogTitle": "Futa kampeni hii?",
|
||||
"deleteDialogBody": "Hii inachapisha ombi la kufutwa la NIP-09. Relei zinazoendesha vyema zitaiondoa kampeni kutoka kwa milisho na viungo vya moja kwa moja. Risiti za michango zilizopita zinabaki katika mnyororo bila kujali. Kitendo hiki hakiwezi kutenduliwa — ili kuendelea kupokea michango, hariri kampeni badala yake.",
|
||||
"storyHeading": "Hadithi",
|
||||
"campaignEnded": "Kampeni imeisha",
|
||||
"donate": "Changia",
|
||||
"share": "Shiriki",
|
||||
"target": "Lengo: {{amount}}",
|
||||
@@ -1187,7 +1181,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.",
|
||||
@@ -1229,6 +1223,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Orodha za mada za kampeni zilizoratibiwa",
|
||||
"create": "Orodha mpya",
|
||||
"showMore": "Onyesha {{count}} zaidi",
|
||||
"showLess": "Onyesha kidogo",
|
||||
"createDesc": "Tengeneza orodha mpya ya mada. Iratibu kampeni ndani yake kutoka ukurasa wowote wa kampeni.",
|
||||
"createSubmit": "Tengeneza orodha",
|
||||
"createFailed": "Imeshindikana kutengeneza orodha",
|
||||
@@ -1544,6 +1540,7 @@
|
||||
"walletSend": {
|
||||
"title": "Tuma Bitcoin",
|
||||
"send": "Tuma Bitcoin",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Bonyeza tena kuthibitisha",
|
||||
"satPerVB": "sat/vB {{rate}}",
|
||||
"notEnoughBitcoin": "Bitcoin haitoshi",
|
||||
|
||||
+19
-20
@@ -939,20 +939,23 @@
|
||||
"wallet": "Bitcoin cüzdanı",
|
||||
"myWalletLabel": "{{name}} cüzdanı",
|
||||
"myWalletDefault": "Cüzdanım",
|
||||
"walletHeroNote": "Bağışlar doğrudan kendi Agora cüzdanınıza akar. Aracı yok, ödeme ayarı yok, bekleme yok.",
|
||||
"walletHeroReassurance": "Anahtar sizde olduğu için para da sizde. Cüzdan sekmesinden istediğiniz zaman çekebilirsiniz.",
|
||||
"walletChoose": "Bir cüzdan seçin",
|
||||
"walletCustom": "Özel",
|
||||
"walletUseCustom": "Bunun yerine özel bir cüzdan kullan",
|
||||
"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.",
|
||||
"acceptHeading": "Hangi bağışları kabul edeceksiniz?",
|
||||
"acceptUnavailable": "Bu girişle kullanılamaz.",
|
||||
"acceptAllTitle": "Her türlü bağış",
|
||||
"acceptPublicTitle": "Yalnızca açık bağışlar",
|
||||
"acceptPrivateTitle": "Yalnızca gizli bağışlar",
|
||||
"acceptAllHint": "Hem açık hem de gizli bağışları kabul edin.",
|
||||
"acceptPublicHint": "Bağışçılar normal bir Bitcoin adresine gönderir. Bu bağışlar herkese görünür.",
|
||||
"acceptPrivateHint": "Bağışçılar gizlice gönderir, böylece kimlikleri herkesten gizli kalır.",
|
||||
"customWalletIntro": "Kabul etmek istediğiniz bağışları girin: açık bir adres, gizli bir kod ya da her ikisi. En az biri gereklidir.",
|
||||
"customOnchainMeaning": "Açık. Bu bağışları herkes görebilir.",
|
||||
"customSpMeaning": "Gizli. Bağışçının kimliği gizli kalır.",
|
||||
"bitcoinAddress": "Bitcoin adresi",
|
||||
"bitcoinAddressPlaceholder": "bc1q… veya bc1p…",
|
||||
"silentPaymentCode": "Sessiz ödeme kodu",
|
||||
@@ -987,7 +990,6 @@
|
||||
"goal": "Hedef",
|
||||
"goalPlaceholder": "25.000",
|
||||
"goalNote": "Tam ABD Doları. Bağışçılar Bitcoin ile öder; istemciler görüntüleme anında USD karşılığını tahmin eder.",
|
||||
"deadline": "Son tarih",
|
||||
"submitCreate": "Kampanyayı başlat",
|
||||
"submitEdit": "Kampanyayı güncelle",
|
||||
"publishing": "Yayımlanıyor…",
|
||||
@@ -1012,8 +1014,6 @@
|
||||
"errorSpInvalid": "Sessiz ödeme kodu tanınan bir BIP-352 kodu değil (sp1…).",
|
||||
"errorWalletRequired": "En az bir cüzdan uç noktası sağlayın — bir Bitcoin mainnet adresi (bc1q… / bc1p…) ya da bir sessiz ödeme kodu (sp1…).",
|
||||
"errorGoalInvalid": "Hedef pozitif bir tam dolar tutarı olmalıdır.",
|
||||
"errorDeadlinePast": "Son tarih geçmişte olamaz.",
|
||||
"errorDeadlineInvalid": "Son tarih geçerli bir tarih değil.",
|
||||
"errorEditLatestMissing": "Bu kampanyanın güncellemek için en son sürümü bulunamadı.",
|
||||
"errorSlugCollision": "\"{{slug}}\" tanımlayıcısına sahip bir kampanyanız zaten var. Başkasını seçin.",
|
||||
"errorBannerInvalid": "Pankart geçerli bir https:// URL'i olmalıdır.",
|
||||
@@ -1030,6 +1030,8 @@
|
||||
"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.",
|
||||
"goalStepTitle": "Hedef",
|
||||
"goalStepSubtitle": "İsteğe bağlı — açık uçlu bir kampanya için boş bırakın.",
|
||||
"next": "İleri",
|
||||
"back": "Geri",
|
||||
"skip": "Atla",
|
||||
@@ -1039,11 +1041,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | {{appName}} Fon Toplama",
|
||||
"seoDescriptionFallback": "{{appName}}'da {{title}} kampanyasını destekleyin.",
|
||||
"deadlineEndedOn": "{{date}} tarihinde bitti",
|
||||
"deadlineEndsToday": "Bugün bitiyor",
|
||||
"deadlineDaysLeft_one": "{{count}} gün kaldı",
|
||||
"deadlineDaysLeft_other": "{{count}} gün kaldı",
|
||||
"deadlineEndsOn": "{{date}} tarihinde bitiyor",
|
||||
"back": "Geri",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
@@ -1086,7 +1083,6 @@
|
||||
"deleteDialogTitle": "Bu kampanya silinsin mi?",
|
||||
"deleteDialogBody": "Bu, bir NIP-09 silme isteği yayımlar. Düzgün davranan röleler kampanyayı akışlardan ve doğrudan bağlantılardan kaldırır. Geçmiş bağış makbuzları yine zincir üstünde kalır. Bu işlem geri alınamaz — bağış almaya devam etmek için kampanyayı düzenleyin.",
|
||||
"storyHeading": "Hikaye",
|
||||
"campaignEnded": "Kampanya bitti",
|
||||
"donate": "Bağışla",
|
||||
"share": "Paylaş",
|
||||
"target": "Hedef: {{amount}}",
|
||||
@@ -1187,7 +1183,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.",
|
||||
@@ -1229,6 +1225,8 @@
|
||||
"lists": {
|
||||
"stripAria": "Özenle seçilmiş kampanya konu listeleri",
|
||||
"create": "Yeni liste",
|
||||
"showMore": "{{count}} tane daha göster",
|
||||
"showLess": "Daha az göster",
|
||||
"createDesc": "Yeni bir konu listesi oluşturun. Herhangi bir kampanya sayfasından kampanyaları listeye ekleyin.",
|
||||
"createSubmit": "Liste oluştur",
|
||||
"createFailed": "Liste oluşturulamadı",
|
||||
@@ -1584,6 +1582,7 @@
|
||||
"walletSend": {
|
||||
"title": "Bitcoin Gönder",
|
||||
"send": "Bitcoin Gönder",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "Onaylamak için tekrar dokunun",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "Yetersiz Bitcoin",
|
||||
|
||||
+19
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "比特幣錢包",
|
||||
"myWalletLabel": "{{name}} 的錢包",
|
||||
"myWalletDefault": "我的錢包",
|
||||
"walletHeroNote": "捐款會直接進入您自己的 Agora 錢包。沒有中間人,不必設定提領,也不用等待。",
|
||||
"walletHeroReassurance": "您持有金鑰,因此您持有資金。隨時都可以從錢包分頁提領。",
|
||||
"walletChoose": "選擇錢包",
|
||||
"walletCustom": "自定義",
|
||||
"walletUseCustom": "改用自定義錢包",
|
||||
"walletUseMine": "使用我的 Agora 錢包",
|
||||
"acceptAll": "接受所有支付型別",
|
||||
"acceptPublic": "僅接受公開支付",
|
||||
"acceptPrivate": "僅接受私密支付",
|
||||
"acceptAllShort": "全部接受",
|
||||
"acceptPublicShort": "僅公開",
|
||||
"acceptPrivateShort": "僅私密",
|
||||
"acceptAllHint": "同時接受公開的鏈上支付與私密的靜默支付。",
|
||||
"acceptPublicHint": "僅接受發送至公開地址的鏈上捐款。",
|
||||
"acceptPrivateHint": "僅接受靜默支付——捐款者的地址將保持私密。",
|
||||
"customWalletIntro": "輸入比特幣地址、靜默支付代碼或兩者皆可。至少需要一個。",
|
||||
"acceptHeading": "您願意接受哪些捐款?",
|
||||
"acceptUnavailable": "此登入方式無法使用。",
|
||||
"acceptAllTitle": "任何捐款",
|
||||
"acceptPublicTitle": "僅接受公開捐款",
|
||||
"acceptPrivateTitle": "僅接受私密捐款",
|
||||
"acceptAllHint": "同時接受公開與私密的捐款。",
|
||||
"acceptPublicHint": "捐款者捐到一個一般的比特幣地址。這些捐款任何人都看得到。",
|
||||
"acceptPrivateHint": "捐款者以私密方式捐款,因此他們的身分不會對外公開。",
|
||||
"customWalletIntro": "填入您想要接受的任何捐款方式:公開地址、私密代碼,或兩者皆可。至少需要填寫一項。",
|
||||
"customOnchainMeaning": "公開。任何人都能看到這些捐款。",
|
||||
"customSpMeaning": "私密。捐款者的身分將保持隱藏。",
|
||||
"bitcoinAddress": "比特幣地址",
|
||||
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
|
||||
"silentPaymentCode": "靜默支付代碼",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "目標",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "整數美元。捐贈者以比特幣支付;客戶端在檢視時估算美元等值。",
|
||||
"deadline": "截止日期",
|
||||
"submitCreate": "發起活動",
|
||||
"submitEdit": "更新活動",
|
||||
"publishing": "釋出中……",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "靜默支付代碼不是已識別的 BIP-352 程式碼(sp1…)。",
|
||||
"errorWalletRequired": "至少提供一個錢包端點 — 一個比特幣主網地址(bc1q… / bc1p…)或一個靜默支付代碼(sp1…)。",
|
||||
"errorGoalInvalid": "目標必須是正整數美元金額。",
|
||||
"errorDeadlinePast": "截止日期不能在過去。",
|
||||
"errorDeadlineInvalid": "截止日期不是有效的日期。",
|
||||
"errorEditLatestMissing": "找不到此活動的最新版本以更新。",
|
||||
"errorSlugCollision": "你已有一個識別符號為「{{slug}}」的活動。請選擇其他。",
|
||||
"errorBannerInvalid": "橫幅必須是有效的 https:// URL。",
|
||||
@@ -599,6 +599,8 @@
|
||||
"bannerStepSubtitle": "一張搶眼的圖片,讓你的活動在每張卡片上脫穎而出。",
|
||||
"storyStepTitle": "說說你的故事",
|
||||
"storyStepSubtitle": "誰是受益者,以及這些資金將如何運用。",
|
||||
"goalStepTitle": "目標",
|
||||
"goalStepSubtitle": "選填——留空即為不設期限的活動。",
|
||||
"next": "下一步",
|
||||
"back": "返回",
|
||||
"skip": "跳過",
|
||||
@@ -608,11 +610,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | {{appName}} 募款",
|
||||
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
|
||||
"deadlineEndedOn": "{{date}} 已結束",
|
||||
"deadlineEndsToday": "今天截止",
|
||||
"deadlineDaysLeft_one": "還剩 {{count}} 天",
|
||||
"deadlineDaysLeft_other": "還剩 {{count}} 天",
|
||||
"deadlineEndsOn": "{{date}} 截止",
|
||||
"back": "返回",
|
||||
"edit": "編輯",
|
||||
"delete": "刪除",
|
||||
@@ -655,7 +652,6 @@
|
||||
"deleteDialogTitle": "刪除此活動?",
|
||||
"deleteDialogBody": "這將釋出一個 NIP-09 刪除請求。行為良好的中繼會將活動從資訊流和直接連結中移除。過往的捐贈收據無論如何都會保留在鏈上。此操作無法撤銷 — 若要繼續接受捐贈,請改為編輯活動。",
|
||||
"storyHeading": "故事",
|
||||
"campaignEnded": "活動已結束",
|
||||
"donate": "捐贈",
|
||||
"share": "分享",
|
||||
"target": "目標:{{amount}}",
|
||||
@@ -756,7 +752,7 @@
|
||||
"wlcDesc": "由世界自由大會(World Liberty Congress)精選的活動。",
|
||||
"allCampaigns": "所有活動",
|
||||
"allCampaignsDesc": "網絡上的所有活動,按時間順序排列。",
|
||||
"browseAll": "瀏覽所有活動 →",
|
||||
"browseAll": "瀏覽所有活動",
|
||||
"hidden": "已隱藏",
|
||||
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
|
||||
"hiddenEmpty": "當前沒有被隱藏的活動。",
|
||||
@@ -798,6 +794,8 @@
|
||||
"lists": {
|
||||
"stripAria": "精選活動主題清單",
|
||||
"create": "新清單",
|
||||
"showMore": "再顯示 {{count}} 個",
|
||||
"showLess": "顯示較少",
|
||||
"createDesc": "建立一個新的主題清單。可從任何活動頁面將活動加入其中。",
|
||||
"createSubmit": "建立清單",
|
||||
"createFailed": "無法建立清單",
|
||||
@@ -1152,6 +1150,7 @@
|
||||
"walletSend": {
|
||||
"title": "傳送比特幣",
|
||||
"send": "傳送比特幣",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "再次點選確認",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "比特幣不足",
|
||||
|
||||
+19
-20
@@ -508,20 +508,23 @@
|
||||
"wallet": "比特币钱包",
|
||||
"myWalletLabel": "{{name}} 的钱包",
|
||||
"myWalletDefault": "我的钱包",
|
||||
"walletHeroNote": "捐款将直接进入你自己的 Agora 钱包。没有中间人,无需设置收款,也无需等待。",
|
||||
"walletHeroReassurance": "你掌握密钥,就掌握资金。随时可在钱包标签页提现。",
|
||||
"walletChoose": "选择钱包",
|
||||
"walletCustom": "自定义",
|
||||
"walletUseCustom": "改用自定义钱包",
|
||||
"walletUseMine": "使用我的 Agora 钱包",
|
||||
"acceptAll": "接受所有支付类型",
|
||||
"acceptPublic": "仅接受公开支付",
|
||||
"acceptPrivate": "仅接受私密支付",
|
||||
"acceptAllShort": "全部接受",
|
||||
"acceptPublicShort": "仅公开",
|
||||
"acceptPrivateShort": "仅私密",
|
||||
"acceptAllHint": "同时接受公开链上支付和私密静默支付。",
|
||||
"acceptPublicHint": "仅接受发送至公开地址的链上捐款。",
|
||||
"acceptPrivateHint": "仅接受静默支付——捐赠者地址保持私密。",
|
||||
"customWalletIntro": "输入比特币地址、静默支付代码或两者皆可。至少需要一个。",
|
||||
"acceptHeading": "你愿意接受哪些捐款?",
|
||||
"acceptUnavailable": "此登录方式无法使用。",
|
||||
"acceptAllTitle": "任何捐款",
|
||||
"acceptPublicTitle": "仅接受公开捐款",
|
||||
"acceptPrivateTitle": "仅接受私密捐款",
|
||||
"acceptAllHint": "公开和私密捐款都接受。",
|
||||
"acceptPublicHint": "捐赠者将款项发送到一个普通的 Bitcoin 地址。这些捐款任何人都能看到。",
|
||||
"acceptPrivateHint": "捐赠者以私密方式捐款,因此他们的身份不会对外公开。",
|
||||
"customWalletIntro": "填写您愿意接受的任意捐赠方式:公开地址、私密代码,或两者皆可。至少需要填写一项。",
|
||||
"customOnchainMeaning": "公开。任何人都能看到这些捐赠。",
|
||||
"customSpMeaning": "私密。捐赠者的身份将保持隐藏。",
|
||||
"bitcoinAddress": "比特币地址",
|
||||
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
|
||||
"silentPaymentCode": "静默支付代码",
|
||||
@@ -556,7 +559,6 @@
|
||||
"goal": "目标",
|
||||
"goalPlaceholder": "25,000",
|
||||
"goalNote": "整数美元。捐赠者以比特币支付;客户端在查看时估算美元等值。",
|
||||
"deadline": "截止日期",
|
||||
"submitCreate": "发起活动",
|
||||
"submitEdit": "更新活动",
|
||||
"publishing": "发布中……",
|
||||
@@ -581,8 +583,6 @@
|
||||
"errorSpInvalid": "静默支付代码不是已识别的 BIP-352 代码(sp1…)。",
|
||||
"errorWalletRequired": "至少提供一个钱包端点 — 一个比特币主网地址(bc1q… / bc1p…)或一个静默支付代码(sp1…)。",
|
||||
"errorGoalInvalid": "目标必须是正整数美元金额。",
|
||||
"errorDeadlinePast": "截止日期不能在过去。",
|
||||
"errorDeadlineInvalid": "截止日期不是有效的日期。",
|
||||
"errorEditLatestMissing": "找不到此活动的最新版本以更新。",
|
||||
"errorSlugCollision": "你已有一个标识符为「{{slug}}」的活动。请选择其他。",
|
||||
"errorBannerInvalid": "横幅必须是有效的 https:// URL。",
|
||||
@@ -599,6 +599,8 @@
|
||||
"bannerStepSubtitle": "一张醒目的图片,让活动在每张卡片上脱颖而出。",
|
||||
"storyStepTitle": "讲述你的故事",
|
||||
"storyStepSubtitle": "谁将从中受益,资金将如何使用。",
|
||||
"goalStepTitle": "目标",
|
||||
"goalStepSubtitle": "可选 — 留空则为开放式活动。",
|
||||
"next": "下一步",
|
||||
"back": "返回",
|
||||
"skip": "跳过",
|
||||
@@ -608,11 +610,6 @@
|
||||
"campaignsDetail": {
|
||||
"seoTitle": "{{title}} | {{appName}} 募款",
|
||||
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
|
||||
"deadlineEndedOn": "{{date}} 已结束",
|
||||
"deadlineEndsToday": "今天截止",
|
||||
"deadlineDaysLeft_one": "还剩 {{count}} 天",
|
||||
"deadlineDaysLeft_other": "还剩 {{count}} 天",
|
||||
"deadlineEndsOn": "{{date}} 截止",
|
||||
"back": "返回",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
@@ -655,7 +652,6 @@
|
||||
"deleteDialogTitle": "删除此活动?",
|
||||
"deleteDialogBody": "这将发布一个 NIP-09 删除请求。行为良好的中继会将活动从信息流和直接链接中移除。过往的捐赠收据无论如何都会保留在链上。此操作无法撤销 — 若要继续接受捐赠,请改为编辑活动。",
|
||||
"storyHeading": "故事",
|
||||
"campaignEnded": "活动已结束",
|
||||
"donate": "捐赠",
|
||||
"share": "分享",
|
||||
"target": "目标:{{amount}}",
|
||||
@@ -756,7 +752,7 @@
|
||||
"wlcDesc": "由世界自由大会(World Liberty Congress)精选的活动。",
|
||||
"allCampaigns": "所有活动",
|
||||
"allCampaignsDesc": "网络上的所有活动,按时间顺序排列。",
|
||||
"browseAll": "浏览所有活动 →",
|
||||
"browseAll": "浏览所有活动",
|
||||
"hidden": "已隐藏",
|
||||
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
|
||||
"hiddenEmpty": "当前没有被隐藏的活动。",
|
||||
@@ -826,6 +822,8 @@
|
||||
"lists": {
|
||||
"stripAria": "精选活动主题列表",
|
||||
"create": "新建列表",
|
||||
"showMore": "再显示 {{count}} 个",
|
||||
"showLess": "显示较少",
|
||||
"createDesc": "创建一个新的主题列表。可以从任意活动页面将活动收录其中。",
|
||||
"createSubmit": "创建列表",
|
||||
"createFailed": "创建列表失败",
|
||||
@@ -1244,6 +1242,7 @@
|
||||
"walletSend": {
|
||||
"title": "发送比特币",
|
||||
"send": "发送比特币",
|
||||
"max": "MAX",
|
||||
"tapAgainToConfirm": "再次点击确认",
|
||||
"satPerVB": "{{rate}} sat/vB",
|
||||
"notEnoughBitcoin": "比特币不足",
|
||||
|
||||
@@ -4,13 +4,10 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import {
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
HandHeart,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
@@ -91,18 +88,6 @@ function formatSatsFull(sats: number, btcPrice: number | undefined): string {
|
||||
return `${sats.toLocaleString()} sats`;
|
||||
}
|
||||
|
||||
function formatDeadline(unixSeconds: number, t: TFunction): { label: string; isPast: boolean } {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = unixSeconds - now;
|
||||
if (diff <= 0) {
|
||||
return { label: t('campaignsDetail.deadlineEndedOn', { date: new Date(unixSeconds * 1000).toLocaleDateString() }), isPast: true };
|
||||
}
|
||||
const days = Math.ceil(diff / 86_400);
|
||||
if (days <= 1) return { label: t('campaignsDetail.deadlineEndsToday'), isPast: false };
|
||||
if (days < 60) return { label: t('campaignsDetail.deadlineDaysLeft', { count: days }), isPast: false };
|
||||
return { label: t('campaignsDetail.deadlineEndsOn', { date: new Date(unixSeconds * 1000).toLocaleDateString() }), isPast: false };
|
||||
}
|
||||
|
||||
function collectReplyEvents(nodes: ReplyNode[], out = new Map<string, NostrEvent>()): Map<string, NostrEvent> {
|
||||
for (const node of nodes) {
|
||||
out.set(node.event.id, node.event);
|
||||
@@ -254,7 +239,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
|
||||
const authorMetadata = author.data?.metadata;
|
||||
const cover = sanitizeUrl(campaign.banner) ?? sanitizeUrl(authorMetadata?.banner) ?? sanitizeUrl(authorMetadata?.picture);
|
||||
|
||||
const deadline = campaign.deadline ? formatDeadline(campaign.deadline, t) : null;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const pendingSats = stats?.pendingSats ?? 0;
|
||||
@@ -347,7 +331,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
|
||||
statsLoading={statsLoading}
|
||||
btcPrice={btcPrice}
|
||||
donations={donationReceipts}
|
||||
deadline={deadline}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
);
|
||||
@@ -371,7 +354,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
|
||||
<CampaignHeading
|
||||
campaign={displayCampaign}
|
||||
creatorPubkey={campaign.pubkey}
|
||||
deadline={deadline}
|
||||
countryLabel={countryLabel}
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
@@ -944,7 +926,6 @@ function CampaignHero({
|
||||
interface CampaignHeadingProps {
|
||||
campaign: ParsedCampaign;
|
||||
creatorPubkey: string;
|
||||
deadline: { label: string; isPast: boolean } | null;
|
||||
countryLabel: string | undefined;
|
||||
onReply: () => void;
|
||||
onMore: () => void;
|
||||
@@ -954,7 +935,6 @@ interface CampaignHeadingProps {
|
||||
function CampaignHeading({
|
||||
campaign,
|
||||
creatorPubkey,
|
||||
deadline,
|
||||
countryLabel,
|
||||
onReply,
|
||||
onMore,
|
||||
@@ -983,20 +963,13 @@ function CampaignHeading({
|
||||
<AuthorByline pubkey={creatorPubkey} />
|
||||
</div>
|
||||
|
||||
{(countryLabel || deadline) && (
|
||||
{(countryLabel) && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-4" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarClock className="size-4" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1056,7 +1029,6 @@ interface DonateColumnProps {
|
||||
btcPrice: number | undefined;
|
||||
/** Aggregated kind 8333 donation events, newest first. */
|
||||
donations: NostrEvent[];
|
||||
deadline: { label: string; isPast: boolean } | null;
|
||||
onShare: () => void;
|
||||
}
|
||||
|
||||
@@ -1067,15 +1039,12 @@ function DonateColumn({
|
||||
statsLoading,
|
||||
btcPrice,
|
||||
donations,
|
||||
deadline,
|
||||
onShare,
|
||||
}: DonateColumnProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const hdAccess = useHdWalletAccess();
|
||||
const [sendOpen, setSendOpen] = useState(false);
|
||||
const ended = !!deadline?.isPast;
|
||||
const endedLabel = ended ? t('campaignsDetail.campaignEnded') : null;
|
||||
const isSilentPayment = !campaign.wallets.onchain;
|
||||
|
||||
// The in-app "Pay with Agora" button opens HDSendBitcoinDialog
|
||||
@@ -1084,7 +1053,6 @@ function DonateColumn({
|
||||
// they'd use from /wallet to send Bitcoin to anywhere else.
|
||||
//
|
||||
// Hide the button when:
|
||||
// - the campaign has ended.
|
||||
// - the donor is the campaign owner (paying yourself is a foot-gun).
|
||||
// - the campaign is silent-payment-only (no on-chain address to
|
||||
// prefill; SP donations require a BIP-352-aware wallet that derives
|
||||
@@ -1094,7 +1062,6 @@ function DonateColumn({
|
||||
// logins don't expose the secret key, so we can't derive child
|
||||
// keys — see useHdWalletAccess).
|
||||
const canPayInApp =
|
||||
!ended &&
|
||||
!!user &&
|
||||
!isSilentPayment &&
|
||||
user.pubkey !== campaign.pubkey &&
|
||||
@@ -1168,18 +1135,7 @@ function DonateColumn({
|
||||
)}
|
||||
|
||||
{/* Primary actions */}
|
||||
{ended ? (
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" className="w-full" disabled>
|
||||
<HandHeart className="size-5 mr-2" />
|
||||
{endedLabel ?? t('campaignsDetail.donate')}
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="w-full" onClick={onShare}>
|
||||
<Share2 className="size-4 mr-2" />
|
||||
{t('campaignsDetail.share')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
{
|
||||
// Donors can either pay from their in-app Agora wallet (HD
|
||||
// send dialog prefilled with the campaign address) or scan the
|
||||
// QR from any external wallet. Both routes terminate at the
|
||||
@@ -1207,7 +1163,7 @@ function DonateColumn({
|
||||
{t('campaignsDetail.share')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</CardContent>
|
||||
{canPayInApp && campaign.wallets.onchain && (
|
||||
<HDSendBitcoinDialog
|
||||
|
||||
@@ -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';
|
||||
@@ -83,9 +82,9 @@ const WLC_NPUB = 'npub126e6hwd6a5std2upv9a22xwgvd8fyrhsx5wjjchv99g6nv3n4vhs5fr9g
|
||||
* Hidden-campaign moderation lives entirely on `/campaigns` — the
|
||||
* Show-hidden toggle there is available to every viewer, and the
|
||||
* moderator-only Hidden collapsible there is the structured review
|
||||
* surface. The home page deliberately carries no Hidden affordance
|
||||
* so it never leads with suppressed content for anyone, moderators
|
||||
* included.
|
||||
* surface. The home page applies no label-based filtering of its own:
|
||||
* the WLC hero row renders exactly what the curated list declares, in
|
||||
* list order. Curation here is the list's membership, nothing more.
|
||||
*
|
||||
* Campaigns are the home page's sole focus. Groups and Pledges each
|
||||
* have their own dedicated browse pages (`/groups`, `/pledges`).
|
||||
@@ -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 curator'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}`,
|
||||
|
||||
+230
-167
@@ -9,10 +9,16 @@ import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bitcoin,
|
||||
Check,
|
||||
ChevronDown,
|
||||
EyeOff,
|
||||
Globe,
|
||||
HandHeart,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
@@ -31,7 +37,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -52,7 +57,6 @@ import {
|
||||
parseCampaignWallet,
|
||||
sanitizeCampaignTitle,
|
||||
} from '@/lib/campaign';
|
||||
import { getTodayDateInput } from '@/lib/dateInput';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
|
||||
@@ -86,11 +90,6 @@ function getEditTarget(value: string | null): EditTarget | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateInput(unixSeconds: number | undefined): string {
|
||||
if (!unixSeconds) return '';
|
||||
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a NIP-92 `imeta` tag from a Blossom upload's NIP-94 tag array.
|
||||
*
|
||||
@@ -197,7 +196,6 @@ export function CreateCampaignPage() {
|
||||
const [customOnchain, setCustomOnchain] = useState('');
|
||||
const [customSp, setCustomSp] = useState('');
|
||||
const [goalUsd, setGoalUsd] = useState('');
|
||||
const [deadline, setDeadline] = useState('');
|
||||
const [countryQuery, setCountryQuery] = useState('');
|
||||
const [countryCode, setCountryCode] = useState('');
|
||||
/**
|
||||
@@ -322,7 +320,6 @@ export function CreateCampaignPage() {
|
||||
// payload, so the slug's only audience is relays and other clients.
|
||||
const derivedSlug = useMemo(() => buildCampaignSlug(title), [title]);
|
||||
const activeIdentifier = editCampaign?.identifier ?? derivedSlug.slug;
|
||||
const minDeadline = useMemo(() => getTodayDateInput(), []);
|
||||
|
||||
// Live-parsed custom inputs, used to drive disclaimers and inline
|
||||
// validation. Empty strings parse to `null` (no inline error).
|
||||
@@ -362,7 +359,6 @@ export function CreateCampaignPage() {
|
||||
setCustomSp(editCampaign.wallets.sp?.value ?? '');
|
||||
setWalletDefaultsApplied(true);
|
||||
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
|
||||
setDeadline(formatDateInput(editCampaign.deadline));
|
||||
const editCountryCode = editCampaign.countryCode ?? '';
|
||||
setCountryCode(editCountryCode);
|
||||
setCountryQuery(editCountryCode ? (getCountryInfo(editCountryCode)?.subdivisionName ?? getCountryInfo(editCountryCode)?.name ?? editCountryCode) : '');
|
||||
@@ -520,18 +516,6 @@ export function CreateCampaignPage() {
|
||||
goalNum = n;
|
||||
}
|
||||
|
||||
let deadlineNum: number | undefined;
|
||||
if (deadline.trim()) {
|
||||
if (deadline < minDeadline) {
|
||||
throw new Error(t('campaignsCreate.errorDeadlinePast'));
|
||||
}
|
||||
const ts = Math.floor(new Date(deadline).getTime() / 1000);
|
||||
if (!Number.isFinite(ts) || ts <= 0) {
|
||||
throw new Error(t('campaignsCreate.errorDeadlineInvalid'));
|
||||
}
|
||||
deadlineNum = ts;
|
||||
}
|
||||
|
||||
const resolvedCountryCode = countryCode;
|
||||
// Iterate the canonical category list (not the Set) so the tag
|
||||
// order on the event is stable and matches the picker's display
|
||||
@@ -623,7 +607,6 @@ export function CreateCampaignPage() {
|
||||
if (onchainWallet) tags.push(['w', onchainWallet.value]);
|
||||
if (spWallet) tags.push(['w', spWallet.value]);
|
||||
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
|
||||
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
|
||||
if (resolvedCountryCode) {
|
||||
tags.push(['i', createCountryIdentifier(resolvedCountryCode)]);
|
||||
tags.push(['k', 'iso3166']);
|
||||
@@ -989,62 +972,47 @@ export function CreateCampaignPage() {
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
const goalDeadlineSection = (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{/* Goal — integer USD */}
|
||||
<FormSection
|
||||
title={(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('campaignsCreate.goal')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label={t('campaignsCreate.goalNote')}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
{t('campaignsCreate.goalNote')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
requirement="Optional"
|
||||
>
|
||||
<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
|
||||
id="campaign-goal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder={t('campaignsCreate.goalPlaceholder')}
|
||||
value={goalUsd}
|
||||
onChange={(e) => setGoalUsd(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('campaignsCreate.deadline')} requirement="Optional">
|
||||
const goalSection = (
|
||||
<FormSection
|
||||
title={(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('campaignsCreate.goal')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label={t('campaignsCreate.goalNote')}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
{t('campaignsCreate.goalNote')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
requirement="Optional"
|
||||
>
|
||||
<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
|
||||
id="campaign-deadline"
|
||||
type="date"
|
||||
min={minDeadline}
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
className="[color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
|
||||
id="campaign-goal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder={t('campaignsCreate.goalPlaceholder')}
|
||||
value={goalUsd}
|
||||
onChange={(e) => setGoalUsd(e.target.value)}
|
||||
className="pl-7 pr-14"
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
const header = (
|
||||
@@ -1119,7 +1087,7 @@ export function CreateCampaignPage() {
|
||||
{tagsSection}
|
||||
{bannerSection}
|
||||
{storySection}
|
||||
{goalDeadlineSection}
|
||||
{goalSection}
|
||||
</div>
|
||||
|
||||
{errorAlert}
|
||||
@@ -1181,7 +1149,7 @@ export function CreateCampaignPage() {
|
||||
{
|
||||
title: t('campaignsCreate.wizard.goalStepTitle'),
|
||||
subtitle: t('campaignsCreate.wizard.goalStepSubtitle'),
|
||||
body: goalDeadlineSection,
|
||||
body: goalSection,
|
||||
},
|
||||
{
|
||||
title: t('campaignsCreate.wizard.tagsStepTitle'),
|
||||
@@ -1243,13 +1211,14 @@ export function CreateCampaignPage() {
|
||||
* Two modes selectable via a single inline toggle:
|
||||
*
|
||||
* 1. **My wallet** (`'mine'`, default when nsec is available) — a
|
||||
* compact identity card shows the user's avatar, display name and
|
||||
* live USD/BTC balance, modelled on the wallet-page balance
|
||||
* treatment. A small pencil affordance to the right is the entry
|
||||
* point to swap into custom mode; mirror-link beneath the inputs
|
||||
* swaps back. The HD-wallet mode also surfaces a segmented
|
||||
* "Accept" picker (All / Public / Private) that picks which
|
||||
* donation types the campaign accepts.
|
||||
* primary-tinted hero card (modelled on the onboarding "Save your
|
||||
* key" surface) whose centerpiece is a linked-icon trio
|
||||
* (campaign ↔ key ↔ wallet) explaining that donations land in the
|
||||
* creator's own Agora wallet. An avatar + live USD/BTC balance
|
||||
* chip confirms the exact destination, and a "Use a custom wallet"
|
||||
* sub-link swaps into custom mode. The HD-wallet mode also
|
||||
* surfaces a segmented "Accept" picker (All / Public / Private)
|
||||
* that picks which donation types the campaign accepts.
|
||||
* 2. **Custom** (`'custom'`) — two address inputs (on-chain + silent
|
||||
* payment). At least one must parse to a valid endpoint of its
|
||||
* mode.
|
||||
@@ -1337,32 +1306,60 @@ function WalletPicker({
|
||||
<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>
|
||||
)}
|
||||
{/* Hero card. Modelled on the onboarding "Save your key"
|
||||
surface: a primary-tinted card whose visual centerpiece
|
||||
is an icon pair (the campaign -> the wallet) so a
|
||||
first-time creator instantly grasps that donations land
|
||||
in their own Agora wallet. The avatar + live balance
|
||||
below confirm the exact destination. */}
|
||||
<div className="rounded-xl border-2 border-primary/30 bg-primary/10 p-5 space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-background shadow-sm ring-2 ring-primary/30">
|
||||
<HandHeart className="size-7 text-primary" />
|
||||
</div>
|
||||
<ArrowRight className="size-5 shrink-0 text-primary rtl:rotate-180" />
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-background shadow-sm ring-2 ring-primary/30">
|
||||
<Wallet className="size-7 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm leading-relaxed text-foreground">
|
||||
{t('campaignsCreate.walletHeroNote')}
|
||||
</p>
|
||||
|
||||
{/* Destination confirmation — avatar + live balance, framed
|
||||
as a self-contained chip so it reads as "this exact
|
||||
wallet" rather than incidental chrome. */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-primary/20 bg-background/60 px-3 py-2.5">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 border-t border-primary/20 pt-3">
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t('campaignsCreate.walletHeroReassurance')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1411,6 +1408,13 @@ function WalletPicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restate the field-driven accept model in the same plain
|
||||
voice as the "mine" branch's accept picker, so swapping to
|
||||
custom mode doesn't drop the public/private hand-holding. */}
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t('campaignsCreate.customWalletIntro')}
|
||||
</p>
|
||||
|
||||
<CustomWalletInput
|
||||
id="campaign-wallet-onchain"
|
||||
label={t('campaignsCreate.bitcoinAddress')}
|
||||
@@ -1436,11 +1440,14 @@ function WalletPicker({
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented "Accept" picker for the HD-wallet branch. Three pill
|
||||
* buttons (Accept All / Public Only / Private Only) with a one-line
|
||||
* caption beneath that explains the current selection. Public is
|
||||
* always available; the All and Private buttons disable when SP isn't
|
||||
* supported (extension / bunker logins).
|
||||
* "What donations will you accept?" picker for the HD-wallet branch.
|
||||
*
|
||||
* Written for a first-time, possibly anxious creator: instead of three
|
||||
* terse jargon pills (Accept All / Public Only / Private Only) it
|
||||
* presents three full-width selectable cards, each with a friendly
|
||||
* icon, a plain-language title, and a one-line reassurance. The two
|
||||
* SP-dependent options are disabled (with a short note) when silent
|
||||
* payments aren't supported on this login (extension / bunker).
|
||||
*/
|
||||
function AcceptModePicker({
|
||||
value,
|
||||
@@ -1453,58 +1460,102 @@ function AcceptModePicker({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const caption = {
|
||||
all: t('campaignsCreate.acceptAllHint'),
|
||||
public: t('campaignsCreate.acceptPublicHint'),
|
||||
private: t('campaignsCreate.acceptPrivateHint'),
|
||||
}[value];
|
||||
const options: {
|
||||
key: 'all' | 'public' | 'private';
|
||||
icon: typeof Globe;
|
||||
title: string;
|
||||
description: string;
|
||||
requiresSp?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: 'all',
|
||||
icon: HandHeart,
|
||||
title: t('campaignsCreate.acceptAllTitle'),
|
||||
description: t('campaignsCreate.acceptAllHint'),
|
||||
requiresSp: true,
|
||||
},
|
||||
{
|
||||
key: 'public',
|
||||
icon: Globe,
|
||||
title: t('campaignsCreate.acceptPublicTitle'),
|
||||
description: t('campaignsCreate.acceptPublicHint'),
|
||||
},
|
||||
{
|
||||
key: 'private',
|
||||
icon: EyeOff,
|
||||
title: t('campaignsCreate.acceptPrivateTitle'),
|
||||
description: t('campaignsCreate.acceptPrivateHint'),
|
||||
requiresSp: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value}
|
||||
// Radix ToggleGroup emits '' when the user toggles off the
|
||||
// selected item. Required campaigns can never be in "no
|
||||
// mode" state — coerce empty back to the previous value.
|
||||
onValueChange={(next) => {
|
||||
if (!next) return;
|
||||
onChange(next as 'all' | 'public' | 'private');
|
||||
}}
|
||||
variant="outline"
|
||||
className="grid w-full grid-cols-3 gap-1.5"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="all"
|
||||
disabled={!silentPaymentSupported}
|
||||
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
{t('campaignsCreate.acceptAllShort')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="public"
|
||||
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
{t('campaignsCreate.acceptPublicShort')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="private"
|
||||
disabled={!silentPaymentSupported}
|
||||
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
{t('campaignsCreate.acceptPrivateShort')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<p className="text-xs text-muted-foreground">{caption}</p>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t('campaignsCreate.acceptHeading')}</p>
|
||||
<div className="space-y-2" role="radiogroup" aria-label={t('campaignsCreate.acceptHeading')}>
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const selected = value === option.key;
|
||||
const disabled = option.requiresSp && !silentPaymentSupported;
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option.key)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
selected
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-background hover:border-primary/40 hover:bg-muted/40',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:border-border hover:bg-background',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-full',
|
||||
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-0.5">
|
||||
<span className="block text-sm font-semibold">{option.title}</span>
|
||||
<span className="block text-xs leading-relaxed text-muted-foreground">
|
||||
{disabled ? t('campaignsCreate.acceptUnavailable') : option.description}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{selected && <Check className="size-3" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single labeled custom-wallet input. The inline error fires only when
|
||||
* a non-empty value either fails to parse OR parses to a mode that
|
||||
* doesn't match {@link expectedMode} (e.g., an `sp1…` typed into the
|
||||
* on-chain field).
|
||||
* Single labeled custom-wallet input. Mirrors the accept-picker
|
||||
* language so the field-driven custom flow keeps the same public /
|
||||
* private framing: a {@link Bitcoin}/{@link EyeOff} icon next to the
|
||||
* label and a one-line caption spell out what filling this field
|
||||
* means, so the user never has to infer "address = public,
|
||||
* code = private".
|
||||
*
|
||||
* The inline error fires only when a non-empty value either fails to
|
||||
* parse OR parses to a mode that doesn't match {@link expectedMode}
|
||||
* (e.g., an `sp1…` typed into the on-chain field).
|
||||
*/
|
||||
function CustomWalletInput({
|
||||
id,
|
||||
@@ -1530,11 +1581,19 @@ function CustomWalletInput({
|
||||
expectedMode === 'onchain'
|
||||
? t('campaignsCreate.onchainInvalid')
|
||||
: t('campaignsCreate.spInvalid');
|
||||
const MeaningIcon = expectedMode === 'onchain' ? Bitcoin : EyeOff;
|
||||
const meaning =
|
||||
expectedMode === 'onchain'
|
||||
? t('campaignsCreate.customOnchainMeaning')
|
||||
: t('campaignsCreate.customSpMeaning');
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MeaningIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<label htmlFor={id} className="text-xs font-medium">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -1549,7 +1608,11 @@ function CustomWalletInput({
|
||||
aria-invalid={hasError}
|
||||
/>
|
||||
</div>
|
||||
{hasError && <p className="text-xs text-destructive">{errorMessage}</p>}
|
||||
{hasError ? (
|
||||
<p className="text-xs text-destructive">{errorMessage}</p>
|
||||
) : (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{meaning}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ export function ExternalContentPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
<main className="w-full max-w-3xl mx-auto">
|
||||
{/* Non-sticky transparent header — skipped on country pages because
|
||||
the country hero carries its own back arrow overlaid on the
|
||||
photo, which lets the cinematic banner reach all the way to the
|
||||
|
||||
@@ -472,7 +472,7 @@ export function PostDetailShell({
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
<main className="w-full max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<button
|
||||
onClick={() =>
|
||||
|
||||
@@ -44,6 +44,8 @@ export function WalletPage() {
|
||||
availability,
|
||||
currentReceiveAddress,
|
||||
silentPaymentAddress,
|
||||
scan,
|
||||
silentPaymentStorage,
|
||||
transactions,
|
||||
totalBalance,
|
||||
pendingBalance,
|
||||
@@ -69,6 +71,13 @@ export function WalletPage() {
|
||||
const address = currentReceiveAddress?.address ?? '';
|
||||
const spAddress = silentPaymentAddress?.address ?? '';
|
||||
|
||||
// Whether the wallet holds any spendable inputs. Mirrors the Send dialog's
|
||||
// `ownedInputs` set (BIP-86 UTXOs from the Blockbook scan + silent-payment
|
||||
// UTXOs from local storage). When empty, sending is impossible, so the
|
||||
// Send button is disabled just like the modal's "Send Bitcoin" button.
|
||||
const hasSpendableBalance =
|
||||
(scan?.utxos?.length ?? 0) > 0 || (silentPaymentStorage?.utxos?.length ?? 0) > 0;
|
||||
|
||||
// Combined BIP-21 payload: `bitcoin:<bc1>?sp=<sp1>` when both are
|
||||
// available, falling back to the single endpoint that exists.
|
||||
// Mirrors the campaign donate panel's QR payload so BIP-352-aware
|
||||
@@ -278,6 +287,7 @@ export function WalletPage() {
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setSendOpen(true)}
|
||||
disabled={!hasSpendableBalance}
|
||||
className="flex-1 rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<ArrowUpRight className="mr-2" />
|
||||
|
||||
Reference in New Issue
Block a user