Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ENV } from '../env'
import { useExperimentConfig } from '../hooks/useExperimentConfig'
import { useMount } from '../hooks/useMount'
import { lstrings } from '../locales/strings'
import { cleanBrandName } from '../plugins/gift-cards/phazeGiftCardTypes'
import { AddressFormScene } from '../plugins/gui/scenes/AddressFormScene'
import { ConfirmationScene } from '../plugins/gui/scenes/ConfirmationScene'
import { ContactFormScene } from '../plugins/gui/scenes/ContactFormScene'
Expand Down Expand Up @@ -948,7 +949,7 @@ const EdgeAppStack: React.FC = () => {
options={{
headerTitle: () => (
<ParamHeaderTitle<'giftCardPurchase'>
fromParams={params => params.brand.brandName}
fromParams={params => cleanBrandName(params.brand.brandName)}
/>
)
}}
Expand Down
2 changes: 2 additions & 0 deletions src/components/cards/ErrorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface Props {
* If the error is an I18nError, it will display the localized message.
* Otherwise, it will display a localized generic error message with a report
* error button for unexpected errors.
*
* TODO: Add a warning variant
*/
export const ErrorCard: React.FC<Props> = props => {
const { error } = props
Expand Down
8 changes: 4 additions & 4 deletions src/components/cards/HomeTileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@ interface Props {
footer: string
gradientBackground: LinearGradientProps
nodeBackground: React.ReactNode
onPress: () => void
onPress: () => void | Promise<void>
}

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

const theme = useTheme()
const styles = getStyles(theme)

const handlePress = useHandler(() => {
onPress()
const handlePress = useHandler(async () => {
await onPress()
})

return (
Expand Down
102 changes: 91 additions & 11 deletions src/components/scenes/GiftCardPurchaseScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import Ionicons from 'react-native-vector-icons/Ionicons'
import { sprintf } from 'sprintf-js'
import { v4 as uuidv4 } from 'uuid'

import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants'
import { ENV } from '../../env'
import { displayFiatAmount } from '../../hooks/useFiatText'
import { useGiftCardProvider } from '../../hooks/useGiftCardProvider'
import { useHandler } from '../../hooks/useHandler'
import { usePhazeBrand } from '../../hooks/usePhazeBrand'
import { lstrings } from '../../locales/strings'
import type {
PhazeCreateOrderResponse,
PhazeFxRate,
PhazeGiftCardBrand,
PhazeToken
} from '../../plugins/gift-cards/phazeGiftCardTypes'
Expand All @@ -36,6 +39,7 @@ import { DropdownInputButton } from '../buttons/DropdownInputButton'
import { KavButtons } from '../buttons/KavButtons'
import { AlertCardUi4 } from '../cards/AlertCard'
import { EdgeCard } from '../cards/EdgeCard'
import { ErrorCard } from '../cards/ErrorCard'
import { EdgeAnim } from '../common/EdgeAnim'
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
import { SceneWrapper } from '../common/SceneWrapper'
Expand Down Expand Up @@ -118,6 +122,13 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
footer: string
} | null>(null)

// Warning state for product unavailable
const [productUnavailable, setProductUnavailable] =
React.useState<boolean>(false)

// Error state for unexpected errors
const [error, setError] = React.useState<unknown>(null)

// Fetch allowed tokens from Phaze API
const { data: tokenQueryResult } = useQuery({
queryKey: ['phazeTokens', account?.id, isReady],
Expand Down Expand Up @@ -155,6 +166,42 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
gcTime: 10 * 60 * 1000
})

// Get cached FX rates (already loaded during provider initialization)
const fxRates = provider?.getCachedFxRates() ?? null

/**
* Convert USD amount to brand's currency using FX rates.
* Returns formatted string like "€5" or "$5.00" for USD brands.
*/
const formatMinimumInBrandCurrency = React.useCallback(
(minimumUsd: number): string => {
const symbol = getFiatSymbol(brand.currency)

if (brand.currency === 'USD') {
return `${symbol}${displayFiatAmount(minimumUsd, 2)}`
}

if (fxRates == null) {
// Fallback to USD if rates not loaded
return `$${displayFiatAmount(minimumUsd, 2)}`
}

const rate = fxRates.find(
(r: PhazeFxRate) =>
r.fromCurrency === 'USD' && r.toCurrency === brand.currency
)
if (rate == null) {
// Fallback to USD if rate not found
return `$${displayFiatAmount(minimumUsd, 2)}`
}

const amountInBrandCurrency = Math.ceil(minimumUsd * rate.rate)
// Use 0 decimals for non-USD since we ceil to whole number
return `${symbol}${displayFiatAmount(amountInBrandCurrency, 0)}`
},
[fxRates, brand.currency]
)

// Extract assets for wallet list modal and sync token map to ref
// This ensures the ref is populated even when query returns cached data
const allowedAssets = tokenQueryResult?.assets
Expand Down Expand Up @@ -195,8 +242,10 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {

// Handle amount text change for variable range
const handleAmountChange = useHandler((text: string) => {
// Clear minimum warning when user modifies amount
// Clear warnings/errors when user modifies amount
setMinimumWarning(null)
setProductUnavailable(false)
setError(null)

// Only allow numbers and decimal point
const cleaned = text.replace(/[^0-9.]/g, '')
Expand All @@ -216,6 +265,8 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
const handleMaxPress = useHandler(() => {
if (hasVariableRange) {
setMinimumWarning(null)
setProductUnavailable(false)
setError(null)
setAmountText(String(maxVal))
setSelectedAmount(maxVal)
}
Expand Down Expand Up @@ -259,8 +310,10 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
)

if (result != null) {
// Clear minimum warning when user modifies amount
// Clear warnings/errors when user modifies amount
setMinimumWarning(null)
setProductUnavailable(false)
setError(null)
setSelectedAmount(result.amount)
setAmountText(String(result.amount))
}
Expand All @@ -278,7 +331,6 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
headerTitle={lstrings.gift_card_pay_from_wallet}
navigation={navigation as NavigationBase}
allowedAssets={allowedAssets}
showCreateWallet
/>
))

Expand Down Expand Up @@ -324,7 +376,7 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
),
footer: sprintf(
lstrings.gift_card_minimum_warning_footer,
`$${tokenInfo.minimumAmountInUSD.toFixed(2)} USD`
formatMinimumInBrandCurrency(tokenInfo.minimumAmountInUSD)
)
})
return
Expand Down Expand Up @@ -384,8 +436,8 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
const quantity = orderResponse.quantity.toFixed(DECIMAL_PRECISION)
const nativeAmount = String(ceil(mul(quantity, multiplier), 0))

// Calculate expiry time
const expiryDate = new Date(orderResponse.quoteExpiry * 1000)
// Calculate expiry time (quoteExpiry is Unix timestamp in milliseconds)
const expiryDate = new Date(orderResponse.quoteExpiry)
const isoExpireDate = expiryDate.toISOString()

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

// Check for minimum amount error from API
// Clear previous warnings/errors
setMinimumWarning(null)
setProductUnavailable(false)
setError(null)

const errorMessage = err instanceof Error ? err.message : ''

// Check for product unavailable error
if (errorMessage.includes('Product is unavailable')) {
setProductUnavailable(true)
return
}

// Check for minimum amount error from API
const minimumMatch = /Minimum cart cost should be above: ([\d.]+)/.exec(
errorMessage
)
Expand All @@ -507,11 +576,12 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
),
footer: sprintf(
lstrings.gift_card_minimum_warning_footer,
`$${minimumUSD.toFixed(2)} USD`
formatMinimumInBrandCurrency(minimumUSD)
)
})
} else {
showError(err)
// Show ErrorCard for other errors
setError(err)
}
} finally {
setIsCreatingOrder(false)
Expand Down Expand Up @@ -628,6 +698,8 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
style={styles.maxButton}
onPress={() => {
setMinimumWarning(null)
setProductUnavailable(false)
setError(null)
const maxDenom =
sortedDenominations[sortedDenominations.length - 1]
setSelectedAmount(maxDenom)
Expand Down Expand Up @@ -664,14 +736,22 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
)}
</EdgeAnim>

{/* Minimum Amount Warning */}
{minimumWarning != null ? (
{/* Warnings/Errors - product unavailable takes precedence */}
{productUnavailable ? (
<AlertCardUi4
type="warning"
title={lstrings.gift_card_product_unavailable_title}
body={lstrings.gift_card_product_unavailable_warning}
/>
) : minimumWarning != null ? (
<AlertCardUi4
type="warning"
title={lstrings.gift_card_minimum_warning_title}
header={minimumWarning.header}
footer={minimumWarning.footer}
/>
) : error != null ? (
<ErrorCard error={error} />
) : null}

{/* Product Description Card */}
Expand Down
9 changes: 7 additions & 2 deletions src/components/scenes/HomeScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings'
import { ENV } from '../../env'
import { useHandler } from '../../hooks/useHandler'
import { lstrings } from '../../locales/strings'
import { hasStoredPhazeIdentity } from '../../plugins/gift-cards/phazeGiftCardProvider'
import { useSceneScrollHandler } from '../../state/SceneScrollState'
import { config } from '../../theme/appConfig'
import { useSelector } from '../../types/reactRedux'
Expand Down Expand Up @@ -84,6 +85,7 @@ export const HomeScene: React.FC<Props> = props => {
const styles = getStyles(theme)

const countryCode = useSelector(state => state.ui.countryCode)
const account = useSelector(state => state.core.account)

const { width: screenWidth } = useSafeAreaFrame()

Expand Down Expand Up @@ -111,8 +113,11 @@ export const HomeScene: React.FC<Props> = props => {
const handleSwapPress = useHandler(() => {
navigation.navigate('swapTab')
})
const handleSpendPress = useHandler(() => {
navigation.navigate('edgeAppStack', { screen: 'giftCardMarket' })
const handleSpendPress = useHandler(async () => {
const hasIdentity = await hasStoredPhazeIdentity(account)
navigation.navigate('edgeAppStack', {
screen: hasIdentity ? 'giftCardList' : 'giftCardMarket'
})
})
const handleViewAssetsPress = useHandler(() => {
navigation.navigate('edgeTabs', {
Expand Down
23 changes: 18 additions & 5 deletions src/components/scenes/SendScene2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ export interface SendScene2Params {
error: Error | null,
edgeTransaction?: EdgeTransaction
) => void | Promise<void>
/**
* Called when the quote expires (isoExpireDate countdown reaches zero).
* If provided, handles expiry smoothly without showing an error.
* If not provided, falls back to displaying an expiry error message.
*/
onExpired?: () => void
beforeTransaction?: () => Promise<void>
alternateBroadcast?: (
edgeTransaction: EdgeTransaction
Expand Down Expand Up @@ -208,6 +214,7 @@ const SendComponent = (props: Props): React.ReactElement => {
hiddenFeaturesMap = {},
onDone,
onBack,
onExpired,
beforeTransaction,
alternateBroadcast,
doCheckAndShowGetCryptoModal = true
Expand Down Expand Up @@ -740,12 +747,18 @@ const SendComponent = (props: Props): React.ReactElement => {
}

const handleTimeoutDone = useHandler((): void => {
setError(
new I18nError(
lstrings.transaction_failure,
lstrings.send_address_expired_error_message
if (onExpired != null) {
// Caller provided custom expiry handler - call it without showing error
onExpired()
} else {
// Fall back to showing expiry error message
setError(
new I18nError(
lstrings.transaction_failure,
lstrings.send_address_expired_error_message
)
)
)
}
})

const renderTimeout = (): React.ReactElement | null => {
Expand Down
10 changes: 7 additions & 3 deletions src/components/themed/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings'
import { ENV } from '../../env'
import { useWatch } from '../../hooks/useWatch'
import { lstrings } from '../../locales/strings'
import { hasStoredPhazeIdentity } from '../../plugins/gift-cards/phazeGiftCardProvider'
import { getDefaultFiat } from '../../selectors/SettingsSelectors'
import { config } from '../../theme/appConfig'
import { useDispatch, useSelector } from '../../types/reactRedux'
Expand Down Expand Up @@ -312,12 +313,15 @@ export function SideMenuComponent(props: Props): React.ReactElement {
title: lstrings.title_markets
},
{
handlePress: () => {
handlePress: async () => {
navigation.dispatch(DrawerActions.closeDrawer())
// Light accounts need to back up before using gift cards
if (checkAndShowLightBackupModal(account, navigationBase)) return
// Navigate to gift card list - it has a "Purchase New" button
navigation.navigate('edgeAppStack', { screen: 'giftCardList' })
const hasIdentity = await hasStoredPhazeIdentity(account)
// Navigate to gift card list only if we have identities
navigation.navigate('edgeAppStack', {
screen: hasIdentity ? 'giftCardList' : 'giftCardMarket'
})
},
iconNameFontAwesome: 'gift',
title: lstrings.gift_card_branded
Expand Down
Loading
Loading