From aa03ee03804c1260167b5b807aac61b9f2ceef71 Mon Sep 17 00:00:00 2001 From: Joel234-png Date: Sat, 29 Aug 2026 02:05:18 +0100 Subject: [PATCH] Extract UI copy for i18n; add notification a11y and batch progress announcements - Extract create-form.tsx and stream detail page strings into lib/copy/ modules as a first i18n-prep step (#682, #683). - Add roving focus + Up/Down arrow-key navigation to the notification dropdown, focusing the first item (or panel) on open (#684). - Add an aria-live="polite" status region announcing batch-create start and outcome to screen readers (#686). Closes #682 Closes #683 Closes #684 Closes #686 --- app/app/create/batch/page.tsx | 13 ++ app/app/create/create-form.tsx | 263 +++++++++++++----------- app/app/stream/[id]/page.tsx | 215 +++++++++---------- components/layout/notification-bell.tsx | 67 +++++- lib/copy/create-form.ts | 134 ++++++++++++ lib/copy/stream-detail.ts | 149 ++++++++++++++ 6 files changed, 606 insertions(+), 235 deletions(-) create mode 100644 lib/copy/create-form.ts create mode 100644 lib/copy/stream-detail.ts 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 809eb54..ad1e926 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -52,6 +52,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__"; @@ -107,17 +108,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 { @@ -251,7 +252,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", ""); }); @@ -388,9 +389,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); @@ -399,9 +398,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); @@ -409,7 +406,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); } @@ -425,17 +422,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 ( @@ -443,38 +440,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; } } @@ -545,8 +544,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 { @@ -598,15 +597,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}

@@ -620,7 +619,7 @@ export function CreateForm() {

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

)} @@ -631,14 +630,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, + )}

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

- Amount + {copy.amountSection.title}

- +
@@ -708,12 +709,12 @@ export function CreateForm() { {isCustom && (
{ setCustomAddress(e.target.value); @@ -731,7 +732,7 @@ export function CreateForm() { {customLoading ? ( ) : ( - "Lookup" + copy.amountSection.lookupButton )}
@@ -740,8 +741,10 @@ export function CreateForm() { )} {customToken && (

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

)}
@@ -751,13 +754,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}
@@ -774,7 +780,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} + )}
)} @@ -801,7 +809,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)} @@ -814,7 +822,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} @@ -836,7 +844,7 @@ export function CreateForm() { ) } > - Max + {copy.amountSection.maxButton} )}
@@ -844,30 +852,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, + )}

)} @@ -880,14 +896,14 @@ export function CreateForm() { {/* Recipient */}

- Recipient + {copy.recipientSection.title}

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

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

{federationResolved.accountId} @@ -930,7 +948,7 @@ export function CreateForm() { label: federationResolved?.accountId === trimmed ? federationResolved.federationAddress - : "Saved recipient", + : copy.recipientSection.savedDefaultLabel, address: trimmed, federationAddress: federationResolved?.accountId === trimmed @@ -938,20 +956,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) => ( @@ -980,10 +998,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 */} @@ -992,11 +1007,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}

@@ -1013,7 +1027,7 @@ export function CreateForm() { {recipientChecking && (
- Checking recipient account... + {copy.recipientSection.checking}
)}
@@ -1022,7 +1036,7 @@ export function CreateForm() { {/* Schedule */}

- Schedule + {copy.scheduleSection.title}

{/* Issue #170: timezone selector */} @@ -1031,8 +1045,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)} @@ -1143,15 +1157,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}

