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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased (develop)

- fixed: The info server's `giftCardInfo` config now reaches the app. It was read from the rollup after `asInfoRollup` had already dropped the field, so remotely disabling a gift card provider did nothing at all.
- fixed: Remotely disabling the Phaze gift card provider now drops the Spend flow straight to Bitrefill instead of into the Phaze scenes. The app no longer builds the Phaze provider, registers a Phaze identity, or fetches its catalog while the provider is disabled.

## 4.51.0 (staging)

- added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks.
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/GiftCardActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from '@jest/globals'

import { pickGiftCardDestination } from '../actions/GiftCardActions'

describe('pickGiftCardDestination', () => {
test('opens the market when Phaze is available and unused', () => {
expect(
pickGiftCardDestination({
disablePlugins: {},
hasPhazeApiKey: true,
hasPhazeOrders: false
})
).toBe('giftCardMarket')
})

test('opens the list when the account already holds Phaze orders', () => {
expect(
pickGiftCardDestination({
disablePlugins: {},
hasPhazeApiKey: true,
hasPhazeOrders: true
})
).toBe('giftCardList')
})

test('falls back to Bitrefill when Phaze is remotely disabled', () => {
expect(
pickGiftCardDestination({
disablePlugins: { phaze: true },
hasPhazeApiKey: true,
hasPhazeOrders: false
})
).toBe('bitrefill')
})

test('falls back to Bitrefill when there is no Phaze API key', () => {
expect(
pickGiftCardDestination({
disablePlugins: {},
hasPhazeApiKey: false,
hasPhazeOrders: false
})
).toBe('bitrefill')
})

test('a disabled Phaze skips the list scene even with past orders', () => {
expect(
pickGiftCardDestination({
disablePlugins: { phaze: true },
hasPhazeApiKey: true,
hasPhazeOrders: true
})
).toBe('bitrefill')
})

test('a per-brand Phaze disable leaves the provider usable', () => {
expect(
pickGiftCardDestination({
disablePlugins: { phaze: { '12345': true } },
hasPhazeApiKey: true,
hasPhazeOrders: false
})
).toBe('giftCardMarket')
})

test('falls back to the market when both providers are disabled', () => {
expect(
pickGiftCardDestination({
disablePlugins: { bitrefill: true, phaze: true },
hasPhazeApiKey: true,
hasPhazeOrders: false
})
).toBe('giftCardMarket')
})
})
77 changes: 70 additions & 7 deletions src/actions/GiftCardActions.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,60 @@
import { guiPlugins } from '../constants/plugins/GuiPlugins'
import { ENV } from '../env'
import { hasStoredPhazeIdentity } from '../plugins/gift-cards/phazeGiftCardProvider'
import type { ThunkAction } from '../types/reduxTypes'
import type { NavigationBase } from '../types/routerTypes'
import { showCountrySelectionModal } from './CountryListActions'
import type { NestedDisableMap } from './ExchangeInfoActions'
import {
BITREFILL_PLUGIN_ID,
isGiftCardProviderDisabled,
PHAZE_PLUGIN_ID
} from './GiftCardInfoActions'
import { readSyncedSettings } from './SettingsActions'

export type GiftCardDestination =
| 'bitrefill'
| 'giftCardList'
| 'giftCardMarket'

interface GiftCardDestinationParams {
disablePlugins: NestedDisableMap
hasPhazeApiKey: boolean
/** The account already holds Phaze identities, so it has purchase history. */
hasPhazeOrders: boolean
}

/**
* Picks which gift card destination the Spend entry points open.
*
* Phaze backs every gift card scene, so when it is unavailable — no API key, or
* remotely disabled through the info server — Bitrefill is the whole offering
* and the Phaze scenes are skipped entirely. That includes the list scene: it
* polls the Phaze API every ten seconds, which is exactly the traffic a remote
* disable exists to stop.
*/
export const pickGiftCardDestination = (
params: GiftCardDestinationParams
): GiftCardDestination => {
const { disablePlugins, hasPhazeApiKey, hasPhazeOrders } = params

if (
!hasPhazeApiKey ||
isGiftCardProviderDisabled(disablePlugins, PHAZE_PLUGIN_ID)
) {
// Bitrefill can be remotely disabled too. With both providers off there is
// nothing to open, so the market scene shows its unavailable state.
return isGiftCardProviderDisabled(disablePlugins, BITREFILL_PLUGIN_ID)
? 'giftCardMarket'
: 'bitrefill'
}

return hasPhazeOrders ? 'giftCardList' : 'giftCardMarket'
}

