diff --git a/src/components/Main.tsx b/src/components/Main.tsx index ca556b37dd9..e74502054cd 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -17,6 +17,7 @@ import { ENV } from '../env' import { useExperimentConfig } from '../hooks/useExperimentConfig' import { useMount } from '../hooks/useMount' import { lstrings } from '../locales/strings' +import { cleanBrandName } from '../plugins/gift-cards/phazeGiftCardTypes' import { AddressFormScene } from '../plugins/gui/scenes/AddressFormScene' import { ConfirmationScene } from '../plugins/gui/scenes/ConfirmationScene' import { ContactFormScene } from '../plugins/gui/scenes/ContactFormScene' @@ -948,7 +949,7 @@ const EdgeAppStack: React.FC = () => { options={{ headerTitle: () => ( - fromParams={params => params.brand.brandName} + fromParams={params => cleanBrandName(params.brand.brandName)} /> ) }} diff --git a/src/components/cards/ErrorCard.tsx b/src/components/cards/ErrorCard.tsx index 6fcb395ec43..bc716fa2a4c 100644 --- a/src/components/cards/ErrorCard.tsx +++ b/src/components/cards/ErrorCard.tsx @@ -29,6 +29,8 @@ interface Props { * If the error is an I18nError, it will display the localized message. * Otherwise, it will display a localized generic error message with a report * error button for unexpected errors. + * + * TODO: Add a warning variant */ export const ErrorCard: React.FC = props => { const { error } = props diff --git a/src/components/cards/HomeTileCard.tsx b/src/components/cards/HomeTileCard.tsx index e63c4792cb1..2e825255275 100644 --- a/src/components/cards/HomeTileCard.tsx +++ b/src/components/cards/HomeTileCard.tsx @@ -12,20 +12,20 @@ interface Props { footer: string gradientBackground: LinearGradientProps nodeBackground: React.ReactNode - onPress: () => void + onPress: () => void | Promise } /** * Tappable card that shows a corner chevron, background, and title */ -export const HomeTileCard = (props: Props) => { +export const HomeTileCard: React.FC = props => { const { title, footer, gradientBackground, nodeBackground, onPress } = props const theme = useTheme() const styles = getStyles(theme) - const handlePress = useHandler(() => { - onPress() + const handlePress = useHandler(async () => { + await onPress() }) return ( diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx index 1e88fd19e05..22eeef3c5f3 100644 --- a/src/components/scenes/GiftCardPurchaseScene.tsx +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -15,13 +15,16 @@ import Ionicons from 'react-native-vector-icons/Ionicons' import { sprintf } from 'sprintf-js' import { v4 as uuidv4 } from 'uuid' +import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants' import { ENV } from '../../env' +import { displayFiatAmount } from '../../hooks/useFiatText' import { useGiftCardProvider } from '../../hooks/useGiftCardProvider' import { useHandler } from '../../hooks/useHandler' import { usePhazeBrand } from '../../hooks/usePhazeBrand' import { lstrings } from '../../locales/strings' import type { PhazeCreateOrderResponse, + PhazeFxRate, PhazeGiftCardBrand, PhazeToken } from '../../plugins/gift-cards/phazeGiftCardTypes' @@ -36,6 +39,7 @@ import { DropdownInputButton } from '../buttons/DropdownInputButton' import { KavButtons } from '../buttons/KavButtons' import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' +import { ErrorCard } from '../cards/ErrorCard' import { EdgeAnim } from '../common/EdgeAnim' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { SceneWrapper } from '../common/SceneWrapper' @@ -118,6 +122,13 @@ export const GiftCardPurchaseScene: React.FC = props => { footer: string } | null>(null) + // Warning state for product unavailable + const [productUnavailable, setProductUnavailable] = + React.useState(false) + + // Error state for unexpected errors + const [error, setError] = React.useState(null) + // Fetch allowed tokens from Phaze API const { data: tokenQueryResult } = useQuery({ queryKey: ['phazeTokens', account?.id, isReady], @@ -155,6 +166,42 @@ export const GiftCardPurchaseScene: React.FC = props => { gcTime: 10 * 60 * 1000 }) + // Get cached FX rates (already loaded during provider initialization) + const fxRates = provider?.getCachedFxRates() ?? null + + /** + * Convert USD amount to brand's currency using FX rates. + * Returns formatted string like "€5" or "$5.00" for USD brands. + */ + const formatMinimumInBrandCurrency = React.useCallback( + (minimumUsd: number): string => { + const symbol = getFiatSymbol(brand.currency) + + if (brand.currency === 'USD') { + return `${symbol}${displayFiatAmount(minimumUsd, 2)}` + } + + if (fxRates == null) { + // Fallback to USD if rates not loaded + return `$${displayFiatAmount(minimumUsd, 2)}` + } + + const rate = fxRates.find( + (r: PhazeFxRate) => + r.fromCurrency === 'USD' && r.toCurrency === brand.currency + ) + if (rate == null) { + // Fallback to USD if rate not found + return `$${displayFiatAmount(minimumUsd, 2)}` + } + + const amountInBrandCurrency = Math.ceil(minimumUsd * rate.rate) + // Use 0 decimals for non-USD since we ceil to whole number + return `${symbol}${displayFiatAmount(amountInBrandCurrency, 0)}` + }, + [fxRates, brand.currency] + ) + // Extract assets for wallet list modal and sync token map to ref // This ensures the ref is populated even when query returns cached data const allowedAssets = tokenQueryResult?.assets @@ -195,8 +242,10 @@ export const GiftCardPurchaseScene: React.FC = props => { // Handle amount text change for variable range const handleAmountChange = useHandler((text: string) => { - // Clear minimum warning when user modifies amount + // Clear warnings/errors when user modifies amount setMinimumWarning(null) + setProductUnavailable(false) + setError(null) // Only allow numbers and decimal point const cleaned = text.replace(/[^0-9.]/g, '') @@ -216,6 +265,8 @@ export const GiftCardPurchaseScene: React.FC = props => { const handleMaxPress = useHandler(() => { if (hasVariableRange) { setMinimumWarning(null) + setProductUnavailable(false) + setError(null) setAmountText(String(maxVal)) setSelectedAmount(maxVal) } @@ -259,8 +310,10 @@ export const GiftCardPurchaseScene: React.FC = props => { ) if (result != null) { - // Clear minimum warning when user modifies amount + // Clear warnings/errors when user modifies amount setMinimumWarning(null) + setProductUnavailable(false) + setError(null) setSelectedAmount(result.amount) setAmountText(String(result.amount)) } @@ -278,7 +331,6 @@ export const GiftCardPurchaseScene: React.FC = props => { headerTitle={lstrings.gift_card_pay_from_wallet} navigation={navigation as NavigationBase} allowedAssets={allowedAssets} - showCreateWallet /> )) @@ -324,7 +376,7 @@ export const GiftCardPurchaseScene: React.FC = props => { ), footer: sprintf( lstrings.gift_card_minimum_warning_footer, - `$${tokenInfo.minimumAmountInUSD.toFixed(2)} USD` + formatMinimumInBrandCurrency(tokenInfo.minimumAmountInUSD) ) }) return @@ -384,8 +436,8 @@ export const GiftCardPurchaseScene: React.FC = props => { const quantity = orderResponse.quantity.toFixed(DECIMAL_PRECISION) const nativeAmount = String(ceil(mul(quantity, multiplier), 0)) - // Calculate expiry time - const expiryDate = new Date(orderResponse.quoteExpiry * 1000) + // Calculate expiry time (quoteExpiry is Unix timestamp in milliseconds) + const expiryDate = new Date(orderResponse.quoteExpiry) const isoExpireDate = expiryDate.toISOString() // Navigate to SendScene2 @@ -436,6 +488,11 @@ export const GiftCardPurchaseScene: React.FC = props => { ), isoExpireDate, + onExpired: () => { + // Quote expired - navigate back to purchase scene and show toast + navigation.goBack() + showToast(lstrings.gift_card_quote_expired_toast) + }, onDone: async (error: Error | null, tx?: EdgeTransaction) => { if (error != null) { debugLog('phaze', 'Transaction error:', error) @@ -492,8 +549,20 @@ export const GiftCardPurchaseScene: React.FC = props => { } catch (err: unknown) { debugLog('phaze', 'Order creation error:', err) - // Check for minimum amount error from API + // Clear previous warnings/errors + setMinimumWarning(null) + setProductUnavailable(false) + setError(null) + const errorMessage = err instanceof Error ? err.message : '' + + // Check for product unavailable error + if (errorMessage.includes('Product is unavailable')) { + setProductUnavailable(true) + return + } + + // Check for minimum amount error from API const minimumMatch = /Minimum cart cost should be above: ([\d.]+)/.exec( errorMessage ) @@ -507,11 +576,12 @@ export const GiftCardPurchaseScene: React.FC = props => { ), footer: sprintf( lstrings.gift_card_minimum_warning_footer, - `$${minimumUSD.toFixed(2)} USD` + formatMinimumInBrandCurrency(minimumUSD) ) }) } else { - showError(err) + // Show ErrorCard for other errors + setError(err) } } finally { setIsCreatingOrder(false) @@ -628,6 +698,8 @@ export const GiftCardPurchaseScene: React.FC = props => { style={styles.maxButton} onPress={() => { setMinimumWarning(null) + setProductUnavailable(false) + setError(null) const maxDenom = sortedDenominations[sortedDenominations.length - 1] setSelectedAmount(maxDenom) @@ -664,14 +736,22 @@ export const GiftCardPurchaseScene: React.FC = props => { )} - {/* Minimum Amount Warning */} - {minimumWarning != null ? ( + {/* Warnings/Errors - product unavailable takes precedence */} + {productUnavailable ? ( + + ) : minimumWarning != null ? ( + ) : error != null ? ( + ) : null} {/* Product Description Card */} diff --git a/src/components/scenes/HomeScene.tsx b/src/components/scenes/HomeScene.tsx index 336a97304b9..36b1376b564 100644 --- a/src/components/scenes/HomeScene.tsx +++ b/src/components/scenes/HomeScene.tsx @@ -9,6 +9,7 @@ import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { ENV } from '../../env' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' +import { hasStoredPhazeIdentity } from '../../plugins/gift-cards/phazeGiftCardProvider' import { useSceneScrollHandler } from '../../state/SceneScrollState' import { config } from '../../theme/appConfig' import { useSelector } from '../../types/reactRedux' @@ -84,6 +85,7 @@ export const HomeScene: React.FC = props => { const styles = getStyles(theme) const countryCode = useSelector(state => state.ui.countryCode) + const account = useSelector(state => state.core.account) const { width: screenWidth } = useSafeAreaFrame() @@ -111,8 +113,11 @@ export const HomeScene: React.FC = props => { const handleSwapPress = useHandler(() => { navigation.navigate('swapTab') }) - const handleSpendPress = useHandler(() => { - navigation.navigate('edgeAppStack', { screen: 'giftCardMarket' }) + const handleSpendPress = useHandler(async () => { + const hasIdentity = await hasStoredPhazeIdentity(account) + navigation.navigate('edgeAppStack', { + screen: hasIdentity ? 'giftCardList' : 'giftCardMarket' + }) }) const handleViewAssetsPress = useHandler(() => { navigation.navigate('edgeTabs', { diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 266d17f56ed..1ca07938cb6 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -140,6 +140,12 @@ export interface SendScene2Params { error: Error | null, edgeTransaction?: EdgeTransaction ) => void | Promise + /** + * Called when the quote expires (isoExpireDate countdown reaches zero). + * If provided, handles expiry smoothly without showing an error. + * If not provided, falls back to displaying an expiry error message. + */ + onExpired?: () => void beforeTransaction?: () => Promise alternateBroadcast?: ( edgeTransaction: EdgeTransaction @@ -208,6 +214,7 @@ const SendComponent = (props: Props): React.ReactElement => { hiddenFeaturesMap = {}, onDone, onBack, + onExpired, beforeTransaction, alternateBroadcast, doCheckAndShowGetCryptoModal = true @@ -740,12 +747,18 @@ const SendComponent = (props: Props): React.ReactElement => { } const handleTimeoutDone = useHandler((): void => { - setError( - new I18nError( - lstrings.transaction_failure, - lstrings.send_address_expired_error_message + if (onExpired != null) { + // Caller provided custom expiry handler - call it without showing error + onExpired() + } else { + // Fall back to showing expiry error message + setError( + new I18nError( + lstrings.transaction_failure, + lstrings.send_address_expired_error_message + ) ) - ) + } }) const renderTimeout = (): React.ReactElement | null => { diff --git a/src/components/themed/SideMenu.tsx b/src/components/themed/SideMenu.tsx index 3250324cc21..d302286d220 100644 --- a/src/components/themed/SideMenu.tsx +++ b/src/components/themed/SideMenu.tsx @@ -40,6 +40,7 @@ import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { ENV } from '../../env' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' +import { hasStoredPhazeIdentity } from '../../plugins/gift-cards/phazeGiftCardProvider' import { getDefaultFiat } from '../../selectors/SettingsSelectors' import { config } from '../../theme/appConfig' import { useDispatch, useSelector } from '../../types/reactRedux' @@ -312,12 +313,15 @@ export function SideMenuComponent(props: Props): React.ReactElement { title: lstrings.title_markets }, { - handlePress: () => { + handlePress: async () => { navigation.dispatch(DrawerActions.closeDrawer()) // Light accounts need to back up before using gift cards if (checkAndShowLightBackupModal(account, navigationBase)) return - // Navigate to gift card list - it has a "Purchase New" button - navigation.navigate('edgeAppStack', { screen: 'giftCardList' }) + const hasIdentity = await hasStoredPhazeIdentity(account) + // Navigate to gift card list only if we have identities + navigation.navigate('edgeAppStack', { + screen: hasIdentity ? 'giftCardList' : 'giftCardMarket' + }) }, iconNameFontAwesome: 'gift', title: lstrings.gift_card_branded diff --git a/src/components/tiles/CountdownTile.tsx b/src/components/tiles/CountdownTile.tsx index d1b90928b8b..f4864c633e1 100644 --- a/src/components/tiles/CountdownTile.tsx +++ b/src/components/tiles/CountdownTile.tsx @@ -1,5 +1,6 @@ import * as React from 'react' +import { formatCountdown } from '../../locales/intl' import { useState } from '../../types/reactHooks' import { EdgeRow } from '../rows/EdgeRow' @@ -10,7 +11,7 @@ interface Props { title: string } -export const CountdownTile = (props: Props) => { +export const CountdownTile: React.FC = props => { const { isoExpireDate, maximumHeight, onDone, title } = props const timeoutHandler = React.useRef< @@ -42,9 +43,7 @@ export const CountdownTile = (props: Props) => { if (expireSeconds == null) return null - const date = new Date(expireSeconds * 1000) - let time = date.toISOString().slice(11, 19) - if (time.startsWith('00:')) time = time.slice(3) + const time = formatCountdown(expireSeconds) return } diff --git a/src/hooks/useGiftCardProvider.ts b/src/hooks/useGiftCardProvider.ts index b2e6a12dc6c..fb98d2650f9 100644 --- a/src/hooks/useGiftCardProvider.ts +++ b/src/hooks/useGiftCardProvider.ts @@ -22,14 +22,11 @@ export function useGiftCardProvider(options: UseGiftCardProviderOptions): { const { data: provider = null, isSuccess } = useQuery({ queryKey: ['phazeProvider', account?.id, apiKey, baseUrl], queryFn: async () => { - const instance = makePhazeGiftCardProvider( - { - baseUrl, - apiKey, - publicKey - }, - account - ) + const instance = makePhazeGiftCardProvider({ + baseUrl, + apiKey, + publicKey + }) // Attach persisted userApiKey if present: await instance.ensureUser(account) return instance diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 38f178f5b4e..251ac0ba033 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1912,6 +1912,18 @@ const strings = { gift_card_pending: 'Pending Delivery, Please Wait...', gift_card_pending_toast: 'Your gift card is being delivered. Please wait for a few minutes for it to arrive.', + gift_card_quote_expired_toast: 'Your quote has expired. Please try again.', + gift_card_product_unavailable_title: 'Temporarily Unavailable', + gift_card_product_unavailable_warning: + 'Card is temporarily unavailable. Please select another card from this brand or try again later.', + + // #endregion + + // #region Countdown Duration + // Used for countdown timers (e.g., quote expiration) + countdown_hours: '>%sh', + countdown_minutes_seconds: '%sm %ss', + countdown_seconds: '%ss', // #endregion diff --git a/src/locales/intl.ts b/src/locales/intl.ts index 2b5853e78d4..4056a1f3e19 100644 --- a/src/locales/intl.ts +++ b/src/locales/intl.ts @@ -2,9 +2,11 @@ import { gt, mul, toBns, toFixed } from 'biggystring' import { asMaybe } from 'cleaners' import { format } from 'date-fns' import { getLocales, getNumberFormatSettings } from 'react-native-localize' +import { sprintf } from 'sprintf-js' import { asBiggystring } from '../util/cleaners' import { locales } from './dateLocales' +import { lstrings } from './strings' export interface IntlLocaleType { localeIdentifier: string // Like en_US or en-US @@ -60,7 +62,7 @@ export function formatNumberInput( } if (input.includes(NATIVE_DECIMAL_SEPARATOR)) { const decimalPart = input.split(NATIVE_DECIMAL_SEPARATOR)[1] - if (decimalPart) { + if (decimalPart != null && decimalPart !== '') { _options.toFixed = decimalPart.length } } @@ -92,17 +94,21 @@ export function formatNumber( } const [integers, decimals] = stringify.split(NATIVE_DECIMAL_SEPARATOR) const len = integers.length - if (!options?.noGrouping) { - i = len % NUMBER_GROUP_SIZE || NUMBER_GROUP_SIZE - intPart = integers.substr(0, i) + if (options.noGrouping !== true) { + const remainder = len % NUMBER_GROUP_SIZE + i = remainder !== 0 ? remainder : NUMBER_GROUP_SIZE + intPart = integers.substring(0, i) for (; i < len; i += NUMBER_GROUP_SIZE) { intPart += - locale.groupingSeparator + integers.substr(i, NUMBER_GROUP_SIZE) + locale.groupingSeparator + integers.substring(i, i + NUMBER_GROUP_SIZE) } } else { intPart = integers } - stringify = decimals ? intPart + locale.decimalSeparator + decimals : intPart + stringify = + decimals != null && decimals !== '' + ? intPart + locale.decimalSeparator + decimals + : intPart return stringify } @@ -236,12 +242,12 @@ export function formatDate( try { // TODO: Determine the purpose of this replace() and mapping... const dateFormattingLocale = - // @ts-expect-error + // @ts-expect-error - locales is a dynamic import map without proper typing locales[localeIdentifier.replace('_', '-')] ?? - // @ts-expect-error + // @ts-expect-error - locales is a dynamic import map without proper typing locales[localeIdentifier.split('-')?.[0]] return format(date, dateFormat, { locale: dateFormattingLocale }) - } catch (e: any) { + } catch (e: unknown) { // } return format(date, DEFAULT_DATE_FMT) @@ -255,10 +261,10 @@ export function formatTime(date: Date): string { try { return format(date, 'p', { - // @ts-expect-error + // @ts-expect-error - locales is a dynamic import map without proper typing locale: locales[localeIdentifier.replace('_', '-')] }) - } catch (e: any) { + } catch (e: unknown) { // } return format(date, 'h:mm bb') @@ -272,9 +278,15 @@ export function formatTimeDate(date: Date, dateFormat?: string): string { } export function setIntlLocale(l: IntlLocaleType): void { - if (!l) throw new Error('Please select locale for internationalization') + if (l == null) { + throw new Error('Please select locale for internationalization') + } - if (!l.decimalSeparator || !l.groupingSeparator || !l.localeIdentifier) { + if ( + l.decimalSeparator === '' || + l.groupingSeparator === '' || + l.localeIdentifier === '' + ) { console.warn( 'Cannot recognize user locale preferences. Default will be used.' ) @@ -296,6 +308,26 @@ export function toLocaleDateTime(date: Date): string { return formatDate(date, SHORT_DATE_FMT) + ' ' + toLocaleTime(date) } +/** + * Formats a countdown duration in seconds to a localized string. + * - 1+ hours: ">Xh" (e.g., ">2h") + * - 1-59 minutes: "Xm Ys" (e.g., "15m 30s") + * - <1 minute: "Xs" (e.g., "45s") + */ +export function formatCountdown(totalSeconds: number): string { + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours >= 1) { + return sprintf(lstrings.countdown_hours, hours) + } else if (minutes >= 1) { + return sprintf(lstrings.countdown_minutes_seconds, minutes, seconds) + } else { + return sprintf(lstrings.countdown_seconds, seconds) + } +} + // Remove starting and trailing zeros and separator export const trimEnd = (val: string): string => { const _ = locale.decimalSeparator @@ -343,7 +375,7 @@ export const toPercentString = ( )}%` } -const normalizeLang = (l: string) => +const normalizeLang = (l: string): string => l.replace('-', '').replace('_', '').toLowerCase() /** Given a language code, ie 'en_US', 'en-US', 'en-us', 'en'. Pick the language diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 4ccb766d899..ec510b37bb3 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1483,6 +1483,12 @@ "gift_card_active_cards": "Active Cards", "gift_card_pending": "Pending Delivery, Please Wait...", "gift_card_pending_toast": "Your gift card is being delivered. Please wait for a few minutes for it to arrive.", + "gift_card_quote_expired_toast": "Your quote has expired. Please try again.", + "gift_card_product_unavailable_title": "Temporarily Unavailable", + "gift_card_product_unavailable_warning": "Card is temporarily unavailable. Please select another card from this brand or try again later.", + "countdown_hours": ">%sh", + "countdown_minutes_seconds": "%sm %ss", + "countdown_seconds": "%ss", "backup_account": "Back Up Account", "backup_delete_confirm_message": "Are you sure you want to delete this account without backing up first? You will NOT be able to recover wallets and transactions for this account!", "backup_info_message": "Create a username and password to create a full account and secure your funds. No personal information is required", diff --git a/src/plugins/gift-cards/phazeApi.ts b/src/plugins/gift-cards/phazeApi.ts index 739840155e9..7871ade69e2 100644 --- a/src/plugins/gift-cards/phazeApi.ts +++ b/src/plugins/gift-cards/phazeApi.ts @@ -4,15 +4,25 @@ import { debugLog, maskHeaders } from '../../util/logger' import { asPhazeCreateOrderResponse, asPhazeError, + asPhazeFxRatesResponse, asPhazeGiftCardsResponse, asPhazeOrderStatusResponse, asPhazeRegisterUserResponse, asPhazeTokensResponse, type PhazeCreateOrderRequest, + type PhazeFxRate, + type PhazeGiftCardBrand, type PhazeOrderStatusResponse, type PhazeRegisterUserRequest } from './phazeGiftCardTypes' +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Minimum card value in USD. Cards below this are filtered out. */ +export const MINIMUM_CARD_VALUE_USD = 5 + // --------------------------------------------------------------------------- // Field definitions for different use cases // --------------------------------------------------------------------------- @@ -71,6 +81,16 @@ export interface PhazeApi { // Endpoints: getTokens: () => Promise> + /** + * Ensures FX rates are fetched and cached. + * Called during provider initialization to guarantee rates are available. + */ + ensureFxRates: () => Promise + /** + * Returns cached FX rates. Guaranteed to be available after + * ensureFxRates() completes (called during provider init). + */ + getCachedFxRates: () => PhazeFxRate[] | null getGiftCards: (params: { countryCode: string currentPage?: number @@ -101,6 +121,7 @@ export interface PhazeApi { export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => { let userApiKey = config.userApiKey + let cachedFxRates: PhazeFxRate[] | null = null const makeHeaders = (opts?: { includeUserKey?: boolean @@ -175,9 +196,26 @@ export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => { } throw new Error(`HTTP error! status: ${response.status} body: ${text}`) } + debugLog('phaze', `Response: ${response.status} ${response.statusText}`) return response } + /** Fetch FX rates, using cached value if available */ + const getOrFetchFxRates = async (): Promise => { + if (cachedFxRates != null) return cachedFxRates + const response = await fetchPhaze(buildUrl('/crypto/exchange-rates'), { + headers: makeHeaders() + }) + const text = await response.text() + debugLog( + 'phaze', + `getFxRates response: ${response.status} ${response.statusText}` + ) + const parsed = asJSON(asPhazeFxRatesResponse)(text) + cachedFxRates = parsed.rates + return cachedFxRates + } + return { setUserApiKey: (key?: string) => { userApiKey = key @@ -191,37 +229,61 @@ export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => { headers: makeHeaders() }) const text = await response.text() + debugLog( + 'phaze', + `getTokens response: ${response.status} ${response.statusText} ${text}` + ) return asJSON(asPhazeTokensResponse)(text) }, + ensureFxRates: async () => { + await getOrFetchFxRates() + }, + + getCachedFxRates: () => cachedFxRates, + // GET /gift-cards/:country getGiftCards: async params => { const { countryCode, currentPage = 1, perPage = 50, brandName } = params - const response = await fetchPhaze( - buildUrl(`/gift-cards/${countryCode}`, { - currentPage, - perPage, - brandName - }), - { - headers: makeHeaders({ includePublicKey: true }) - } - ) + const [response, fxRates] = await Promise.all([ + fetchPhaze( + buildUrl(`/gift-cards/${countryCode}`, { + currentPage, + perPage, + brandName + }), + { + headers: makeHeaders({ includePublicKey: true }) + } + ), + getOrFetchFxRates() + ]) const text = await response.text() - return asJSON(asPhazeGiftCardsResponse)(text) + const parsed = asJSON(asPhazeGiftCardsResponse)(text) + return { + ...parsed, + brands: filterBrandsByMinimum(parsed.brands, fxRates) + } }, // GET /gift-cards/full/:country - Returns all brands without pagination getFullGiftCards: async params => { const { countryCode, fields, filter } = params - const response = await fetchPhaze( - buildUrl(`/gift-cards/full/${countryCode}`, { fields, filter }), - { - headers: makeHeaders({ includePublicKey: true }) - } - ) + const [response, fxRates] = await Promise.all([ + fetchPhaze( + buildUrl(`/gift-cards/full/${countryCode}`, { fields, filter }), + { + headers: makeHeaders({ includePublicKey: true }) + } + ), + getOrFetchFxRates() + ]) const text = await response.text() - return asJSON(asPhazeGiftCardsResponse)(text) + const parsed = asJSON(asPhazeGiftCardsResponse)(text) + return { + ...parsed, + brands: filterBrandsByMinimum(parsed.brands, fxRates) + } }, // GET /crypto/user?email=... - Lookup existing user by email @@ -278,3 +340,100 @@ export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => { } } } + +// --------------------------------------------------------------------------- +// Brand Filtering Utilities +// --------------------------------------------------------------------------- + +/** + * Convert USD amount to a local currency using FX rates. + * Rates are expected to be FROM USD TO the target currency. + * Rounds up to the next whole number to handle varying fiat precisions. + * Returns null if no rate is found for the currency. + */ +const convertFromUsd = ( + amountUsd: number, + toCurrency: string, + fxRates: PhazeFxRate[] +): number | null => { + if (toCurrency === 'USD') return amountUsd + const rate = fxRates.find( + r => r.fromCurrency === 'USD' && r.toCurrency === toCurrency + ) + if (rate == null) return null + return Math.ceil(amountUsd * rate.rate) +} + +/** + * Filter gift card brands to enforce minimum card value. + * + * - Fixed denomination cards: removes denominations below the minimum, + * and removes the brand entirely if no denominations remain. + * - Variable amount cards: caps minVal to the minimum if below, + * and removes the brand if maxVal is below the minimum. + * + * @param brands - Array of gift card brands to filter + * @param fxRates - FX rates from USD to other currencies + * @param minimumUsd - Minimum card value in USD (defaults to MINIMUM_CARD_VALUE_USD) + * @returns Filtered array of brands with valid denominations/restrictions + */ +export const filterBrandsByMinimum = ( + brands: PhazeGiftCardBrand[], + fxRates: PhazeFxRate[], + minimumUsd: number = MINIMUM_CARD_VALUE_USD +): PhazeGiftCardBrand[] => { + return brands + .map(brand => { + const { currency, denominations, valueRestrictions } = brand + + // Convert minimum USD to brand's currency + const minInBrandCurrency = convertFromUsd(minimumUsd, currency, fxRates) + + // If we can't convert, just return the brand as-is + if (minInBrandCurrency == null) return brand + + // Variable amount card (has minVal/maxVal restrictions) + if (valueRestrictions.maxVal != null) { + // Exclude brand if maxVal is below minimum + if (valueRestrictions.maxVal < minInBrandCurrency) { + return null + } + + // Cap minVal to our minimum if it's below + const cappedMinVal = + valueRestrictions.minVal == null + ? minInBrandCurrency + : Math.max(valueRestrictions.minVal, minInBrandCurrency) + + return { + ...brand, + valueRestrictions: { + ...valueRestrictions, + minVal: cappedMinVal + } + } + } + + // Fixed denomination card + if (denominations.length > 0) { + const filteredDenoms = denominations.filter( + denom => denom >= minInBrandCurrency + ) + + // No valid denominations remain, exclude the brand + if (filteredDenoms.length === 0) { + return null + } + + // Return brand with filtered denominations + return { + ...brand, + denominations: filteredDenoms + } + } + + // No denominations and no value restrictions - exclude + return null + }) + .filter((brand): brand is PhazeGiftCardBrand => brand != null) +} diff --git a/src/plugins/gift-cards/phazeGiftCardProvider.ts b/src/plugins/gift-cards/phazeGiftCardProvider.ts index 1c1d0781267..e0f4f14c2b0 100644 --- a/src/plugins/gift-cards/phazeGiftCardProvider.ts +++ b/src/plugins/gift-cards/phazeGiftCardProvider.ts @@ -1,4 +1,4 @@ -import { asArray, asMaybe } from 'cleaners' +import { asMaybe, asNumber, asObject, asOptional, asString } from 'cleaners' import type { EdgeAccount } from 'edge-core-js' import { debugLog } from '../../util/logger' @@ -15,9 +15,9 @@ import { } from './phazeGiftCardCache' import { saveOrderAugment } from './phazeGiftCardOrderStore' import { - asPhazeUser, cleanBrandName, type PhazeCreateOrderRequest, + type PhazeFxRate, type PhazeGiftCardBrand, type PhazeGiftCardsResponse, type PhazeOrderStatusResponse, @@ -29,13 +29,35 @@ import { // dataStore keys - encrypted storage for privacy const STORE_ID = 'phaze' -const IDENTITIES_KEY = 'identities' +// Each identity is stored as a separate item keyed by uniqueId to prevent +// race conditions when multiple devices create identities simultaneously. +const IDENTITY_KEY_PREFIX = 'identity-' -// Cleaner for identity storage (array of PhazeUser with uniqueId) +export const hasStoredPhazeIdentity = async ( + account: EdgeAccount +): Promise => { + try { + const itemIds = await account.dataStore.listItemIds(STORE_ID) + return itemIds.some(id => id.startsWith(IDENTITY_KEY_PREFIX)) + } catch { + return false + } +} + +// Cleaner for individual identity storage (PhazeUser fields + uniqueId) interface StoredIdentity extends PhazeUser { uniqueId: string } -const asStoredIdentities = asArray(asPhazeUser) +const asStoredIdentity = asObject({ + id: asNumber, + email: asString, + firstName: asString, + lastName: asString, + userApiKey: asOptional(asString), + balance: asString, + balanceCurrency: asString, + uniqueId: asString +}) /** * Clean a brand object by stripping trailing currency symbols from the name. @@ -69,6 +91,11 @@ export interface PhazeGiftCardProvider { getCache: () => PhazeGiftCardCache getTokens: () => Promise + /** + * Returns cached FX rates. Rates are automatically fetched and cached + * when calling getMarketBrands or other brand-fetching methods. + */ + getCachedFxRates: () => PhazeFxRate[] | null // --------------------------------------------------------------------------- // Brand fetching - smart methods @@ -181,44 +208,55 @@ export interface PhazeGiftCardProvider { } export const makePhazeGiftCardProvider = ( - config: PhazeApiConfig, - account: EdgeAccount + config: PhazeApiConfig ): PhazeGiftCardProvider => { const api = makePhazeApi(config) const cache = makePhazeGiftCardCache() /** * Load all stored identities from encrypted dataStore. - * Multiple identities is an edge case (multi-device before sync completes). + * Each identity is stored as a separate item keyed by uniqueId, preventing + * race conditions when multiple devices create identities simultaneously. */ const loadIdentities = async ( account: EdgeAccount ): Promise => { try { - const text = await account.dataStore.getItem(STORE_ID, IDENTITIES_KEY) - const parsed = asMaybe(asStoredIdentities)(JSON.parse(text)) - // Add uniqueId if missing (migration from older format) - return (parsed ?? []).map((identity, index) => ({ - ...identity, - uniqueId: (identity as StoredIdentity).uniqueId ?? `legacy-${index}` - })) + const itemIds = await account.dataStore.listItemIds(STORE_ID) + const identityKeys = itemIds.filter(id => + id.startsWith(IDENTITY_KEY_PREFIX) + ) + + const identities: StoredIdentity[] = [] + for (const key of identityKeys) { + try { + const text = await account.dataStore.getItem(STORE_ID, key) + const parsed = asMaybe(asStoredIdentity)(JSON.parse(text)) + if (parsed != null) { + identities.push(parsed) + } + } catch { + // Skip malformed identity entries + } + } + + debugLog('phaze', 'Loaded identities:', identities) + return identities } catch { return [] } } /** - * Save identities to encrypted dataStore. + * Save a single identity to encrypted dataStore using its uniqueId as the key. + * This prevents race conditions - each device writes to its own unique key. */ - const saveIdentities = async ( + const saveIdentity = async ( account: EdgeAccount, - identities: StoredIdentity[] + identity: StoredIdentity ): Promise => { - await account.dataStore.setItem( - STORE_ID, - IDENTITIES_KEY, - JSON.stringify(identities) - ) + const key = `${IDENTITY_KEY_PREFIX}${identity.uniqueId}` + await account.dataStore.setItem(STORE_ID, key, JSON.stringify(identity)) } return { @@ -233,6 +271,12 @@ export const makePhazeGiftCardProvider = ( return false } + // Pre-fetch FX rates so they're available for brand filtering and + // minimum amount display. This runs in parallel with identity loading. + const fxRatesPromise = api.ensureFxRates().catch((err: unknown) => { + debugLog('phaze', 'Failed to pre-fetch FX rates:', err) + }) + // Check for existing identities. Uses the first identity found for purchases/orders. // Multiple identities is an edge case (multi-device before sync completes) - // new orders simply go to whichever identity is active. @@ -243,6 +287,7 @@ export const makePhazeGiftCardProvider = ( if (identity.userApiKey != null) { api.setUserApiKey(identity.userApiKey) debugLog('phaze', 'Using existing identity:', identity.uniqueId) + await fxRatesPromise return true } } @@ -272,15 +317,16 @@ export const makePhazeGiftCardProvider = ( return false } - // Save to identities array in encrypted dataStore + // Save identity to its own unique key in encrypted dataStore const newIdentity: StoredIdentity = { ...response.data, uniqueId } - await saveIdentities(account, [...identities, newIdentity]) + await saveIdentity(account, newIdentity) api.setUserApiKey(userApiKey) debugLog('phaze', 'Auto-registered and saved identity:', uniqueId) + await fxRatesPromise return true } catch (err: unknown) { debugLog('phaze', 'Auto-registration failed:', err) @@ -299,6 +345,10 @@ export const makePhazeGiftCardProvider = ( return await api.getTokens() }, + getCachedFxRates: () => { + return api.getCachedFxRates() + }, + // --------------------------------------------------------------------------- // Brand fetching - smart methods // --------------------------------------------------------------------------- diff --git a/src/plugins/gift-cards/phazeGiftCardTypes.ts b/src/plugins/gift-cards/phazeGiftCardTypes.ts index 921fb643739..f0d227ace73 100644 --- a/src/plugins/gift-cards/phazeGiftCardTypes.ts +++ b/src/plugins/gift-cards/phazeGiftCardTypes.ts @@ -77,6 +77,22 @@ export const asPhazeInitOptions = asObject({ }) export type PhazeInitOptions = ReturnType +// --------------------------------------------------------------------------- +// /crypto/exchange-rates (Forex Rates) +// --------------------------------------------------------------------------- + +export const asPhazeFxRate = asObject({ + fromCurrency: asString, + toCurrency: asString, + rate: asNumber +}) +export type PhazeFxRate = ReturnType + +export const asPhazeFxRatesResponse = asObject({ + rates: asArray(asPhazeFxRate) +}) +export type PhazeFxRatesResponse = ReturnType + // --------------------------------------------------------------------------- // /crypto/tokens // --------------------------------------------------------------------------- @@ -84,7 +100,7 @@ export type PhazeInitOptions = ReturnType export const asPhazeToken = asObject({ symbol: asString, name: asString, - chainId: asNumber, + chainId: asOptional(asString), networkType: asString, address: asString, type: asString, diff --git a/src/util/caip19Utils.ts b/src/util/caip19Utils.ts index 27db0385076..09e7e005ac4 100644 --- a/src/util/caip19Utils.ts +++ b/src/util/caip19Utils.ts @@ -57,6 +57,8 @@ const TRON_CHAIN_REF = '0x2b6653dc' * - BIP-122 chains: bip122:{genesisHash}/slip44:{coinType} * - Solana: solana:{genesisHash}/slip44:501 * - Tron: tron:{chainRef}/trc20:{contract} (non-standard, for Phaze compatibility) + * - Monero: monero:mainnet/slip44:128 + * - Zcash: zcash:mainnet/slip44:133 * * Examples: * eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 @@ -65,8 +67,10 @@ const TRON_CHAIN_REF = '0x2b6653dc' * bip122:000000000000000000651ef99cb9fcbe/slip44:145 * solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501 * tron:0x2b6653dc/trc20:TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj + * monero:mainnet/slip44:128 + * zcash:mainnet/slip44:133 * - * See: https://chainagnostic.org/CAIPs/caip-19 + * See: https://standards.chainagnostic.org/CAIPs/caip-19 */ export function edgeAssetToCaip19( account: EdgeAccount, @@ -138,6 +142,16 @@ export function edgeAssetToCaip19( // Native TRX return `tron:${TRON_CHAIN_REF}/slip44:195` } + + // Monero native + if (pluginId === 'monero' && tokenId == null) { + return 'monero:mainnet/slip44:128' + } + + // Zcash native + if (pluginId === 'zcash' && tokenId == null) { + return 'zcash:mainnet/slip44:133' + } } /** @@ -150,6 +164,11 @@ export function edgeAssetToCaip19( * - bip122:{genesisHash}/slip44:{coinType} - BTC, BCH, LTC * - solana:{genesisHash}/slip44:501 - Solana native * - tron:{chainRef}/trc20:{contract} - TRC20 tokens + * - monero:mainnet/slip44:128 - Monero native + * - zcash:mainnet/slip44:133 - Zcash native + * + * Special cases: + * - Polygon precompile 0x...1010 treated as native MATIC (Phaze API workaround) * * Returns undefined if not resolvable/supported. */ @@ -178,6 +197,15 @@ export function caip19ToEdgeAsset( const [assetNs, assetRef] = assetPart.split(':') if (assetNs === 'erc20') { + // Polygon native token precompile - treat as native MATIC + // TODO: Remove once Phaze fixes their CAIP-19 for MATIC (should be slip44:966) + if ( + pluginId === 'polygon' && + assetRef.toLowerCase() === '0x0000000000000000000000000000000000001010' + ) { + return { pluginId, tokenId: null } + } + const tokenId = findTokenIdByNetworkLocation({ account, pluginId, @@ -265,4 +293,21 @@ export function caip19ToEdgeAsset( } } } + + // Monero - native only + if (namespace === 'monero' && reference === 'mainnet') { + const [assetNs] = assetPart.split(':') + if (assetNs === 'slip44') { + return { pluginId: 'monero', tokenId: null } + } + return + } + + // Zcash - native only + if (namespace === 'zcash' && reference === 'mainnet') { + const [assetNs] = assetPart.split(':') + if (assetNs === 'slip44') { + return { pluginId: 'zcash', tokenId: null } + } + } }