1- import { useFocusEffect } from '@react-navigation/native'
1+ import { useIsFocused } from '@react-navigation/native'
2+ import { useQuery } from '@tanstack/react-query'
23import * as React from 'react'
34import { 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'
2422import type { FooterRender } from '../../state/SceneFooterState'
2523import { useDispatch , useSelector } from '../../types/reactRedux'
2624import type { EdgeAppSceneProps } from '../../types/routerTypes'
2725import { debugLog } from '../../util/logger'
28- import { makePeriodicTask } from '../../util/PeriodicTask'
2926import { SceneButtons } from '../buttons/SceneButtons'
27+ import { AlertCardUi4 } from '../cards/AlertCard'
3028import { EdgeCard } from '../cards/EdgeCard'
3129import {
3230 GiftCardDisplayCard ,
@@ -49,27 +47,7 @@ import { SceneFooterWrapper } from '../themed/SceneFooterWrapper'
4947
5048interface 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 */
7553export 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
0 commit comments