diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6bda9efd8..62c51934e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - added: Nym (NYM) wallet support - added: opBNB (BNB) support - added: Register SwapKit V3 as a separate exchange plugin +- added: Gift card account information scene with Get Help access from kebab menu and failed cards +- added: Quote ID display in gift card transaction details with card-level copy +- changed: Distinguish network vs service errors in gift card scenes +- changed: Lock network fee to high priority for gift card purchases - changed: Manage tokens scene saves changes on explicit save instead of live toggling - changed: Unify split wallet scene titles and add chain-specific descriptions for EVM and UTXO splits - fixed: Missing 2-factor approve / deny scene on login diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 82e1977a731..ac7c71d8729 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -49,6 +49,7 @@ import { EdgeHeader } from './navigation/EdgeHeader' import { PluginBackButton } from './navigation/GuiPluginBackButton' import { HeaderBackground } from './navigation/HeaderBackground' import { HeaderTextButton } from './navigation/HeaderTextButton' +import { HeaderTitle } from './navigation/HeaderTitle' import { ParamHeaderTitle } from './navigation/ParamHeaderTitle' import { SideMenuButton } from './navigation/SideMenuButton' import { TransactionDetailsTitle } from './navigation/TransactionDetailsTitle' @@ -96,6 +97,7 @@ import { FioSentRequestDetailsScene as FioSentRequestDetailsSceneComponent } fro import { FioStakingChangeScene as FioStakingChangeSceneComponent } from './scenes/Fio/FioStakingChangeScene' import { FioStakingOverviewScene as FioStakingOverviewSceneComponent } from './scenes/Fio/FioStakingOverviewScene' import { GettingStartedScene } from './scenes/GettingStartedScene' +import { GiftCardAccountInfoScene as GiftCardAccountInfoSceneComponent } from './scenes/GiftCardAccountInfoScene' import { GiftCardListScene as GiftCardListSceneComponent } from './scenes/GiftCardListScene' import { GiftCardMarketScene as GiftCardMarketSceneComponent } from './scenes/GiftCardMarketScene' import { GiftCardPurchaseScene as GiftCardPurchaseSceneComponent } from './scenes/GiftCardPurchaseScene' @@ -244,6 +246,7 @@ const FioStakingChangeScene = ifLoggedIn(FioStakingChangeSceneComponent) const FioStakingOverviewScene = ifLoggedIn(FioStakingOverviewSceneComponent) const GuiPluginViewScene = ifLoggedIn(GuiPluginViewSceneComponent) const HomeScene = ifLoggedIn(HomeSceneComponent) +const GiftCardAccountInfoScene = ifLoggedIn(GiftCardAccountInfoSceneComponent) const GiftCardListScene = ifLoggedIn(GiftCardListSceneComponent) const GiftCardMarketScene = ifLoggedIn(GiftCardMarketSceneComponent) const GiftCardPurchaseScene = ifLoggedIn(GiftCardPurchaseSceneComponent) @@ -951,6 +954,15 @@ const EdgeAppStack: React.FC = () => { name="fioStakingOverview" component={FioStakingOverviewScene} /> + ( + + ) + }} + /> = ({ action }) => { const { card, redemption } = action + const theme = useTheme() + const styles = getStyles(theme) + + // Backward compat: Prior versions stored the quoteId in the orderId field. + // Detect legacy transactions by checking whether the explicit quoteId field + // is populated. If missing, fall back to orderId which held the quoteId. + const quoteId = action.quoteId ?? action.orderId + const productId = action.productId + // orderId is only meaningful when quoteId is separately populated (new format) + const orderId = action.quoteId != null ? action.orderId : undefined const handleRedeemPress = useHandler(() => { if (redemption?.url != null) { @@ -43,30 +63,112 @@ export const GiftCardDetailsCard: React.FC = ({ action }) => { const fiatCurrency = removeIsoPrefix(card.fiatCurrencyCode) const amountDisplay = `${card.fiatAmount} ${fiatCurrency}` + // Build formatted string for card-level copy + const copyText = React.useMemo(() => { + const lines = [ + `${lstrings.gift_card_label}: ${card.name}`, + `${lstrings.string_amount}: ${amountDisplay}`, + `${lstrings.gift_card_quote_id_label}: ${quoteId}` + ] + if (productId != null) { + lines.push(`${lstrings.gift_card_product_id_label}: ${productId}`) + } + if (orderId != null) { + lines.push(`${lstrings.gift_card_order_id_label}: ${orderId}`) + } + if (redemption?.code != null) { + lines.push(`${lstrings.gift_card_security_code}: ${redemption.code}`) + } + return lines.join('\n') + }, [card.name, amountDisplay, quoteId, productId, orderId, redemption?.code]) + + const handleCopyAll = useHandler(() => { + triggerHaptic('impactLight') + Clipboard.setString(copyText) + showToast(lstrings.fragment_copied) + }) + return ( - - - {card.name} - - - - - {redemption?.code != null ? ( - - ) : null} + + + {/* Left column: data rows with dividers */} + + + {card.name} + + + + + + + + + + {productId != null ? ( + <> + + + + ) : null} + + {orderId != null ? ( + <> + + + + ) : null} + + {redemption?.code != null ? ( + <> + + + + ) : null} + + + {/* Right column: card-level copy button */} + + + + + + {/* Redeem row outside the copy layout - has its own chevron */} {redemption?.url != null ? ( - + <> + + + ) : null} ) } + +const getStyles = cacheStyles((theme: Theme) => ({ + cardLayout: { + flexDirection: 'row' as const + }, + dataColumn: { + flex: 1, + flexDirection: 'column' as const + }, + copyColumn: { + justifyContent: 'center' as const, + alignItems: 'center' as const, + paddingHorizontal: theme.rem(0.75) + } +})) diff --git a/src/components/cards/GiftCardDisplayCard.tsx b/src/components/cards/GiftCardDisplayCard.tsx index 9ce751064ba..85b3933ed76 100644 --- a/src/components/cards/GiftCardDisplayCard.tsx +++ b/src/components/cards/GiftCardDisplayCard.tsx @@ -25,17 +25,26 @@ const ZOOM_FACTOR = 1.025 /** * Card display states: - * - pending: Order broadcasted but voucher not yet received + * - confirming: Payment tx broadcasted, awaiting blockchain confirmations + * - pending: Confirmations received, waiting for voucher from Phaze * - available: Voucher received, ready to redeem + * - failed: Order failed or expired * - redeemed: User has marked as redeemed */ -export type GiftCardStatus = 'pending' | 'available' | 'redeemed' +export type GiftCardStatus = + | 'confirming' + | 'pending' + | 'available' + | 'failed' + | 'redeemed' interface Props { order: PhazeDisplayOrder /** Display state of the card */ status: GiftCardStatus onMenuPress: () => void + /** Called when user taps the "Get Help" button on failed cards */ + onGetHelpPress?: () => void /** Called when user taps redeem and completes viewing (webview closes) */ onRedeemComplete?: () => void } @@ -46,16 +55,16 @@ interface Props { * and redemption link overlaid. */ export const GiftCardDisplayCard: React.FC = props => { - const { order, status, onMenuPress, onRedeemComplete } = props + const { order, status, onMenuPress, onGetHelpPress, onRedeemComplete } = props const theme = useTheme() const styles = getStyles(theme) const code = order.vouchers?.[0]?.code const redemptionUrl = order.vouchers?.[0]?.url - // Redeemed cards are dimmed; pending cards use shimmer overlay instead + // Redeemed and failed cards are dimmed; pending/confirming use shimmer const cardContainerStyle = - status === 'redeemed' + status === 'redeemed' || status === 'failed' ? [styles.cardContainer, styles.dimmedCard] : styles.cardContainer @@ -136,10 +145,28 @@ export const GiftCardDisplayCard: React.FC = props => { )} - {status === 'pending' ? ( + {status === 'confirming' ? ( + + {lstrings.gift_card_confirming} + + ) : status === 'pending' ? ( {lstrings.gift_card_pending} + ) : status === 'failed' ? ( + + + {lstrings.gift_card_get_help} + + + ) : status === 'available' && redemptionUrl != null ? ( = props => { - {/* Shimmer overlay for pending state */} - + {/* Shimmer overlay for confirming/pending states */} + ) } diff --git a/src/components/modals/GiftCardMenuModal.tsx b/src/components/modals/GiftCardMenuModal.tsx index a2b2374c230..ac53858e8f0 100644 --- a/src/components/modals/GiftCardMenuModal.tsx +++ b/src/components/modals/GiftCardMenuModal.tsx @@ -2,14 +2,16 @@ import type { EdgeCurrencyWallet, EdgeTransaction } from 'edge-core-js' import * as React from 'react' import { ActivityIndicator, View } from 'react-native' import type { AirshipBridge } from 'react-native-airship' +import { sprintf } from 'sprintf-js' import { useHandler } from '../../hooks/useHandler' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' import type { PhazeDisplayOrder } from '../../plugins/gift-cards/phazeGiftCardTypes' import { useSelector } from '../../types/reactRedux' -import { ArrowRightIcon, CheckIcon } from '../icons/ThemedIcons' +import { ArrowRightIcon, CheckIcon, QuestionIcon } from '../icons/ThemedIcons' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' import { SelectableRow } from '../themed/SelectableRow' import { EdgeModal } from './EdgeModal' @@ -17,6 +19,7 @@ export type GiftCardMenuResult = | { type: 'goToTransaction'; transaction: EdgeTransaction; walletId: string } | { type: 'markAsRedeemed' } | { type: 'unmarkAsRedeemed' } + | { type: 'getHelp' } | undefined interface Props { @@ -91,6 +94,10 @@ export const GiftCardMenuModal: React.FC = props => { }) }) + const handleGetHelp = useHandler(() => { + bridge.resolve({ type: 'getHelp' }) + }) + // Determine "Go to Transaction" state const hasTx = transaction != null const canNavigate = hasTx && order.walletId != null @@ -105,6 +112,9 @@ export const GiftCardMenuModal: React.FC = props => { return ( + + {sprintf(lstrings.gift_card_quote_id_label_1s, order.quoteId)} + = props => { } /> + + + + } + /> ) } const getStyles = cacheStyles((theme: Theme) => ({ + quoteIdText: { + fontSize: theme.rem(0.75), + color: theme.secondaryText, + marginHorizontal: theme.rem(0.5), + marginBottom: theme.rem(0.5) + }, iconContainer: { width: theme.rem(2.5), height: theme.rem(2.5), diff --git a/src/components/scenes/GiftCardAccountInfoScene.tsx b/src/components/scenes/GiftCardAccountInfoScene.tsx new file mode 100644 index 00000000000..0fdd990e829 --- /dev/null +++ b/src/components/scenes/GiftCardAccountInfoScene.tsx @@ -0,0 +1,134 @@ +import * as React from 'react' +import { View } from 'react-native' + +import { ENV } from '../../env' +import { useGiftCardProvider } from '../../hooks/useGiftCardProvider' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import type { PhazeUser } from '../../plugins/gift-cards/phazeGiftCardTypes' +import { useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { EdgeButton } from '../buttons/EdgeButton' +import { EdgeCard } from '../cards/EdgeCard' +import { SceneWrapper } from '../common/SceneWrapper' +import { ConfirmContinueModal } from '../modals/ConfirmContinueModal' +import { EdgeRow } from '../rows/EdgeRow' +import { Airship, showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { Paragraph } from '../themed/EdgeText' + +export interface GiftCardAccountInfoParams { + quoteId?: string +} + +/** + * Displays Phaze gift card account credentials behind a confirmation wall. + * Accessible from the kebab menu (with quoteId context) or developer settings. + */ +export const GiftCardAccountInfoScene: React.FC< + EdgeAppSceneProps<'giftCardAccountInfo'> +> = props => { + const { route } = props + const { quoteId } = route.params + const theme = useTheme() + const styles = getStyles(theme) + + const account = useSelector(state => state.core.account) + + // Provider for identity lookup + const phazeConfig = (ENV.PLUGIN_API_KEYS as Record) + ?.phaze as { apiKey?: string; baseUrl?: string } | undefined + const { provider } = useGiftCardProvider({ + account, + apiKey: phazeConfig?.apiKey ?? '', + baseUrl: phazeConfig?.baseUrl ?? '' + }) + + const [isRevealed, setIsRevealed] = React.useState(false) + const [identities, setIdentities] = React.useState([]) + + // Load identities only after user confirms the reveal modal + React.useEffect(() => { + if (!isRevealed || provider == null) return + provider + .listIdentities(account) + .then(setIdentities) + .catch((err: unknown) => { + showError(err) + }) + }, [isRevealed, account, provider]) + + const handleReveal = useHandler(async () => { + const confirmed = await Airship.show(bridge => ( + true} + /> + )) + if (confirmed) { + setIsRevealed(true) + } + }) + + return ( + + + {lstrings.gift_card_account_info_body} + + {quoteId != null ? ( + + + + ) : null} + + {!isRevealed ? ( + + + + ) : ( + + {identities.map(identity => ( + + + + + ))} + + )} + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + padding: theme.rem(0.5) + }, + containerFill: { + flex: 1 + }, + buttonContainer: { + flex: 1, + alignItems: 'center' as const, + justifyContent: 'center' as const + } +})) diff --git a/src/components/scenes/GiftCardListScene.tsx b/src/components/scenes/GiftCardListScene.tsx index 6e1b331e11f..a5431de221a 100644 --- a/src/components/scenes/GiftCardListScene.tsx +++ b/src/components/scenes/GiftCardListScene.tsx @@ -27,6 +27,7 @@ import type { EdgeAppSceneProps } from '../../types/routerTypes' import { debugLog } from '../../util/logger' import { makePeriodicTask } from '../../util/PeriodicTask' import { SceneButtons } from '../buttons/SceneButtons' +import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' import { GiftCardDisplayCard, @@ -79,11 +80,13 @@ export const GiftCardListScene: React.FC = (props: Props) => { const dispatch = useDispatch() const account = useSelector(state => state.core.account) + const isConnected = useSelector(state => state.network.isConnected) const { countryCode, stateProvinceCode } = useSelector( state => state.ui.settings ) - // Clear cache if account changed (prevents data leaking between users) + // Clear module-level cache if account changed (prevents data leaking + // between users). Runs before useState so initializers see empty caches. if (cachedAccountId !== account.id) { clearOrderCache() cachedAccountId = account.id @@ -109,6 +112,20 @@ export const GiftCardListScene: React.FC = (props: Props) => { React.useState(cachedRedeemedOrders) // Only show loading on very first load; subsequent mounts refresh silently const [isLoading, setIsLoading] = React.useState(!hasLoadedOnce) + // Error flag for API load failures (informational only, auto-clears on success) + const [loadError, setLoadError] = React.useState(false) + + // Reset React state on account switch. Module-level caches are already + // cleared above (before useState), but useState initializers only run on + // mount, so re-renders with a different account keep stale values. + const prevAccountIdRef = React.useRef(account.id) + if (prevAccountIdRef.current !== account.id) { + prevAccountIdRef.current = account.id + setActiveOrders([]) + setRedeemedOrders([]) + setIsLoading(true) + setLoadError(false) + } // Footer height for floating button const [footerHeight, setFooterHeight] = React.useState() @@ -169,11 +186,12 @@ export const GiftCardListScene: React.FC = (props: Props) => { } applyAugments(allOrders) + setLoadError(false) return didFetchBrands } catch (err: unknown) { debugLog('phaze', 'Error loading orders:', err) - setActiveOrders([]) - setRedeemedOrders([]) + // Keep existing cached orders visible — don't clear state on error + setLoadError(true) return false } finally { setIsLoading(false) @@ -200,9 +218,17 @@ export const GiftCardListScene: React.FC = (props: Props) => { ) // Reload orders when scene comes into focus, then poll periodically - // to detect when pending orders receive their vouchers + // to detect when pending orders receive their vouchers. + // Polling pauses when offline and auto-resumes when connectivity returns. useFocusEffect( React.useCallback(() => { + // Don't attempt API calls while offline. Clear loading state so the + // scene can show the offline error or empty state instead of spinning. + if (!isConnected) { + setIsLoading(false) + return + } + // First load: fetch both brands and orders // Subsequent loads: only fetch orders (brands change infrequently) const includeBrands = !hasFetchedBrands @@ -225,7 +251,7 @@ export const GiftCardListScene: React.FC = (props: Props) => { return () => { task.stop() } - }, [loadOrdersFromApi]) + }, [isConnected, loadOrdersFromApi]) ) const handlePurchaseNew = useHandler(async () => { @@ -276,6 +302,10 @@ export const GiftCardListScene: React.FC = (props: Props) => { await saveOrderAugment(account, order.quoteId, { redeemedDate: undefined }) + } else if (result.type === 'getHelp') { + navigation.navigate('giftCardAccountInfo', { + quoteId: order.quoteId + }) } } ) @@ -314,15 +344,24 @@ export const GiftCardListScene: React.FC = (props: Props) => { /** * Derive card status from order data: - * - pending: Broadcasted but no voucher yet + * - confirming: Payment tx sent, awaiting blockchain confirmations + * - pending: Confirmations received, waiting for voucher * - available: Has voucher, not yet redeemed + * - failed: Order failed or expired * - redeemed: User marked as redeemed */ const getCardStatus = React.useCallback( (order: PhazeDisplayOrder): GiftCardStatus => { if (order.redeemedDate != null) return 'redeemed' - if (order.vouchers.length === 0) return 'pending' - return 'available' + if (order.status === 'failed' || order.status === 'expired') + return 'failed' + if (order.vouchers.length > 0) return 'available' + // Phaze API status 'processing' means payment confirmed, generating + // vouchers. Otherwise txid present means tx broadcast, awaiting + // Phaze confirmation. + if (order.status === 'processing') return 'pending' + if (order.txid != null) return 'confirming' + return 'pending' }, [] ) @@ -366,6 +405,15 @@ export const GiftCardListScene: React.FC = (props: Props) => { onMenuPress={() => { handleMenuPress(order, false).catch(() => {}) }} + onGetHelpPress={ + status !== 'failed' + ? undefined + : () => { + navigation.navigate('giftCardAccountInfo', { + quoteId: order.quoteId + }) + } + } onRedeemComplete={ status !== 'available' ? undefined @@ -390,19 +438,25 @@ export const GiftCardListScene: React.FC = (props: Props) => { ) }, - [handleFooterLayoutHeight, handlePurchaseNew] + [handleFooterLayoutHeight, handlePurchaseNew, isConnected, loadError] ) const hasNoCards = activeOrders.length === 0 && redeemedOrders.length === 0 return ( - + {({ insetStyle, undoInsetStyle }) => ( = (props: Props) => { > {isLoading ? ( + ) : hasNoCards && loadError ? ( + + {isConnected + ? lstrings.gift_card_service_error + : lstrings.gift_card_network_error} + ) : hasNoCards ? ( {lstrings.gift_card_list_no_cards} ) : ( <> + {/* Error banner when data exists but refresh failed */} + {loadError ? ( + + ) : null} + {/* Active Cards Section */} {activeOrders.map(order => renderCard(order))} diff --git a/src/components/scenes/GiftCardMarketScene.tsx b/src/components/scenes/GiftCardMarketScene.tsx index cec9430097e..b34437fe8eb 100644 --- a/src/components/scenes/GiftCardMarketScene.tsx +++ b/src/components/scenes/GiftCardMarketScene.tsx @@ -23,6 +23,7 @@ import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps } from '../../types/routerTypes' import { debugLog } from '../../util/logger' import { CountryButton } from '../buttons/RegionButton' +import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' import { GiftCardTile } from '../cards/GiftCardTile' import { CircularBrandIcon } from '../common/CircularBrandIcon' @@ -107,6 +108,7 @@ export const GiftCardMarketScene: React.FC = props => { // Get user's current country code (specific selector to avoid re-renders on other setting changes) const countryCode = useSelector(state => state.ui.settings.countryCode) const account = useSelector(state => state.core.account) + const isConnected = useSelector(state => state.network.isConnected) // Provider (requires API key configured) const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze @@ -238,8 +240,10 @@ export const GiftCardMarketScene: React.FC = props => { prevCountryCodeRef.current = countryCode }, [countryCode]) - // Fetch brands. Initial data comes from synchronous cache read in useState - const { data: apiBrands } = useQuery({ + // Fetch brands. Initial data comes from synchronous cache read in useState. + // Adding isConnected to enabled so the query auto-retries when connectivity + // returns after being offline. + const { data: apiBrands, isError: isBrandsError } = useQuery({ queryKey: ['phazeBrands', countryCode, isReady], queryFn: async () => { if (provider == null || cache == null) { @@ -279,7 +283,7 @@ export const GiftCardMarketScene: React.FC = props => { return allBrands }, - enabled: isReady && provider != null && countryCode !== '', + enabled: isConnected && isReady && provider != null && countryCode !== '', staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, retry: 1 @@ -489,7 +493,16 @@ export const GiftCardMarketScene: React.FC = props => { headerTitle={lstrings.title_gift_card_market} headerTitleChildren={} > - {items == null ? ( + {items == null && isBrandsError ? ( + + ) : items == null ? ( ) : ( <> diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx index 640bf3b9967..edb6b9d22ed 100644 --- a/src/components/scenes/GiftCardPurchaseScene.tsx +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -91,11 +91,16 @@ export const GiftCardPurchaseScene: React.FC = props => { const { width: screenWidth } = useWindowDimensions() const account = useSelector(state => state.core.account) + const isConnected = useSelector(state => state.network.isConnected) // Provider (requires API key configured) const phazeConfig = (ENV.PLUGIN_API_KEYS as Record) ?.phaze as { apiKey?: string; baseUrl?: string } | undefined - const { provider, isReady } = useGiftCardProvider({ + const { + provider, + isReady, + isError: isProviderError + } = useGiftCardProvider({ account, apiKey: phazeConfig?.apiKey ?? '', baseUrl: phazeConfig?.baseUrl ?? '' @@ -131,7 +136,7 @@ export const GiftCardPurchaseScene: React.FC = props => { const [error, setError] = React.useState(null) // Fetch allowed tokens from Phaze API - const { data: tokenQueryResult } = useQuery({ + const { data: tokenQueryResult, refetch: refetchTokens } = useQuery({ queryKey: ['phazeTokens', account?.id, isReady], queryFn: async () => { if (provider == null) { @@ -351,7 +356,12 @@ export const GiftCardPurchaseScene: React.FC = props => { }) const handleNextPress = useHandler(async () => { - if (selectedAmount == null || provider == null || !isReady) { + if ( + selectedAmount == null || + provider == null || + !isReady || + isCreatingOrder + ) { return } @@ -360,13 +370,34 @@ export const GiftCardPurchaseScene: React.FC = props => { return } + // Ensure token data is available before proceeding. If a previous fetch + // failed, attempt a fresh fetch so the user isn't stuck on a dead-end. + let resolvedAssets = allowedAssets + if (resolvedAssets == null) { + setIsCreatingOrder(true) + try { + const { data } = await refetchTokens() + resolvedAssets = data?.assets + } catch (err: unknown) { + showError(err) + return + } finally { + setIsCreatingOrder(false) + } + } + + if (resolvedAssets == null || resolvedAssets.length === 0) { + showError(new Error(lstrings.gift_card_no_supported_assets)) + return + } + // Show wallet selection modal with only supported assets const walletResult = await Airship.show(bridge => ( )) @@ -450,6 +481,11 @@ export const GiftCardPurchaseScene: React.FC = props => { quoteExpiry: orderResponse.quoteExpiry }) + // Ensure we have a payment address before navigating to send + if (orderResponse.deliveryAddress === '') { + throw new Error(lstrings.gift_card_no_payment_address) + } + // Store the order for the onDone callback pendingOrderRef.current = orderResponse @@ -476,6 +512,7 @@ export const GiftCardPurchaseScene: React.FC = props => { tokenId, spendInfo: { tokenId, + networkFeeOption: 'high', spendTargets: [ { publicAddress: orderResponse.deliveryAddress, @@ -491,6 +528,7 @@ export const GiftCardPurchaseScene: React.FC = props => { lockTilesMap: { address: true, amount: true, + fee: true, wallet: true }, hiddenFeaturesMap: { @@ -534,9 +572,12 @@ export const GiftCardPurchaseScene: React.FC = props => { const order = pendingOrderRef.current // Save the gift card action to the transaction (synced via edge-core) + const cartItem = order.cart[0] const savedAction: EdgeTxActionGiftCard = { actionType: 'giftCard', - orderId: order.quoteId, + orderId: cartItem.orderId, + quoteId: order.quoteId, + productId: cartItem.productId, provider: { providerId: 'phaze', displayName: 'Phaze' @@ -688,7 +729,7 @@ export const GiftCardPurchaseScene: React.FC = props => { primary={{ label: lstrings.string_next_capitalized, onPress: handleNextPress, - disabled: !isAmountValid || isCreatingOrder, + disabled: !isAmountValid || isCreatingOrder || isProviderError, spinner: isCreatingOrder }} /> @@ -782,8 +823,17 @@ export const GiftCardPurchaseScene: React.FC = props => { )} - {/* Warnings/Errors - product unavailable takes precedence */} - {productUnavailable ? ( + {/* Warnings/Errors - provider error, product unavailable, minimum, or general */} + {isProviderError ? ( + + ) : productUnavailable ? ( = props => { onPress={handleSelectTheme} /> )} + {developerModeOn && ( + { + navigation.navigate('giftCardAccountInfo', {}) + }} + /> + )} )} diff --git a/src/hooks/useGiftCardProvider.ts b/src/hooks/useGiftCardProvider.ts index fb98d2650f9..2ec1c34d6c1 100644 --- a/src/hooks/useGiftCardProvider.ts +++ b/src/hooks/useGiftCardProvider.ts @@ -16,10 +16,17 @@ interface UseGiftCardProviderOptions { export function useGiftCardProvider(options: UseGiftCardProviderOptions): { provider: PhazeGiftCardProvider | null isReady: boolean + isError: boolean + error: Error | null } { const { account, apiKey, baseUrl, publicKey } = options - const { data: provider = null, isSuccess } = useQuery({ + const { + data: provider = null, + isSuccess, + isError, + error + } = useQuery({ queryKey: ['phazeProvider', account?.id, apiKey, baseUrl], queryFn: async () => { const instance = makePhazeGiftCardProvider({ @@ -36,5 +43,5 @@ export function useGiftCardProvider(options: UseGiftCardProviderOptions): { gcTime: 300000 }) - return { provider, isReady: isSuccess } + return { provider, isReady: isSuccess, isError, error } } diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index e274ddf1b8f..c2a99dc8454 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1933,8 +1933,16 @@ const strings = { gift_card_slider_terms: 'By sliding to confirm, you are agreeing to the {{link}}gift card terms and conditions{{/link}}.', gift_card_more_options: 'Browse more gift cards', + gift_card_no_payment_address: + 'Order created but no payment address was returned. Please try again.', gift_card_network_error: 'Unable to load gift cards. Please check your network connection.', + gift_card_service_error: + 'Gift card service is temporarily unavailable. Please try again later.', + gift_card_refresh_error: + 'Unable to refresh. Card information may not be up to date.', + gift_card_refresh_service_error: + 'Gift card service unavailable. Card information may not be up to date.', gift_card_minimum_warning_title: 'Below Minimum', gift_card_minimum_warning_header_1s: 'The selected amount is below the minimum for %1$s.', @@ -1945,13 +1953,30 @@ const strings = { gift_card_redeemed_cards: 'Redeemed Cards', gift_card_unmark_as_redeemed: 'Unmark as Redeemed', gift_card_active_cards: 'Active Cards', + gift_card_confirming: 'Awaiting Payment Confirmations...', + gift_card_failed: 'Failed', + gift_card_get_help: 'Get Help', + gift_card_account_info_title: 'Gift Card Account Information', + gift_card_account_info_body: + 'The information below is your customer credentials used with our Phaze gift card provider. Share this with our support team to troubleshoot gift card purchases.', + gift_card_account_info_reveal_button: 'View Phaze Account Info', + gift_card_account_info_warning: + 'Anyone with access to this information may be able to redeem your unredeemed gift cards. Do not share this publicly.', + gift_card_account_info_email: 'Account Email', + gift_card_account_info_user_id: 'Phaze User ID', 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_order_id_label: 'Order ID', + gift_card_product_id_label: 'Product ID', + gift_card_quote_id_label: 'Quote ID', + gift_card_quote_id_label_1s: 'QuoteID: %1$s', 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.', + gift_card_no_supported_assets: + 'No supported payment methods available. Please try again later.', // #endregion diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index d3ce2839f27..4a2146ffd82 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1506,7 +1506,11 @@ "gift_card_terms_and_conditions_body": "By purchasing a gift card, you are agreeing to the terms and conditions that apply. {{link}}Read the terms and conditions here.{{/link}}", "gift_card_slider_terms": "By sliding to confirm, you are agreeing to the {{link}}gift card terms and conditions{{/link}}.", "gift_card_more_options": "Browse more gift cards", + "gift_card_no_payment_address": "Order created but no payment address was returned. Please try again.", "gift_card_network_error": "Unable to load gift cards. Please check your network connection.", + "gift_card_service_error": "Gift card service is temporarily unavailable. Please try again later.", + "gift_card_refresh_error": "Unable to refresh. Card information may not be up to date.", + "gift_card_refresh_service_error": "Gift card service unavailable. Card information may not be up to date.", "gift_card_minimum_warning_title": "Below Minimum", "gift_card_minimum_warning_header_1s": "The selected amount is below the minimum for %1$s.", "gift_card_minimum_warning_footer_1s": "Please select a different payment method or increase your purchase amount to at least %1$s.", @@ -1514,11 +1518,25 @@ "gift_card_redeemed_cards": "Redeemed Cards", "gift_card_unmark_as_redeemed": "Unmark as Redeemed", "gift_card_active_cards": "Active Cards", + "gift_card_confirming": "Awaiting Payment Confirmations...", + "gift_card_failed": "Failed", + "gift_card_get_help": "Get Help", + "gift_card_account_info_title": "Gift Card Account Information", + "gift_card_account_info_body": "The information below is your customer credentials used with our Phaze gift card provider. Share this with our support team to troubleshoot gift card purchases.", + "gift_card_account_info_reveal_button": "View Phaze Account Info", + "gift_card_account_info_warning": "Anyone with access to this information may be able to redeem your unredeemed gift cards. Do not share this publicly.", + "gift_card_account_info_email": "Account Email", + "gift_card_account_info_user_id": "Phaze User ID", "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_order_id_label": "Order ID", + "gift_card_product_id_label": "Product ID", + "gift_card_quote_id_label": "Quote ID", + "gift_card_quote_id_label_1s": "QuoteID: %1$s", "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.", + "gift_card_no_supported_assets": "No supported payment methods available. Please try again later.", "countdown_hours": ">%sh", "countdown_minutes_seconds": "%sm %ss", "countdown_seconds": "%ss", diff --git a/src/plugins/gift-cards/phazeGiftCardTypes.ts b/src/plugins/gift-cards/phazeGiftCardTypes.ts index f0d227ace73..912ad0df1d8 100644 --- a/src/plugins/gift-cards/phazeGiftCardTypes.ts +++ b/src/plugins/gift-cards/phazeGiftCardTypes.ts @@ -3,10 +3,12 @@ import { asBoolean, asDate, asEither, + asMaybe, asNumber, asObject, asOptional, asString, + asUnknown, asValue, type Cleaner } from 'cleaners' @@ -50,6 +52,21 @@ const asNumberOrNumericString: Cleaner = asEither( } ) +/** + * Tolerant array cleaner: skips items that fail cleaning instead of throwing. + */ +const asTolerantArray = + (cleaner: Cleaner): Cleaner => + (raw: unknown): T[] => { + const arr = asArray(asUnknown)(raw) + const out: T[] = [] + for (const item of arr) { + const cleaned = asMaybe(cleaner)(item) + if (cleaned != null) out.push(cleaned) + } + return out + } + /** * Cleaner for denominations array that deduplicates values. * The Phaze API sometimes returns duplicate denominations. @@ -192,7 +209,7 @@ export type PhazeGiftCardBrand = ReturnType export const asPhazeGiftCardsResponse = asObject({ country: asString, countryCode: asString, - brands: asArray(asPhazeGiftCardBrand), + brands: asTolerantArray(asPhazeGiftCardBrand), currentPage: asNumber, totalCount: asNumber }) @@ -239,7 +256,8 @@ export const asPhazeOrderStatusValue = asEither( asValue('complete'), asValue('pending'), asValue('processing'), - asValue('expired') + asValue('expired'), + asValue('failed') ) export type PhazeOrderStatusValue = ReturnType @@ -247,7 +265,7 @@ export const asPhazeCreateOrderResponse = asObject({ externalUserId: asString, quoteId: asString, status: asPhazeOrderStatusValue, - deliveryAddress: asString, + deliveryAddress: asOptional(asString, ''), tokenIdentifier: asString, quantity: asNumber, amountInUSD: asNumber, @@ -297,7 +315,7 @@ export const asPhazeCompletedCartItem = asObject({ status: asOptional(asPhazeCartItemStatus), faceValue: asOptional(asNumber), voucherCurrency: asOptional(asString), - vouchers: asOptional(asArray(asPhazeVoucher)), + vouchers: asOptional(asTolerantArray(asPhazeVoucher)), // Additional fields we may use externalUserId: asOptional(asString), voucherDiscountPercent: asOptional(asNumber), @@ -315,7 +333,7 @@ export const asPhazeOrderStatusItem = asObject({ externalUserId: asString, quoteId: asString, status: asPhazeOrderStatusValue, - deliveryAddress: asString, + deliveryAddress: asOptional(asString, ''), tokenIdentifier: asString, quantity: asNumber, amountInUSD: asNumber, @@ -326,7 +344,7 @@ export const asPhazeOrderStatusItem = asObject({ export type PhazeOrderStatusItem = ReturnType export const asPhazeOrderStatusResponse = asObject({ - data: asArray(asPhazeOrderStatusItem), + data: asTolerantArray(asPhazeOrderStatusItem), totalCount: asNumber }) export type PhazeOrderStatusResponse = ReturnType< diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index e3ec4b65425..9c53eddc19f 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -35,6 +35,7 @@ import type { FioSentRequestDetailsParams } from '../components/scenes/Fio/FioSe import type { FioStakingChangeParams } from '../components/scenes/Fio/FioStakingChangeScene' import type { FioStakingOverviewParams } from '../components/scenes/Fio/FioStakingOverviewScene' import type { GettingStartedParams } from '../components/scenes/GettingStartedScene' +import type { GiftCardAccountInfoParams } from '../components/scenes/GiftCardAccountInfoScene' import type { GiftCardPurchaseParams } from '../components/scenes/GiftCardPurchaseScene' import type { GuiPluginListParams } from '../components/scenes/GuiPluginListScene' import type { PluginViewParams } from '../components/scenes/GuiPluginViewScene' @@ -206,6 +207,7 @@ export type EdgeAppStackParamList = {} & { fioSentRequestDetails: FioSentRequestDetailsParams fioStakingChange: FioStakingChangeParams fioStakingOverview: FioStakingOverviewParams + giftCardAccountInfo: GiftCardAccountInfoParams giftCardList: undefined giftCardMarket: undefined giftCardPurchase: GiftCardPurchaseParams diff --git a/yarn.lock b/yarn.lock index 8e221c0d71f..274b28906bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2120,9 +2120,9 @@ randombytes "^2.1.0" text-encoding "0.7.0" -"@fioprotocol/fiosdk@git+https://github.com/EdgeApp/fiosdk_typescript.git#47df5818442edec69b735d6a723747aad33b8d71": +"@fioprotocol/fiosdk@https://github.com/EdgeApp/fiosdk_typescript.git#47df5818442edec69b735d6a723747aad33b8d71": version "1.9.0" - resolved "git+https://github.com/EdgeApp/fiosdk_typescript.git#47df5818442edec69b735d6a723747aad33b8d71" + resolved "https://github.com/EdgeApp/fiosdk_typescript.git#47df5818442edec69b735d6a723747aad33b8d71" dependencies: "@fioprotocol/fiojs" "1.0.1" "@types/text-encoding" "0.0.35"