diff --git a/package.json b/package.json index 8fac2404..c82ff083 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.3.4-beta.19", + "version": "0.3.4-beta.20", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", diff --git a/src/components/AnimatedHeader.tsx b/src/components/AnimatedHeader.tsx index 87fc405a..bd5eea90 100644 --- a/src/components/AnimatedHeader.tsx +++ b/src/components/AnimatedHeader.tsx @@ -8,6 +8,7 @@ import Animated, { } from 'react-native-reanimated' import { spacing, typography, useThemeColor } from '../theme' import { translate, TxKeyPath } from '../i18n' +import { verticalScale } from '@gocodingnow/rn-size-matters' export interface AnimatedHeaderProps { /** @@ -107,6 +108,7 @@ const $headerContainer: ViewStyle = { } const $headerTitle: TextStyle = { - fontSize: spacing.extraLarge, + fontSize: verticalScale(32), + lineHeight: verticalScale(44), fontFamily: typography.primary?.medium, } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 3eaacf06..1fec4ef7 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -41,6 +41,7 @@ import { faXmark } from '@fortawesome/free-solid-svg-icons/faXmark' import { faInfoCircle } from '@fortawesome/free-solid-svg-icons/faInfoCircle' import { faBug } from '@fortawesome/free-solid-svg-icons/faBug' import { faCheckCircle } from '@fortawesome/free-solid-svg-icons/faCheckCircle' +import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck' import { faArrowTurnUp } from '@fortawesome/free-solid-svg-icons/faArrowTurnUp' import { faArrowTurnDown } from '@fortawesome/free-solid-svg-icons/faArrowTurnDown' import { faPencil } from '@fortawesome/free-solid-svg-icons/faPencil' @@ -108,7 +109,7 @@ export type IconTypes = keyof typeof iconRegistry // TODO remove need for manual iconregistry? // would be best to just import all of them, i guess, or figure out something smart -export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft } +export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faCheck, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft } interface IconProps extends TouchableOpacityProps { diff --git a/src/components/NumericKeypad.tsx b/src/components/NumericKeypad.tsx new file mode 100644 index 00000000..a0f03982 --- /dev/null +++ b/src/components/NumericKeypad.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { View, ViewStyle, TextStyle, Pressable } from 'react-native' +import { verticalScale, moderateScale } from '@gocodingnow/rn-size-matters' +import { spacing, useThemeColor, typography } from '../theme' +import { Text } from './Text' +import { Icon } from './Icon' + +export interface NumericKeypadProps { + onKeyPress: (key: string) => void + onClear?: () => void + onLongPressBackspace?: () => void + disabled?: boolean +} + +const KEYS = [ + ['1', '2', '3'], + ['4', '5', '6'], + ['7', '8', '9'], + ['clear', '0', 'backspace'], +] + +export function NumericKeypad({ onKeyPress, onClear, onLongPressBackspace, disabled = false }: NumericKeypadProps) { + const keyBg = useThemeColor('card') + const keyBgPressed = useThemeColor('buttonSecondary') + const textColor = useThemeColor('text') + const textDimColor = useThemeColor('textDim') + + const renderKey = (key: string) => { + const isBackspace = key === 'backspace' + const isClear = key === 'clear' + + return ( + { + if (disabled) return + if (isClear && onClear) { + onClear() + } else { + onKeyPress(key) + } + }} + onLongPress={isBackspace && onLongPressBackspace ? onLongPressBackspace : undefined} + delayLongPress={500} + disabled={disabled} + style={({ pressed }) => [ + $key, + { backgroundColor: pressed ? keyBgPressed : keyBg }, + disabled && $keyDisabled, + ]} + > + {isBackspace ? ( + + ) : isClear ? ( + + ) : ( + + {key} + + )} + + ) + } + + return ( + + {KEYS.map((row, rowIndex) => ( + + {row.map(renderKey)} + + ))} + + ) +} + +const $container: ViewStyle = { + width: '100%', + paddingHorizontal: spacing.medium, +} + +const $row: ViewStyle = { + flexDirection: 'row', + justifyContent: 'center', + marginVertical: spacing.small, +} + +const $key: ViewStyle = { + width: moderateScale(80), + height: verticalScale(56), + borderRadius: spacing.small, + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: spacing.small, +} + +const $keyDisabled: ViewStyle = { + opacity: 0.5, +} + +const $keyText: TextStyle = { + //fontFamily: typography.primary.medium, + //fontSize: moderateScale(28), + +} diff --git a/src/components/PosIcon.tsx b/src/components/PosIcon.tsx new file mode 100644 index 00000000..d5368d52 --- /dev/null +++ b/src/components/PosIcon.tsx @@ -0,0 +1,14 @@ +export const PosIcon = ` + + + + + + + + + + + + +` \ No newline at end of file diff --git a/src/components/StaticHeader.tsx b/src/components/StaticHeader.tsx new file mode 100644 index 00000000..752f8dbe --- /dev/null +++ b/src/components/StaticHeader.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { TextStyle, View, ViewStyle } from 'react-native' +import Animated, { + SharedValue, + useAnimatedStyle, + interpolate, + Extrapolation, +} from 'react-native-reanimated' +import { spacing, typography, useThemeColor } from '../theme' +import { translate, TxKeyPath } from '../i18n' +import { verticalScale } from '@gocodingnow/rn-size-matters' +import { Text } from './Text' + +export interface StaticHeaderProps { + /** + * Title text to display. + */ + title?: string + /** + * Title text which is looked up via i18n. + */ + titleTx?: TxKeyPath + + height?: number + +} + + +export function StaticHeader(props: StaticHeaderProps) { + const { + title, + titleTx, + height = spacing.screenHeight * 0.15, + } = props + + const headerBg = useThemeColor('header') + const headerTitleColor = useThemeColor('headerTitle') + + + const titleContent = titleTx ? translate(titleTx) : title + + return ( + + + {titleContent} + + + ) +} + +const $headerContainer: ViewStyle = { + alignItems: 'center', + paddingBottom: spacing.medium, +} + +const $headerTitle: TextStyle = { + fontSize: verticalScale(32), + lineHeight: verticalScale(44), + fontFamily: typography.primary?.medium, +} diff --git a/src/components/Text.tsx b/src/components/Text.tsx index b5482226..be03b6f2 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -76,7 +76,7 @@ export function Text(props: TextProps) { } export const $sizeStyles = { - xxl: { fontSize: verticalScale(36), lineHeight: verticalScale(44) } as TextStyle, + xxl: { fontSize: verticalScale(32), lineHeight: verticalScale(44) } as TextStyle, xl: { fontSize: verticalScale(24), lineHeight: verticalScale(34) } as TextStyle, lg: { fontSize: verticalScale(20), lineHeight: verticalScale(32) } as TextStyle, md: { fontSize: verticalScale(18), lineHeight: verticalScale(26) } as TextStyle, diff --git a/src/components/index.ts b/src/components/index.ts index 2c916d60..7e17d920 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ export * from "./Button" export * from "./Card" export * from "./Header" export * from "./AnimatedHeader" +export * from "./StaticHeader" export * from "./Icon" export * from "./ListItem" export * from "./Screen" @@ -25,4 +26,5 @@ export * from "./MintIcon" export * from "./HeaderBg" export * from "./NwcIcon" export * from "./ContactlessIcon" - +export * from "./PosIcon" +export * from "./NumericKeypad" diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 9387ecdb..85ca33c2 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -310,6 +310,9 @@ "payCommon_requestPayment": "Request payment", "payCommon_send": "Send", "payCommon_withdraw": "Withdraw", + "pos_exchangeRateUnavailable": "Exchange rate unavailable", + "pos_newTransaction": "New transaction", + "pos_waitingForPayment": "Waiting for payment...", "payerMemo": "Memo for the payer", "payWithLightningDesc": "Enter or paste Lightning address, invoice or LNURL pay code you want to pay to.", "pictureRetrieveFail": "Could not retrieve pictures from the server, try again later.", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index 308e172b..6a328319 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -309,6 +309,9 @@ "payCommon_requestPayment": "Solicitar pago", "payCommon_send": "Enviar", "payCommon_withdraw": "Retirar", + "pos_exchangeRateUnavailable": "Tipo de cambio no disponible", + "pos_newTransaction": "Nueva transacción", + "pos_waitingForPayment": "Esperando el pago...", "payerMemo": "Memo para el pagador", "payWithLightningDesc": "Ingrese o pegue la dirección Lightning, la factura o el código de pago LNURL al que desea pagar.", "pictureRetrieveFail": "No se pudieron recuperar las imágenes del servidor, inténtelo nuevamente más tarde.", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index 7f3fb70c..478baac3 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -310,6 +310,9 @@ "payCommon_requestPayment": "Solicitar pagamento", "payCommon_send": "Enviar", "payCommon_withdraw": "Retirar", + "pos_exchangeRateUnavailable": "Taxa de câmbio indisponível", + "pos_newTransaction": "Nova transação", + "pos_waitingForPayment": "Aguardando pagamento...", "payerMemo": "Memorando para o pagador", "payWithLightningDesc": "Insira ou cole endereço Lightning, invoice ou código de pagamento LNURL para pagar.", "pictureRetrieveFail": "Não foi possível recuperar imagens do servidor, tente mais tarde.", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 2adf2991..9996c800 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -310,6 +310,9 @@ "payCommon_requestPayment": "Vyžiadaj platbu", "payCommon_send": "Pošli", "payCommon_withdraw": "Vyber", + "pos_exchangeRateUnavailable": "Výmenný kurz nie je k dispozícii", + "pos_newTransaction": "Nová transakcia", + "pos_waitingForPayment": "Čakám na platbu...", "payerMemo": "Poznámka pre príjemcu", "payWithLightningDesc": "Zadaj Lightning adresu, invoice alebo LNURL kód ktorý chceš zaplatiť.", "pictureRetrieveFail": "Nejde načítať obrázky zo servera, skús neskôr.", diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index a00f6830..a9cee1b7 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -27,7 +27,7 @@ import { Proof } from './Proof' import { InFlightRequest, Mint } from './Mint' import { getRootStore } from './helpers/getRootStore' import { Transaction } from './Transaction' -// refresh refresh +// /* Not persisted, in-memory only model of the cashu-ts wallet instances and wallet keys persisted in the device secure store. diff --git a/src/navigation/TabsNavigator.tsx b/src/navigation/TabsNavigator.tsx index f5d86293..65e8bf87 100644 --- a/src/navigation/TabsNavigator.tsx +++ b/src/navigation/TabsNavigator.tsx @@ -37,7 +37,8 @@ import { TokenReceiveScreen, NwcScreen, CashuPaymentRequestScreen, - RecoveryOptionsScreen + RecoveryOptionsScreen, + POSScreen } from "../screens" import { colors, spacing, typography } from "../theme" @@ -73,7 +74,8 @@ const WalletStack = createNativeStackNavigator({ NfcPay: NfcPayScreen, Transfer: TransferScreen, Topup: TopupScreen, - CashuPaymentRequest: CashuPaymentRequestScreen, + CashuPaymentRequest: CashuPaymentRequestScreen, + POS: POSScreen, } }) diff --git a/src/screens/Contacts/PrivateContacts.tsx b/src/screens/Contacts/PrivateContacts.tsx index 4daf0dde..223b8b87 100644 --- a/src/screens/Contacts/PrivateContacts.tsx +++ b/src/screens/Contacts/PrivateContacts.tsx @@ -352,6 +352,11 @@ export const PrivateContacts = observer(function (props: { } value={newContactName} autoCapitalize="none" + autoComplete='off' + importantForAutofill='no' + autoCorrect={false} + spellCheck={false} + smartInsertDelete={false} keyboardType="email-address" maxLength={80} selectTextOnFocus={true} diff --git a/src/screens/DeveloperScreen.tsx b/src/screens/DeveloperScreen.tsx index 91591a03..1d0bda61 100644 --- a/src/screens/DeveloperScreen.tsx +++ b/src/screens/DeveloperScreen.tsx @@ -34,7 +34,7 @@ import RNExitApp from 'react-native-exit-app' import { TransactionStatus } from '../models/Transaction' import { maxTransactionsInHistory } from '../models/TransactionsStore' import { StaticScreenProps, useNavigation } from '@react-navigation/native' -// refresh +// type Props = StaticScreenProps diff --git a/src/screens/Mints/MintHeader.tsx b/src/screens/Mints/MintHeader.tsx index e51f1c77..98008ea7 100644 --- a/src/screens/Mints/MintHeader.tsx +++ b/src/screens/Mints/MintHeader.tsx @@ -1,4 +1,5 @@ -import React from "react" +import React, { useState } from "react" +import { TouchableOpacity } from "react-native" import { Header, Text } from "../../components" import { spacing, useThemeColor } from "../../theme" import { Mint } from "../../models/Mint" @@ -14,45 +15,39 @@ export const MintHeader = observer(function(props: { unit: MintUnit, mint?: Mint hideBalance?: boolean - onBackPress?: () => void + onBackPress?: () => void + backgroundColor?: string + textColor?: string + leftIconColor?: string } ) { const navigation = useNavigation() - const {mint, unit, hideBalance, onBackPress} = props - - const getActiveUnitColor = () => { - /* switch (props.unit) { - case 'usd': - return useThemeColor('usd') - case 'eur': - return useThemeColor('eur') - default: - return useThemeColor('btc') - } */ - - return useThemeColor('headerTitle') - - } + const {mint, unit, hideBalance, onBackPress, backgroundColor, textColor, leftIconColor} = props + const [isBalanceHidden, setIsBalanceHidden] = useState(false) const tabWidth = moderateScale(80) const headerTitle = useThemeColor('headerTitle') - + + const resolvedTextColor = textColor || 'white' + return ( -
- {mint && ()} - @@ -61,16 +56,28 @@ export const MintHeader = observer(function(props: { leftIcon='faArrowLeft' onLeftPress={() => { onBackPress ? onBackPress() : navigation.goBack() - }} + }} RightActionComponent={mint && unit && !hideBalance ? ( - + setIsBalanceHidden(!isBalanceHidden)} + style={{marginRight: spacing.medium}} + > + {isBalanceHidden ? ( + + ) : ( + + )} + ) : undefined} /> ) diff --git a/src/screens/POSScreen.tsx b/src/screens/POSScreen.tsx new file mode 100644 index 00000000..24ec5b3e --- /dev/null +++ b/src/screens/POSScreen.tsx @@ -0,0 +1,494 @@ +import { observer } from 'mobx-react-lite' +import React, { useEffect, useState, useRef } from 'react' +import { + View, + ViewStyle, + TextStyle, +} from 'react-native' +import Animated, { + useSharedValue, + useAnimatedStyle, + withSequence, + withTiming, + withDelay, + Easing, +} from 'react-native-reanimated' +import { spacing, useThemeColor, colors, typography } from '../theme' +import { + Button, + Icon, + Screen, + Loading, + InfoModal, + ErrorModal, + BottomModal, + Text, + NumericKeypad, +} from '../components' +import { TransactionStatus, Transaction } from '../models/Transaction' +import { useStores } from '../models' +import { + HANDLE_PENDING_TOPUP_TASK, + TransactionTaskResult, + WalletTask, +} from '../services' +import { log } from '../services/logService' +import AppError from '../utils/AppError' +import { MintBalance } from '../models/Mint' +import EventEmitter from '../utils/eventEmitter' +import { ResultModalInfo } from './Wallet/ResultModalInfo' +import { StaticScreenProps, useNavigation } from '@react-navigation/native' +import { infoMessage } from '../utils/utils' +import { round } from '../utils/number' +import { verticalScale, moderateScale } from '@gocodingnow/rn-size-matters' +import { + CurrencyCode, + MintUnit, + convertToFromSats, + Currencies, + formatCurrency, +} from '../services/wallet/currency' +import { MintHeader } from './Mints/MintHeader' +import useIsInternetReachable from '../utils/useIsInternetReachable' +import { QRCodeBlock } from './Wallet/QRCode' +import { translate } from '../i18n' + +type Props = StaticScreenProps<{ + unit: MintUnit + mintUrl?: string +}> + +export const POSScreen = observer(function POSScreen({ route }: Props) { + const navigation = useNavigation() + const isInternetReachable = useIsInternetReachable() + + const { + proofsStore, + mintsStore, + walletStore, + userSettingsStore, + } = useStores() + + const unitRef = useRef('sat') + + // Amount state - stored as integer cents (e.g., 123 = $1.23) + const [amountCents, setAmountCents] = useState(0) + const [satsAmount, setSatsAmount] = useState(0) + + // Max digits for amount entry (e.g., 7 = max $99,999.99) + const MAX_CENTS_DIGITS = 7 + + // Mint selection + const [mintBalanceToTopup, setMintBalanceToTopup] = useState() + + // Transaction state + const [transactionStatus, setTransactionStatus] = useState() + const [transactionId, setTransactionId] = useState() + const [transaction, setTransaction] = useState() + const [invoiceToPay, setInvoiceToPay] = useState('') + + // UI state + const [info, setInfo] = useState('') + const [error, setError] = useState() + const [isLoading, setIsLoading] = useState(false) + + // Success icon animation + const iconScale = useSharedValue(0.8) + + // Get fiat currency settings + const fiatCode = userSettingsStore.exchangeCurrency || CurrencyCode.USD + const fiatCurrency = Currencies[fiatCode] + const fiatSymbol = fiatCurrency?.symbol || '$' + const fiatPrecision = fiatCurrency?.precision ?? 100 + const fiatMantissa = fiatCurrency?.mantissa ?? 2 + + // Initialize unit from route params + useEffect(() => { + if (route.params?.unit) { + unitRef.current = route.params.unit + } + }, [route.params?.unit]) + + // Convert cents to sats whenever amountCents or exchange rate changes + useEffect(() => { + if (!walletStore.exchangeRate) { + setSatsAmount(0) + return + } + + const sats = convertToFromSats(amountCents, fiatCode, walletStore.exchangeRate) + setSatsAmount(round(sats, 0)) + }, [amountCents, walletStore.exchangeRate, fiatCode]) + + // Trigger success icon animation when transaction completes + useEffect(() => { + if (transactionStatus === TransactionStatus.COMPLETED) { + iconScale.value = 0.8 + iconScale.value = withDelay( + 300, + withSequence( + withTiming(1.15, { duration: 400, easing: Easing.out(Easing.ease) }), + withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) }) + ) + ) + } + }, [transactionStatus]) + + const iconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: iconScale.value }], + })) + + // Payment detection via EventEmitter + useEffect(() => { + const handlePendingTopupTaskResult = (result: TransactionTaskResult) => { + log.trace('[POSScreen] handlePendingTopupTaskResult triggered') + + if (!transactionId) return + if (result.transaction?.id !== transactionId) return + if (result.transaction.status !== TransactionStatus.COMPLETED) return + + log.trace('[POSScreen] Invoice paid, proofs received') + + setTransactionStatus(TransactionStatus.COMPLETED) + setTransaction(result.transaction) + } + + if (transactionId) { + EventEmitter.on( + `ev_${HANDLE_PENDING_TOPUP_TASK}_result`, + handlePendingTopupTaskResult + ) + } + + return () => { + EventEmitter.off( + `ev_${HANDLE_PENDING_TOPUP_TASK}_result`, + handlePendingTopupTaskResult + ) + } + }, [transactionId]) + + // Handle keypad input - cents-first entry (each digit shifts left) + const handleKeyPress = (key: string) => { + if (transactionStatus === TransactionStatus.PENDING) return + + if (key === 'backspace') { + // Shift right - remove last digit + setAmountCents(prev => Math.floor(prev / 10)) + } else { + // Only process digit keys + const digit = parseInt(key, 10) + if (isNaN(digit)) return + + // Check max digits limit + if (amountCents.toString().length >= MAX_CENTS_DIGITS) return + + // Shift left and add new digit + setAmountCents(prev => prev * 10 + digit) + } + } + + // Clear amount (also used for long-press backspace) + const onClearAmount = () => { + setAmountCents(0) + setSatsAmount(0) + } + + // Confirm amount and create invoice immediately with default mint + const onConfirmAmount = async () => { + if (!isInternetReachable) { + infoMessage(translate('commonOfflinePretty')) + return + } + + if (satsAmount <= 0) { + infoMessage(translate('payCommon_amountZeroOrNegative')) + return + } + + const availableBalances = proofsStore.getMintBalancesWithUnit(unitRef.current) + + if (availableBalances.length === 0) { + infoMessage( + translate('topup_missingMintAddFirst'), + translate('topup_missingMintAddFirstDesc') + ) + return + } + + // Use first available mint (highest balance) and create invoice immediately + const selectedMint = availableBalances[0] + setMintBalanceToTopup(selectedMint) + setIsLoading(true) + + const result = await WalletTask.topupQueueAwaitable( + selectedMint, + satsAmount, + unitRef.current, + '' // empty memo for POS + ) + + await handleTopupTaskResult(result) + } + + // Handle invoice creation result + const handleTopupTaskResult = async (result: TransactionTaskResult) => { + log.trace('[POSScreen] handleTopupTaskResult') + + setIsLoading(false) + + const { status, id } = result.transaction as Transaction + setTransactionStatus(status) + setTransactionId(id) + + if (result.encodedInvoice) { + setInvoiceToPay(result.encodedInvoice) + } + + if (result.error) { + setError(result.error) + return + } + + } + + // Reset for new transaction + const resetState = () => { + setAmountCents(0) + setSatsAmount(0) + setTransactionStatus(undefined) + setTransactionId(undefined) + setTransaction(undefined) + setInvoiceToPay('') + //setMintBalanceToTopup(undefined) + } + + + const gotoWallet = () => { + resetState() + navigation.goBack() + } + + const handleError = (e: AppError) => { + setError(e) + } + + // Get colors + const headerBg = useThemeColor('background') + const textColor = useThemeColor('text') + const textDimColor = useThemeColor('textDim') + const mainButtonColor = useThemeColor('button') + + // Get selected mint info + const selectedMint = mintBalanceToTopup + ? mintsStore.findByUrl(mintBalanceToTopup.mintUrl) + : undefined + + // Format display amount from cents to decimal string (e.g., 123 → "1.23") + const formatCentsToDisplay = (cents: number): string => { + const divisor = fiatPrecision // e.g., 100 for USD + const whole = Math.floor(cents / divisor) + const fraction = cents % divisor + return `${whole}.${fraction.toString().padStart(fiatMantissa, '0')}` + } + + const displayFiatAmount = formatCentsToDisplay(amountCents) + + return ( + + + + {/* Amount Display */} + + + {walletStore.exchangeRate && ( + + + + )} + + + {/* Content Area */} + + {/* Show keypad when no invoice yet */} + {!invoiceToPay && ( + + + +