diff --git a/.gitignore b/.gitignore index 6994365c..aaec83ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.claude/settings.local.json .env .env.* dist/ diff --git a/package-lock.json b/package-lock.json index 130efdaa..75f01196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "wasm-example-app", "version": "0.1.0", "dependencies": { - "@breeztech/breez-sdk-spark": "^0.12.1", + "@breeztech/breez-sdk-spark": "file:vendor/breez-sdk-spark.tgz", "@headlessui/react": "^1.7.17", "@zxing/browser": "^0.1.5", "@zxing/library": "^0.21.3", @@ -407,9 +407,9 @@ } }, "node_modules/@breeztech/breez-sdk-spark": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.12.1.tgz", - "integrity": "sha512-SfdvzyhZpaSVvuueACa/LnX7bu0zbCYJSUnfWKJX5HqG5qQ5p80zl7C+DkR97mQ2d0+cTyiQ7xzObuYTC1XqIg==", + "version": "0.1.0", + "resolved": "file:vendor/breez-sdk-spark.tgz", + "integrity": "sha512-w5Fl8VOiZS2vWtsG8ROp7SGCroK+xYYFiK/3G9xm8bAsoK9c1fAEqnoDQqyLSFAoQjymLyq5wSzo3bReA8E4sg==", "license": "MIT", "engines": { "node": ">=22" diff --git a/package.json b/package.json index 0ab3bd40..f98ef0b1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { - "@breeztech/breez-sdk-spark": "^0.12.1", + "@breeztech/breez-sdk-spark": "file:vendor/breez-sdk-spark.tgz", "@headlessui/react": "^1.7.17", "@zxing/browser": "^0.1.5", "@zxing/library": "^0.21.3", diff --git a/src/App.tsx b/src/App.tsx index dfd588c3..49ae28fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { WalletProvider } from './contexts/WalletContext'; import LoadingSpinner from './components/LoadingSpinner'; import PaymentReceivedCelebration from './components/PaymentReceivedCelebration'; @@ -7,6 +7,8 @@ import StagingGate from './components/StagingGate'; import { ToastProvider, useToast } from './contexts/ToastContext'; import AppShell from './components/layout/AppShell'; import { useBreezSdk } from './hooks/useBreezSdk'; +import { FiatDataProvider } from './contexts/FiatDataContext'; +import { StableBalanceProvider, useStableBalance } from './contexts/StableBalanceContext'; import HomePage from './pages/HomePage'; import WalletPage from './pages/WalletPage'; @@ -19,19 +21,29 @@ import SettingsPage from './pages/SettingsPage'; import FiatCurrenciesPage from './pages/FiatCurrenciesPage'; import { ContactsProvider } from './contexts/ContactsContext'; import { useIOSViewportFix } from './hooks/useIOSViewportFix'; -import type { Seed } from '@breeztech/breez-sdk-spark'; +import type { Seed, Payment } from '@breeztech/breez-sdk-spark'; type Screen = 'home' | 'restore' | 'generate' | 'wallet' | 'getRefund' | 'settings' | 'backup' | 'fiatCurrencies' | 'passkey'; +// Bridge component that feeds StableBalance formatter back to useBreezSdk via a mutable ref +const StableBalanceFormatterBridge: React.FC<{ formatterRef: React.MutableRefObject<((payment: Payment) => string) | undefined> }> = ({ formatterRef }) => { + const stableBalance = useStableBalance(); + useEffect(() => { + formatterRef.current = stableBalance.formatPaymentAmount; + }, [formatterRef, stableBalance.formatPaymentAmount]); + return null; +}; + const AppContent: React.FC = () => { const [currentScreen, setCurrentScreen] = useState('home'); const [refundAnimationDirection, setRefundAnimationDirection] = useState<'left' | 'up'>('left'); const [passkeySdkConnected, setPasskeySdkConnected] = useState(false); const { showToast } = useToast(); + const formatPaymentAmountRef = useRef<((payment: Payment) => string) | undefined>(undefined); useIOSViewportFix(); - const sdk = useBreezSdk(showToast); + const sdk = useBreezSdk(showToast, formatPaymentAmountRef); // Auto-navigate to wallet when SDK reconnects from saved mnemonic useEffect(() => { @@ -163,8 +175,6 @@ const AppContent: React.FC = () => { walletInfo={sdk.walletInfo} transactions={sdk.transactions} unclaimedDeposits={sdk.unclaimedDeposits} - fiatRates={sdk.fiatRates} - fiatCurrencies={sdk.fiatCurrencies} refreshWalletData={sdk.refreshWalletData} isSyncing={sdk.isSyncing} error={sdk.error} @@ -188,21 +198,26 @@ const AppContent: React.FC = () => { }; return ( - - {sdk.isConnected ? ( - - {renderCurrentScreen()} - - ) : ( - renderCurrentScreen() - )} - {sdk.celebrationAmount !== null && ( - - )} - + + + + + {sdk.isConnected ? ( + + {renderCurrentScreen()} + + ) : ( + renderCurrentScreen() + )} + {sdk.celebrationPayment !== null && ( + + )} + + + ); }; diff --git a/src/components/CollapsingWalletHeader.tsx b/src/components/CollapsingWalletHeader.tsx index 5485db6e..fecf7af6 100644 --- a/src/components/CollapsingWalletHeader.tsx +++ b/src/components/CollapsingWalletHeader.tsx @@ -1,9 +1,13 @@ import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'; -import type { GetInfoResponse, Rate, FiatCurrency } from '@breeztech/breez-sdk-spark'; +import type { GetInfoResponse, FiatCurrency } from '@breeztech/breez-sdk-spark'; import { getFiatSettings } from '../services/settings'; import { formatWithThinSpaces } from '../utils/formatNumber'; import { useAnimatedNumber } from '../hooks/useAnimatedNumber'; import { MenuIcon, AlertTriangleIcon, CurrencyIcon } from './Icons'; +import { useStableBalance } from '../contexts/StableBalanceContext'; +import { useFiatData } from '../contexts/FiatDataContext'; +import { getTokenBalance, formatTokenAmount } from '../utils/tokenFormatting'; +import StableBalanceToggleFlow from './StableBalanceToggleFlow'; // Module-level flag: once the balance count-up has played, skip it on remount. // Resets on full page reload. @@ -11,12 +15,11 @@ let hasPlayedInitialAnimation = false; interface CollapsingWalletHeaderProps { walletInfo: GetInfoResponse | null; - fiatRates: Rate[]; - fiatCurrencies: FiatCurrency[]; scrollProgress: number; onOpenMenu: () => void; onOpenBuyBitcoin?: () => void; isSyncing?: boolean; + refreshWalletData?: () => Promise; hasRejectedDeposits?: boolean; onOpenGetRefund?: () => void; } @@ -24,15 +27,24 @@ interface CollapsingWalletHeaderProps { const CollapsingWalletHeader: React.FC = ({ walletInfo, scrollProgress, - fiatRates, - fiatCurrencies, onOpenMenu, onOpenBuyBitcoin, isSyncing, + refreshWalletData, hasRejectedDeposits, onOpenGetRefund, }) => { + const { fiatRates, fiatCurrencies } = useFiatData(); + const stableBalance = useStableBalance(); const [activeFiatIndex, setActiveFiatIndex] = useState(0); + const [toggleFlowOpen, setToggleFlowOpen] = useState(false); + const [toggleDirection, setToggleDirection] = useState<'toToken' | 'toBitcoin'>('toToken'); + + const handleSuffixTap = useCallback(() => { + if (stableBalance.isToggling) return; + setToggleDirection(stableBalance.isActive ? 'toBitcoin' : 'toToken'); + setToggleFlowOpen(true); + }, [stableBalance]); // Build lookup maps for O(1) access (js-index-maps optimization) const ratesMap = useMemo(() => { @@ -87,26 +99,39 @@ const CollapsingWalletHeader: React.FC = ({ } }, [fiatCurrencyInfo.length]); - const balanceSat = walletInfo?.balanceSats || 0; - - // Track when both balance and fiat are ready to trigger synced animation - const hasFiatData = fiatCurrencyInfo.length > 0; + // Compute balances + const btcBalanceSat = walletInfo?.balanceSats || 0; + + const tokenBalanceInfo = useMemo(() => { + if (!stableBalance.isActive || !walletInfo?.tokenBalances || !stableBalance.tokenIdentifier) return null; + return getTokenBalance(walletInfo.tokenBalances, stableBalance.tokenIdentifier); + }, [stableBalance.isActive, walletInfo?.tokenBalances, stableBalance.tokenIdentifier]); + + const tokenBalanceRaw = tokenBalanceInfo ? Number(tokenBalanceInfo.balance) : 0; + + // Primary balance for animation (token base units or sats) + const primaryBalance = stableBalance.isActive ? tokenBalanceRaw : btcBalanceSat; + + // Track when both balance and secondary data are ready to trigger synced animation + const hasSecondaryData = stableBalance.isActive + ? true + : fiatCurrencyInfo.length > 0; const skipAnimation = hasPlayedInitialAnimation; const [animationReady, setAnimationReady] = useState(skipAnimation); const hasTriggeredAnimation = useRef(skipAnimation); useEffect(() => { - // Start animation only when BOTH balance and fiat are available - if (balanceSat > 0 && hasFiatData && !hasTriggeredAnimation.current) { + // Start animation only when BOTH balance and secondary data are available + if (primaryBalance > 0 && hasSecondaryData && !hasTriggeredAnimation.current) { hasTriggeredAnimation.current = true; hasPlayedInitialAnimation = true; setAnimationReady(true); } - }, [balanceSat, hasFiatData]); + }, [primaryBalance, hasSecondaryData]); - // Timeout fallback: if fiat doesn't load within 2s, animate balance anyway + // Timeout fallback: if secondary data doesn't load within 2s, animate balance anyway useEffect(() => { - if (balanceSat > 0 && !hasTriggeredAnimation.current) { + if (primaryBalance > 0 && !hasTriggeredAnimation.current) { const timeout = setTimeout(() => { if (!hasTriggeredAnimation.current) { hasTriggeredAnimation.current = true; @@ -116,35 +141,54 @@ const CollapsingWalletHeader: React.FC = ({ }, 2000); return () => clearTimeout(timeout); } - }, [balanceSat]); + }, [primaryBalance]); // Only animate from 80% when both are ready; otherwise show full value // On return visits, skip the count-up effect (no initialStartPercent) const animatedBalance = useAnimatedNumber( - animationReady ? balanceSat : 0, + animationReady ? primaryBalance : 0, skipAnimation ? {} : { initialStartPercent: 0.8 } ); // Display: if animation started, use animated value; otherwise use actual balance - const displayBalance = animationReady ? animatedBalance : balanceSat; + const displayBalance = animationReady ? animatedBalance : primaryBalance; // Calculate current fiat value from display balance (so both animate together) + // Used only in non-stable-balance mode const currentFiat = useMemo(() => { - if (!hasFiatData || balanceSat === 0) return null; - + if (stableBalance.isActive) return null; + if (!hasSecondaryData || btcBalanceSat === 0) return null; + const info = fiatCurrencyInfo[activeFiatIndex % fiatCurrencyInfo.length]; const btcValue = displayBalance / 100000000; const fiatValue = btcValue * info.rate; - + return { ...info, value: fiatValue.toFixed(info.fractionSize), }; - }, [fiatCurrencyInfo, activeFiatIndex, displayBalance, balanceSat, hasFiatData]); + }, [stableBalance.isActive, fiatCurrencyInfo, activeFiatIndex, displayBalance, btcBalanceSat, hasSecondaryData]); + + // Stable balance secondary line + const stableSecondaryText = useMemo(() => { + if (!stableBalance.isActive) return null; + if (btcBalanceSat > 0) return `${formatWithThinSpaces(btcBalanceSat)} sats change`; + return null; + }, [stableBalance.isActive, tokenBalanceRaw, btcBalanceSat]); if (!walletInfo) return null; + // Format primary balance display + const formattedPrimaryBalance = stableBalance.isActive && stableBalance.displayConfig + ? formatTokenAmount(BigInt(displayBalance), stableBalance.displayConfig) + : formatWithThinSpaces(displayBalance); + + const balanceSuffix = stableBalance.isActive ? 'USD' : 'sats'; + + const hasSecondaryLine = stableBalance.isActive ? !!stableSecondaryText : !!currentFiat; + return ( + <>
{/* Glassmorphism background - extends into safe area */}
= ({ isSyncing ? 'opacity-0' : 'opacity-100' }`} > - Balance·sats + Balance +
{/* Main balance */} -
- {formatWithThinSpaces(displayBalance)} + {formattedPrimaryBalance}
- {/* Fiat value with accent marks - always reserve space to prevent layout shift */} + {/* Secondary line - fiat value or BTC as fiat equivalent */}
1 ? 'cursor-pointer' : ''}`} - onClick={fiatCurrencyInfo.length > 1 ? handleFiatTap : undefined} + hasSecondaryLine ? 'opacity-100' : 'opacity-0' + } ${!stableBalance.isActive && fiatCurrencyInfo.length > 1 ? 'cursor-pointer' : ''}`} + onClick={!stableBalance.isActive && fiatCurrencyInfo.length > 1 ? handleFiatTap : undefined} > - {currentFiat ? ( + {stableBalance.isActive ? ( + stableSecondaryText ? ( + <>{stableSecondaryText} + ) : ( + $0.00 + ) + ) : currentFiat ? ( <> {currentFiat.symbolPosition === 'before' ? currentFiat.symbol : ''} {currentFiat.value} {currentFiat.symbolPosition === 'after' ? ` ${currentFiat.symbol}` : ''} ) : ( - /* Invisible placeholder to reserve space */ $0.00 )} @@ -274,7 +332,16 @@ const CollapsingWalletHeader: React.FC = ({ {/* Bottom spacing */}
+
+ + { refreshWalletData?.(); setToggleFlowOpen(false); }} + onCancel={() => setToggleFlowOpen(false)} + /> + ); }; diff --git a/src/components/FeeBreakdownCard.tsx b/src/components/FeeBreakdownCard.tsx index 9378f1f1..0f87ccb8 100644 --- a/src/components/FeeBreakdownCard.tsx +++ b/src/components/FeeBreakdownCard.tsx @@ -8,13 +8,15 @@ import { formatWithSpaces } from '../utils/formatNumber'; export interface FeeBreakdownItem { label: string; - value: number | bigint; + value: number | bigint | string; unit?: string; highlight?: boolean; } export interface FeeBreakdownCardProps { items: FeeBreakdownItem[]; + /** When true, values are pre-formatted strings — skip numeric formatting and unit suffix */ + useRawStrings?: boolean; /** Optional className for additional styling */ className?: string; } @@ -33,6 +35,7 @@ export interface FeeBreakdownCardProps { */ export const FeeBreakdownCard: React.FC = ({ items, + useRawStrings = false, className = '', }) => { return ( @@ -45,7 +48,10 @@ export const FeeBreakdownCard: React.FC = ({ {item.label} - {formatWithSpaces(item.value)} {item.unit ?? 'sats'} + {useRawStrings || typeof item.value === 'string' + ? String(item.value) + : <>{formatWithSpaces(item.value)} {item.unit ?? 'sats'} + }
@@ -58,8 +64,10 @@ export const FeeBreakdownCard: React.FC = ({ * Simplified version for the common amount + fee + total pattern. */ export interface SimpleFeeBreakdownProps { - amount: number | bigint; - fee: number | bigint; + amount: number | bigint | string; + fee: number | bigint | string; + /** When true, values are pre-formatted strings — skip numeric formatting and unit suffix */ + useRawStrings?: boolean; /** Optional custom label for the amount row */ amountLabel?: string; /** Optional custom label for the fee row */ @@ -70,15 +78,29 @@ export interface SimpleFeeBreakdownProps { export const SimpleFeeBreakdown: React.FC = ({ amount, fee, + useRawStrings = false, amountLabel = 'Amount', feeLabel = 'Network fee', className = '', }) => { - const total = Number(amount) + Number(fee); + if (useRawStrings) { + return ( + + ); + } + const total = Number(amount) + Number(fee); return ( ({ preimage: false, destinationPubkey: false, txId: false, - swapId: false, - assetId: false, - destination: false, description: false, comment: false, message: false, url: false, lnAddress: false, - lnurlDomain: false + lnurlDomain: false, + conversionDetails: false, }); const PaymentDetailsDialog: React.FC = ({ optionalPayment, onClose }) => { - const { findContactByAddress } = useContactsContext(); const [visibleFields, setVisibleFields] = useState>(getDefaultVisibleFields()); // Reset all expanded fields when a new payment is opened @@ -61,6 +61,9 @@ const PaymentDetailsDialog: React.FC = ({ optionalPay })); }; + const stableBalance = useStableBalance(); + const { fiatCurrencies } = useFiatData(); + if (!optionalPayment) return ( { @@ -69,22 +72,56 @@ const PaymentDetailsDialog: React.FC = ({ optionalPay ); const payment = optionalPayment!; + + // Format a conversion step's amount or fee in its native unit + const formatStepValue = (step: ConversionStep, value: bigint, isFee?: boolean): string => { + if (step.method === 'token' && step.tokenMetadata) { + const config = stableBalance.displayConfig ?? buildTokenDisplayConfig(step.tokenMetadata, fiatCurrencies); + return formatTokenAmount(value, config, isFee ? { fullPrecision: true } : undefined); + } + return `${formatWithSpaces(Number(value))} sats`; + }; + + // Format a fee value in the payment's native denomination + const formatPaymentFee = (fee: bigint): string => { + if (payment.details?.type === 'token') { + const config = stableBalance.displayConfig ?? buildTokenDisplayConfig(payment.details.metadata, fiatCurrencies); + return formatTokenAmount(fee, config, { fullPrecision: true }); + } + return `${formatWithSpaces(Number(fee))} sats`; + }; + + // When the conversion amount was adjusted (min limit floor or dust prevention), + // the token amount doesn't match the payment — show sats instead. + const isAmountAdjusted = !!payment.conversionDetails?.from?.amountAdjusted; + const tokenInfo = getTokenAmountFromPayment(payment); + const tokenDisplayConfig = stableBalance.displayConfig + ?? (tokenInfo ? buildTokenDisplayConfig(tokenInfo.metadata, fiatCurrencies) : null); + const hasTokenDisplay = !isAmountAdjusted && !!tokenInfo && !!tokenDisplayConfig; + const sign = payment.paymentType === 'receive' ? '+' : '-'; + const amountDisplay = hasTokenDisplay + ? `${sign} ${formatTokenAmount(tokenInfo.amount, tokenDisplayConfig)}` + : `${sign} ${formatWithSpaces(payment.amount)} sats`; + const feeDisplay = payment.fees > 0 + ? (isAmountAdjusted ? `${formatWithSpaces(Number(payment.fees))} sats` : formatPaymentFee(BigInt(payment.fees))) + : null; + return ( - +
{/* General Payment Information */} - {payment.fees > 0 && ( + {feeDisplay && ( )} @@ -225,7 +262,7 @@ const PaymentDetailsDialog: React.FC = ({ optionalPay )} - {payment.details?.type === 'deposit' && payment.details.txId && ( + {(payment.details?.type === 'deposit' || payment.details?.type === 'withdraw') && payment.details.txId && (
= ({ optionalPay />
)} - {payment.details?.type === 'withdraw' && payment.details.txId && ( -
- toggleField('txId')} - /> -
+ + + {/* Conversion Details — shows original payment values */} + {payment.conversionDetails && ( + toggleField('conversionDetails')} + > + {payment.conversionDetails.from && ( + + )} + {payment.conversionDetails.to && ( + + )} + {(() => { + // Find the fee from whichever step has it + const fromStep = payment.conversionDetails!.from; + const toStep = payment.conversionDetails!.to; + const fee = fromStep?.fee != null && fromStep.fee > 0n ? fromStep.fee + : (toStep?.fee != null && toStep.fee > 0n) ? toStep.fee + : null; + if (fee != null && fee > 0n) { + // Always denominate using the token step when available + const tokenStep = fromStep?.method === 'token' ? fromStep + : toStep?.method === 'token' ? toStep + : null; + const feeFormatted = formatStepValue(tokenStep ?? fromStep ?? toStep!, fee, true); + return ; + } + // Fall back to conversionInfo.fee — denominated in the token side's units + const conversionInfoFee = (payment.details?.type === 'spark' || payment.details?.type === 'token') + ? payment.details.conversionInfo?.fee : undefined; + if (!conversionInfoFee || conversionInfoFee === '0') return null; + // Format using the token step metadata if available + const tokenStep = fromStep?.method === 'token' ? fromStep + : toStep?.method === 'token' ? toStep : null; + const feeFormatted = tokenStep?.tokenMetadata + ? formatTokenAmount(BigInt(conversionInfoFee), + stableBalance.displayConfig ?? buildTokenDisplayConfig(tokenStep.tokenMetadata, fiatCurrencies), + { fullPrecision: true }) + : formatPaymentFee(BigInt(conversionInfoFee)); + return ; + })()} + )}
diff --git a/src/components/PaymentReceivedCelebration.tsx b/src/components/PaymentReceivedCelebration.tsx index fd74e21f..0509ae1b 100644 --- a/src/components/PaymentReceivedCelebration.tsx +++ b/src/components/PaymentReceivedCelebration.tsx @@ -1,5 +1,8 @@ import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +import type { Payment } from '@breeztech/breez-sdk-spark'; +import { useStableBalance } from '../contexts/StableBalanceContext'; +import { getTokenAmountFromPayment, formatTokenAmount } from '../utils/tokenFormatting'; // Star positions around the logo (same as sidebar) const STARS = [ @@ -14,11 +17,12 @@ const STARS = [ ]; interface PaymentReceivedCelebrationProps { - amount: number; + payment: Payment; onClose: () => void; } -const PaymentReceivedCelebration: React.FC = ({ amount, onClose }) => { +const PaymentReceivedCelebration: React.FC = ({ payment, onClose }) => { + const stableBalance = useStableBalance(); const [isVisible, setIsVisible] = useState(false); const [starsAnimating, setStarsAnimating] = useState(false); @@ -41,10 +45,19 @@ const PaymentReceivedCelebration: React.FC = ({ }; }, [onClose]); - const formatAmount = (sats: number) => { + const formatSatsAmount = (sats: number) => { return sats.toLocaleString('en-US').replace(/,/g, ' '); }; + // Determine display: token amount or sats + let displayText: string | null = null; + if (stableBalance.isActive && stableBalance.displayConfig) { + const tokenInfo = getTokenAmountFromPayment(payment); + if (tokenInfo) { + displayText = formatTokenAmount(tokenInfo.amount, stableBalance.displayConfig); + } + } + return createPortal(
= ({
{/* Outer glow */}
- + {/* Logo container */}
= ({ alt="Glow" className="w-24 h-24 object-contain drop-shadow-[0_0_30px_rgba(212,165,116,0.6)]" /> - + {/* Sparkle stars */} {STARS.map((star, i) => ( = ({
- - +{formatAmount(amount)} - - sats + {displayText ? ( + + +{displayText} + + ) : ( + <> + + +{formatSatsAmount(Number(payment.amount))} + + sats + + )}
diff --git a/src/components/StableBalanceDisclaimer.tsx b/src/components/StableBalanceDisclaimer.tsx new file mode 100644 index 00000000..b3d89e63 --- /dev/null +++ b/src/components/StableBalanceDisclaimer.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { DialogContainer, DialogCard } from './ui'; + +interface StableBalanceDisclaimerProps { + isOpen: boolean; + onAccept: () => void; + onCancel: () => void; +} + +const StableBalanceDisclaimer: React.FC = ({ + isOpen, + onAccept, + onCancel, +}) => { + if (!isOpen) return null; + + return ( + + +
+

+ Stable Balance +

+

+ Your balance is held in USD. Incoming BTC is automatically converted to USD, + and outgoing payments are converted back to BTC. Amounts under the conversion + threshold remain as change until they accumulate. +

+
+ + +
+
+
+
+ ); +}; + +export default StableBalanceDisclaimer; diff --git a/src/components/StableBalanceFeeConfirm.tsx b/src/components/StableBalanceFeeConfirm.tsx new file mode 100644 index 00000000..0dc5df0b --- /dev/null +++ b/src/components/StableBalanceFeeConfirm.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { DialogContainer, DialogCard } from './ui'; +import { SpinnerIcon } from './Icons'; +import { formatTokenAmount, type TokenDisplayConfig } from '../utils/tokenFormatting'; +import type { ConversionEstimate } from '@breeztech/breez-sdk-spark'; + +interface StableBalanceFeeConfirmProps { + isOpen: boolean; + direction: 'toToken' | 'toBitcoin'; + conversionEstimate: ConversionEstimate | null; + displayConfig: TokenDisplayConfig | null; + isEstimating: boolean; + isExecuting: boolean; + error: string | null; + info: string | null; + onConfirm: () => void; + onCancel: () => void; +} + +const StableBalanceFeeConfirm: React.FC = ({ + isOpen, + direction, + conversionEstimate, + displayConfig, + isEstimating, + isExecuting, + error, + info, + onConfirm, + onCancel, +}) => { + if (!isOpen) return null; + + const title = direction === 'toToken' ? 'Convert to USD' : 'Convert to BTC'; + const description = direction === 'toToken' + ? 'Your BTC balance will be converted to USD.' + : 'Your USD balance will be converted back to BTC.'; + + const feeText = conversionEstimate && displayConfig + ? formatTokenAmount(conversionEstimate.fee, displayConfig, { fullPrecision: true }) + : null; + + return ( + + +
+

+ {title} +

+

+ {description} +

+ + {isEstimating && ( +
+ +
+ )} + + {!isEstimating && feeText && ( +

+ Conversion fee: {feeText} +

+ )} + + {!isEstimating && !feeText && !error && info && ( +

+ {info} +

+ )} + + {!isEstimating && !feeText && !error && !info && ( +

+ Couldn't estimate fee +

+ )} + + {error && ( +

{error}

+ )} + +
+ + +
+
+
+
+ ); +}; + +export default StableBalanceFeeConfirm; diff --git a/src/components/StableBalanceToggleFlow.tsx b/src/components/StableBalanceToggleFlow.tsx new file mode 100644 index 00000000..5ae75b39 --- /dev/null +++ b/src/components/StableBalanceToggleFlow.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import type { ConversionEstimate } from '@breeztech/breez-sdk-spark'; +import { useWallet } from '../contexts/WalletContext'; +import { useStableBalance } from '../contexts/StableBalanceContext'; +import { useFiatData } from '../contexts/FiatDataContext'; +import { USDB_TICKER, USDB_TOKEN_IDENTIFIER } from '../constants/stableBalance'; +import { getTokenBalance, fiatToSats, buildTokenDisplayConfig, type TokenDisplayConfig } from '../utils/tokenFormatting'; +import { hasAcceptedStableDisclaimer, setStableDisclaimerAccepted } from '../services/settings'; +import { logger, LogCategory } from '../services/logger'; +import StableBalanceDisclaimer from './StableBalanceDisclaimer'; +import StableBalanceFeeConfirm from './StableBalanceFeeConfirm'; + +type FlowStep = 'disclaimer' | 'estimating' | 'confirm' | 'executing'; + +interface StableBalanceToggleFlowProps { + isOpen: boolean; + direction: 'toToken' | 'toBitcoin'; + onComplete: () => void; + onCancel: () => void; +} + +const StableBalanceToggleFlow: React.FC = ({ + isOpen, + direction, + onComplete, + onCancel, +}) => { + const wallet = useWallet(); + const stableBalance = useStableBalance(); + const { getOrFetchFiatData } = useFiatData(); + + const [step, setStep] = useState('disclaimer'); + const [conversionEstimate, setConversionEstimate] = useState(null); + const [resolvedDisplayConfig, setResolvedDisplayConfig] = useState(null); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + const startEstimation = useCallback(async () => { + setError(null); + + try { + // Fetch wallet info and ensure fiat data is available (cached or freshly fetched) + const [freshInfo, fiatData, metadataResult] = await Promise.all([ + wallet.getInfo({}), + getOrFetchFiatData(), + wallet.getTokensMetadata({ tokenIdentifiers: [USDB_TOKEN_IDENTIFIER] }), + ]); + + // Build display config from fresh data + const metadata = metadataResult.tokensMetadata[0]; + const config = metadata ? buildTokenDisplayConfig(metadata, fiatData.fiatCurrencies) : null; + if (config) setResolvedDisplayConfig(config); + + const tokenBal = getTokenBalance(freshInfo.tokenBalances, USDB_TOKEN_IDENTIFIER); + const decimals = config?.decimals ?? 8; + const fiatCurrencyId = config?.fiatCurrencyId ?? 'USD'; + const btcRate = fiatData.fiatRates.find(r => r.coin === fiatCurrencyId)?.value ?? 0; + + // Skip fee dialog if no source balance + const hasBalance = direction === 'toToken' + ? freshInfo.balanceSats > 0 + : (tokenBal !== null && tokenBal.balance > 0n); + + if (!hasBalance) { + setInfo('Balance too low to convert — it will remain as change'); + setStep('confirm'); + return; + } + + const receiveResponse = await wallet.receivePayment({ + paymentMethod: { type: 'sparkAddress' }, + }); + const sparkAddress = receiveResponse.paymentRequest; + + const conversionType = direction === 'toToken' + ? { type: 'fromBitcoin' as const } + : { type: 'toBitcoin' as const, fromTokenIdentifier: USDB_TOKEN_IDENTIFIER }; + + // Compute amount: SDK treats amount as MinAmountOut for the conversion validator. + // Use 90% of expected output to leave headroom for fees/slippage. + let amount: bigint; + const FEE_HEADROOM = 0.9; + if (direction === 'toToken') { + const btcValue = freshInfo.balanceSats / 100_000_000; + const fiatValue = btcValue * btcRate * FEE_HEADROOM; + amount = BigInt(Math.round(fiatValue * Math.pow(10, decimals))); + } else { + const fiatValue = Number(tokenBal?.balance ?? 0n) / Math.pow(10, decimals); + amount = BigInt(fiatToSats(fiatValue * FEE_HEADROOM, btcRate)); + } + + // If amount rounds to zero (e.g. fiat rate not loaded yet), skip fee dialog + if (amount <= 0n) { + setInfo('Balance too low to convert — it will remain as change'); + setStep('confirm'); + return; + } + + // Show modal now — all quick checks passed, preparing fee estimate + setStep('estimating'); + + const prepareResponse = await wallet.prepareSendPayment({ + paymentRequest: sparkAddress, + amount, + tokenIdentifier: direction === 'toToken' ? USDB_TOKEN_IDENTIFIER : undefined, + conversionOptions: { conversionType }, + }); + + if (prepareResponse.conversionEstimate) { + setConversionEstimate(prepareResponse.conversionEstimate); + } + setStep('confirm'); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + if (errorMsg.includes('less than minimum required')) { + setInfo('Balance too low to convert — it will remain as change'); + setStep('confirm'); + return; + } + setStep('confirm'); + } + }, [wallet, direction, getOrFetchFiatData]); + + // Use a ref so the isOpen effect always calls the latest startEstimation + const startEstimationRef = useRef(startEstimation); + startEstimationRef.current = startEstimation; + + // Reset state when flow opens + useEffect(() => { + if (isOpen) { + setConversionEstimate(null); + setResolvedDisplayConfig(null); + setError(null); + setInfo(null); + + if (hasAcceptedStableDisclaimer()) { + startEstimationRef.current(); + } else { + setStep('disclaimer'); + } + } + }, [isOpen]); + + const executeToggle = useCallback(async () => { + logger.debug(LogCategory.SDK, 'executeToggle: starting', { direction, hasEstimate: !!conversionEstimate }); + setStep('executing'); + try { + // Snapshot current balance before toggling so we can detect an increase + let balanceBefore = 0n; + if (conversionEstimate) { + const infoBefore = await wallet.getInfo({}); + if (direction === 'toToken') { + const tokenBal = getTokenBalance(infoBefore.tokenBalances, USDB_TOKEN_IDENTIFIER); + balanceBefore = tokenBal?.balance ?? 0n; + } else { + balanceBefore = BigInt(infoBefore.balanceSats); + } + logger.debug(LogCategory.SDK, 'executeToggle: balance before toggle', { balanceBefore: balanceBefore.toString() }); + } + + const ticker = direction === 'toToken' ? USDB_TICKER : null; + logger.debug(LogCategory.SDK, 'executeToggle: calling toggleStableBalance', { ticker }); + await stableBalance.toggleStableBalance(ticker); + logger.debug(LogCategory.SDK, 'executeToggle: toggleStableBalance returned'); + + // If we had a conversion estimate, poll until the new-mode balance increases + if (conversionEstimate) { + logger.debug(LogCategory.SDK, 'executeToggle: starting balance poll'); + const maxAttempts = 30; + for (let i = 0; i < maxAttempts; i++) { + await new Promise(r => setTimeout(r, 1000)); + const info = await wallet.getInfo({}); + let currentBalance = 0n; + if (direction === 'toToken') { + const tokenBal = getTokenBalance(info.tokenBalances, USDB_TOKEN_IDENTIFIER); + currentBalance = tokenBal?.balance ?? 0n; + } else { + currentBalance = BigInt(info.balanceSats); + } + logger.debug(LogCategory.SDK, `executeToggle: poll ${i + 1}/${maxAttempts}`, { currentBalance: currentBalance.toString(), balanceBefore: balanceBefore.toString() }); + if (currentBalance > balanceBefore) break; + } + logger.debug(LogCategory.SDK, 'executeToggle: poll finished'); + } else { + logger.debug(LogCategory.SDK, 'executeToggle: no estimate, skipping poll'); + } + + logger.debug(LogCategory.SDK, 'executeToggle: calling onComplete'); + onComplete(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + logger.error(LogCategory.SDK, 'Failed to toggle stable balance', { error: errorMsg }); + setError(`Failed to switch: ${errorMsg}`); + setStep('confirm'); + } + }, [direction, stableBalance, conversionEstimate, wallet, onComplete]); + + const handleDisclaimerAccept = useCallback(() => { + setStableDisclaimerAccepted(); + startEstimation(); + }, [startEstimation]); + + const handleConfirm = useCallback(() => { + executeToggle(); + }, [executeToggle]); + + if (!isOpen) return null; + + if (step === 'disclaimer') { + return ( + + ); + } + + return ( + + ); +}; + +export default StableBalanceToggleFlow; diff --git a/src/components/TransactionList.tsx b/src/components/TransactionList.tsx index bc49dd27..b02694ec 100644 --- a/src/components/TransactionList.tsx +++ b/src/components/TransactionList.tsx @@ -1,9 +1,11 @@ import React, { useMemo } from 'react'; import { Payment } from '@breeztech/breez-sdk-spark'; -import { useContactsContext } from '../contexts/ContactsContext'; -import { getPaymentDescription } from '../utils/paymentDescription'; import { formatWithCommas } from '../utils/formatNumber'; import { ArrowDownIcon, ArrowUpIcon, LightningBoltIcon, WalletIcon } from './Icons'; +import { useStableBalance } from '../contexts/StableBalanceContext'; +import { useFiatData } from '../contexts/FiatDataContext'; +import { formatTokenAmount, buildTokenDisplayConfig, tokenAmountDisplaysAsZero } from '../utils/tokenFormatting'; +import { getPaymentTitle } from '../utils/paymentLabels'; // Use centralized formatting utility const formatWithSpaces = formatWithCommas; @@ -34,6 +36,8 @@ const getTransactionIcon = (payment: Payment): React.ReactNode => { return payment.paymentType === 'receive' ? ReceiveIcon : SendIcon; }; + + const getMethodIcon = (payment: Payment): React.ReactNode => { return payment.method === 'lightning' ? LightningIcon : null; }; @@ -59,9 +63,9 @@ interface TransactionListProps { } const TransactionList: React.FC = ({ transactions, onPaymentSelected, isSyncing }) => { - const { findContactByAddress } = useContactsContext(); - - // Split transactions in single pass (js-combine-iterations optimization) + const stableBalance = useStableBalance(); + const { fiatCurrencies } = useFiatData(); + // Split transactions into pending deposits and regular payments const { pendingApproval, regularPayments } = useMemo(() => { const pending: Payment[] = []; const regular: Payment[] = []; @@ -108,7 +112,7 @@ const TransactionList: React.FC = ({ transactions, onPayme const renderTransactionItem = (tx: Payment, index: number) => { const isReceive = tx.paymentType === 'receive'; - const isPending = tx.status === 'pending'; + const isPending = tx.status === 'pending' || tx.conversionDetails?.status === 'pending'; const isFailed = tx.status === 'failed'; return ( @@ -132,7 +136,7 @@ const TransactionList: React.FC = ({ transactions, onPayme

- {getPaymentDescription(tx, findContactByAddress)} + {getPaymentTitle(tx, stableBalance.displayConfig?.fiatCurrencyName)}

{getMethodIcon(tx)} {isPending && ( @@ -146,12 +150,25 @@ const TransactionList: React.FC = ({ transactions, onPayme
{formatTimeAgo(tx.timestamp)} - {tx.fees > 0 && !isFailed && ( - <> - · - fee {formatWithSpaces(Number(tx.fees))} - - )} + {(() => { + if (isFailed || tx.fees <= 0) return null; + let feeText: string; + if (tx.details?.type === 'token') { + const feeBigInt = BigInt(tx.fees); + const config = stableBalance.displayConfig + ?? buildTokenDisplayConfig(tx.details.metadata, fiatCurrencies); + if (tokenAmountDisplaysAsZero(feeBigInt, config)) return null; + feeText = formatTokenAmount(feeBigInt, config); + } else { + feeText = formatWithSpaces(Number(tx.fees)); + } + return ( + <> + · + fee {feeText} + + ); + })()}
@@ -165,7 +182,8 @@ const TransactionList: React.FC = ({ transactions, onPayme `} data-testid="transaction-amount" > - {isReceive ? '+' : '-'}{formatWithSpaces(Number(tx.amount))} + {isReceive ? '+' : '-'} + {stableBalance.formatPaymentAmount(tx)} ); diff --git a/src/components/ui/CurrencySwitcher.tsx b/src/components/ui/CurrencySwitcher.tsx new file mode 100644 index 00000000..ebbe28ef --- /dev/null +++ b/src/components/ui/CurrencySwitcher.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +interface CurrencySwitcherProps { + isTokenMode: boolean; + tokenSymbol: string; + onSwitch: () => void; + disabled?: boolean; +} + +const CurrencySwitcher: React.FC = ({ + isTokenMode, + tokenSymbol, + onSwitch, + disabled, +}) => ( + +); + +export default CurrencySwitcher; diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index c50c48b4..fc4e193d 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -4,7 +4,6 @@ import { logger, LogCategory } from '@/services/logger'; import { CloseIcon, ChevronDownIcon, - ExternalLinkIcon, CopyFilledIcon, ShareIcon, InfoIcon, @@ -151,45 +150,60 @@ export const PaymentInfoRow: React.FC<{ ); -export const CollapsibleCodeField: React.FC<{ +export const CollapsibleSection: React.FC<{ label: string; - value: string; isVisible: boolean; onToggle: () => void; - href?: string; -}> = ({ label, value, isVisible, onToggle, href }) => ( + children: ReactNode; +}> = ({ label, isVisible, onToggle, children }) => (
-
+ -
+ + {isVisible && ( -
- {href ? ( - - {value} - - - ) : ( - - {value} - - )} +
+ {children}
)}
); +export const CollapsibleCodeField: React.FC<{ + label: string; + value: string; + isVisible: boolean; + onToggle: () => void; + href?: string; +}> = ({ label, value, isVisible, onToggle, href }) => ( + +
+ {href ? ( + + {value} + + + + + ) : ( + + {value} + + )} +
+
+); + // ============================================ // TEXT COMPONENTS // ============================================ diff --git a/src/constants/stableBalance.ts b/src/constants/stableBalance.ts new file mode 100644 index 00000000..088ea1b3 --- /dev/null +++ b/src/constants/stableBalance.ts @@ -0,0 +1,2 @@ +export const USDB_TOKEN_IDENTIFIER = 'btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87'; +export const USDB_TICKER = 'USDB'; diff --git a/src/contexts/FiatDataContext.tsx b/src/contexts/FiatDataContext.tsx new file mode 100644 index 00000000..202ab9a7 --- /dev/null +++ b/src/contexts/FiatDataContext.tsx @@ -0,0 +1,77 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import type { Rate, FiatCurrency } from '@breeztech/breez-sdk-spark'; +import { useWalletConnection } from './WalletContext'; +import { logger, LogCategory } from '../services/logger'; + +interface FiatData { + fiatRates: Rate[]; + fiatCurrencies: FiatCurrency[]; +} + +interface FiatDataContextValue extends FiatData { + /** Returns cached fiat data if available, otherwise fetches and caches it. */ + getOrFetchFiatData: () => Promise; +} + +const FiatDataContext = createContext(null); + +export const FiatDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { sdk, isConnected } = useWalletConnection(); + const [fiatRates, setFiatRates] = useState([]); + const [fiatCurrencies, setFiatCurrencies] = useState([]); + + const fetchFiatData = useCallback(async () => { + if (!sdk) return; + try { + const [ratesResult, currenciesResult] = await Promise.all([ + sdk.listFiatRates(), + sdk.listFiatCurrencies(), + ]); + setFiatRates(ratesResult.rates); + setFiatCurrencies(currenciesResult.currencies); + logger.info(LogCategory.SDK, 'Fiat data fetched', { + ratesCount: ratesResult.rates.length, + currenciesCount: currenciesResult.currencies.length, + }); + } catch (error) { + logger.warn(LogCategory.SDK, 'Failed to fetch fiat data', { + error: error instanceof Error ? error.message : String(error), + }); + } + }, [sdk]); + + const getOrFetchFiatData = useCallback(async (): Promise => { + if (fiatRates.length > 0 && fiatCurrencies.length > 0) { + return { fiatRates, fiatCurrencies }; + } + if (!sdk) return { fiatRates: [], fiatCurrencies: [] }; + const [ratesResult, currenciesResult] = await Promise.all([ + sdk.listFiatRates(), + sdk.listFiatCurrencies(), + ]); + setFiatRates(ratesResult.rates); + setFiatCurrencies(currenciesResult.currencies); + return { fiatRates: ratesResult.rates, fiatCurrencies: currenciesResult.currencies }; + }, [sdk, fiatRates, fiatCurrencies]); + + useEffect(() => { + if (!isConnected || !sdk) return; + fetchFiatData(); + const interval = setInterval(fetchFiatData, 60000); + return () => clearInterval(interval); + }, [isConnected, sdk, fetchFiatData]); + + return ( + + {children} + + ); +}; + +export const useFiatData = (): FiatDataContextValue => { + const ctx = useContext(FiatDataContext); + if (!ctx) { + throw new Error('useFiatData must be used within a FiatDataProvider'); + } + return ctx; +}; diff --git a/src/contexts/StableBalanceContext.tsx b/src/contexts/StableBalanceContext.tsx new file mode 100644 index 00000000..0b19ce60 --- /dev/null +++ b/src/contexts/StableBalanceContext.tsx @@ -0,0 +1,194 @@ +import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import type { Payment } from '@breeztech/breez-sdk-spark'; +import { useWalletConnection } from './WalletContext'; +import { useFiatData } from './FiatDataContext'; +import { USDB_TOKEN_IDENTIFIER, USDB_TICKER } from '../constants/stableBalance'; +import { + type TokenDisplayConfig, + buildTokenDisplayConfig, + formatTokenAmount, + getTokenAmountFromPayment, +} from '../utils/tokenFormatting'; +import { logger, LogCategory } from '../services/logger'; +import { getCachedStableTicker, setCachedStableTicker } from '../services/settings'; +import { formatWithSpaces } from '@/utils/formatNumber'; + +interface StableBalanceContextValue { + isActive: boolean; + activeTicker: string | null; + tokenIdentifier: string | null; + displayConfig: TokenDisplayConfig | null; + btcFiatRate: number; + formatPaymentAmount: (payment: Payment) => string; + toggleStableBalance: (ticker: string | null) => Promise; + isToggling: boolean; +} + +const StableBalanceContext = createContext(null); + +interface StableBalanceProviderProps { + children: React.ReactNode; +} + +export const StableBalanceProvider: React.FC = ({ children }) => { + const { sdk, isConnected } = useWalletConnection(); + const { fiatRates, fiatCurrencies } = useFiatData(); + const [activeTicker, setActiveTicker] = useState(() => getCachedStableTicker()); + const [displayConfig, setDisplayConfig] = useState(null); + const [isToggling, setIsToggling] = useState(false); + + // Derive tokenIdentifier from activeTicker + const tokenIdentifier = useMemo(() => { + if (!activeTicker) return null; + if (activeTicker === USDB_TICKER) return USDB_TOKEN_IDENTIFIER; + return null; + }, [activeTicker]); + + // Load active ticker from SDK user settings on connect + useEffect(() => { + if (!isConnected || !sdk) { + // Don't clear activeTicker here — it's initialized from cache so + // the UI can show the correct mode instantly on reload. The SDK + // sync below will correct any drift once connected. + setDisplayConfig(null); + return; + } + + let cancelled = false; + + (async () => { + try { + const settings = await sdk.getUserSettings(); + if (cancelled) return; + const ticker = settings.stableBalanceActiveTicker ?? null; + setActiveTicker(ticker); + setCachedStableTicker(ticker); + } catch (e) { + logger.warn(LogCategory.SDK, 'Failed to load user settings for stable balance', { + error: e instanceof Error ? e.message : String(e), + }); + } + })(); + + return () => { cancelled = true; }; + }, [isConnected, sdk]); + + // Fetch token metadata and build display config (re-runs when fiat currencies load for better symbol matching) + useEffect(() => { + if (!tokenIdentifier || !sdk) return; + + let cancelled = false; + + (async () => { + try { + const result = await sdk.getTokensMetadata({ tokenIdentifiers: [tokenIdentifier] }); + if (cancelled) return; + + const metadata = result.tokensMetadata[0]; + if (metadata) { + const config = buildTokenDisplayConfig(metadata, fiatCurrencies); + setDisplayConfig(config); + logger.info(LogCategory.SDK, 'Stable balance display config built', { + symbol: config.symbol, + decimals: config.decimals, + fractionSize: config.fractionSize, + }); + } + } catch (e) { + logger.warn(LogCategory.SDK, 'Failed to fetch token metadata for stable balance', { + error: e instanceof Error ? e.message : String(e), + }); + } + })(); + + return () => { cancelled = true; }; + }, [tokenIdentifier, fiatCurrencies, sdk]); + + // Extract BTC rate for the matched fiat currency + const btcFiatRate = useMemo(() => { + if (!displayConfig?.fiatCurrencyId) return 0; + const rate = fiatRates.find(r => r.coin === displayConfig.fiatCurrencyId); + return rate?.value ?? 0; + }, [fiatRates, displayConfig?.fiatCurrencyId]); + + const isActive = !!activeTicker && !!tokenIdentifier && !!displayConfig; + + // Toggle stable balance via SDK user settings + const toggleStableBalance = useCallback(async (ticker: string | null) => { + if (!sdk) return; + setIsToggling(true); + try { + if (ticker) { + await sdk.updateUserSettings({ + stableBalanceActiveTicker: { type: 'set', ticker }, + }); + setActiveTicker(ticker); + setCachedStableTicker(ticker); + } else { + await sdk.updateUserSettings({ + stableBalanceActiveTicker: { type: 'unset' }, + }); + setActiveTicker(null); + setCachedStableTicker(null); + } + } catch (e) { + logger.error(LogCategory.SDK, 'Failed to toggle stable balance', { + error: e instanceof Error ? e.message : String(e), + }); + } finally { + setIsToggling(false); + } + }, [sdk]); + + const formatPaymentAmount = useCallback( + (payment: Payment): string => { + // When the conversion amount was adjusted (min limit floor or dust prevention), + // the token amount doesn't match the payment — show sats instead + if (payment.conversionDetails?.from?.amountAdjusted) { + return formatWithSpaces(Number(payment.amount)); + } + + const tokenInfo = getTokenAmountFromPayment(payment); + + if (displayConfig && tokenInfo) { + return formatTokenAmount(tokenInfo.amount, displayConfig); + } + + if (tokenInfo) { + const config = buildTokenDisplayConfig(tokenInfo.metadata, fiatCurrencies); + return formatTokenAmount(tokenInfo.amount, config); + } + + return formatWithSpaces(Number(payment.amount)) + }, + [displayConfig, fiatCurrencies] + ); + + const value = useMemo( + () => ({ + isActive, + activeTicker, + tokenIdentifier, + displayConfig, + btcFiatRate, + formatPaymentAmount, + toggleStableBalance, + isToggling, + }), + [isActive, activeTicker, tokenIdentifier, displayConfig, btcFiatRate, formatPaymentAmount, toggleStableBalance, isToggling] + ); + + return ( + + {children} + + ); +}; + +export const useStableBalance = (): StableBalanceContextValue => { + const ctx = useContext(StableBalanceContext); + if (!ctx) { + throw new Error('useStableBalance must be used within a StableBalanceProvider'); + } + return ctx; +}; diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 3a287518..14918a79 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1,13 +1,19 @@ import React, { createContext, useContext } from 'react'; import type { BreezSdk } from '@breeztech/breez-sdk-spark'; -const WalletContext = createContext(null); +interface WalletContextValue { + sdk: BreezSdk | null; + isConnected: boolean; +} + +const WalletContext = createContext({ sdk: null, isConnected: false }); export const WalletProvider: React.FC<{ children: React.ReactNode; client: BreezSdk | null; -}> = ({ children, client }) => ( - {children} + isConnected?: boolean; +}> = ({ children, client, isConnected = false }) => ( + {children} ); /** @@ -15,9 +21,16 @@ export const WalletProvider: React.FC<{ * Only use in components rendered after connection. */ export const useWallet = (): BreezSdk => { - const ctx = useContext(WalletContext); - if (!ctx) { + const { sdk } = useContext(WalletContext); + if (!sdk) { throw new Error('useWallet: SDK not connected. This component should only render after connection.'); } - return ctx; + return sdk; +}; + +/** + * Returns SDK and connection state. Safe to use before connection. + */ +export const useWalletConnection = () => { + return useContext(WalletContext); }; diff --git a/src/features/receive/AmountPanel.tsx b/src/features/receive/AmountPanel.tsx index f3072696..82f753f6 100644 --- a/src/features/receive/AmountPanel.tsx +++ b/src/features/receive/AmountPanel.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import LoadingSpinner from '../../components/LoadingSpinner'; import { FormError, @@ -8,6 +8,14 @@ import { DialogHeader, } from '../../components/ui'; import { LightningBoltIcon } from '../../components/Icons'; +import { useStableBalance } from '../../contexts/StableBalanceContext'; +import { + TOKEN_QUICK_AMOUNTS, + formatQuickAmount, + sanitizeTokenInput, + fiatToSats, +} from '../../utils/tokenFormatting'; +import CurrencySwitcher from '../../components/ui/CurrencySwitcher'; interface AmountPanelProps { isOpen: boolean; @@ -22,11 +30,7 @@ interface AmountPanelProps { onClose: () => void; } -const formatWithSpaces = (num: number): string => { - return num.toLocaleString('en-US').replace(/,/g, '\u2009'); -}; - -const QUICK_AMOUNTS = [100, 1000, 10000, 100000]; +const RECEIVE_QUICK_AMOUNTS_SATS = [100, 1000, 10000, 100000]; const AmountPanel: React.FC = ({ isOpen, @@ -40,6 +44,53 @@ const AmountPanel: React.FC = ({ onCreateInvoice, onClose, }) => { + const stableBalance = useStableBalance(); + const hasTokenConfig = !!stableBalance.displayConfig; + const [isTokenMode, setIsTokenMode] = useState(stableBalance.isActive && hasTokenConfig); + const config = stableBalance.displayConfig; + + // In token mode we show the fiat value locally; the parent's `amount` always holds sats. + const [displayAmount, setDisplayAmount] = useState(''); + + const handleToggleDenomination = () => { + setIsTokenMode(prev => !prev); + setAmount(''); + setDisplayAmount(''); + }; + + const quickAmounts = isTokenMode ? TOKEN_QUICK_AMOUNTS : RECEIVE_QUICK_AMOUNTS_SATS; + + const handleAmountChange = (value: string) => { + if (isTokenMode && config) { + const sanitized = sanitizeTokenInput(value, config.fractionSize); + if (sanitized !== null) { + setDisplayAmount(sanitized); + const fiat = parseFloat(sanitized); + if (fiat > 0 && stableBalance.btcFiatRate > 0) { + setAmount(String(fiatToSats(fiat, stableBalance.btcFiatRate))); + } else { + setAmount(''); + } + } + } else { + const sats = value.replace(/[^0-9]/g, ''); + setAmount(sats); + setDisplayAmount(sats); + } + }; + + const handleQuickAmount = (quickAmount: number) => { + if (isTokenMode && stableBalance.btcFiatRate > 0) { + setDisplayAmount(String(quickAmount)); + setAmount(String(fiatToSats(quickAmount, stableBalance.btcFiatRate))); + } else { + setDisplayAmount(String(quickAmount)); + setAmount(String(quickAmount)); + } + }; + + const validAmount = amount !== '' && parseInt(amount) > 0; + return ( @@ -52,40 +103,49 @@ const AmountPanel: React.FC = ({ {/* Amount Input */}
- -
+ +