diff --git a/app/app/create/batch/page.tsx b/app/app/create/batch/page.tsx index 248f028..430ada7 100644 --- a/app/app/create/batch/page.tsx +++ b/app/app/create/batch/page.tsx @@ -74,6 +74,7 @@ export default function BatchCreatePage() { const [completedCount, setCompletedCount] = useState(0) const [queuedCount, setQueuedCount] = useState(0) const [executionErrors, setExecutionErrors] = useState([]) + const [liveStatus, setLiveStatus] = useState('') const selectedTokenInfo = TOKENS.find((t) => t.address === selectedToken) ?? TOKENS[0] ?? { address: '', symbol: 'XLM', decimals: 7 } @@ -190,6 +191,11 @@ export default function BatchCreatePage() { setExecutionErrors([]) setCompletedCount(0) setQueuedCount(validRows.length) + // Issue #686: announce progress for screen-reader/keyboard-only users. + // `createStreamsBatch` submits every row as a single on-chain + // transaction (not one call per row), so there's no real per-row + // progress to announce — we report the start and the final outcome. + setLiveStatus(`Creating ${validRows.length} stream${validRows.length === 1 ? '' : 's'}…`) const failures: string[] = [] @@ -213,8 +219,10 @@ export default function BatchCreatePage() { if (failures.length > 0) { setExecutionErrors(failures) + setLiveStatus(`Batch failed: ${failures[0]}`) toast.error('Batch create failed.') } else { + setLiveStatus(`${validRows.length} of ${validRows.length} streams created successfully.`) toast.success('Batch create completed', { description: `${validRows.length} streams created successfully.`, }) @@ -381,6 +389,11 @@ export default function BatchCreatePage() { )} + {/* Issue #686: screen-reader-only live region announcing batch progress */} +
+ {liveStatus} +
+
diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx index 82aaf5b..0008d06 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -63,6 +63,7 @@ import { import { useTokenPrice } from "@/hooks/use-token-price"; import type { TokenInfo } from "@/types/stream"; import { useNetwork } from "@/components/providers/network-provider"; +import { createFormCopy as copy } from "@/lib/copy/create-form"; const CUSTOM_VALUE = "__custom__"; @@ -118,17 +119,17 @@ const COMMON_TIMEZONES = [ ] as const; const DURATION_PRESETS = [ - { label: "1 week", seconds: 7 * 24 * 3600 }, - { label: "1 month", seconds: 30 * 24 * 3600 }, - { label: "3 months", seconds: 90 * 24 * 3600 }, - { label: "6 months", seconds: 180 * 24 * 3600 }, - { label: "1 year", seconds: 365 * 24 * 3600 }, + { label: copy.scheduleSection.durationPresetLabels[0], seconds: 7 * 24 * 3600 }, + { label: copy.scheduleSection.durationPresetLabels[1], seconds: 30 * 24 * 3600 }, + { label: copy.scheduleSection.durationPresetLabels[2], seconds: 90 * 24 * 3600 }, + { label: copy.scheduleSection.durationPresetLabels[3], seconds: 180 * 24 * 3600 }, + { label: copy.scheduleSection.durationPresetLabels[4], seconds: 365 * 24 * 3600 }, ] as const; const CLIFF_PRESETS = [ - { label: "No cliff", seconds: 0 }, - { label: "1 month", seconds: 30 * 24 * 3600 }, - { label: "3 months", seconds: 90 * 24 * 3600 }, + { label: copy.scheduleSection.cliffPresetLabels[0], seconds: 0 }, + { label: copy.scheduleSection.cliffPresetLabels[1], seconds: 30 * 24 * 3600 }, + { label: copy.scheduleSection.cliffPresetLabels[2], seconds: 90 * 24 * 3600 }, ] as const; interface FormState { @@ -268,7 +269,7 @@ export function CreateForm() { setFederationResolved(null); setFederationStatus("error"); setFederationError( - err instanceof Error ? err.message : "Federation lookup failed", + err instanceof Error ? err.message : copy.errors.federationLookupFailed, ); set("recipient", ""); }); @@ -405,9 +406,7 @@ export function CreateForm() { async function handleCustomTokenLookup() { if (!customAddress || customAddress.length < 56) { - setCustomError( - "Enter a valid Stellar contract address (56 chars, starts with C)", - ); + setCustomError(copy.errors.invalidContractAddress); return; } setCustomLoading(true); @@ -416,9 +415,7 @@ export function CreateForm() { try { const meta = await getTokenMetadata(customAddress); if (!meta) { - setCustomError( - "Could not fetch token metadata. Verify this is a valid SEP-41 token contract.", - ); + setCustomError(copy.errors.tokenMetadataFetchFailed); return; } setCustomToken(meta); @@ -426,7 +423,7 @@ export function CreateForm() { setTokens(getAllTokens(network).map((t) => ({ ...t }))); set("tokenAddress", meta.address); } catch { - setCustomError("Failed to query token contract"); + setCustomError(copy.errors.tokenContractQueryFailed); } finally { setCustomLoading(false); } @@ -464,17 +461,17 @@ export function CreateForm() { // Issue #155: block submission while a Federation address is still resolving if (federationStatus === "loading") { - newErrors.recipient = "Still resolving the Federation address…"; + newErrors.recipient = copy.errors.federationResolving; } else if (federationStatus === "error") { - newErrors.recipient = federationError ?? "Could not resolve Federation address"; + newErrors.recipient = federationError ?? copy.errors.federationLookupFailed; } else if ( // Issue #28: use StrKey for proper Stellar address validation !form.recipient.trim() || !StrKey.isValidEd25519PublicKey(form.recipient.trim()) ) { newErrors.recipient = isFederationAddress(recipientInput.trim()) - ? "Federation address did not resolve to a valid Stellar account" - : "Invalid Stellar address format"; + ? copy.errors.federationDidNotResolve + : copy.errors.invalidAddressFormat; } // Issue #103: require warning acknowledgment for unfunded accounts if ( @@ -482,38 +479,40 @@ export function CreateForm() { !recipientAccountInfo.exists && !recipientWarningAcknowledged ) { - newErrors.recipient = - "Please acknowledge the warning about this recipient address"; + newErrors.recipient = copy.errors.acknowledgeRecipientWarning; } if ( !form.amount || isNaN(Number(form.amount)) || Number(form.amount) <= 0 ) { - newErrors.amount = "Enter a valid amount greater than 0"; + newErrors.amount = copy.errors.invalidAmount; } // Issue #29: validate against balance if (form.amount && tokenBalance !== null) { const parsed = parseTokenAmount(form.amount, selectedToken.decimals); if (parsed > tokenBalance) { - newErrors.amount = `Amount exceeds your balance (${formatTokenAmount(tokenBalance, selectedToken.decimals, 4)} ${selectedToken.symbol})`; + newErrors.amount = copy.errors.amountExceedsBalance( + formatTokenAmount(tokenBalance, selectedToken.decimals, 4), + selectedToken.symbol, + ); } } if (isCustom && !customToken) { - newErrors.tokenAddress = "Look up a valid custom token first"; + newErrors.tokenAddress = copy.errors.lookupCustomTokenFirst; } const start = new Date(form.startDate).getTime(); const end = new Date(form.endDate).getTime(); if (!form.endDate || end <= start) { - newErrors.endDate = "End date must be after start date"; + newErrors.endDate = copy.errors.endDateBeforeStart; } if (form.hasCliff) { const cliff = new Date(form.cliffDate).getTime(); if (!form.cliffDate || cliff < start || cliff > end) { - newErrors.cliffDate = "Cliff must be between start and end date"; + newErrors.cliffDate = copy.errors.cliffOutOfRange; } if (form.cliffAmount && Number(form.cliffAmount) > Number(form.amount)) { - newErrors.cliffAmount = "Cliff amount cannot exceed total amount"; + newErrors.cliffAmount = copy.errors.cliffExceedsTotal; } } @@ -584,8 +583,8 @@ export function CreateForm() { discard(); setShowConfirmation(false); - toast.success("Stream created", { - description: `Stream #${id} is live.`, + toast.success(copy.toasts.streamCreatedTitle, { + description: copy.toasts.streamCreatedDescription(id), }); router.push(`/app/stream/${id}`); } catch { @@ -637,15 +636,15 @@ export function CreateForm() { className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground" > - Back to dashboard + {copy.backToDashboard}

- Create a stream + {copy.heading}

- Tokens unlock continuously to the recipient from start to end. + {copy.subheading}

@@ -659,7 +658,7 @@ export function CreateForm() {

- Duplicating Stream #{cloneId} — form pre-filled with its parameters. + {copy.cloneNotice(cloneId)}

)} @@ -670,14 +669,14 @@ export function CreateForm() {

- You have an unsaved draft from{" "} - {draftSavedAt - ? new Date(draftSavedAt).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : "earlier"} - . + {copy.draft.bannerText( + draftSavedAt + ? new Date(draftSavedAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : copy.draft.bannerTimeFallback, + )}

@@ -687,10 +686,10 @@ export function CreateForm() { onClick={() => { restore(); setShowDraftBanner(false); - toast.success("Draft restored"); + toast.success(copy.draft.restoredToast); }} > - Restore + {copy.draft.restoreButton}
@@ -711,11 +710,11 @@ export function CreateForm() { {/* Token + Amount */}

- Amount + {copy.amountSection.title}

- +
@@ -747,12 +748,12 @@ export function CreateForm() { {isCustom && (
{ setCustomAddress(e.target.value); @@ -770,7 +771,7 @@ export function CreateForm() { {customLoading ? ( ) : ( - "Lookup" + copy.amountSection.lookupButton )}
@@ -779,8 +780,10 @@ export function CreateForm() { )} {customToken && (

- Found: {customToken.symbol} ({customToken.decimals}{" "} - decimals) + {copy.amountSection.customTokenFound( + customToken.symbol, + customToken.decimals, + )}

)}
@@ -790,13 +793,16 @@ export function CreateForm() { {/* Issue #29: show balance + Max button */}
{balanceLoading - ? "Loading balance…" + ? copy.amountSection.loadingBalance : tokenBalance !== null - ? `Balance: ${formatTokenAmount(tokenBalance, selectedToken.decimals, 4)} ${selectedToken.symbol}` + ? copy.amountSection.balanceLabel( + formatTokenAmount(tokenBalance, selectedToken.decimals, 4), + selectedToken.symbol, + ) : null}
@@ -813,7 +819,7 @@ export function CreateForm() { }} className={`rounded-full border px-2 py-0.5 transition-colors ${!usdInputMode ? "border-primary bg-primary/10 text-foreground" : "border-border hover:border-foreground"}`} > - {selectedToken.symbol} amount + {copy.amountSection.tokenAmountToggle(selectedToken.symbol)} {priceStale && ( - Price may be stale + + {copy.amountSection.priceStale} + )}
)} @@ -840,7 +848,7 @@ export function CreateForm() { type="number" min="0" step="any" - placeholder="e.g. 500" + placeholder={copy.amountSection.usdAmountPlaceholder} className="pl-6" value={usdAmount} onChange={(e) => handleUsdAmountChange(e.target.value)} @@ -853,7 +861,7 @@ export function CreateForm() { type="number" min="0" step="any" - placeholder="e.g. 10000" + placeholder={copy.amountSection.tokenAmountPlaceholder} value={form.amount} onChange={(e) => set("amount", e.target.value)} aria-invalid={!!errors.amount} @@ -875,7 +883,7 @@ export function CreateForm() { ) } > - Max + {copy.amountSection.maxButton} )}
@@ -883,30 +891,38 @@ export function CreateForm() { {/* Issue #186: USD equivalent + per-second rate */} {usdEquivalent && !usdInputMode && (

- ≈ ${usdEquivalent} USD + {copy.amountSection.usdEquivalent(usdEquivalent)} {amountPerSecondUsd && ( - · Streaming rate:{" "} - {( - tokenAmountNum / - Math.max( - 1, - (new Date(form.endDate).getTime() - - new Date(form.startDate).getTime()) / - 1000, - ) - ).toFixed(6)}{" "} - {selectedToken.symbol}/sec (≈ ${amountPerSecondUsd}/sec) + ·{" "} + {copy.amountSection.streamingRate( + ( + tokenAmountNum / + Math.max( + 1, + (new Date(form.endDate).getTime() - + new Date(form.startDate).getTime()) / + 1000, + ) + ).toFixed(6), + selectedToken.symbol, + amountPerSecondUsd, + )} )} {priceLoading && ( - Fetching price… + + {copy.amountSection.fetchingPrice} + )}

)} {usdInputMode && form.amount && (

- ≈ {form.amount} {selectedToken.symbol} + {copy.amountSection.tokenEquivalentInUsdMode( + form.amount, + selectedToken.symbol, + )}

)} @@ -919,14 +935,14 @@ export function CreateForm() { {/* Recipient */}

- Recipient + {copy.recipientSection.title}

- +
setRecipientInput(e.target.value)} aria-invalid={!!errors.recipient} @@ -945,7 +961,9 @@ export function CreateForm() {

- {federationResolved.federationAddress} resolved + {copy.recipientSection.federationResolved( + federationResolved.federationAddress, + )}

{federationResolved.accountId} @@ -969,7 +987,7 @@ export function CreateForm() { label: federationResolved?.accountId === trimmed ? federationResolved.federationAddress - : "Saved recipient", + : copy.recipientSection.savedDefaultLabel, address: trimmed, federationAddress: federationResolved?.accountId === trimmed @@ -977,20 +995,20 @@ export function CreateForm() { : undefined, }); setAddressBookEntries(getAddressBookEntries()); - toast.success("Recipient saved"); + toast.success(copy.recipientSection.savedToast); }} disabled={ !form.recipient.trim() || !StrKey.isValidEd25519PublicKey(form.recipient.trim()) } > - Save recipient + {copy.recipientSection.saveButton}

{addressBookEntries.length > 0 && (

- Recent recipients + {copy.recipientSection.recentRecipientsLabel}

{addressBookEntries.slice(0, 6).map((entry) => ( @@ -1039,10 +1057,7 @@ export function CreateForm() { {walletAddress && form.recipient.trim() === walletAddress && (
- - This is your own address. Self-streams are allowed but may - have been unintended. - + {copy.recipientSection.selfStreamWarning}
)} {/* Issue #103: Warning for unfunded/unknown recipient accounts */} @@ -1051,11 +1066,10 @@ export function CreateForm() {

- This address has no transaction history. + {copy.recipientSection.unfundedTitle}

- Tokens sent to this address may be unrecoverable if it's - not funded. + {copy.recipientSection.unfundedBody}

@@ -1072,7 +1086,7 @@ export function CreateForm() { {recipientChecking && (
- Checking recipient account... + {copy.recipientSection.checking}
)}
@@ -1081,7 +1095,7 @@ export function CreateForm() { {/* Schedule */}

- Schedule + {copy.scheduleSection.title}

{/* Issue #170: timezone selector */} @@ -1090,8 +1104,7 @@ export function CreateForm() { htmlFor="timezone" className="text-xs text-muted-foreground" > - Timezone — dates below are interpreted in this timezone ( - {timezoneOffset}) + {copy.scheduleSection.timezoneLabel(timezoneOffset)} @@ -1202,15 +1216,22 @@ export function CreateForm() { - Do not repeat - Weekly - Monthly - Quarterly + + {copy.scheduleSection.recurrenceOptions.none} + + + {copy.scheduleSection.recurrenceOptions.weekly} + + + {copy.scheduleSection.recurrenceOptions.monthly} + + + {copy.scheduleSection.recurrenceOptions.quarterly} +

- Save a renewal rule for this recipient so the schedule can be - recreated later. + {copy.scheduleSection.recurrenceHelp}

@@ -1219,7 +1240,7 @@ export function CreateForm() { {/* Cliff presets */}
{CLIFF_PRESETS.map((preset) => ( @@ -1246,7 +1267,7 @@ export function CreateForm() {
- - Mainnet uses real funds. Double-check the recipient, amount, and - token before creating a stream. - + {copy.mainnetWarning}
)} @@ -1316,15 +1334,14 @@ export function CreateForm() { {feeEstimate && (
- Estimated transaction fee: ~{feeEstimate} XLM (includes 15% - buffer) + {copy.feeEstimateText(feeEstimate)}
)} {/* Submit */}
@@ -1364,7 +1381,7 @@ export function CreateForm() { input={input} network={network} sender={walletAddress ?? ""} - operationLabel="Create Stream" + operationLabel={copy.txPreviewOperationLabel} onConfirm={() => { setShowTxPreview(false); setShowConfirmation(true); diff --git a/app/app/stream/[id]/page.tsx b/app/app/stream/[id]/page.tsx index 9cd2f44..7d65c0c 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -77,6 +77,7 @@ import { DownloadReceiptButton } from "@/components/streams/download-receipt-but import { bumpStreamTtl } from "@/lib/contract"; import { getFederationNameForAddress } from "@/lib/address-book"; import { QrShareDialog } from "@/components/streams/qr-share-dialog"; +import { streamDetailCopy as copy } from "@/lib/copy/stream-detail"; // ─── Address copy button ──────────────────────────────────────────────────── @@ -101,7 +102,7 @@ function CopyableAddress({ - {copied ? "Copied" : ""} + {copied ? copy.copyableAddress.copiedStatus : ""} {href && ( @@ -187,11 +188,14 @@ function WithdrawDialog({ async function handleWithdraw() { try { const hash = await withdraw(streamId, parsed); - toast.success("Withdrawal successful", { - description: `${formatTokenAmount(parsed, token.decimals, 4)} ${token.symbol} sent to your wallet.`, + toast.success(copy.withdrawDialog.successToastTitle, { + description: copy.withdrawDialog.successToastDescription( + formatTokenAmount(parsed, token.decimals, 4), + token.symbol, + ), ...(hash && { action: { - label: "View transaction", + label: copy.withdrawDialog.viewTransactionAction, onClick: () => window.open(explorerUrl(network, "tx", hash), "_blank"), }, @@ -212,9 +216,9 @@ function WithdrawDialog({ > - Withdraw funds + {copy.withdrawDialog.title} - Enter how much to withdraw. Max:{" "} + {copy.withdrawDialog.maxPrefix}{" "} {max} {token.symbol} @@ -222,14 +226,16 @@ function WithdrawDialog({
- +
setInputAmount(e.target.value)} aria-invalid={!!inputAmount && invalid} @@ -240,12 +246,12 @@ function WithdrawDialog({ size="sm" onClick={() => setInputAmount(max)} > - Max + {copy.withdrawDialog.maxButton}
{inputAmount && invalid && (

- Amount exceeds withdrawable balance + {copy.withdrawDialog.exceedsBalance}

)}
@@ -253,26 +259,26 @@ function WithdrawDialog({ {/* Fee info */}

- Estimated network fee:{" "} + {copy.withdrawDialog.estimatedFeeLabel}{" "} {(estimatedFee / 1e7).toFixed(7)} XLM

- Fee will be shown again before wallet confirmation. + {copy.withdrawDialog.feeShownAgainNote}

{error &&

{error}

}
@@ -329,18 +335,14 @@ function CancelDialog({ > - Cancel stream - - Unlocked funds will be sent to the recipient. Any remaining locked - tokens will be returned to your wallet. You'll have a few - seconds to undo before this is submitted. - + {copy.cancelDialog.title} + {copy.cancelDialog.description} {/* Fee info */}

- Estimated network fee:{" "} + {copy.cancelDialog.estimatedFeeLabel}{" "} {(estimatedFee / 1e7).toFixed(7)} XLM @@ -349,13 +351,13 @@ function CancelDialog({

@@ -430,10 +432,10 @@ function CleanupDialog({ // ─── Auto-withdraw settings ───────────────────────────────────────────────── const INTERVAL_OPTIONS = [ - { label: "Every 6 hours", hours: 6 }, - { label: "Every 12 hours", hours: 12 }, - { label: "Every 24 hours", hours: 24 }, - { label: "Every 48 hours", hours: 48 }, + { label: copy.autoWithdraw.intervalOptionLabels[0], hours: 6 }, + { label: copy.autoWithdraw.intervalOptionLabels[1], hours: 12 }, + { label: copy.autoWithdraw.intervalOptionLabels[2], hours: 24 }, + { label: copy.autoWithdraw.intervalOptionLabels[3], hours: 48 }, ] as const; function AutoWithdrawSection({ @@ -459,23 +461,19 @@ function AutoWithdrawSection({ }> = [ { value: "time-based", - label: "Time-based", - description: "Withdraw on fixed intervals", + ...copy.autoWithdraw.strategies.timeBased, }, { value: "threshold-based", - label: "Threshold-based", - description: "Withdraw when amount reaches threshold", + ...copy.autoWithdraw.strategies.thresholdBased, }, { value: "gas-optimized", - label: "Gas-optimized", - description: "Limit frequency to reduce gas costs", + ...copy.autoWithdraw.strategies.gasOptimized, }, { value: "max", - label: "Max amount", - description: "Always withdraw maximum available", + ...copy.autoWithdraw.strategies.max, }, ]; @@ -485,7 +483,7 @@ function AutoWithdrawSection({

- Auto-withdraw + {copy.autoWithdraw.title}