From e3b1615d8c778a0c59596faf2ace75459bd80a25 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Wed, 11 Feb 2026 15:47:42 -0800 Subject: [PATCH 01/12] Make Phaze order cleaners fault-tolerant Add asTolerantArray helper that skips malformed items instead of throwing on the entire array. Apply it to brand lists and voucher arrays so a single bad entry doesn't break the whole UI. Also add 'failed' to recognized order statuses and make deliveryAddress optional with an empty-string default. --- src/plugins/gift-cards/phazeGiftCardTypes.ts | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/plugins/gift-cards/phazeGiftCardTypes.ts b/src/plugins/gift-cards/phazeGiftCardTypes.ts index f0d227ace73..f134dd50d4d 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,22 @@ const asNumberOrNumericString: Cleaner = asEither( } ) +/** + * Healing array cleaner: skips items that fail cleaning instead of throwing. + * Matches the asHealingArray pattern from edge-server-tools. + */ +const asHealingArray = + (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 +210,7 @@ export type PhazeGiftCardBrand = ReturnType export const asPhazeGiftCardsResponse = asObject({ country: asString, countryCode: asString, - brands: asArray(asPhazeGiftCardBrand), + brands: asHealingArray(asPhazeGiftCardBrand), currentPage: asNumber, totalCount: asNumber }) @@ -239,7 +257,8 @@ export const asPhazeOrderStatusValue = asEither( asValue('complete'), asValue('pending'), asValue('processing'), - asValue('expired') + asValue('expired'), + asValue('failed') ) export type PhazeOrderStatusValue = ReturnType @@ -247,7 +266,7 @@ export const asPhazeCreateOrderResponse = asObject({ externalUserId: asString, quoteId: asString, status: asPhazeOrderStatusValue, - deliveryAddress: asString, + deliveryAddress: asOptional(asString, ''), tokenIdentifier: asString, quantity: asNumber, amountInUSD: asNumber, @@ -297,7 +316,7 @@ export const asPhazeCompletedCartItem = asObject({ status: asOptional(asPhazeCartItemStatus), faceValue: asOptional(asNumber), voucherCurrency: asOptional(asString), - vouchers: asOptional(asArray(asPhazeVoucher)), + vouchers: asOptional(asHealingArray(asPhazeVoucher)), // Additional fields we may use externalUserId: asOptional(asString), voucherDiscountPercent: asOptional(asNumber), @@ -315,7 +334,7 @@ export const asPhazeOrderStatusItem = asObject({ externalUserId: asString, quoteId: asString, status: asPhazeOrderStatusValue, - deliveryAddress: asString, + deliveryAddress: asOptional(asString, ''), tokenIdentifier: asString, quantity: asNumber, amountInUSD: asNumber, @@ -326,7 +345,7 @@ export const asPhazeOrderStatusItem = asObject({ export type PhazeOrderStatusItem = ReturnType export const asPhazeOrderStatusResponse = asObject({ - data: asArray(asPhazeOrderStatusItem), + data: asHealingArray(asPhazeOrderStatusItem), totalCount: asNumber }) export type PhazeOrderStatusResponse = ReturnType< From 810d44a22c418b5a31bfa203eeb36ed0bf33180d Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Wed, 11 Feb 2026 15:48:42 -0800 Subject: [PATCH 02/12] Improve error resilience across gift card scenes Expose isError/error from useGiftCardProvider so scenes can detect provider initialization failures. On GiftCardListScene, stop clearing orders on API errors (keep last-known-good data visible), pause polling when offline and auto-resume on reconnect, and show an informational warning banner that auto-clears on success. On GiftCardMarketScene, gate the brand query on network connectivity so it auto-retries when online, and show a warning instead of an infinite loader when the initial fetch fails. On GiftCardPurchaseScene, guard against empty delivery addresses and show a warning when the provider fails to initialize. --- src/components/scenes/GiftCardListScene.tsx | 216 ++++++------------ src/components/scenes/GiftCardMarketScene.tsx | 21 +- .../scenes/GiftCardPurchaseScene.tsx | 51 ++++- src/hooks/useGiftCardProvider.ts | 11 +- src/locales/en_US.ts | 10 + src/locales/strings/enUS.json | 5 + 6 files changed, 161 insertions(+), 153 deletions(-) diff --git a/src/components/scenes/GiftCardListScene.tsx b/src/components/scenes/GiftCardListScene.tsx index 6e1b331e11f..9fad6b03859 100644 --- a/src/components/scenes/GiftCardListScene.tsx +++ b/src/components/scenes/GiftCardListScene.tsx @@ -1,4 +1,5 @@ -import { useFocusEffect } from '@react-navigation/native' +import { useIsFocused } from '@react-navigation/native' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { Linking, ScrollView, View } from 'react-native' @@ -17,16 +18,13 @@ import { saveOrderAugment, usePhazeOrderAugments } from '../../plugins/gift-cards/phazeGiftCardOrderStore' -import type { - PhazeDisplayOrder, - PhazeOrderStatusItem -} from '../../plugins/gift-cards/phazeGiftCardTypes' +import type { PhazeDisplayOrder } from '../../plugins/gift-cards/phazeGiftCardTypes' import type { FooterRender } from '../../state/SceneFooterState' import { useDispatch, useSelector } from '../../types/reactRedux' 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, @@ -49,27 +47,7 @@ import { SceneFooterWrapper } from '../themed/SceneFooterWrapper' interface Props extends EdgeAppSceneProps<'giftCardList'> {} -/** - * Module-level cache to persist order data across scene mounts. - * Keyed by account ID to prevent data leaking between user sessions. - */ -let cachedAccountId: string | null = null -let cachedApiOrders: PhazeOrderStatusItem[] = [] -let cachedActiveOrders: PhazeDisplayOrder[] = [] -let cachedRedeemedOrders: PhazeDisplayOrder[] = [] -/** Necessary to ensure if the user truly has zero gift cards, we don't show loading every time. */ -let hasLoadedOnce = false -/** Track if brands have been loaded at least once (brands change infrequently) */ -let hasFetchedBrands = false - -/** Clear module-level cache (called when account changes) */ -const clearOrderCache = (): void => { - cachedApiOrders = [] - cachedActiveOrders = [] - cachedRedeemedOrders = [] - hasLoadedOnce = false - hasFetchedBrands = false -} +const POLL_INTERVAL_MS = 10000 /** List of purchased gift cards */ export const GiftCardListScene: React.FC = (props: Props) => { @@ -79,15 +57,13 @@ export const GiftCardListScene: React.FC = (props: Props) => { const dispatch = useDispatch() const account = useSelector(state => state.core.account) - const { countryCode, stateProvinceCode } = useSelector( - state => state.ui.settings + const isConnected = useSelector(state => state.network.isConnected) + const countryCode = useSelector(state => state.ui.settings.countryCode) + const stateProvinceCode = useSelector( + state => state.ui.settings.stateProvinceCode ) - // Clear cache if account changed (prevents data leaking between users) - if (cachedAccountId !== account.id) { - clearOrderCache() - cachedAccountId = account.id - } + const isFocused = useIsFocused() // Get Phaze provider for API access const phazeConfig = (ENV.PLUGIN_API_KEYS as Record) @@ -101,17 +77,28 @@ export const GiftCardListScene: React.FC = (props: Props) => { // Get augments from synced storage const augments = usePhazeOrderAugments() - // Orders from Phaze API merged with augments - separate active and redeemed - // Initialize from module-level cache to avoid flash of empty state on re-mount - const [activeOrders, setActiveOrders] = - React.useState(cachedActiveOrders) - const [redeemedOrders, setRedeemedOrders] = - React.useState(cachedRedeemedOrders) - // Only show loading on very first load; subsequent mounts refresh silently - const [isLoading, setIsLoading] = React.useState(!hasLoadedOnce) - - // Footer height for floating button - const [footerHeight, setFooterHeight] = React.useState() + // Fetch orders + brands from API via TanStack Query. + // Query key includes rootLoginId so each account gets its own cache entry. + // Polling and focus gating are handled by refetchInterval + enabled. + const { + data: apiOrders, + isLoading, + isError: loadError + } = useQuery({ + queryKey: ['phazeOrders', account.rootLoginId], + queryFn: async () => { + if (provider == null) throw new Error('Provider not ready') + + await provider.getMarketBrands(countryCode) + const allOrders = await provider.getAllOrdersFromAllIdentities(account) + debugLog('phaze', 'Got', allOrders.length, 'orders from API') + return allOrders + }, + enabled: isFocused && isReady, + refetchInterval: POLL_INTERVAL_MS, + refetchOnMount: 'always', + staleTime: POLL_INTERVAL_MS + }) // Brand lookup function using provider cache const brandLookup = React.useCallback( @@ -122,73 +109,25 @@ export const GiftCardListScene: React.FC = (props: Props) => { [countryCode, provider] ) - // Apply augments to cached API orders (no API call) - const applyAugments = React.useCallback( - (apiOrders: typeof cachedApiOrders) => { - const merged = mergeOrdersWithAugments(apiOrders, augments, brandLookup) - - // Show all orders that have augments (we purchased them) or have vouchers - const relevantOrders = merged.filter( - order => order.vouchers.length > 0 || order.txid != null - ) - - // Separate active and redeemed - const active = relevantOrders.filter(order => order.redeemedDate == null) - const redeemed = relevantOrders.filter( - order => order.redeemedDate != null - ) - - cachedActiveOrders = active - cachedRedeemedOrders = redeemed - setActiveOrders(active) - setRedeemedOrders(redeemed) - }, - [augments, brandLookup] - ) - - // Fetch orders from API (full refresh) - const loadOrdersFromApi = React.useCallback( - async (includeBrands: boolean): Promise => { - debugLog('phaze', 'loadOrdersFromApi called, isReady:', isReady) - if (provider == null || !isReady) { - debugLog('phaze', 'Provider not ready, skipping') - return false - } + // Merge API orders with local augments, then split into active/redeemed. + // Recomputes when augments change (e.g., user marks as redeemed) without + // needing an API refetch. + const { activeOrders, redeemedOrders } = React.useMemo(() => { + if (apiOrders == null) return { activeOrders: [], redeemedOrders: [] } - try { - // Aggregate orders from all identities - const allOrders = await provider.getAllOrdersFromAllIdentities(account) - debugLog('phaze', 'Got', allOrders.length, 'orders from API') - cachedApiOrders = allOrders - - // Only fetch brands on first load (they change infrequently) - let didFetchBrands = false - if (includeBrands) { - await provider.getMarketBrands(countryCode) - didFetchBrands = true - } - - applyAugments(allOrders) - return didFetchBrands - } catch (err: unknown) { - debugLog('phaze', 'Error loading orders:', err) - setActiveOrders([]) - setRedeemedOrders([]) - return false - } finally { - setIsLoading(false) - hasLoadedOnce = true - } - }, - [account, provider, isReady, countryCode, applyAugments] - ) + const merged = mergeOrdersWithAugments(apiOrders, augments, brandLookup) + const relevantOrders = merged.filter( + order => order.vouchers.length > 0 || order.txid != null + ) - // Re-apply augments when they change (no API call needed) - React.useEffect(() => { - if (cachedApiOrders.length > 0) { - applyAugments(cachedApiOrders) + return { + activeOrders: relevantOrders.filter(order => order.redeemedDate == null), + redeemedOrders: relevantOrders.filter(order => order.redeemedDate != null) } - }, [augments, applyAugments]) + }, [apiOrders, augments, brandLookup]) + + // Footer height for floating button + const [footerHeight, setFooterHeight] = React.useState() // Load augments on mount useAsyncEffect( @@ -199,35 +138,6 @@ export const GiftCardListScene: React.FC = (props: Props) => { 'GiftCardListScene:refreshAugments' ) - // Reload orders when scene comes into focus, then poll periodically - // to detect when pending orders receive their vouchers - useFocusEffect( - React.useCallback(() => { - // First load: fetch both brands and orders - // Subsequent loads: only fetch orders (brands change infrequently) - const includeBrands = !hasFetchedBrands - loadOrdersFromApi(includeBrands) - .then(didFetchBrands => { - if (didFetchBrands) hasFetchedBrands = true - }) - .catch(() => {}) - - // Poll every 10 seconds while focused (orders only, not brands) - const task = makePeriodicTask( - async () => { - await loadOrdersFromApi(false) - }, - 10000, - { onError: () => {} } - ) - task.start() - - return () => { - task.stop() - } - }, [loadOrdersFromApi]) - ) - const handlePurchaseNew = useHandler(async () => { // Provider auto-registers user if needed via ensureUser() // Ensure country is set: @@ -390,19 +300,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..c5c30dfde42 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) { @@ -360,13 +365,33 @@ 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. + // Note: refetch() always resolves - it never rejects. On failure, data will + // be undefined, which is handled by the null check below. + let resolvedAssets = allowedAssets + if (resolvedAssets == null) { + setIsCreatingOrder(true) + try { + const { data } = await refetchTokens() + resolvedAssets = data?.assets + } 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 +475,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 @@ -688,7 +718,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 +812,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 ? ( { 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 9dce7c12c5e..0a2f8b457fc 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1944,8 +1944,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.', @@ -1963,6 +1971,8 @@ const strings = { 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 0406d99057d..a2002ec3ccf 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1514,7 +1514,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.", @@ -1527,6 +1531,7 @@ "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", From c5a4f2bf5b12ff6940317f94a0c3840f9e0c07b5 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Wed, 11 Feb 2026 16:22:23 -0800 Subject: [PATCH 03/12] Prevent double-tap on Next in gift card purchase scene Add isCreatingOrder to the early-return guard in handleNextPress so a second tap during the brief window before React disables the button cannot trigger duplicate order creation. --- src/components/scenes/GiftCardPurchaseScene.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx index c5c30dfde42..4c4c8fd91ed 100644 --- a/src/components/scenes/GiftCardPurchaseScene.tsx +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -356,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 } From 1484d1d331ea9c51369882caf7819bdd107b1e29 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Wed, 11 Feb 2026 17:09:25 -0800 Subject: [PATCH 04/12] Add confirming and failed states to gift card display card Distinguish between awaiting blockchain confirmations (txid exists but no voucher yet), pending voucher delivery, and failed/expired orders. Failed cards are dimmed like redeemed cards. --- src/components/cards/GiftCardDisplayCard.tsx | 29 +++++++++++++++----- src/components/scenes/GiftCardListScene.tsx | 15 ++++++++-- src/locales/en_US.ts | 2 ++ src/locales/strings/enUS.json | 2 ++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/components/cards/GiftCardDisplayCard.tsx b/src/components/cards/GiftCardDisplayCard.tsx index 9ce751064ba..c1c1c5c0871 100644 --- a/src/components/cards/GiftCardDisplayCard.tsx +++ b/src/components/cards/GiftCardDisplayCard.tsx @@ -25,11 +25,18 @@ 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 @@ -53,9 +60,9 @@ export const GiftCardDisplayCard: React.FC = props => { 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 +143,18 @@ 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_failed} + ) : status === 'available' && redemptionUrl != null ? ( = props => { - {/* Shimmer overlay for pending state */} - + {/* Shimmer overlay for confirming/pending states */} + ) } diff --git a/src/components/scenes/GiftCardListScene.tsx b/src/components/scenes/GiftCardListScene.tsx index 9fad6b03859..ccb1116c88e 100644 --- a/src/components/scenes/GiftCardListScene.tsx +++ b/src/components/scenes/GiftCardListScene.tsx @@ -224,15 +224,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' }, [] ) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 0a2f8b457fc..38f1854a739 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1964,6 +1964,8 @@ 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_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.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index a2002ec3ccf..85900c250e3 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1526,6 +1526,8 @@ "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_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.", From ed853c7d76b6fa2dc24d7e46e77a7103f7b40d71 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Wed, 11 Feb 2026 17:10:47 -0800 Subject: [PATCH 05/12] Show QuoteID in gift card menu modal Display the Phaze quoteId at the top of the kebab menu modal for easier debugging and support reference. --- src/components/modals/GiftCardMenuModal.tsx | 11 +++++++++++ src/locales/en_US.ts | 1 + src/locales/strings/enUS.json | 1 + 3 files changed, 13 insertions(+) diff --git a/src/components/modals/GiftCardMenuModal.tsx b/src/components/modals/GiftCardMenuModal.tsx index a2b2374c230..dfe37e6982e 100644 --- a/src/components/modals/GiftCardMenuModal.tsx +++ b/src/components/modals/GiftCardMenuModal.tsx @@ -2,6 +2,7 @@ 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' @@ -10,6 +11,7 @@ import type { PhazeDisplayOrder } from '../../plugins/gift-cards/phazeGiftCardTy import { useSelector } from '../../types/reactRedux' import { ArrowRightIcon, CheckIcon } 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' @@ -105,6 +107,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/locales/en_US.ts b/src/locales/en_US.ts index 38f1854a739..01d1ab92cfb 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1969,6 +1969,7 @@ 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_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: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 85900c250e3..660527695d4 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1530,6 +1530,7 @@ "gift_card_failed": "Failed", "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_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.", From 20f5b45b13f7456a57ec0371e038b53c9f25691c Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Fri, 13 Feb 2026 15:19:29 -0800 Subject: [PATCH 06/12] Add QuoteId row and card-level copy to GiftCardDetailsCard Extend the gift card tx details card with a Quote ID row and a single copy button on the right side that copies all data at once. Dividers stop short of the copy button. The redeem row remains separate with its own chevron. Document backward-compat: orderId stores quoteId in prior versions. --- src/components/cards/GiftCardDetailsCard.tsx | 119 +++++++++++++++---- src/locales/en_US.ts | 1 + src/locales/strings/enUS.json | 1 + 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/components/cards/GiftCardDetailsCard.tsx b/src/components/cards/GiftCardDetailsCard.tsx index 8f8a082f2be..535f800d875 100644 --- a/src/components/cards/GiftCardDetailsCard.tsx +++ b/src/components/cards/GiftCardDetailsCard.tsx @@ -1,12 +1,19 @@ +import Clipboard from '@react-native-clipboard/clipboard' import type { EdgeTxActionGiftCard } from 'edge-core-js' import * as React from 'react' -import { Linking } from 'react-native' +import { Linking, View } from 'react-native' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' +import { triggerHaptic } from '../../util/haptic' import { removeIsoPrefix } from '../../util/utils' import { CircularBrandIcon } from '../common/CircularBrandIcon' +import { DividerLineUi4 } from '../common/DividerLineUi4' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { CopyIcon } from '../icons/ThemedIcons' import { EdgeRow } from '../rows/EdgeRow' +import { showToast } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' import { EdgeCard } from './EdgeCard' @@ -15,11 +22,21 @@ interface Props { } /** - * Displays gift card details including brand, amount, and redemption code - * in TransactionDetailsScene for gift card purchases. + * Displays gift card details including brand, amount, quote ID, and redemption + * code in TransactionDetailsScene for gift card purchases. + * + * Layout: A left column of data rows with dividers and a single card-level copy + * button on the right. Dividers stop short of the copy button area. */ export const GiftCardDetailsCard: React.FC = ({ action }) => { const { card, redemption } = action + const theme = useTheme() + const styles = getStyles(theme) + + // Backward compat: Prior versions stored the quoteId in the `orderId` field + // of EdgeTxActionGiftCard. Once edge-core-js adds an explicit `quoteId` + // field, prefer that and fall back to `orderId` for older transactions. + const quoteId = action.orderId const handleRedeemPress = useHandler(() => { if (redemption?.url != null) { @@ -43,30 +60,86 @@ 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 (redemption?.code != null) { + lines.push(`${lstrings.gift_card_security_code}: ${redemption.code}`) + } + return lines.join('\n') + }, [card.name, amountDisplay, quoteId, 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} + + + + + + + + + + {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/locales/en_US.ts b/src/locales/en_US.ts index 01d1ab92cfb..808f85e752b 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1969,6 +1969,7 @@ 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_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', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 660527695d4..bfd57980637 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1530,6 +1530,7 @@ "gift_card_failed": "Failed", "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_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", From f4b2009559d2cd68700c789f00f1a60dc7c66d2a Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Mon, 16 Feb 2026 13:13:24 -0800 Subject: [PATCH 07/12] Add Gift Card Account Information scene New scene to view Phaze account credentials behind a confirmation wall. Shows quoteId context when accessed from a specific card. After the user confirms a ConfirmContinueModal warning about redemption risk, identity data (email, user ID) is revealed with copy buttons. --- src/components/Main.tsx | 12 ++ .../scenes/GiftCardAccountInfoScene.tsx | 139 ++++++++++++++++++ src/locales/en_US.ts | 9 ++ src/locales/strings/enUS.json | 7 + src/types/routerTypes.tsx | 2 + 5 files changed, 169 insertions(+) create mode 100644 src/components/scenes/GiftCardAccountInfoScene.tsx 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} /> + ( + + ) + }} + /> +> = 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 { data: identities = [], error } = useQuery({ + queryKey: ['phazeIdentities', account.id], + queryFn: async () => { + if (provider == null) throw new Error('Provider not ready') + return await provider.listIdentities(account) + }, + enabled: isRevealed && provider != null + }) + + React.useEffect(() => { + if (error != null) showError(error) + }, [error]) + + const handleReveal = useHandler(async () => { + const confirmed = await Airship.show(bridge => ( + true} + /> + )) + if (confirmed) { + setIsRevealed(true) + } + }) + + const handleCopyAll = useHandler(async () => { + const lines: string[] = [] + + if (quoteId != null) { + lines.push(`${lstrings.gift_card_quote_id_label}: ${quoteId}`) + } + + identities.forEach(identity => { + lines.push(`${lstrings.gift_card_account_info_email}: ${identity.email}`) + }) + + const text = lines.join('\n') + Clipboard.setString(text) + showToast(lstrings.fragment_copied) + }) + + return ( + + + {lstrings.gift_card_account_info_body} + + {isRevealed && ( + + {quoteId != null && ( + + )} + {identities.map(identity => ( + + ))} + + )} + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + padding: theme.rem(0.5) + } +})) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 808f85e752b..15181c55b09 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1966,6 +1966,15 @@ const strings = { 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.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index bfd57980637..83ace45cb77 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1528,6 +1528,13 @@ "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_quote_id_label": "Quote ID", 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 From 3cc3b4da660d4c465cc9638a9be2051bf8859c0b Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Fri, 13 Feb 2026 15:27:09 -0800 Subject: [PATCH 08/12] Add Get Help to kebab menu and failed card button Add a 'Get Help' row to the gift card kebab menu that navigates to the new Gift Card Account Information scene with the quoteId. Also surface a 'Get Help' button on failed cards, reusing the existing redeem button pattern. --- src/components/cards/GiftCardDisplayCard.tsx | 20 ++++++++++++++++---- src/components/modals/GiftCardMenuModal.tsx | 16 +++++++++++++++- src/components/scenes/GiftCardListScene.tsx | 13 +++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/components/cards/GiftCardDisplayCard.tsx b/src/components/cards/GiftCardDisplayCard.tsx index c1c1c5c0871..85b3933ed76 100644 --- a/src/components/cards/GiftCardDisplayCard.tsx +++ b/src/components/cards/GiftCardDisplayCard.tsx @@ -43,6 +43,8 @@ interface Props { /** 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 } @@ -53,7 +55,7 @@ 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) @@ -152,9 +154,19 @@ export const GiftCardDisplayCard: React.FC = props => { {lstrings.gift_card_pending} ) : status === 'failed' ? ( - - {lstrings.gift_card_failed} - + + + {lstrings.gift_card_get_help} + + + ) : status === 'available' && redemptionUrl != null ? ( = props => { }) }) + const handleGetHelp = useHandler(() => { + bridge.resolve({ type: 'getHelp' }) + }) + // Determine "Go to Transaction" state const hasTx = transaction != null const canNavigate = hasTx && order.walletId != null @@ -140,6 +145,15 @@ export const GiftCardMenuModal: React.FC = props => { } /> + + + + } + /> ) } diff --git a/src/components/scenes/GiftCardListScene.tsx b/src/components/scenes/GiftCardListScene.tsx index ccb1116c88e..1340a5dae25 100644 --- a/src/components/scenes/GiftCardListScene.tsx +++ b/src/components/scenes/GiftCardListScene.tsx @@ -186,6 +186,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 + }) } } ) @@ -285,6 +289,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 From d306631dea2d8934c6a5340e0a2f71e2dc46e966 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Fri, 13 Feb 2026 15:30:29 -0800 Subject: [PATCH 09/12] Add developer settings row for Gift Card Account Info Add a Gift Card Account Info row in the developer mode section of SettingsScene that navigates to the new account information scene. --- src/components/scenes/SettingsScene.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/scenes/SettingsScene.tsx b/src/components/scenes/SettingsScene.tsx index b707dbffbe2..6f87e29bd85 100644 --- a/src/components/scenes/SettingsScene.tsx +++ b/src/components/scenes/SettingsScene.tsx @@ -724,6 +724,14 @@ export const SettingsScene: React.FC = props => { onPress={handleSelectTheme} /> )} + {developerModeOn && ( + { + navigation.navigate('giftCardAccountInfo', {}) + }} + /> + )} )} From 15019b2275103291cccdd845cd413db2a55b9c00 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Fri, 13 Feb 2026 15:33:53 -0800 Subject: [PATCH 10/12] Lock network fee to high for gift card purchases Set networkFeeOption to high and lock the fee tile so users cannot lower the fee priority. Gift card orders are time-sensitive with expiring quotes, so high priority reduces the risk of missed deadlines. --- src/components/scenes/GiftCardPurchaseScene.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx index 4c4c8fd91ed..57905f86a6d 100644 --- a/src/components/scenes/GiftCardPurchaseScene.tsx +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -511,6 +511,7 @@ export const GiftCardPurchaseScene: React.FC = props => { tokenId, spendInfo: { tokenId, + networkFeeOption: 'high', spendTargets: [ { publicAddress: orderResponse.deliveryAddress, @@ -526,6 +527,7 @@ export const GiftCardPurchaseScene: React.FC = props => { lockTilesMap: { address: true, amount: true, + fee: true, wallet: true }, hiddenFeaturesMap: { From aeb67bad0c32bc5ea1e646e5897390f0fc55cfd1 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Fri, 13 Feb 2026 15:34:25 -0800 Subject: [PATCH 11/12] Wire up new EdgeTxActionGiftCard fields from edge-core-js Populate quoteId, productId, and orderId correctly in the saved action now that the core type has explicit fields. Update GiftCardDetailsCard with backward-compat detection: if quoteId is absent, treat orderId as the quoteId per legacy behavior. --- CHANGELOG.md | 4 ++ src/components/cards/GiftCardDetailsCard.tsx | 39 ++++++++++++++++--- .../scenes/GiftCardPurchaseScene.tsx | 10 ++++- src/locales/en_US.ts | 2 + src/locales/strings/enUS.json | 2 + 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ad2654af3..482eab59e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - added: Warning card on send scene when Nym mixnet is active and transaction is loading - added: Verify buy tracking values via Moonpay transactions API with mismatch diagnostics - added: Show performance warning when enabling Nym Mixnet on multiple assets +- 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 - changed: ramps: Infinite buy support enabled diff --git a/src/components/cards/GiftCardDetailsCard.tsx b/src/components/cards/GiftCardDetailsCard.tsx index 535f800d875..9f85b1dfd11 100644 --- a/src/components/cards/GiftCardDetailsCard.tsx +++ b/src/components/cards/GiftCardDetailsCard.tsx @@ -33,10 +33,13 @@ export const GiftCardDetailsCard: React.FC = ({ action }) => { const theme = useTheme() const styles = getStyles(theme) - // Backward compat: Prior versions stored the quoteId in the `orderId` field - // of EdgeTxActionGiftCard. Once edge-core-js adds an explicit `quoteId` - // field, prefer that and fall back to `orderId` for older transactions. - const quoteId = action.orderId + // 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) { @@ -67,11 +70,17 @@ export const GiftCardDetailsCard: React.FC = ({ action }) => { `${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, redemption?.code]) + }, [card.name, amountDisplay, quoteId, productId, orderId, redemption?.code]) const handleCopyAll = useHandler(() => { triggerHaptic('impactLight') @@ -96,6 +105,26 @@ export const GiftCardDetailsCard: React.FC = ({ action }) => { + {productId != null ? ( + <> + + + + ) : null} + + {orderId != null ? ( + <> + + + + ) : null} + {redemption?.code != null ? ( <> diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx index 57905f86a6d..c8b2f7da16e 100644 --- a/src/components/scenes/GiftCardPurchaseScene.tsx +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -571,9 +571,17 @@ 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] + if (cartItem == null) { + debugLog('phaze', 'Empty cart in order, skipping action save') + navigation.navigate('giftCardList') + return + } const savedAction: EdgeTxActionGiftCard = { actionType: 'giftCard', - orderId: order.quoteId, + orderId: cartItem.orderId, + quoteId: order.quoteId, + productId: cartItem.productId, provider: { providerId: 'phaze', displayName: 'Phaze' diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 15181c55b09..a077ddaacb1 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1978,6 +1978,8 @@ 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_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.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 83ace45cb77..31c7722d9d1 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1537,6 +1537,8 @@ "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.", From 115db6e25abf3c65c9fde5e61aea4a2aa4200a5c Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 19 Feb 2026 17:11:07 -0800 Subject: [PATCH 12/12] Pad payment amounts by 0.00000002 --- CHANGELOG.md | 1 + src/components/scenes/GiftCardPurchaseScene.tsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 482eab59e57..df246a5f107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 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: Pad gift card purchase quantity by 0.00000002 to mitigate underpayments - 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 - changed: ramps: Infinite buy support enabled diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx index c8b2f7da16e..c52a0035cf7 100644 --- a/src/components/scenes/GiftCardPurchaseScene.tsx +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { ceil, mul } from 'biggystring' +import { add, ceil, mul } from 'biggystring' import type { EdgeTransaction, EdgeTxActionGiftCard } from 'edge-core-js' import * as React from 'react' import { @@ -497,8 +497,13 @@ export const GiftCardPurchaseScene: React.FC = props => { ]?.denominations[0]?.multiplier ?? '1' : wallet.currencyInfo.denominations[0]?.multiplier ?? '1' - // quantity from API is in decimal units, convert to native - const quantity = orderResponse.quantity.toFixed(DECIMAL_PRECISION) + // Quantity from API is in decimal units, convert to native. + // HACK: Pad by 0.00000002 to guarantee we never underpay due to either + // unexplained drifts on our side or unreported drifts on their side. + const quantity = add( + orderResponse.quantity.toFixed(DECIMAL_PRECISION), + '0.00000002' + ) const nativeAmount = String(ceil(mul(quantity, multiplier), 0)) // Calculate expiry time (quoteExpiry is Unix timestamp in milliseconds)