Skip to content

Commit 1602cac

Browse files
author
Jon Tzeng
committed
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.
1 parent a45543a commit 1602cac

6 files changed

Lines changed: 161 additions & 153 deletions

File tree

src/components/scenes/GiftCardListScene.tsx

Lines changed: 75 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useFocusEffect } from '@react-navigation/native'
1+
import { useIsFocused } from '@react-navigation/native'
2+
import { useQuery } from '@tanstack/react-query'
23
import * as React from 'react'
34
import { Linking, ScrollView, View } from 'react-native'
45

@@ -17,16 +18,13 @@ import {
1718
saveOrderAugment,
1819
usePhazeOrderAugments
1920
} from '../../plugins/gift-cards/phazeGiftCardOrderStore'
20-
import type {
21-
PhazeDisplayOrder,
22-
PhazeOrderStatusItem
23-
} from '../../plugins/gift-cards/phazeGiftCardTypes'
21+
import type { PhazeDisplayOrder } from '../../plugins/gift-cards/phazeGiftCardTypes'
2422
import type { FooterRender } from '../../state/SceneFooterState'
2523
import { useDispatch, useSelector } from '../../types/reactRedux'
2624
import type { EdgeAppSceneProps } from '../../types/routerTypes'
2725
import { debugLog } from '../../util/logger'
28-
import { makePeriodicTask } from '../../util/PeriodicTask'
2926
import { SceneButtons } from '../buttons/SceneButtons'
27+
import { AlertCardUi4 } from '../cards/AlertCard'
3028
import { EdgeCard } from '../cards/EdgeCard'
3129
import {
3230
GiftCardDisplayCard,
@@ -49,27 +47,7 @@ import { SceneFooterWrapper } from '../themed/SceneFooterWrapper'
4947

5048
interface Props extends EdgeAppSceneProps<'giftCardList'> {}
5149

52-
/**
53-
* Module-level cache to persist order data across scene mounts.
54-
* Keyed by account ID to prevent data leaking between user sessions.
55-
*/
56-
let cachedAccountId: string | null = null
57-
let cachedApiOrders: PhazeOrderStatusItem[] = []
58-
let cachedActiveOrders: PhazeDisplayOrder[] = []
59-
let cachedRedeemedOrders: PhazeDisplayOrder[] = []
60-
/** Necessary to ensure if the user truly has zero gift cards, we don't show loading every time. */
61-
let hasLoadedOnce = false
62-
/** Track if brands have been loaded at least once (brands change infrequently) */
63-
let hasFetchedBrands = false
64-
65-
/** Clear module-level cache (called when account changes) */
66-
const clearOrderCache = (): void => {
67-
cachedApiOrders = []
68-
cachedActiveOrders = []
69-
cachedRedeemedOrders = []
70-
hasLoadedOnce = false
71-
hasFetchedBrands = false
72-
}
50+
const POLL_INTERVAL_MS = 10000
7351

7452
/** List of purchased gift cards */
7553
export const GiftCardListScene: React.FC<Props> = (props: Props) => {
@@ -79,15 +57,13 @@ export const GiftCardListScene: React.FC<Props> = (props: Props) => {
7957
const dispatch = useDispatch()
8058

8159
const account = useSelector(state => state.core.account)
82-
const { countryCode, stateProvinceCode } = useSelector(
83-
state => state.ui.settings
60+
const isConnected = useSelector(state => state.network.isConnected)
61+
const countryCode = useSelector(state => state.ui.settings.countryCode)
62+
const stateProvinceCode = useSelector(
63+
state => state.ui.settings.stateProvinceCode
8464
)
8565

86-
// Clear cache if account changed (prevents data leaking between users)
87-
if (cachedAccountId !== account.id) {
88-
clearOrderCache()
89-
cachedAccountId = account.id
90-
}
66+
const isFocused = useIsFocused()
9167

9268
// Get Phaze provider for API access
9369
const phazeConfig = (ENV.PLUGIN_API_KEYS as Record<string, unknown>)
@@ -101,17 +77,28 @@ export const GiftCardListScene: React.FC<Props> = (props: Props) => {
10177
// Get augments from synced storage
10278
const augments = usePhazeOrderAugments()
10379

104-
// Orders from Phaze API merged with augments - separate active and redeemed
105-
// Initialize from module-level cache to avoid flash of empty state on re-mount
106-
const [activeOrders, setActiveOrders] =
107-
React.useState<PhazeDisplayOrder[]>(cachedActiveOrders)
108-
const [redeemedOrders, setRedeemedOrders] =
109-
React.useState<PhazeDisplayOrder[]>(cachedRedeemedOrders)
110-
// Only show loading on very first load; subsequent mounts refresh silently
111-
const [isLoading, setIsLoading] = React.useState(!hasLoadedOnce)
112-
113-
// Footer height for floating button
114-
const [footerHeight, setFooterHeight] = React.useState<number | undefined>()
80+
// Fetch orders + brands from API via TanStack Query.
81+
// Query key includes rootLoginId so each account gets its own cache entry.
82+
// Polling and focus gating are handled by refetchInterval + enabled.
83+
const {
84+
data: apiOrders,
85+
isLoading,
86+
isError: loadError
87+
} = useQuery({
88+
queryKey: ['phazeOrders', account.rootLoginId],
89+
queryFn: async () => {
90+
if (provider == null) throw new Error('Provider not ready')
91+
92+
await provider.getMarketBrands(countryCode)
93+
const allOrders = await provider.getAllOrdersFromAllIdentities(account)
94+
debugLog('phaze', 'Got', allOrders.length, 'orders from API')
95+
return allOrders
96+
},
97+
enabled: isFocused && isReady,
98+
refetchInterval: POLL_INTERVAL_MS,
99+
refetchOnMount: 'always',
100+
staleTime: POLL_INTERVAL_MS
101+
})
115102

116103
// Brand lookup function using provider cache
117104
const brandLookup = React.useCallback(
@@ -122,73 +109,25 @@ export const GiftCardListScene: React.FC<Props> = (props: Props) => {
122109
[countryCode, provider]
123110
)
124111

125-
// Apply augments to cached API orders (no API call)
126-
const applyAugments = React.useCallback(
127-
(apiOrders: typeof cachedApiOrders) => {
128-
const merged = mergeOrdersWithAugments(apiOrders, augments, brandLookup)
129-
130-
// Show all orders that have augments (we purchased them) or have vouchers
131-
const relevantOrders = merged.filter(
132-
order => order.vouchers.length > 0 || order.txid != null
133-
)
134-
135-
// Separate active and redeemed
136-
const active = relevantOrders.filter(order => order.redeemedDate == null)
137-
const redeemed = relevantOrders.filter(
138-
order => order.redeemedDate != null
139-
)
140-
141-
cachedActiveOrders = active
142-
cachedRedeemedOrders = redeemed
143-
setActiveOrders(active)
144-
setRedeemedOrders(redeemed)
145-
},
146-
[augments, brandLookup]
147-
)
148-
149-
// Fetch orders from API (full refresh)
150-
const loadOrdersFromApi = React.useCallback(
151-
async (includeBrands: boolean): Promise<boolean> => {
152-
debugLog('phaze', 'loadOrdersFromApi called, isReady:', isReady)
153-
if (provider == null || !isReady) {
154-
debugLog('phaze', 'Provider not ready, skipping')
155-
return false
156-
}
112+
// Merge API orders with local augments, then split into active/redeemed.
113+
// Recomputes when augments change (e.g., user marks as redeemed) without
114+
// needing an API refetch.
115+
const { activeOrders, redeemedOrders } = React.useMemo(() => {
116+
if (apiOrders == null) return { activeOrders: [], redeemedOrders: [] }
157117

158-
try {
159-
// Aggregate orders from all identities
160-
const allOrders = await provider.getAllOrdersFromAllIdentities(account)
161-
debugLog('phaze', 'Got', allOrders.length, 'orders from API')
162-
cachedApiOrders = allOrders
163-
164-
// Only fetch brands on first load (they change infrequently)
165-
let didFetchBrands = false
166-
if (includeBrands) {
167-
await provider.getMarketBrands(countryCode)
168-
didFetchBrands = true
169-
}
170-
171-
applyAugments(allOrders)
172-
return didFetchBrands
173-
} catch (err: unknown) {
174-
debugLog('phaze', 'Error loading orders:', err)
175-
setActiveOrders([])
176-
setRedeemedOrders([])
177-
return false
178-
} finally {
179-
setIsLoading(false)
180-
hasLoadedOnce = true
181-
}
182-
},
183-
[account, provider, isReady, countryCode, applyAugments]
184-
)
118+
const merged = mergeOrdersWithAugments(apiOrders, augments, brandLookup)
119+
const relevantOrders = merged.filter(
120+
order => order.vouchers.length > 0 || order.txid != null
121+
)
185122

186-
// Re-apply augments when they change (no API call needed)
187-
React.useEffect(() => {
188-
if (cachedApiOrders.length > 0) {
189-
applyAugments(cachedApiOrders)
123+
return {
124+
activeOrders: relevantOrders.filter(order => order.redeemedDate == null),
125+
redeemedOrders: relevantOrders.filter(order => order.redeemedDate != null)
190126
}
191-
}, [augments, applyAugments])
127+
}, [apiOrders, augments, brandLookup])
128+
129+
// Footer height for floating button
130+
const [footerHeight, setFooterHeight] = React.useState<number | undefined>()
192131

193132
// Load augments on mount
194133
useAsyncEffect(
@@ -199,35 +138,6 @@ export const GiftCardListScene: React.FC<Props> = (props: Props) => {
199138
'GiftCardListScene:refreshAugments'
200139
)
201140

202-
// Reload orders when scene comes into focus, then poll periodically
203-
// to detect when pending orders receive their vouchers
204-
useFocusEffect(
205-
React.useCallback(() => {
206-
// First load: fetch both brands and orders
207-
// Subsequent loads: only fetch orders (brands change infrequently)
208-
const includeBrands = !hasFetchedBrands
209-
loadOrdersFromApi(includeBrands)
210-
.then(didFetchBrands => {
211-
if (didFetchBrands) hasFetchedBrands = true
212-
})
213-
.catch(() => {})
214-
215-
// Poll every 10 seconds while focused (orders only, not brands)
216-
const task = makePeriodicTask(
217-
async () => {
218-
await loadOrdersFromApi(false)
219-
},
220-
10000,
221-
{ onError: () => {} }
222-
)
223-
task.start()
224-
225-
return () => {
226-
task.stop()
227-
}
228-
}, [loadOrdersFromApi])
229-
)
230-
231141
const handlePurchaseNew = useHandler(async () => {
232142
// Provider auto-registers user if needed via ensureUser()
233143
// Ensure country is set:
@@ -390,19 +300,25 @@ export const GiftCardListScene: React.FC<Props> = (props: Props) => {
390300
<SceneButtons
391301
primary={{
392302
label: lstrings.gift_card_list_purchase_new_button,
393-
onPress: handlePurchaseNew
303+
onPress: handlePurchaseNew,
304+
disabled: !isConnected || loadError
394305
}}
395306
/>
396307
</SceneFooterWrapper>
397308
)
398309
},
399-
[handleFooterLayoutHeight, handlePurchaseNew]
310+
[handleFooterLayoutHeight, handlePurchaseNew, isConnected, loadError]
400311
)
401312

402313
const hasNoCards = activeOrders.length === 0 && redeemedOrders.length === 0
403314

404315
return (
405-
<SceneWrapper footerHeight={footerHeight} renderFooter={renderFooter}>
316+
<SceneWrapper
317+
// Use 0 while loading so the FillLoader centers in the full scene area
318+
// without jumping when the footer measures its height.
319+
footerHeight={isLoading ? 0 : footerHeight}
320+
renderFooter={renderFooter}
321+
>
406322
{({ insetStyle, undoInsetStyle }) => (
407323
<SceneContainer
408324
undoInsetStyle={undoInsetStyle}
@@ -423,10 +339,28 @@ export const GiftCardListScene: React.FC<Props> = (props: Props) => {
423339
>
424340
{isLoading ? (
425341
<FillLoader />
342+
) : hasNoCards && loadError ? (
343+
<Paragraph center>
344+
{isConnected
345+
? lstrings.gift_card_service_error
346+
: lstrings.gift_card_network_error}
347+
</Paragraph>
426348
) : hasNoCards ? (
427349
<Paragraph center>{lstrings.gift_card_list_no_cards}</Paragraph>
428350
) : (
429351
<>
352+
{/* Error banner when data exists but refresh failed */}
353+
{loadError ? (
354+
<AlertCardUi4
355+
type="warning"
356+
title={
357+
isConnected
358+
? lstrings.gift_card_refresh_service_error
359+
: lstrings.gift_card_refresh_error
360+
}
361+
/>
362+
) : null}
363+
430364
{/* Active Cards Section */}
431365
{activeOrders.map(order => renderCard(order))}
432366

src/components/scenes/GiftCardMarketScene.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useDispatch, useSelector } from '../../types/reactRedux'
2323
import type { EdgeAppSceneProps } from '../../types/routerTypes'
2424
import { debugLog } from '../../util/logger'
2525
import { CountryButton } from '../buttons/RegionButton'
26+
import { AlertCardUi4 } from '../cards/AlertCard'
2627
import { EdgeCard } from '../cards/EdgeCard'
2728
import { GiftCardTile } from '../cards/GiftCardTile'
2829
import { CircularBrandIcon } from '../common/CircularBrandIcon'
@@ -107,6 +108,7 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
107108
// Get user's current country code (specific selector to avoid re-renders on other setting changes)
108109
const countryCode = useSelector(state => state.ui.settings.countryCode)
109110
const account = useSelector(state => state.core.account)
111+
const isConnected = useSelector(state => state.network.isConnected)
110112

111113
// Provider (requires API key configured)
112114
const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze
@@ -238,8 +240,10 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
238240
prevCountryCodeRef.current = countryCode
239241
}, [countryCode])
240242

241-
// Fetch brands. Initial data comes from synchronous cache read in useState
242-
const { data: apiBrands } = useQuery({
243+
// Fetch brands. Initial data comes from synchronous cache read in useState.
244+
// Adding isConnected to enabled so the query auto-retries when connectivity
245+
// returns after being offline.
246+
const { data: apiBrands, isError: isBrandsError } = useQuery({
243247
queryKey: ['phazeBrands', countryCode, isReady],
244248
queryFn: async () => {
245249
if (provider == null || cache == null) {
@@ -279,7 +283,7 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
279283

280284
return allBrands
281285
},
282-
enabled: isReady && provider != null && countryCode !== '',
286+
enabled: isConnected && isReady && provider != null && countryCode !== '',
283287
staleTime: 5 * 60 * 1000, // 5 minutes
284288
gcTime: 10 * 60 * 1000,
285289
retry: 1
@@ -489,7 +493,16 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
489493
headerTitle={lstrings.title_gift_card_market}
490494
headerTitleChildren={<CountryButton onPress={handleRegionSelect} />}
491495
>
492-
{items == null ? (
496+
{items == null && isBrandsError ? (
497+
<AlertCardUi4
498+
type="warning"
499+
title={
500+
isConnected
501+
? lstrings.gift_card_service_error
502+
: lstrings.gift_card_network_error
503+
}
504+
/>
505+
) : items == null ? (
493506
<FillLoader />
494507
) : (
495508
<>

0 commit comments

Comments
 (0)