@@ -1160,7 +1181,7 @@ export function CreateForm() { {/* Cliff presets */}
{CLIFF_PRESETS.map((preset) => ( @@ -1187,7 +1208,7 @@ export function CreateForm() {
- - Mainnet uses real funds. Double-check the recipient, amount, and - token before creating a stream. - + {copy.mainnetWarning}
)} @@ -1257,15 +1275,14 @@ export function CreateForm() { {feeEstimate && (
- Estimated transaction fee: ~{feeEstimate} XLM (includes 15% - buffer) + {copy.feeEstimateText(feeEstimate)}
)} {/* Submit */}
@@ -1305,7 +1322,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 230c2ec..c0efe47 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -70,6 +70,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 ──────────────────────────────────────────────────── @@ -94,7 +95,7 @@ function CopyableAddress({ - {copied ? "Copied" : ""} + {copied ? copy.copyableAddress.copiedStatus : ""} {href && ( @@ -180,11 +181,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"), }, @@ -205,9 +209,9 @@ function WithdrawDialog({ > - Withdraw funds + {copy.withdrawDialog.title} - Enter how much to withdraw. Max:{" "} + {copy.withdrawDialog.maxPrefix}{" "} {max} {token.symbol} @@ -215,14 +219,16 @@ function WithdrawDialog({
- +
setInputAmount(e.target.value)} aria-invalid={!!inputAmount && invalid} @@ -233,12 +239,12 @@ function WithdrawDialog({ size="sm" onClick={() => setInputAmount(max)} > - Max + {copy.withdrawDialog.maxButton}
{inputAmount && invalid && (

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

)}
@@ -246,26 +252,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}

}
@@ -322,18 +328,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 @@ -342,13 +344,13 @@ function CancelDialog({

@@ -371,10 +373,10 @@ function CancelDialog({ // ─── 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({ @@ -400,23 +402,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, }, ]; @@ -426,7 +424,7 @@ function AutoWithdrawSection({

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

@@ -1027,7 +1028,7 @@ function StreamDetail({ id }: { id: string }) {

- {isSender ? "Sending" : "Receiving"}{" "} + {isSender ? copy.header.sending : copy.header.receiving}{" "}

- Stream #{stream.id} + {copy.header.streamIdPrefix} + {stream.id}

@@ -1045,7 +1047,7 @@ function StreamDetail({ id }: { id: string }) { {/* Live counter */}

- Unlocked so far + {copy.header.unlockedSoFarLabel}

- {(progress * 100).toFixed(2)}% unlocked + + {(progress * 100).toFixed(2)} + {copy.header.unlockedFraction} + {" "} - withdrawn + {copy.header.withdrawnSuffix}
@@ -1093,7 +1098,7 @@ function StreamDetail({ id }: { id: string }) {
{status === "scheduled" && (
-

Starts in

+

{copy.header.startsInLabel}

- {status === "scheduled" ? "Duration" : "Ends in"} + {status === "scheduled" ? copy.header.durationLabel : copy.header.endsInLabel}

- Cancelling… you can still undo this from the toast. + {copy.header.cancellingNotice}
)} @@ -1127,7 +1132,7 @@ function StreamDetail({ id }: { id: string }) { {canWithdraw && ( )} @@ -1147,7 +1152,7 @@ function StreamDetail({ id }: { id: string }) { className="gap-1.5" > - Duplicate stream + {copy.header.duplicateStreamButton} )}
@@ -1167,22 +1172,22 @@ function StreamDetail({ id }: { id: string }) { {/* Details */}

- Details + {copy.details.sectionTitle}

- + - + - +
{stream.token.symbol} {config.streamContractId && ( - + )} - + - + - + 0n ? "text-primary font-medium" : ""}> - + - + {formatDateTime(stream.startTime)} @@ -1238,7 +1243,7 @@ function StreamDetail({ id }: { id: string }) { {stream.cliffTime > stream.startTime && ( - + {formatDateTime(stream.cliffTime)} {stream.cliffAmount > 0n && ( @@ -1253,7 +1258,7 @@ function StreamDetail({ id }: { id: string }) { )} )} - + {formatDateTime(stream.endTime)} @@ -1265,7 +1270,7 @@ function StreamDetail({ id }: { id: string }) { - + {network}
diff --git a/components/layout/notification-bell.tsx b/components/layout/notification-bell.tsx index c8a6426..3f995e2 100644 --- a/components/layout/notification-bell.tsx +++ b/components/layout/notification-bell.tsx @@ -6,11 +6,20 @@ import { formatTimeAgo } from '@/lib/stream-utils' import { useWallet } from '@/hooks/use-wallet' import { useNotifications, type AppNotification } from '@/hooks/use-notifications' -function NotificationItem({ notification }: { notification: AppNotification }) { +function NotificationItem({ + notification, + itemRef, +}: { + notification: AppNotification + itemRef?: (el: HTMLDivElement | null) => void +}) { return (
@@ -30,8 +39,11 @@ export function NotificationBell() { const { address } = useWallet() const { notifications, unreadCount, markAllRead, clearAll } = useNotifications(address) const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) const ref = useRef(null) const triggerRef = useRef(null) + const panelRef = useRef(null) + const itemRefs = useRef>([]) useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -40,9 +52,27 @@ export function NotificationBell() { } } function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && open) { + if (!open) return + if (e.key === 'Escape') { setOpen(false) triggerRef.current?.focus() + return + } + if (notifications.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((i) => { + const next = i < notifications.length - 1 ? i + 1 : 0 + itemRefs.current[next]?.focus() + return next + }) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((i) => { + const next = i > 0 ? i - 1 : notifications.length - 1 + itemRefs.current[next]?.focus() + return next + }) } } if (open) { @@ -53,6 +83,21 @@ export function NotificationBell() { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('keydown', handleKeyDown) } + }, [open, notifications.length]) + + // Issue #684: move focus into the dropdown on open — first notification + // item if there is one, otherwise the panel itself. + useEffect(() => { + if (!open) return + itemRefs.current = itemRefs.current.slice(0, notifications.length) + if (notifications.length > 0) { + setActiveIndex(0) + itemRefs.current[0]?.focus() + } else { + setActiveIndex(-1) + panelRef.current?.focus() + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) function handleToggle() { @@ -83,9 +128,11 @@ export function NotificationBell() { {open && (

Notifications

@@ -104,8 +151,14 @@ export function NotificationBell() { No notifications yet

) : ( - notifications.map((n) => ( - + notifications.map((n, idx) => ( + { + itemRefs.current[idx] = el + }} + /> )) )}
diff --git a/lib/copy/create-form.ts b/lib/copy/create-form.ts new file mode 100644 index 0000000..e23c341 --- /dev/null +++ b/lib/copy/create-form.ts @@ -0,0 +1,134 @@ +// Centralized UI copy for the create-stream form (app/app/create/create-form.tsx). +// First step toward i18n-readiness — extracted so this component's strings +// live in one place instead of scattered through JSX (issue #682). + +export const createFormCopy = { + backToDashboard: "Back to dashboard", + heading: "Create a stream", + subheading: "Tokens unlock continuously to the recipient from start to end.", + cloneNotice: (id: string) => + `Duplicating Stream #${id} — form pre-filled with its parameters.`, + + draft: { + bannerText: (time: string) => `You have an unsaved draft from ${time}.`, + bannerTimeFallback: "earlier", + restoreButton: "Restore", + discardButton: "Discard", + restoredToast: "Draft restored", + }, + + amountSection: { + title: "Amount", + tokenLabel: "Token", + customTokenOption: "Custom token…", + customTokenAddressLabel: "Token contract address", + customTokenPlaceholder: "CABC…", + lookupButton: "Lookup", + customTokenFound: (symbol: string, decimals: number) => + `Found: ${symbol} (${decimals} decimals)`, + totalAmountLabel: (symbol: string) => `Total amount (${symbol})`, + loadingBalance: "Loading balance…", + balanceLabel: (amount: string, symbol: string) => + `Balance: ${amount} ${symbol}`, + tokenAmountToggle: (symbol: string) => `${symbol} amount`, + usdAmountToggle: "USD amount", + priceStale: "Price may be stale", + tokenAmountPlaceholder: "e.g. 10000", + usdAmountPlaceholder: "e.g. 500", + maxButton: "Max", + usdEquivalent: (usd: string) => `≈ $${usd} USD`, + streamingRate: (ratePerSec: string, symbol: string, usdPerSec: string) => + `Streaming rate: ${ratePerSec} ${symbol}/sec (≈ $${usdPerSec}/sec)`, + fetchingPrice: "Fetching price…", + tokenEquivalentInUsdMode: (amount: string, symbol: string) => + `≈ ${amount} ${symbol}`, + }, + + recipientSection: { + title: "Recipient", + label: "Stellar address or Federation name", + placeholder: "GABC… or alice*domain.com", + federationResolved: (federationAddress: string) => + `${federationAddress} resolved`, + saveButton: "Save recipient", + savedDefaultLabel: "Saved recipient", + savedToast: "Recipient saved", + recentRecipientsLabel: "Recent recipients", + selfStreamWarning: + "This is your own address. Self-streams are allowed but may have been unintended.", + unfundedTitle: "This address has no transaction history.", + unfundedBody: + "Tokens sent to this address may be unrecoverable if it's not funded.", + acknowledgeWarningButton: "I understand, proceed", + checking: "Checking recipient account...", + }, + + scheduleSection: { + title: "Schedule", + timezoneLabel: (offset: string) => + `Timezone — dates below are interpreted in this timezone (${offset})`, + startDateLabel: "Start date", + endDateLabel: "End date", + quickDurationLabel: "Quick duration", + addCliffLabel: "Add a cliff", + addCliffHelp: + "Nothing unlocks before the cliff date. Optionally release a lump sum at the cliff.", + recurrenceLabel: "Recurring cadence", + recurrenceOptions: { + none: "Do not repeat", + weekly: "Weekly", + monthly: "Monthly", + quarterly: "Quarterly", + }, + recurrenceHelp: + "Save a renewal rule for this recipient so the schedule can be recreated later.", + durationPresetLabels: ["1 week", "1 month", "3 months", "6 months", "1 year"] as const, + cliffPresetLabels: ["No cliff", "1 month", "3 months"] as const, + quickCliffLabel: "Quick cliff", + cliffDateLabel: "Cliff date", + cliffAmountLabel: (symbol: string) => `Cliff amount (${symbol})`, + optionalTag: "optional", + }, + + txPreviewOperationLabel: "Create Stream", + + mainnetWarning: + "Mainnet uses real funds. Double-check the recipient, amount, and token before creating a stream.", + feeEstimateText: (fee: string) => + `Estimated transaction fee: ~${fee} XLM (includes 15% buffer)`, + + actions: { + cancel: "Cancel", + estimatingFee: "Estimating…", + estimateFee: "Estimate fee", + creating: "Creating…", + createStream: "Create stream", + }, + + toasts: { + streamCreatedTitle: "Stream created", + streamCreatedDescription: (id: string) => `Stream #${id} is live.`, + }, + + errors: { + federationResolving: "Still resolving the Federation address…", + federationLookupFailed: "Federation lookup failed", + federationDidNotResolve: + "Federation address did not resolve to a valid Stellar account", + invalidAddressFormat: "Invalid Stellar address format", + acknowledgeRecipientWarning: + "Please acknowledge the warning about this recipient address", + invalidAmount: "Enter a valid amount greater than 0", + amountExceedsBalance: (balance: string, symbol: string) => + `Amount exceeds your balance (${balance} ${symbol})`, + lookupCustomTokenFirst: "Look up a valid custom token first", + endDateBeforeStart: "End date must be after start date", + cliffOutOfRange: "Cliff must be between start and end date", + cliffExceedsTotal: "Cliff amount cannot exceed total amount", + invalidContractAddress: + "Enter a valid Stellar contract address (56 chars, starts with C)", + tokenMetadataFetchFailed: + "Could not fetch token metadata. Verify this is a valid SEP-41 token contract.", + tokenContractQueryFailed: "Failed to query token contract", + }, +}; diff --git a/lib/copy/stream-detail.ts b/lib/copy/stream-detail.ts new file mode 100644 index 0000000..c201089 --- /dev/null +++ b/lib/copy/stream-detail.ts @@ -0,0 +1,149 @@ +// Centralized UI copy for the stream detail page (app/app/stream/[id]/page.tsx). +// First step toward i18n-readiness — extracted so this page's strings live in +// one place instead of scattered through JSX (issue #683). + +export const streamDetailCopy = { + copyableAddress: { + copyAriaLabel: "Copy address", + copiedStatus: "Copied", + viewOnExplorerAriaLabel: "View on Stellar Expert", + }, + + withdrawDialog: { + title: "Withdraw funds", + maxPrefix: "Enter how much to withdraw. Max:", + amountLabel: (symbol: string) => `Amount (${symbol})`, + amountPlaceholder: "0.00", + maxButton: "Max", + exceedsBalance: "Amount exceeds withdrawable balance", + estimatedFeeLabel: "Estimated network fee:", + feeShownAgainNote: "Fee will be shown again before wallet confirmation.", + cancelButton: "Cancel", + withdrawing: "Withdrawing…", + reviewFeesButton: "Review fees", + successToastTitle: "Withdrawal successful", + successToastDescription: (amount: string, symbol: string) => + `${amount} ${symbol} sent to your wallet.`, + viewTransactionAction: "View transaction", + }, + + cancelDialog: { + title: "Cancel stream", + description: + "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.", + estimatedFeeLabel: "Estimated network fee:", + keepStreamButton: "Keep stream", + reviewAndCancelButton: "Review & cancel", + }, + + autoWithdraw: { + title: "Auto-withdraw", + enableAriaLabel: "Enable auto-withdraw", + helpText: + "Automatically withdraw funds using your chosen strategy. The app must be open and your wallet connected.", + strategyLabel: "Strategy", + strategies: { + timeBased: { label: "Time-based", description: "Withdraw on fixed intervals" }, + thresholdBased: { + label: "Threshold-based", + description: "Withdraw when amount reaches threshold", + }, + gasOptimized: { + label: "Gas-optimized", + description: "Limit frequency to reduce gas costs", + }, + max: { label: "Max amount", description: "Always withdraw maximum available" }, + }, + frequencyLabel: "Frequency", + intervalOptionLabels: [ + "Every 6 hours", + "Every 12 hours", + "Every 24 hours", + "Every 48 hours", + ] as const, + thresholdLabel: "Threshold (% of total deposited)", + minAmountLabel: (symbol: string) => `Minimum amount (${symbol})`, + minAmountPlaceholder: "0 (no minimum)", + minAmountHelp: "Skip auto-withdraw if the available amount is below this threshold.", + maxLimitLabel: (symbol: string) => `Maximum safety limit (${symbol})`, + maxLimitPlaceholder: "0 (no limit)", + maxLimitHelp: "Never withdraw more than this amount per transaction.", + autoWithdrawingStatus: "Auto-withdrawing...", + lastAutoWithdrawalPrefix: "Last auto-withdrawal:", + showHistoryButton: "Show", + hideHistoryButton: "Hide", + withdrawalHistorySuffix: "withdrawal history", + historyErrorPrefix: "Error:", + historyWithdrewPrefix: "Withdrew:", + }, + + ttlWarning: { + title: "Storage may be expiring soon", + body: (daysLeft: number, plural: boolean) => + `This stream's on-chain data may expire in ~${daysLeft} day${plural ? "s" : ""}. Extend the TTL to prevent data loss and keep the stream active.`, + extendingButton: "Extending…", + extendTtlButton: "Extend TTL", + successToast: "Storage TTL extended by 30 days", + errorToast: "Failed to extend TTL", + }, + + shareButtons: { + shareButton: "Share", + shareAriaLabel: "Share stream", + copyLinkAriaLabel: "Copy link", + copiedLinkButton: "Copied!", + copyLinkButton: "Copy link", + linkCopiedToast: "Link copied to clipboard", + twitterAriaLabel: "Share on Twitter", + twitterButton: "Twitter", + telegramAriaLabel: "Share on Telegram", + telegramButton: "Telegram", + qrAriaLabel: "Show QR code", + qrButton: "QR code", + shareText: (streamId: string) => + `Check out this token stream on FlowStar - Stream #${streamId}`, + }, + + connectPrompt: { + body: "Connect your wallet to withdraw, cancel, or interact with this stream.", + }, + + notFound: { + title: "Stream not found", + body: "This stream may not exist or may have expired.", + backButton: "Back to dashboard", + }, + + header: { + backLabel: "Dashboard", + sending: "Sending", + receiving: "Receiving", + streamIdPrefix: "Stream #", + unlockedSoFarLabel: "Unlocked so far", + unlockedFraction: "% unlocked", + withdrawnSuffix: "withdrawn", + startsInLabel: "Starts in", + durationLabel: "Duration", + endsInLabel: "Ends in", + cancellingNotice: "Cancelling… you can still undo this from the toast.", + withdrawButton: "Withdraw", + cancelStreamButton: "Cancel stream", + duplicateStreamButton: "Duplicate stream", + }, + + details: { + sectionTitle: "Details", + sender: "Sender", + recipient: "Recipient", + token: "Token", + streamContract: "Stream Contract", + totalDeposited: "Total deposited", + withdrawn: "Withdrawn", + withdrawableNow: "Withdrawable now", + rate: "Rate", + start: "Start", + cliff: "Cliff", + end: "End", + network: "Network", + }, +};