/**
* Navigates to the appropriate gift card scene (list or market) after ensuring
* a country is selected. Shows a country selection modal if needed.
* Navigates to the appropriate gift card destination after ensuring a country is
* selected. Shows a country selection modal if needed.
*
* @returns true if navigation occurred, false if user cancelled country selection
*/
Expand All @@ -15,8 +63,27 @@ export const navigateToGiftCards =
async (dispatch, getState) => {
const state = getState()
const { account } = state.core
const { disablePlugins } = state.ui.giftCardInfo
let { countryCode } = state.ui.settings

const hasPhazeApiKey = ENV.PLUGIN_API_KEYS?.phaze?.apiKey != null
const destination = pickGiftCardDestination({
disablePlugins,
hasPhazeApiKey,
hasPhazeOrders: hasPhazeApiKey && (await hasStoredPhazeIdentity(account))
})

// Going through the Phaze scenes to reach Bitrefill would register a Phaze
// identity and fetch a catalog that is thrown away, which is what surfaced
// an error to the user once the provider was remotely disabled.
if (destination === 'bitrefill') {
navigation.navigate('edgeAppStack', {
screen: 'pluginView',
params: { plugin: guiPlugins.bitrefill }
})
return true
}

// Ensure country is set before proceeding
if (countryCode === '') {
await dispatch(
Expand All @@ -36,11 +103,7 @@ export const navigateToGiftCards =
return false
}

// Navigate to list if user has purchased before, otherwise market
const hasIdentity = await hasStoredPhazeIdentity(account)
navigation.navigate('edgeAppStack', {
screen: hasIdentity ? 'giftCardList' : 'giftCardMarket'
})
navigation.navigate('edgeAppStack', { screen: destination })

return true
}
18 changes: 14 additions & 4 deletions src/actions/GiftCardInfoActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@ export const asGiftCardInfo = asObject({
disablePlugins: asMaybe(asNestedDisableMap, () => ({}))
})

// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map.
// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a
// webview, so only whole-provider disabling applies.
export const PHAZE_PLUGIN_ID = 'phaze'
export const BITREFILL_PLUGIN_ID = 'bitrefill'

export type GiftCardInfo = ReturnType<typeof asGiftCardInfo>

export function updateGiftCardInfo(): ThunkAction<Promise<void>> {
return async dispatch => {
try {
// `giftCardInfo` is a forward-compatible read: the field arrives once the
// edge-info-server dependency that defines it is published and bumped.
// Until then the rollup omits it and we fall back to an empty config.
const rollup = infoServerData.rollup as
// Read `giftCardInfo` from the RAW rollup, not the cleaned one:
// `asInfoRollup` in edge-info-server 3.12.0 has no such key and drops it,
// so the cleaned rollup reports every provider enabled no matter what the
// info server serves. 3.13.0 does define the field, but it also exports an
// attestation module that pulls `jose`'s node build, which Metro cannot
// resolve, so the bump is blocked. This cleaner is ours, so parsing the
// raw payload here needs neither.
const rollup = infoServerData.rollupRaw as
| { giftCardInfo?: unknown }
| undefined
const data = asGiftCardInfo(rollup?.giftCardInfo ?? {})
Expand Down
71 changes: 50 additions & 21 deletions src/components/scenes/GiftCardMarketScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import Animated from 'react-native-reanimated'

import { showCountrySelectionModal } from '../../actions/CountryListActions'
import {
BITREFILL_PLUGIN_ID,
isGiftCardBrandDisabled,
isGiftCardProviderDisabled
isGiftCardProviderDisabled,
PHAZE_PLUGIN_ID
} from '../../actions/GiftCardInfoActions'
import { readSyncedSettings } from '../../actions/SettingsActions'
import { EDGE_CONTENT_SERVER_URI } from '../../constants/CdnConstants'
Expand Down Expand Up @@ -47,12 +49,6 @@ type ViewMode = 'grid' | 'list'
// Internal constant for "All" category comparison - display uses lstrings.string_all
const CATEGORY_ALL = 'All'

// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map.
// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a
// webview, so only whole-provider disabling applies.
const PHAZE_PLUGIN_ID = 'phaze'
const BITREFILL_PLUGIN_ID = 'bitrefill'

/**
* Formats a normalized category for display:
* - Replaces dashes with " & "
Expand Down Expand Up @@ -125,12 +121,27 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
state => state.ui.giftCardInfo.disablePlugins
)

// Provider (requires API key configured)
// Provider config (the Phaze scenes need an API key to do anything)
const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze

// Phaze is the catalog behind this scene. While it is off the scene is a
// Bitrefill shortcut, so skip the provider (which would register a Phaze
// identity) and every query that feeds off it. This matches the condition
// pickGiftCardDestination routes on, so a build with no key lands here in the
// same state a remote disable produces rather than on a permanent loader.
const isPhazeOff =
phazeConfig?.apiKey == null ||
isGiftCardProviderDisabled(giftCardDisablePlugins, PHAZE_PLUGIN_ID)
const isBitrefillDisabled = isGiftCardProviderDisabled(
giftCardDisablePlugins,
BITREFILL_PLUGIN_ID
)
Comment thread
cursor[bot] marked this conversation as resolved.

const { provider, isReady } = useGiftCardProvider({
account,
apiKey: phazeConfig?.apiKey ?? '',
baseUrl: phazeConfig?.baseUrl ?? ''
baseUrl: phazeConfig?.baseUrl ?? '',
enabled: !isPhazeOff
})

// Cache for gift card brands (accessed via provider)
Expand Down Expand Up @@ -269,7 +280,12 @@ export const GiftCardMarketScene: React.FC<Props> = props => {

return allBrands
},
enabled: isConnected && isReady && provider != null && countryCode !== '',
enabled:
isConnected &&
isReady &&
provider != null &&
countryCode !== '' &&
!isPhazeOff,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000,
retry: 1
Expand All @@ -282,19 +298,26 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
}
}, [apiBrands, updateFromBrands])

// A disabled Phaze is never queried, so its half of the market is a settled
// empty list rather than one that is still loading or failed:
const phazeItems = React.useMemo(
() => (isPhazeOff ? [] : items),
[isPhazeOff, items]
)

// Remove phaze brands disabled by the info server, either because the whole
// phaze provider is disabled or because the brand's productId is listed.
const enabledItems = React.useMemo(() => {
if (items == null) return null
return items.filter(
if (phazeItems == null) return null
return phazeItems.filter(
item =>
!isGiftCardBrandDisabled(
giftCardDisablePlugins,
PHAZE_PLUGIN_ID,
String(item.productId)
)
)
}, [items, giftCardDisablePlugins])
}, [phazeItems, giftCardDisablePlugins])

// Build the category list from the enabled items only, so a category whose
// brands are all disabled by the info server does not show a chip that leads
Expand Down Expand Up @@ -498,13 +521,14 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
// Bitrefill provider is remotely disabled)
const listData = React.useMemo(() => {
const base = filteredItems ?? []
if (
isGiftCardProviderDisabled(giftCardDisablePlugins, BITREFILL_PLUGIN_ID)
) {
return base
}
if (isBitrefillDisabled) return base
return [...base, BITREFILL_ITEM]
}, [filteredItems, giftCardDisablePlugins])
}, [filteredItems, isBitrefillDisabled])

// Both providers off leaves nothing to browse, which is a different state
// from a search that matched nothing:
const hasNoProviders =
isBitrefillDisabled && enabledItems != null && enabledItems.length === 0

return (
<SceneWrapper
Expand All @@ -518,7 +542,7 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
headerTitle={lstrings.title_gift_card_market}
headerTitleChildren={<CountryButton onPress={handleRegionSelect} />}
>
{items == null && isBrandsError ? (
{phazeItems == null && isBrandsError ? (
<AlertCardUi4
type="warning"
title={
Expand All @@ -527,8 +551,13 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
: lstrings.gift_card_network_error
}
/>
) : items == null ? (
) : phazeItems == null ? (
<FillLoader />
) : hasNoProviders ? (
<AlertCardUi4
type="warning"
title={lstrings.gift_card_providers_unavailable}
/>
) : (
<>
<View style={styles.categoryRow}>
Expand Down
Loading
Loading