Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
- 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: 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
Expand Down
12 changes: 12 additions & 0 deletions src/components/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -951,6 +954,15 @@ const EdgeAppStack: React.FC = () => {
name="fioStakingOverview"
component={FioStakingOverviewScene}
/>
<AppStack.Screen
name="giftCardAccountInfo"
component={GiftCardAccountInfoScene}
options={{
headerTitle: () => (
<HeaderTitle title={lstrings.gift_card_account_info_title} />
)
}}
/>
<AppStack.Screen name="giftCardList" component={GiftCardListScene} />
<AppStack.Screen name="giftCardMarket" component={GiftCardMarketScene} />
<AppStack.Screen
Expand Down
148 changes: 125 additions & 23 deletions src/components/cards/GiftCardDetailsCard.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -15,11 +22,24 @@ 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<Props> = ({ action }) => {
const { card, redemption } = action
const theme = useTheme()
const styles = getStyles(theme)

// 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) {
Expand All @@ -43,30 +63,112 @@ export const GiftCardDetailsCard: React.FC<Props> = ({ 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 (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, productId, orderId, redemption?.code])

const handleCopyAll = useHandler(() => {
triggerHaptic('impactLight')
Clipboard.setString(copyText)
showToast(lstrings.fragment_copied)
})

return (
<EdgeCard sections>
<EdgeRow icon={brandIcon} title={lstrings.gift_card_label}>
<EdgeText>{card.name}</EdgeText>
</EdgeRow>

<EdgeRow title={lstrings.string_amount} body={amountDisplay} />

{redemption?.code != null ? (
<EdgeRow
title={lstrings.gift_card_security_code}
body={redemption.code}
rightButtonType="copy"
/>
) : null}
<EdgeCard>
<View style={styles.cardLayout}>
{/* Left column: data rows with dividers */}
<View style={styles.dataColumn}>
<EdgeRow icon={brandIcon} title={lstrings.gift_card_label}>
<EdgeText>{card.name}</EdgeText>
</EdgeRow>

<DividerLineUi4 />

<EdgeRow title={lstrings.string_amount} body={amountDisplay} />

<DividerLineUi4 />

<EdgeRow title={lstrings.gift_card_quote_id_label} body={quoteId} />

{productId != null ? (
<>
<DividerLineUi4 />
<EdgeRow
title={lstrings.gift_card_product_id_label}
body={productId}
/>
</>
) : null}

{orderId != null ? (
<>
<DividerLineUi4 />
<EdgeRow
title={lstrings.gift_card_order_id_label}
body={orderId}
/>
</>
) : null}

{redemption?.code != null ? (
<>
<DividerLineUi4 />
<EdgeRow
title={lstrings.gift_card_security_code}
body={redemption.code}
/>
</>
) : null}
</View>

{/* Right column: card-level copy button */}
<EdgeTouchableOpacity style={styles.copyColumn} onPress={handleCopyAll}>
<CopyIcon size={theme.rem(1)} color={theme.iconTappable} />
</EdgeTouchableOpacity>
</View>

{/* Redeem row outside the copy layout - has its own chevron */}
{redemption?.url != null ? (
<EdgeRow
title={lstrings.gift_card_redeem}
body={lstrings.gift_card_redeem_visit}
rightButtonType="touchable"
onPress={handleRedeemPress}
/>
<>
<DividerLineUi4 />
<EdgeRow
title={lstrings.gift_card_redeem}
body={lstrings.gift_card_redeem_visit}
rightButtonType="touchable"
onPress={handleRedeemPress}
/>
</>
) : null}
</EdgeCard>
)
}

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)
}
}))
43 changes: 35 additions & 8 deletions src/components/cards/GiftCardDisplayCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,26 @@ 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
/** 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
}
Expand All @@ -46,16 +55,16 @@ interface Props {
* and redemption link overlaid.
*/
export const GiftCardDisplayCard: React.FC<Props> = props => {
const { order, status, onMenuPress, onRedeemComplete } = props
const { order, status, onMenuPress, onGetHelpPress, onRedeemComplete } = props
const theme = useTheme()
const styles = getStyles(theme)

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

Expand Down Expand Up @@ -136,10 +145,28 @@ export const GiftCardDisplayCard: React.FC<Props> = props => {
<View />
)}

{status === 'pending' ? (
{status === 'confirming' ? (
<EdgeText style={styles.pendingText}>
{lstrings.gift_card_confirming}
</EdgeText>
) : status === 'pending' ? (
<EdgeText style={styles.pendingText}>
{lstrings.gift_card_pending}
</EdgeText>
) : status === 'failed' ? (
<EdgeTouchableOpacity
onPress={onGetHelpPress}
style={styles.redeemContainer}
>
<EdgeText style={styles.redeemText}>
{lstrings.gift_card_get_help}
</EdgeText>
<ChevronRightIcon
size={theme.rem(1)}
color={theme.iconTappable}
style={styles.embossedShadow}
/>
</EdgeTouchableOpacity>
) : status === 'available' && redemptionUrl != null ? (
<EdgeTouchableOpacity
onPress={handleRedeem}
Expand All @@ -158,8 +185,8 @@ export const GiftCardDisplayCard: React.FC<Props> = props => {
</View>
</LinearGradient>

{/* Shimmer overlay for pending state */}
<Shimmer isShown={status === 'pending'} />
{/* Shimmer overlay for confirming/pending states */}
<Shimmer isShown={status === 'confirming' || status === 'pending'} />
</View>
)
}
Expand Down
27 changes: 26 additions & 1 deletion src/components/modals/GiftCardMenuModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ 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'
import { lstrings } from '../../locales/strings'
import type { PhazeDisplayOrder } from '../../plugins/gift-cards/phazeGiftCardTypes'
import { useSelector } from '../../types/reactRedux'
import { ArrowRightIcon, CheckIcon } from '../icons/ThemedIcons'
import { ArrowRightIcon, CheckIcon, QuestionIcon } 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'

export type GiftCardMenuResult =
| { type: 'goToTransaction'; transaction: EdgeTransaction; walletId: string }
| { type: 'markAsRedeemed' }
| { type: 'unmarkAsRedeemed' }
| { type: 'getHelp' }
| undefined

interface Props {
Expand Down Expand Up @@ -91,6 +94,10 @@ export const GiftCardMenuModal: React.FC<Props> = props => {
})
})

const handleGetHelp = useHandler(() => {
bridge.resolve({ type: 'getHelp' })
})

// Determine "Go to Transaction" state
const hasTx = transaction != null
const canNavigate = hasTx && order.walletId != null
Expand All @@ -105,6 +112,9 @@ export const GiftCardMenuModal: React.FC<Props> = props => {

return (
<EdgeModal bridge={bridge} title={order.brandName} onCancel={handleCancel}>
<EdgeText style={styles.quoteIdText} numberOfLines={1}>
{sprintf(lstrings.gift_card_quote_id_label_1s, order.quoteId)}
</EdgeText>
<SelectableRow
marginRem={0.5}
title={lstrings.gift_card_go_to_transaction}
Expand Down Expand Up @@ -135,11 +145,26 @@ export const GiftCardMenuModal: React.FC<Props> = props => {
</View>
}
/>
<SelectableRow
title={lstrings.gift_card_get_help}
onPress={handleGetHelp}
icon={
<View style={styles.iconContainer}>
<QuestionIcon size={iconSize} color={iconColor} />
</View>
}
/>
</EdgeModal>
)
}

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),
Expand Down
Loading
Loading