Skip to content

Commit b18c730

Browse files
authored
Merge pull request #5900 from EdgeApp/jon/fix/edgespend
Jon/fix/edgespend
2 parents 46f8f21 + 12cb165 commit b18c730

File tree

16 files changed

+518
-97
lines changed

16 files changed

+518
-97
lines changed

src/components/Main.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ENV } from '../env'
1717
import { useExperimentConfig } from '../hooks/useExperimentConfig'
1818
import { useMount } from '../hooks/useMount'
1919
import { lstrings } from '../locales/strings'
20+
import { cleanBrandName } from '../plugins/gift-cards/phazeGiftCardTypes'
2021
import { AddressFormScene } from '../plugins/gui/scenes/AddressFormScene'
2122
import { ConfirmationScene } from '../plugins/gui/scenes/ConfirmationScene'
2223
import { ContactFormScene } from '../plugins/gui/scenes/ContactFormScene'
@@ -948,7 +949,7 @@ const EdgeAppStack: React.FC = () => {
948949
options={{
949950
headerTitle: () => (
950951
<ParamHeaderTitle<'giftCardPurchase'>
951-
fromParams={params => params.brand.brandName}
952+
fromParams={params => cleanBrandName(params.brand.brandName)}
952953
/>
953954
)
954955
}}

src/components/cards/ErrorCard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ interface Props {
2929
* If the error is an I18nError, it will display the localized message.
3030
* Otherwise, it will display a localized generic error message with a report
3131
* error button for unexpected errors.
32+
*
33+
* TODO: Add a warning variant
3234
*/
3335
export const ErrorCard: React.FC<Props> = props => {
3436
const { error } = props

src/components/cards/HomeTileCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@ interface Props {
1212
footer: string
1313
gradientBackground: LinearGradientProps
1414
nodeBackground: React.ReactNode
15-
onPress: () => void
15+
onPress: () => void | Promise<void>
1616
}
1717

1818
/**
1919
* Tappable card that shows a corner chevron, background, and title
2020
*/
21-
export const HomeTileCard = (props: Props) => {
21+
export const HomeTileCard: React.FC<Props> = props => {
2222
const { title, footer, gradientBackground, nodeBackground, onPress } = props
2323

2424
const theme = useTheme()
2525
const styles = getStyles(theme)
2626

27-
const handlePress = useHandler(() => {
28-
onPress()
27+
const handlePress = useHandler(async () => {
28+
await onPress()
2929
})
3030

3131
return (

src/components/scenes/GiftCardPurchaseScene.tsx

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ import Ionicons from 'react-native-vector-icons/Ionicons'
1515
import { sprintf } from 'sprintf-js'
1616
import { v4 as uuidv4 } from 'uuid'
1717

18+
import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants'
1819
import { ENV } from '../../env'
20+
import { displayFiatAmount } from '../../hooks/useFiatText'
1921
import { useGiftCardProvider } from '../../hooks/useGiftCardProvider'
2022
import { useHandler } from '../../hooks/useHandler'
2123
import { usePhazeBrand } from '../../hooks/usePhazeBrand'
2224
import { lstrings } from '../../locales/strings'
2325
import type {
2426
PhazeCreateOrderResponse,
27+
PhazeFxRate,
2528
PhazeGiftCardBrand,
2629
PhazeToken
2730
} from '../../plugins/gift-cards/phazeGiftCardTypes'
@@ -36,6 +39,7 @@ import { DropdownInputButton } from '../buttons/DropdownInputButton'
3639
import { KavButtons } from '../buttons/KavButtons'
3740
import { AlertCardUi4 } from '../cards/AlertCard'
3841
import { EdgeCard } from '../cards/EdgeCard'
42+
import { ErrorCard } from '../cards/ErrorCard'
3943
import { EdgeAnim } from '../common/EdgeAnim'
4044
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
4145
import { SceneWrapper } from '../common/SceneWrapper'
@@ -118,6 +122,13 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
118122
footer: string
119123
} | null>(null)
120124

125+
// Warning state for product unavailable
126+
const [productUnavailable, setProductUnavailable] =
127+
React.useState<boolean>(false)
128+
129+
// Error state for unexpected errors
130+
const [error, setError] = React.useState<unknown>(null)
131+
121132
// Fetch allowed tokens from Phaze API
122133
const { data: tokenQueryResult } = useQuery({
123134
queryKey: ['phazeTokens', account?.id, isReady],
@@ -155,6 +166,42 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
155166
gcTime: 10 * 60 * 1000
156167
})
157168

169+
// Get cached FX rates (already loaded during provider initialization)
170+
const fxRates = provider?.getCachedFxRates() ?? null
171+
172+
/**
173+
* Convert USD amount to brand's currency using FX rates.
174+
* Returns formatted string like "€5" or "$5.00" for USD brands.
175+
*/
176+
const formatMinimumInBrandCurrency = React.useCallback(
177+
(minimumUsd: number): string => {
178+
const symbol = getFiatSymbol(brand.currency)
179+
180+
if (brand.currency === 'USD') {
181+
return `${symbol}${displayFiatAmount(minimumUsd, 2)}`
182+
}
183+
184+
if (fxRates == null) {
185+
// Fallback to USD if rates not loaded
186+
return `$${displayFiatAmount(minimumUsd, 2)}`
187+
}
188+
189+
const rate = fxRates.find(
190+
(r: PhazeFxRate) =>
191+
r.fromCurrency === 'USD' && r.toCurrency === brand.currency
192+
)
193+
if (rate == null) {
194+
// Fallback to USD if rate not found
195+
return `$${displayFiatAmount(minimumUsd, 2)}`
196+
}
197+
198+
const amountInBrandCurrency = Math.ceil(minimumUsd * rate.rate)
199+
// Use 0 decimals for non-USD since we ceil to whole number
200+
return `${symbol}${displayFiatAmount(amountInBrandCurrency, 0)}`
201+
},
202+
[fxRates, brand.currency]
203+
)
204+
158205
// Extract assets for wallet list modal and sync token map to ref
159206
// This ensures the ref is populated even when query returns cached data
160207
const allowedAssets = tokenQueryResult?.assets
@@ -195,8 +242,10 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
195242

196243
// Handle amount text change for variable range
197244
const handleAmountChange = useHandler((text: string) => {
198-
// Clear minimum warning when user modifies amount
245+
// Clear warnings/errors when user modifies amount
199246
setMinimumWarning(null)
247+
setProductUnavailable(false)
248+
setError(null)
200249

201250
// Only allow numbers and decimal point
202251
const cleaned = text.replace(/[^0-9.]/g, '')
@@ -216,6 +265,8 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
216265
const handleMaxPress = useHandler(() => {
217266
if (hasVariableRange) {
218267
setMinimumWarning(null)
268+
setProductUnavailable(false)
269+
setError(null)
219270
setAmountText(String(maxVal))
220271
setSelectedAmount(maxVal)
221272
}
@@ -259,8 +310,10 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
259310
)
260311

261312
if (result != null) {
262-
// Clear minimum warning when user modifies amount
313+
// Clear warnings/errors when user modifies amount
263314
setMinimumWarning(null)
315+
setProductUnavailable(false)
316+
setError(null)
264317
setSelectedAmount(result.amount)
265318
setAmountText(String(result.amount))
266319
}
@@ -278,7 +331,6 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
278331
headerTitle={lstrings.gift_card_pay_from_wallet}
279332
navigation={navigation as NavigationBase}
280333
allowedAssets={allowedAssets}
281-
showCreateWallet
282334
/>
283335
))
284336

@@ -324,7 +376,7 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
324376
),
325377
footer: sprintf(
326378
lstrings.gift_card_minimum_warning_footer,
327-
`$${tokenInfo.minimumAmountInUSD.toFixed(2)} USD`
379+
formatMinimumInBrandCurrency(tokenInfo.minimumAmountInUSD)
328380
)
329381
})
330382
return
@@ -384,8 +436,8 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
384436
const quantity = orderResponse.quantity.toFixed(DECIMAL_PRECISION)
385437
const nativeAmount = String(ceil(mul(quantity, multiplier), 0))
386438

387-
// Calculate expiry time
388-
const expiryDate = new Date(orderResponse.quoteExpiry * 1000)
439+
// Calculate expiry time (quoteExpiry is Unix timestamp in milliseconds)
440+
const expiryDate = new Date(orderResponse.quoteExpiry)
389441
const isoExpireDate = expiryDate.toISOString()
390442

391443
// Navigate to SendScene2
@@ -436,6 +488,11 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
436488
</Paragraph>
437489
),
438490
isoExpireDate,
491+
onExpired: () => {
492+
// Quote expired - navigate back to purchase scene and show toast
493+
navigation.goBack()
494+
showToast(lstrings.gift_card_quote_expired_toast)
495+
},
439496
onDone: async (error: Error | null, tx?: EdgeTransaction) => {
440497
if (error != null) {
441498
debugLog('phaze', 'Transaction error:', error)
@@ -492,8 +549,20 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
492549
} catch (err: unknown) {
493550
debugLog('phaze', 'Order creation error:', err)
494551

495-
// Check for minimum amount error from API
552+
// Clear previous warnings/errors
553+
setMinimumWarning(null)
554+
setProductUnavailable(false)
555+
setError(null)
556+
496557
const errorMessage = err instanceof Error ? err.message : ''
558+
559+
// Check for product unavailable error
560+
if (errorMessage.includes('Product is unavailable')) {
561+
setProductUnavailable(true)
562+
return
563+
}
564+
565+
// Check for minimum amount error from API
497566
const minimumMatch = /Minimum cart cost should be above: ([\d.]+)/.exec(
498567
errorMessage
499568
)
@@ -507,11 +576,12 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
507576
),
508577
footer: sprintf(
509578
lstrings.gift_card_minimum_warning_footer,
510-
`$${minimumUSD.toFixed(2)} USD`
579+
formatMinimumInBrandCurrency(minimumUSD)
511580
)
512581
})
513582
} else {
514-
showError(err)
583+
// Show ErrorCard for other errors
584+
setError(err)
515585
}
516586
} finally {
517587
setIsCreatingOrder(false)
@@ -628,6 +698,8 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
628698
style={styles.maxButton}
629699
onPress={() => {
630700
setMinimumWarning(null)
701+
setProductUnavailable(false)
702+
setError(null)
631703
const maxDenom =
632704
sortedDenominations[sortedDenominations.length - 1]
633705
setSelectedAmount(maxDenom)
@@ -664,14 +736,22 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
664736
)}
665737
</EdgeAnim>
666738

667-
{/* Minimum Amount Warning */}
668-
{minimumWarning != null ? (
739+
{/* Warnings/Errors - product unavailable takes precedence */}
740+
{productUnavailable ? (
741+
<AlertCardUi4
742+
type="warning"
743+
title={lstrings.gift_card_product_unavailable_title}
744+
body={lstrings.gift_card_product_unavailable_warning}
745+
/>
746+
) : minimumWarning != null ? (
669747
<AlertCardUi4
670748
type="warning"
671749
title={lstrings.gift_card_minimum_warning_title}
672750
header={minimumWarning.header}
673751
footer={minimumWarning.footer}
674752
/>
753+
) : error != null ? (
754+
<ErrorCard error={error} />
675755
) : null}
676756

677757
{/* Product Description Card */}

src/components/scenes/HomeScene.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings'
99
import { ENV } from '../../env'
1010
import { useHandler } from '../../hooks/useHandler'
1111
import { lstrings } from '../../locales/strings'
12+
import { hasStoredPhazeIdentity } from '../../plugins/gift-cards/phazeGiftCardProvider'
1213
import { useSceneScrollHandler } from '../../state/SceneScrollState'
1314
import { config } from '../../theme/appConfig'
1415
import { useSelector } from '../../types/reactRedux'
@@ -84,6 +85,7 @@ export const HomeScene: React.FC<Props> = props => {
8485
const styles = getStyles(theme)
8586

8687
const countryCode = useSelector(state => state.ui.countryCode)
88+
const account = useSelector(state => state.core.account)
8789

8890
const { width: screenWidth } = useSafeAreaFrame()
8991

@@ -111,8 +113,11 @@ export const HomeScene: React.FC<Props> = props => {
111113
const handleSwapPress = useHandler(() => {
112114
navigation.navigate('swapTab')
113115
})
114-
const handleSpendPress = useHandler(() => {
115-
navigation.navigate('edgeAppStack', { screen: 'giftCardMarket' })
116+
const handleSpendPress = useHandler(async () => {
117+
const hasIdentity = await hasStoredPhazeIdentity(account)
118+
navigation.navigate('edgeAppStack', {
119+
screen: hasIdentity ? 'giftCardList' : 'giftCardMarket'
120+
})
116121
})
117122
const handleViewAssetsPress = useHandler(() => {
118123
navigation.navigate('edgeTabs', {

src/components/scenes/SendScene2.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ export interface SendScene2Params {
140140
error: Error | null,
141141
edgeTransaction?: EdgeTransaction
142142
) => void | Promise<void>
143+
/**
144+
* Called when the quote expires (isoExpireDate countdown reaches zero).
145+
* If provided, handles expiry smoothly without showing an error.
146+
* If not provided, falls back to displaying an expiry error message.
147+
*/
148+
onExpired?: () => void
143149
beforeTransaction?: () => Promise<void>
144150
alternateBroadcast?: (
145151
edgeTransaction: EdgeTransaction
@@ -208,6 +214,7 @@ const SendComponent = (props: Props): React.ReactElement => {
208214
hiddenFeaturesMap = {},
209215
onDone,
210216
onBack,
217+
onExpired,
211218
beforeTransaction,
212219
alternateBroadcast,
213220
doCheckAndShowGetCryptoModal = true
@@ -740,12 +747,18 @@ const SendComponent = (props: Props): React.ReactElement => {
740747
}
741748

742749
const handleTimeoutDone = useHandler((): void => {
743-
setError(
744-
new I18nError(
745-
lstrings.transaction_failure,
746-
lstrings.send_address_expired_error_message
750+
if (onExpired != null) {
751+
// Caller provided custom expiry handler - call it without showing error
752+
onExpired()
753+
} else {
754+
// Fall back to showing expiry error message
755+
setError(
756+
new I18nError(
757+
lstrings.transaction_failure,
758+
lstrings.send_address_expired_error_message
759+
)
747760
)
748-
)
761+
}
749762
})
750763

751764
const renderTimeout = (): React.ReactElement | null => {

src/components/themed/SideMenu.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings'
4040
import { ENV } from '../../env'
4141
import { useWatch } from '../../hooks/useWatch'
4242
import { lstrings } from '../../locales/strings'
43+
import { hasStoredPhazeIdentity } from '../../plugins/gift-cards/phazeGiftCardProvider'
4344
import { getDefaultFiat } from '../../selectors/SettingsSelectors'
4445
import { config } from '../../theme/appConfig'
4546
import { useDispatch, useSelector } from '../../types/reactRedux'
@@ -312,12 +313,15 @@ export function SideMenuComponent(props: Props): React.ReactElement {
312313
title: lstrings.title_markets
313314
},
314315
{
315-
handlePress: () => {
316+
handlePress: async () => {
316317
navigation.dispatch(DrawerActions.closeDrawer())
317318
// Light accounts need to back up before using gift cards
318319
if (checkAndShowLightBackupModal(account, navigationBase)) return
319-
// Navigate to gift card list - it has a "Purchase New" button
320-
navigation.navigate('edgeAppStack', { screen: 'giftCardList' })
320+
const hasIdentity = await hasStoredPhazeIdentity(account)
321+
// Navigate to gift card list only if we have identities
322+
navigation.navigate('edgeAppStack', {
323+
screen: hasIdentity ? 'giftCardList' : 'giftCardMarket'
324+
})
321325
},
322326
iconNameFontAwesome: 'gift',
323327
title: lstrings.gift_card_branded

0 commit comments

Comments
 (0)