From a5e3919cb3026d16e3ba155ec7f987a4d7bbee0d Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:04:19 -0800 Subject: [PATCH 01/14] Improve logging with category-based debug and config - Replace bit-flag logging with string categories (e.g. 'phaze', 'coinrank') - Add LOG_CONFIG in env.json for enabling categories - Add header masking for sensitive API keys in logs - Improve type safety (unknown instead of any) - Add Phaze plugin API key configuration --- eslint.config.mjs | 8 +- src/components/cards/MarketsCard.tsx | 8 +- src/components/rows/CoinRankRow.tsx | 16 +- src/components/scenes/CoinRankingScene.tsx | 37 +++-- src/envConfig.ts | 36 ++++- src/util/logger.ts | 174 ++++++++++++++++----- 6 files changed, 200 insertions(+), 79 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ad9c0204ab9..493141bc769 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -125,7 +125,7 @@ export default [ 'src/components/cards/IconMessageCard.tsx', 'src/components/cards/LoanDetailsSummaryCard.tsx', 'src/components/cards/LoanSummaryCard.tsx', - 'src/components/cards/MarketsCard.tsx', + 'src/components/cards/StakingOptionCard.tsx', 'src/components/cards/StakingReturnsCard.tsx', 'src/components/cards/SupportCard.tsx', @@ -226,7 +226,7 @@ export default [ 'src/components/progress-indicators/LoadingSplashScreen.tsx', 'src/components/progress-indicators/StepProgressBar.tsx', - 'src/components/rows/CoinRankRow.tsx', + 'src/components/rows/CryptoFiatAmountRow.tsx', 'src/components/rows/CurrencyRow.tsx', @@ -241,7 +241,7 @@ export default [ 'src/components/scenes/ChangePinScene.tsx', 'src/components/scenes/ChangeUsernameScene.tsx', 'src/components/scenes/CoinRankingDetailsScene.tsx', - 'src/components/scenes/CoinRankingScene.tsx', + 'src/components/scenes/ConfirmScene.tsx', 'src/components/scenes/CreateWalletAccountSelectScene.tsx', 'src/components/scenes/CreateWalletAccountSetupScene.tsx', @@ -516,7 +516,7 @@ export default [ 'src/util/GuiPluginTools.ts', 'src/util/haptic.ts', 'src/util/infoUtils.ts', - 'src/util/logger.ts', + 'src/util/maestro.ts', 'src/util/memoUtils.ts', 'src/util/middleware/perfLogger.ts', diff --git a/src/components/cards/MarketsCard.tsx b/src/components/cards/MarketsCard.tsx index 897e44bfb49..5a94b4e9153 100644 --- a/src/components/cards/MarketsCard.tsx +++ b/src/components/cards/MarketsCard.tsx @@ -12,7 +12,7 @@ import { useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' import type { EdgeAsset } from '../../types/types' import { getCurrencyIconUris } from '../../util/CdnUris' -import { debugLog, LOG_COINRANK } from '../../util/logger' +import { debugLog } from '../../util/logger' import { fetchRates } from '../../util/network' import { makePeriodicTask } from '../../util/PeriodicTask' import { DECIMAL_PRECISION } from '../../util/utils' @@ -71,7 +71,7 @@ interface CoinRowProps { fiatCurrencyCode: string } -const CoinRow = (props: CoinRowProps) => { +const CoinRow: React.FC = props => { const { coinRow, index, navigation, fiatCurrencyCode } = props const theme = useTheme() @@ -139,7 +139,7 @@ const CoinRow = (props: CoinRowProps) => { /** * Card that displays market summary info for top coins */ -export const MarketsCard = (props: Props) => { +export const MarketsCard: React.FC = props => { const { numRows } = props const coingeckoFiat = useSelector(state => getCoingeckoFiat(state)) @@ -190,7 +190,7 @@ export const MarketsCard = (props: Props) => { { onError(error: unknown) { console.warn(error) - debugLog(LOG_COINRANK, String(error)) + debugLog('coinrank', String(error)) } } ) diff --git a/src/components/rows/CoinRankRow.tsx b/src/components/rows/CoinRankRow.tsx index fae72505e8b..bc3abbb4e13 100644 --- a/src/components/rows/CoinRankRow.tsx +++ b/src/components/rows/CoinRankRow.tsx @@ -15,7 +15,7 @@ import type { } from '../../types/coinrankTypes' import type { EdgeAppSceneProps } from '../../types/routerTypes' import { triggerHaptic } from '../../util/haptic' -import { debugLog, LOG_COINRANK } from '../../util/logger' +import { debugLog } from '../../util/logger' import { DECIMAL_PRECISION } from '../../util/utils' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' @@ -35,7 +35,7 @@ const REFRESH_INTERVAL_RANGE = 10000 type Timeout = ReturnType -const CoinRankRowComponent = (props: Props) => { +const CoinRankRowComponent: React.FC = props => { const { navigation, index, @@ -69,12 +69,12 @@ const CoinRankRowComponent = (props: Props) => { }) React.useEffect(() => { - const newTimer = () => { + const newTimer = (): void => { const nextRefresh = MIN_REFRESH_INTERVAL + Math.random() * REFRESH_INTERVAL_RANGE timeoutHandler.current = setTimeout(loop, nextRefresh) } - const loop = () => { + const loop = (): void => { if (!mounted.current) return const newCoinRow = coinRankingDatas[index] if (newCoinRow == null) { @@ -82,7 +82,7 @@ const CoinRankRowComponent = (props: Props) => { return } if (coinRow == null) { - debugLog(LOG_COINRANK, `New Row ${index} ${newCoinRow.currencyCode}`) + debugLog('coinrank', `New Row ${index} ${newCoinRow.currencyCode}`) setCoinRow(newCoinRow) newTimer() return @@ -103,11 +103,11 @@ const CoinRankRowComponent = (props: Props) => { currencyCode !== newCurrencyCode ) { debugLog( - LOG_COINRANK, + 'coinrank', `Refresh Row ${index} old: ${currencyCode} ${price} ${pctf}` ) debugLog( - LOG_COINRANK, + 'coinrank', ` ${index} new: ${newCurrencyCode} ${newPrice} ${newPctf}` ) setCoinRow(newCoinRow) @@ -134,7 +134,7 @@ const CoinRankRowComponent = (props: Props) => { const { currencyCode, price, marketCap, volume24h, percentChange, rank } = coinRow debugLog( - LOG_COINRANK, + 'coinrank', `CoinRankRow index=${index} rank=${rank} currencyCode=${currencyCode}` ) diff --git a/src/components/scenes/CoinRankingScene.tsx b/src/components/scenes/CoinRankingScene.tsx index 9af7310ab0d..7ff03d39020 100644 --- a/src/components/scenes/CoinRankingScene.tsx +++ b/src/components/scenes/CoinRankingScene.tsx @@ -18,7 +18,7 @@ import { } from '../../types/coinrankTypes' import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps } from '../../types/routerTypes' -import { debugLog, enableDebugLogType, LOG_COINRANK } from '../../util/logger' +import { debugLog } from '../../util/logger' import { fetchRates } from '../../util/network' import { EdgeAnim, MAX_LIST_ITEMS_ANIM } from '../common/EdgeAnim' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' @@ -42,9 +42,6 @@ let lastSceneFiat: string const QUERY_PAGE_SIZE = 30 const LISTINGS_REFRESH_INTERVAL = 30000 -// Masking enable bit with 0 disables logging -enableDebugLogType(LOG_COINRANK & 0) - interface Props extends EdgeAppSceneProps<'coinRanking'> {} const percentChangeOrder: PercentChangeTimeFrame[] = [ @@ -66,7 +63,7 @@ const assetSubTextStrings: Record = { volume24h: lstrings.coin_rank_volume_24hr_abbreviation } -const CoinRankingComponent = (props: Props) => { +const CoinRankingComponent: React.FC = props => { const theme = useTheme() const styles = getStyles(theme) const { navigation } = props @@ -98,7 +95,7 @@ const CoinRankingComponent = (props: Props) => { // here as close to the site of the state change as possible to ensure this // happens before anything else. if (lastSceneFiat != null && currentCoinGeckoFiat !== lastSceneFiat) { - debugLog(LOG_COINRANK, 'clearing cache') + debugLog('coinrank', 'clearing cache') lastSceneFiat = currentCoinGeckoFiat coinRanking.coinRankingDatas = [] } @@ -116,13 +113,15 @@ const CoinRankingComponent = (props: Props) => { [assetSubText, coingeckoFiat, percentChangeTimeFrame] ) - const renderItem = (itemObj: ListRenderItemInfo) => { + const renderItem = ( + itemObj: ListRenderItemInfo + ): React.ReactElement => { const { index, item } = itemObj const currencyCode = coinRankingDatas[index]?.currencyCode ?? 'NO_CURRENCY_CODE' const rank = coinRankingDatas[index]?.rank ?? 'NO_RANK' const key = `${index}-${item}-${rank}-${currencyCode}-${coingeckoFiat}` - debugLog(LOG_COINRANK, `renderItem ${key.toString()}`) + debugLog('coinrank', `renderItem ${key.toString()}`) return ( { const handleEndReached = useHandler(() => { debugLog( - LOG_COINRANK, + 'coinrank', `handleEndReached. setRequestDataSize ${ requestDataSize + QUERY_PAGE_SIZE }` @@ -197,7 +196,7 @@ const CoinRankingComponent = (props: Props) => { // Start querying starting from either the last fetched index (scrolling) or // the first index (initial load/timed refresh) const queryLoop = useAbortable(maybeAbort => async (startIndex: number) => { - debugLog(LOG_COINRANK, `queryLoop(start: ${startIndex})`) + debugLog('coinrank', `queryLoop(start: ${startIndex})`) try { // Catch up to the total required items @@ -216,7 +215,7 @@ const CoinRankingComponent = (props: Props) => { const row = listings.data[i] coinRankingDatas[rankIndex] = row debugLog( - LOG_COINRANK, + 'coinrank', `queryLoop: ${rankIndex.toString()} ${row.rank} ${row.currencyCode}` ) } @@ -237,24 +236,24 @@ const CoinRankingComponent = (props: Props) => { React.useEffect(() => { const { promise, abort } = queryLoop(lastStartIndex.current) pageQueryAbortRef.current = abort - promise.catch(e => { - console.error(`Error in query loop: ${e.message}`) + promise.catch((e: unknown) => { + console.error(`Error in query loop: ${String(e)}`) }) return abort }, [queryLoop, requestDataSize]) // Subscribe to changes to coingeckoFiat and update the periodic refresh React.useEffect(() => { - let abort = () => {} + let abort = (): void => {} // Refresh from the beginning periodically let timeoutId = setTimeout(loopBody, LISTINGS_REFRESH_INTERVAL) - function loopBody() { - debugLog(LOG_COINRANK, 'Refreshing list') + function loopBody(): void { + debugLog('coinrank', 'Refreshing list') const abortable = queryLoop(1) abort = abortable.abort abortable.promise - .catch(e => { - console.error(`Error in query loop: ${e.message}`) + .catch((e: unknown) => { + console.error(`Error in query loop: ${String(e)}`) }) .finally(() => { timeoutId = setTimeout(loopBody, LISTINGS_REFRESH_INTERVAL) @@ -275,7 +274,7 @@ const CoinRankingComponent = (props: Props) => { const listData: number[] = React.useMemo(() => { debugLog( - LOG_COINRANK, + 'coinrank', `Updating listData dataSize=${dataSize} searchText=${searchText}` ) const out = [] diff --git a/src/envConfig.ts b/src/envConfig.ts index 300a850c15a..cdb4fc9d5d5 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -78,6 +78,33 @@ export const asEnvConfig = asObject({ mockMode: false } ), + + // Debug logging configuration: + LOG_CONFIG: asOptional( + asObject({ + // Categories to enable (e.g., ['phaze', 'coinrank']) + enabledCategories: asOptional(asArray(asString), () => []), + // Whether to mask sensitive headers in API logs + maskSensitiveHeaders: asOptional(asBoolean, true), + // Header names to mask (case-insensitive) + sensitiveHeaders: asOptional(asArray(asString), () => [ + 'api-key', + 'user-api-key', + 'authorization', + 'x-api-key' + ]) + }), + () => ({ + enabledCategories: [], + maskSensitiveHeaders: true, + sensitiveHeaders: [ + 'api-key', + 'user-api-key', + 'authorization', + 'x-api-key' + ] + }) + ), PLUGIN_API_KEYS: asOptional( asObject({ banxa: asOptional( @@ -133,6 +160,12 @@ export const asEnvConfig = asObject({ merchantId: asNumber, scope: asString }) + ), + phaze: asOptional( + asObject({ + apiKey: asString, + baseUrl: asString + }) ) }).withRest, () => ({ @@ -145,7 +178,8 @@ export const asEnvConfig = asObject({ paybis: undefined, revolut: undefined, simplex: undefined, - ionia: undefined + ionia: undefined, + phaze: undefined }) ), RAMP_PLUGIN_INITS: asOptional( diff --git a/src/util/logger.ts b/src/util/logger.ts index e9ac57d6192..d5877cab352 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -27,12 +27,13 @@ const logMap = { activity: makePaths('activity') } -const getTime = () => new Date().toISOString() +const getTime = (): string => new Date().toISOString() -const isObject = (item: any) => typeof item === 'object' && item !== null -const isError = (item: any): item is Error => item instanceof Error +const isObject = (item: unknown): boolean => + typeof item === 'object' && item !== null +const isError = (item: unknown): item is Error => item instanceof Error -const normalize = (...info: any[]) => +const normalize = (...info: unknown[]): string => `${getTime()} | ${info .map(item => isError(item) @@ -50,8 +51,7 @@ const NUM_WRITES_BEFORE_ROTATE_CHECK = 100 let numWrites = 0 -// @ts-expect-error -async function isLogFileLimitExceeded(filePath) { +async function isLogFileLimitExceeded(filePath: string): Promise { const stats = await RNFS.stat(filePath) return Number(stats.size) > MAX_BYTE_SIZE_PER_FILE @@ -77,8 +77,8 @@ async function rotateLogs(type: LogType): Promise { } await RNFS.writeFile(paths[0], '') numWrites = 0 - } catch (e: any) { - // @ts-expect-error + } catch (e: unknown) { + // @ts-expect-error - global.clog is injected at runtime global.clog(e) } } @@ -124,9 +124,9 @@ async function writeLog(type: LogType, content: string): Promise { } else { await RNFS.writeFile(path, content) } - } catch (e: any) { - // @ts-expect-error - global.clog(e?.message ?? e) + } catch (e: unknown) { + // @ts-expect-error - global.clog is injected at runtime + global.clog(e instanceof Error ? e.message : e) } } @@ -152,9 +152,9 @@ export async function readLogs(type: LogType): Promise { } } return log - } catch (err: any) { - // @ts-expect-error - global.clog(err?.message ?? err) + } catch (err: unknown) { + // @ts-expect-error - global.clog is injected at runtime + global.clog(err instanceof Error ? err.message : err) } } @@ -171,16 +171,16 @@ export async function logWithType( await lock.acquire('logger', async () => { await writeLog(type, d + ': ' + logs) }) - } catch (e: any) { - // @ts-expect-error + } catch (e: unknown) { + // @ts-expect-error - global.clog is injected at runtime global.clog(e) } - // @ts-expect-error + // @ts-expect-error - global.clog is injected at runtime global.clog(logs) } export function log(...info: Array): void { - logWithType('info', ...info).catch(err => { + logWithType('info', ...info).catch((err: unknown) => { console.warn(err) }) } @@ -188,50 +188,138 @@ export function log(...info: Array): void { export function logActivity( ...info: Array ): void { - logWithType('activity', ...info).catch(err => { + logWithType('activity', ...info).catch((err: unknown) => { console.warn(err) }) } -async function request(data: string) { - return await global.fetch( - // @ts-expect-error - `${ENV.LOG_SERVER.host}:${ENV.LOG_SERVER.port}/log`, - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ data }) - } - ) +async function request(data: string): Promise { + // @ts-expect-error - ENV.LOG_SERVER may not be defined in all configs + const logServer = ENV.LOG_SERVER as { host: string; port: number } | undefined + return await global.fetch(`${logServer?.host}:${logServer?.port}/log`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ data }) + }) } -export function logToServer(...info: any[]) { - const args = info[0] +export function logToServer(...info: unknown[]): void { + const args = info[0] as unknown[] let logs = '' for (const item of args) { if (isObject(item)) { logs = logs + (' ' + JSON.stringify(item)) } else { - logs = logs + (' ' + item) + logs = logs + (' ' + String(item)) } } - request(logs).catch(err => { + request(logs).catch((err: unknown) => { console.error(err) console.log('Failed logToServer') }) } -// Log function meant to be used in various modules that is disabled by default and enabled -// by setting enable bits by log type -let debugLogType = 0 -export const LOG_COINRANK = 0x0001 -export const debugLog = (type: number, ...args: any): void => { +// --------------------------------------------------------------------------- +// Configurable Debug Logging +// --------------------------------------------------------------------------- +// Configure via LOG_CONFIG in env.json: +// { +// "LOG_CONFIG": { +// "enabledCategories": ["phaze", "coinrank"], +// "maskSensitiveHeaders": true, +// "sensitiveHeaders": ["api-key", "user-api-key", "authorization"] +// } +// } +// --------------------------------------------------------------------------- + +interface LogConfig { + enabledCategories: Set + maskSensitiveHeaders: boolean + sensitiveHeaders: Set +} + +/** Get log config from ENV with defaults */ +const getLogConfig = (): LogConfig => { + const config = ENV.LOG_CONFIG ?? {} + return { + enabledCategories: new Set( + (config.enabledCategories ?? []).map((c: string) => c.toLowerCase()) + ), + maskSensitiveHeaders: config.maskSensitiveHeaders ?? true, + sensitiveHeaders: new Set( + ( + config.sensitiveHeaders ?? [ + 'api-key', + 'user-api-key', + 'authorization', + 'x-api-key' + ] + ).map((h: string) => h.toLowerCase()) + ) + } +} + +// Cache config at module load (ENV is static) +const logConfig = getLogConfig() + +/** + * Check if a log category is enabled. + * Categories are configured via LOG_CONFIG.enabledCategories in env.json. + */ +export const isLogCategoryEnabled = (category: string): boolean => { + return logConfig.enabledCategories.has(category.toLowerCase()) +} + +/** + * Enable a log category at runtime. + * Useful for debugging in development. + */ +export const enableLogCategory = (category: string): void => { + logConfig.enabledCategories.add(category.toLowerCase()) +} + +/** + * Disable a log category at runtime. + */ +export const disableLogCategory = (category: string): void => { + logConfig.enabledCategories.delete(category.toLowerCase()) +} + +/** + * Debug log function that only outputs when the category is enabled. + * Categories are simple strings like 'phaze', 'coinrank', etc. + * + * @example + * debugLog('phaze', 'Fetching gift cards...') + * debugLog('coinrank', 'Refreshing rankings', { page: 1 }) + */ +export const debugLog = (category: string, ...args: unknown[]): void => { + if (!logConfig.enabledCategories.has(category.toLowerCase())) return // Provides date formatting for the form '01-14 03:43:56.273' const dateTime = new Date().toISOString().slice(5, 23).replace('T', ' ') - if (type & debugLogType) console.log(dateTime, ...args) + console.log(dateTime, `[${category}]`, ...args) } -export const enableDebugLogType = (type: number) => (debugLogType |= type) +/** + * Mask sensitive values in headers for safe logging. + * Shows first 4 characters followed by '***' for sensitive headers. + * Controlled by LOG_CONFIG.maskSensitiveHeaders and LOG_CONFIG.sensitiveHeaders. + */ +export const maskHeaders = ( + headers: Record +): Record => { + if (!logConfig.maskSensitiveHeaders) return headers + + const masked: Record = {} + for (const [key, value] of Object.entries(headers)) { + if (logConfig.sensitiveHeaders.has(key.toLowerCase())) { + masked[key] = value.length > 4 ? value.slice(0, 4) + '***' : '***' + } else { + masked[key] = value + } + } + return masked +} From a294dcb21c1d4c7dde4ab5f068518ad1879b7d5f Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:04:56 -0800 Subject: [PATCH 02/14] Add CAIP-19 and parseLinkedText utilities - caip19Utils: Convert between EdgeAsset and CAIP-19 identifiers - parseLinkedText: Render HTML links as tappable React elements --- src/util/caip19Utils.ts | 249 +++++++++++++++++++++++++++++++++++ src/util/parseLinkedText.tsx | 40 ++++++ 2 files changed, 289 insertions(+) create mode 100644 src/util/caip19Utils.ts create mode 100644 src/util/parseLinkedText.tsx diff --git a/src/util/caip19Utils.ts b/src/util/caip19Utils.ts new file mode 100644 index 00000000000..2616f371f80 --- /dev/null +++ b/src/util/caip19Utils.ts @@ -0,0 +1,249 @@ +import type { EdgeAccount } from 'edge-core-js' + +import { SPECIAL_CURRENCY_INFO } from '../constants/WalletAndCurrencyConstants' +import type { EdgeAsset } from '../types/types' +import { + findTokenIdByNetworkLocation, + getContractAddress +} from './CurrencyInfoHelpers' + +// --------------------------------------------------------------------------- +// CAIP-19 Chain Reference Constants +// See: https://namespaces.chainagnostic.org/ +// --------------------------------------------------------------------------- + +// BIP-122 genesis hashes (first 32 chars of genesis block hash) +// See: https://namespaces.chainagnostic.org/bip122/caip2 +const BIP122_GENESIS = { + bitcoin: '000000000019d6689c085ae165831e93', + // BCH uses block 478559 (first block after fork) per CAIP spec + bitcoincash: '000000000000000000651ef99cb9fcbe', + litecoin: '12a765e31ffd4059bada1e25190f6e98' +} as const + +// Solana mainnet genesis hash (first 32 chars, base58) +// See: https://namespaces.chainagnostic.org/solana/caip2 +const SOLANA_MAINNET_GENESIS = '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' + +// Tron chain reference used by Phaze API +// NOTE: No official CASA namespace spec exists for Tron. This value appears +// to be a truncated genesis hash. We use it for Phaze compatibility. +const TRON_CHAIN_REF = '0x2b6653dc' + +/** + * Convert an `EdgeAsset` (pluginId + tokenId) to a CAIP-19 asset_type string. + * + * Supported formats (per CAIP-19 spec): + * - EVM chains: eip155:{chainId}/erc20:{contract} or eip155:{chainId}/slip44:{coinType} + * - BIP-122 chains: bip122:{genesisHash}/slip44:{coinType} + * - Solana: solana:{genesisHash}/slip44:501 + * - Tron: tron:{chainRef}/trc20:{contract} (non-standard, for Phaze compatibility) + * + * Examples: + * eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + * eip155:1/slip44:60 + * bip122:000000000019d6689c085ae165831e93/slip44:0 + * bip122:000000000000000000651ef99cb9fcbe/slip44:145 + * solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501 + * tron:0x2b6653dc/trc20:TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj + * + * See: https://chainagnostic.org/CAIPs/caip-19 + */ +export function edgeAssetToCaip19( + account: EdgeAccount, + asset: EdgeAsset +): string | undefined { + const { pluginId, tokenId } = asset + const special = SPECIAL_CURRENCY_INFO[pluginId] + const wc = special?.walletConnectV2ChainId + + // EVM chains (eip155) + if (wc?.namespace === 'eip155') { + const chainRef = wc.reference + if (tokenId != null) { + const contract = getContractAddress( + account.currencyConfig[pluginId], + tokenId + ) + if (contract == null) return + return `eip155:${chainRef}/erc20:${contract}` + } + const slip44Map: Record = { + ethereum: 60, + arbitrum: 60, + optimism: 60, + base: 60, + polygon: 966, + avalanche: 9000, + binancesmartchain: 9006 + } + const slip = slip44Map[pluginId] ?? 60 + return `eip155:${chainRef}/slip44:${slip}` + } + + // BIP-122 chains (Bitcoin, Bitcoin Cash, Litecoin) + // Format: bip122:/slip44: + const bip122Chains: Record = + { + bitcoin: { genesisHash: BIP122_GENESIS.bitcoin, slip44: 0 }, + bitcoincash: { genesisHash: BIP122_GENESIS.bitcoincash, slip44: 145 }, + litecoin: { genesisHash: BIP122_GENESIS.litecoin, slip44: 2 } + } + + const bip122Info = bip122Chains[pluginId] + if (bip122Info != null && tokenId == null) { + return `bip122:${bip122Info.genesisHash}/slip44:${bip122Info.slip44}` + } + + // Solana native - uses genesis hash per CAIP spec + if (pluginId === 'solana' && tokenId == null) { + return `solana:${SOLANA_MAINNET_GENESIS}/slip44:501` + } + + // Tron - uses Phaze-specific chain reference (no official CAIP spec) + if (pluginId === 'tron') { + if (tokenId != null) { + let contract: string | undefined + try { + contract = + getContractAddress(account.currencyConfig[pluginId], tokenId) ?? + undefined + } catch { + const raw = account.currencyConfig[pluginId]?.allTokens?.[tokenId] + ?.networkLocation as { contractAddress?: string } | undefined + if (raw?.contractAddress != null) contract = String(raw.contractAddress) + } + if (contract == null) return + return `tron:${TRON_CHAIN_REF}/trc20:${contract}` + } + // Native TRX + return `tron:${TRON_CHAIN_REF}/slip44:195` + } +} + +/** + * Parse a CAIP-19 string and resolve it to an `EdgeAsset` based on the + * account's currency configurations. + * + * Supported formats: + * - eip155:{chainId}/erc20:{contract} - EVM tokens + * - eip155:{chainId}/slip44:{coinType} - EVM native assets + * - bip122:{genesisHash}/slip44:{coinType} - BTC, BCH, LTC + * - solana:{genesisHash}/slip44:501 - Solana native + * - tron:{chainRef}/trc20:{contract} - TRC20 tokens + * + * Returns undefined if not resolvable/supported. + */ +export function caip19ToEdgeAsset( + account: EdgeAccount, + caip19: string +): EdgeAsset | undefined { + // Basic parse: ":/:" + const [chainPart, assetPart] = caip19.split('/') + if (chainPart == null || assetPart == null) return + const [namespace, reference] = chainPart.split(':') + + // EVM chains (eip155) + if (namespace === 'eip155' && reference != null) { + let pluginId: string | undefined + for (const [pid, info] of Object.entries(SPECIAL_CURRENCY_INFO)) { + if ( + info.walletConnectV2ChainId?.namespace === 'eip155' && + info.walletConnectV2ChainId?.reference === reference + ) { + pluginId = pid + break + } + } + if (pluginId == null) return + + const [assetNs, assetRef] = assetPart.split(':') + if (assetNs === 'erc20') { + const tokenId = findTokenIdByNetworkLocation({ + account, + pluginId, + networkLocation: { contractAddress: assetRef.toLowerCase() } + }) + if (tokenId == null) return + return { pluginId, tokenId } + } + if (assetNs === 'slip44') { + return { pluginId, tokenId: null } + } + return + } + + // BIP-122 chains (Bitcoin, Bitcoin Cash, Litecoin) + // Format: bip122:/slip44: + if (namespace === 'bip122' && reference != null) { + const genesisToPlugin: Record = { + [BIP122_GENESIS.bitcoin]: 'bitcoin', + [BIP122_GENESIS.bitcoincash]: 'bitcoincash', + [BIP122_GENESIS.litecoin]: 'litecoin' + } + const [assetNs, assetRef] = assetPart.split(':') + + // Determine pluginId from genesis hash + let pluginId = genesisToPlugin[reference] + + // Fallback: BTC genesis hash with slip44:145 means BCH + // (some implementations may use BTC's genesis for BCH) + if (pluginId == null && reference === BIP122_GENESIS.bitcoin) { + if (assetNs === 'slip44' && assetRef === '145') { + pluginId = 'bitcoincash' + } + } + + if (pluginId == null) return + + if (assetNs === 'slip44') { + return { pluginId, tokenId: null } + } + return + } + + // Solana - accepts genesis hash format per CAIP spec + if (namespace === 'solana' && reference != null) { + // Accept mainnet genesis hash (standard) or 'mainnet' (legacy/non-standard) + if (reference !== SOLANA_MAINNET_GENESIS && reference !== 'mainnet') { + return + } + const [assetNs, assetRef] = assetPart.split(':') + if (assetNs === 'slip44') { + return { pluginId: 'solana', tokenId: null } + } + // Legacy format: solana:mainnet/sol:native + if (assetNs === 'sol' && assetRef === 'native') { + return { pluginId: 'solana', tokenId: null } + } + return + } + + // Tron - accepts Phaze chain reference or legacy 'mainnet' + // NOTE: No official CAIP namespace for Tron exists + if (namespace === 'tron' && reference != null) { + // Accept Phaze's chain reference or legacy 'mainnet' + if (reference !== TRON_CHAIN_REF && reference !== 'mainnet') { + return + } + const [assetNs, assetRef] = assetPart.split(':') + + if (assetNs === 'slip44') { + return { pluginId: 'tron', tokenId: null } + } + + // TRC20 tokens + if (assetNs === 'trc20') { + const currencyConfig = account.currencyConfig.tron + if (currencyConfig == null) return + const allTokens = currencyConfig.allTokens + for (const [tid, token] of Object.entries(allTokens)) { + const contract = (token.networkLocation as { contractAddress?: string }) + ?.contractAddress + if (typeof contract === 'string' && contract === assetRef) { + return { pluginId: 'tron', tokenId: tid } + } + } + } + } +} diff --git a/src/util/parseLinkedText.tsx b/src/util/parseLinkedText.tsx new file mode 100644 index 00000000000..23c1b5b98fa --- /dev/null +++ b/src/util/parseLinkedText.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import type { TextStyle } from 'react-native' + +import { EdgeText } from '../components/themed/EdgeText' + +/** + * Parses a localized string with {{link}}...{{/link}} markers and returns + * React nodes with the link portion wrapped in a tappable EdgeText component. + * + * This allows translators to place the link anywhere in the sentence while + * maintaining proper localization. + * + * Example: + * Input: "By sliding, you agree to the {{link}}terms{{/link}}." + * Output: ["By sliding, you agree to the ", , "."] + * + * @param text The localized string containing {{link}}...{{/link}} markers + * @param onLinkPress Callback when the link is pressed + * @param linkStyle Style to apply to the tappable link text + * @returns React nodes that can be rendered directly in JSX + */ +export const parseLinkedText = ( + text: string, + onLinkPress: () => void, + linkStyle?: TextStyle +): React.ReactNode => { + const match = /^(.*){{link}}(.+){{\/link}}(.*)$/.exec(text) + if (match == null) return text + + const [, before, linkText, after] = match + return ( + <> + {before} + + {linkText} + + {after} + + ) +} From eee9af2958a32258c06b60e5dde8a34991866666 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:05:09 -0800 Subject: [PATCH 03/14] Add Phaze gift card plugin core - phazeApi: HTTP client with header masking - phazeGiftCardTypes: TypeScript types and cleaners - phazeGiftCardCache: Two-layer cache (memory + disk) for brands - phazeGiftCardOrderStore: Order augments persistence - phazeGiftCardProvider: High-level API for UI consumption - phazeOrderPollingService: Background voucher polling --- src/plugins/gift-cards/phazeApi.ts | 280 ++++++++++ src/plugins/gift-cards/phazeGiftCardCache.ts | 357 +++++++++++++ .../gift-cards/phazeGiftCardOrderStore.ts | 220 ++++++++ .../gift-cards/phazeGiftCardProvider.ts | 503 ++++++++++++++++++ src/plugins/gift-cards/phazeGiftCardTypes.ts | 409 ++++++++++++++ .../gift-cards/phazeOrderPollingService.ts | 196 +++++++ 6 files changed, 1965 insertions(+) create mode 100644 src/plugins/gift-cards/phazeApi.ts create mode 100644 src/plugins/gift-cards/phazeGiftCardCache.ts create mode 100644 src/plugins/gift-cards/phazeGiftCardOrderStore.ts create mode 100644 src/plugins/gift-cards/phazeGiftCardProvider.ts create mode 100644 src/plugins/gift-cards/phazeGiftCardTypes.ts create mode 100644 src/plugins/gift-cards/phazeOrderPollingService.ts diff --git a/src/plugins/gift-cards/phazeApi.ts b/src/plugins/gift-cards/phazeApi.ts new file mode 100644 index 00000000000..739840155e9 --- /dev/null +++ b/src/plugins/gift-cards/phazeApi.ts @@ -0,0 +1,280 @@ +import { asJSON, asMaybe } from 'cleaners' + +import { debugLog, maskHeaders } from '../../util/logger' +import { + asPhazeCreateOrderResponse, + asPhazeError, + asPhazeGiftCardsResponse, + asPhazeOrderStatusResponse, + asPhazeRegisterUserResponse, + asPhazeTokensResponse, + type PhazeCreateOrderRequest, + type PhazeOrderStatusResponse, + type PhazeRegisterUserRequest +} from './phazeGiftCardTypes' + +// --------------------------------------------------------------------------- +// Field definitions for different use cases +// --------------------------------------------------------------------------- + +/** + * Fields needed for market listing display (minimal payload). + * These are the fields shown in GiftCardMarketScene tiles/list items. + */ +export const MARKET_LISTING_FIELDS = [ + 'brandName', + 'countryName', + 'currency', + 'denominations', + 'valueRestrictions', + 'productId', + 'productImage', + 'categories' +].join(',') + +/** + * Additional fields needed for the purchase scene. + * Used when fetching full brand details. + */ +export const PURCHASE_DETAIL_FIELDS = [ + 'productDescription', + 'termsAndConditions', + 'howToUse', + 'expiryAndValidity', + 'discount', + 'deliveryFeeInPercentage', + 'deliveryFlatFee', + 'deliveryFlatFeeCurrency' +] + +/** + * All fields needed for a complete brand object. + */ +export const FULL_BRAND_FIELDS = [ + ...MARKET_LISTING_FIELDS.split(','), + ...PURCHASE_DETAIL_FIELDS +].join(',') + +export interface PhazeApiConfig { + baseUrl: string + apiKey: string + /** Optional on first run; required for order endpoints once registered */ + userApiKey?: string + /** Optional: Some endpoints may allow/require a public-key header */ + publicKey?: string +} + +export interface PhazeApi { + // Configuration helpers: + setUserApiKey: (userApiKey: string | undefined) => void + getUserApiKey: () => string | undefined + + // Endpoints: + getTokens: () => Promise> + getGiftCards: (params: { + countryCode: string + currentPage?: number + perPage?: number + brandName?: string + }) => Promise> + getFullGiftCards: (params: { + countryCode: string + /** Comma-separated list of fields to return (reduces payload size) */ + fields?: string + /** Filter expression (e.g., "categories=pets") */ + filter?: string + }) => Promise> + getUserByEmail: ( + email: string + ) => Promise> + registerUser: ( + body: PhazeRegisterUserRequest + ) => Promise> + createOrder: ( + body: PhazeCreateOrderRequest + ) => Promise> + getOrderStatus: (params: { + quoteId?: string + currentPage?: number + }) => Promise +} + +export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => { + let userApiKey = config.userApiKey + + const makeHeaders = (opts?: { + includeUserKey?: boolean + includePublicKey?: boolean + }): Record => { + const headers: Record = { + 'API-Key': config.apiKey + } + if (opts?.includeUserKey === true && userApiKey != null) { + headers['user-api-key'] = userApiKey + } + if (opts?.includePublicKey === true && config.publicKey != null) { + headers['public-key'] = config.publicKey + } + return headers + } + + const buildUrl = ( + path: string, + query?: Record + ): string => { + // Ensure baseUrl ends with / and path doesn't start with / + // to properly join paths (new URL ignores base path if path is absolute) + const base = config.baseUrl.endsWith('/') + ? config.baseUrl + : config.baseUrl + '/' + const cleanPath = path.startsWith('/') ? path.slice(1) : path + const url = new URL(cleanPath, base) + if (query != null) { + for (const [k, v] of Object.entries(query)) { + if (v == null) continue + url.searchParams.set(k, String(v)) + } + } + return url.toString() + } + + const fetchPhaze: typeof fetch = async (input, init) => { + // Input should already be a full URL from buildUrl, just pass through + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + + // Debug logging - only logs when 'phaze' category is enabled, with masked headers + const rawHeaders = (init?.headers as Record) ?? {} + const maskedHeaders = maskHeaders(rawHeaders) + const headersStr = Object.entries(maskedHeaders) + .map(([key, value]) => ` -H '${key}: ${String(value)}'`) + .join('') + const bodyStr = + init?.body != null && typeof init.body === 'string' + ? ` -d '${init.body}'` + : '' + debugLog( + 'phaze', + `curl -X ${init?.method ?? 'GET'}${headersStr} '${url}'${bodyStr}` + ) + + const response = await fetch(url, init) + if (!response.ok) { + const text = await response.text() + const errorObj = asMaybe(asJSON(asPhazeError))(text) + if (errorObj != null) { + throw new Error( + errorObj.httpStatusCode != null + ? `${errorObj.httpStatusCode} ${errorObj.error}` + : errorObj.error + ) + } + throw new Error(`HTTP error! status: ${response.status} body: ${text}`) + } + return response + } + + return { + setUserApiKey: (key?: string) => { + userApiKey = key + }, + + getUserApiKey: () => userApiKey, + + // GET /crypto/tokens + getTokens: async () => { + const response = await fetchPhaze(buildUrl('/crypto/tokens'), { + headers: makeHeaders() + }) + const text = await response.text() + return asJSON(asPhazeTokensResponse)(text) + }, + + // GET /gift-cards/:country + getGiftCards: async params => { + const { countryCode, currentPage = 1, perPage = 50, brandName } = params + const response = await fetchPhaze( + buildUrl(`/gift-cards/${countryCode}`, { + currentPage, + perPage, + brandName + }), + { + headers: makeHeaders({ includePublicKey: true }) + } + ) + const text = await response.text() + return asJSON(asPhazeGiftCardsResponse)(text) + }, + + // GET /gift-cards/full/:country - Returns all brands without pagination + getFullGiftCards: async params => { + const { countryCode, fields, filter } = params + const response = await fetchPhaze( + buildUrl(`/gift-cards/full/${countryCode}`, { fields, filter }), + { + headers: makeHeaders({ includePublicKey: true }) + } + ) + const text = await response.text() + return asJSON(asPhazeGiftCardsResponse)(text) + }, + + // GET /crypto/user?email=... - Lookup existing user by email + getUserByEmail: async email => { + const response = await fetchPhaze(buildUrl('/crypto/user', { email }), { + headers: makeHeaders() + }) + const text = await response.text() + return asJSON(asPhazeRegisterUserResponse)(text) + }, + + // POST /crypto/user + registerUser: async body => { + const response = await fetchPhaze(buildUrl('/crypto/user'), { + method: 'POST', + headers: { ...makeHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + const text = await response.text() + return asJSON(asPhazeRegisterUserResponse)(text) + }, + + // POST /crypto/order + createOrder: async body => { + if (userApiKey == null) { + throw new Error('userApiKey required for createOrder') + } + const response = await fetchPhaze(buildUrl('/crypto/order'), { + method: 'POST', + headers: { + ...makeHeaders({ includeUserKey: true }), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + const text = await response.text() + return asJSON(asPhazeCreateOrderResponse)(text) + }, + + // GET /crypto/orders/status + getOrderStatus: async params => { + if (userApiKey == null) { + throw new Error('userApiKey required for getOrderStatus') + } + const response = await fetchPhaze( + buildUrl('/crypto/orders/status', { + quoteId: params.quoteId, + currentPage: params.currentPage + }), + { headers: makeHeaders({ includeUserKey: true }) } + ) + const text = await response.text() + return asJSON(asPhazeOrderStatusResponse)(text) + } + } +} diff --git a/src/plugins/gift-cards/phazeGiftCardCache.ts b/src/plugins/gift-cards/phazeGiftCardCache.ts new file mode 100644 index 00000000000..c55c08081ad --- /dev/null +++ b/src/plugins/gift-cards/phazeGiftCardCache.ts @@ -0,0 +1,357 @@ +import { asArray, asNumber, asObject, asString } from 'cleaners' +import type { Disklet } from 'disklet' +import { makeReactNativeDisklet, navigateDisklet } from 'disklet' + +import { debugLog } from '../../util/logger' +import { + asPhazeGiftCardBrand, + type PhazeGiftCardBrand +} from './phazeGiftCardTypes' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CacheEntry { + timestamp: number + /** Map of productId -> brand for O(1) lookup */ + brandsByProductId: Map + /** Set of productIds that have full details (not just market listing fields) */ + fullDetailProductIds: Set +} + +/** + * Disk-persisted cache file structure for a single country's gift cards. + */ +const asPhazeGiftCardCacheFile = asObject({ + version: asNumber, + timestamp: asNumber, + countryCode: asString, + brands: asArray(asPhazeGiftCardBrand), + /** ProductIds that have been fetched with full details */ + fullDetailProductIds: asArray(asNumber) +}) +type PhazeGiftCardCacheFile = ReturnType + +export interface PhazeGiftCardCache { + /** + * Get all cached brands for a country as an array (for listing). + * Returns undefined if cache is stale or doesn't exist. + */ + getBrands: (countryCode: string) => PhazeGiftCardBrand[] | undefined + + /** + * Get a single brand by productId (for detail lookup). + * Returns undefined if not in cache. + */ + getBrand: ( + countryCode: string, + productId: number + ) => PhazeGiftCardBrand | undefined + + /** + * Check if a brand has full details (not just market listing fields). + */ + hasFullDetails: (countryCode: string, productId: number) => boolean + + /** + * Set cached brands for a country. Replaces existing cache. + * @param fullDetailProductIds - Optional set of productIds that have full details + */ + setBrands: ( + countryCode: string, + brands: PhazeGiftCardBrand[], + fullDetailProductIds?: Set + ) => void + + /** + * Update a single brand with full details. + */ + setBrandWithFullDetails: ( + countryCode: string, + brand: PhazeGiftCardBrand + ) => void + + /** + * Load cache from disk for a country (call on startup). + * Returns brands if found and not expired, undefined otherwise. + */ + loadFromDisk: ( + countryCode: string + ) => Promise + + /** + * Persist current cache to disk for a country. + */ + saveToDisk: (countryCode: string) => Promise + + /** + * Clear cache for a specific country or all countries. + */ + clear: (countryCode?: string) => void +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CACHE_VERSION = 2 // Bumped for new structure +const CACHE_DISKLET_DIR = 'phazeGiftCards' + +/** + * In-memory cache TTL (1 hour). + * Brand data changes infrequently, so a longer TTL reduces API calls while + * still providing reasonably fresh data. + */ +const MEMORY_CACHE_TTL_MS = 60 * 60 * 1000 + +/** + * Disk cache TTL (24 hours). + * Used for offline/startup scenarios. Stale data is better than no data. + */ +const DISK_CACHE_TTL_MS = 24 * 60 * 60 * 1000 + +// --------------------------------------------------------------------------- +// Module-level singleton cache +// --------------------------------------------------------------------------- + +/** + * Module-level in-memory cache. Brands are the same for all users, + * so this is keyed only by countryCode (not accountId). + */ +const globalMemoryCache = new Map() + +/** + * Global disklet for persistence. Brands are not account-specific so we use + * a global disklet rather than account.localDisklet. + */ +const globalDisklet: Disklet = navigateDisklet( + makeReactNativeDisklet(), + CACHE_DISKLET_DIR +) + +// --------------------------------------------------------------------------- +// Direct memory cache access (for synchronous initial state) +// --------------------------------------------------------------------------- + +/** + * Read brands directly from memory cache without needing account/disklet. + * Useful for synchronous initial state in React components. + * Returns undefined if cache is empty or expired. + */ +export const getCachedBrandsSync = ( + countryCode: string +): PhazeGiftCardBrand[] | undefined => { + const entry = globalMemoryCache.get(countryCode) + if (entry == null) return undefined + + // Check if cache is still valid + const age = Date.now() - entry.timestamp + if (age > MEMORY_CACHE_TTL_MS) { + globalMemoryCache.delete(countryCode) + return undefined + } + + return Array.from(entry.brandsByProductId.values()) +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Create or return the singleton brand cache. + * Brand data is not account-specific, so this uses a global disklet. + */ +export const makePhazeGiftCardCache = (): PhazeGiftCardCache => { + const getCacheFilename = (countryCode: string): string => + `brands-${countryCode.toLowerCase()}.json` + + return { + getBrands(countryCode: string): PhazeGiftCardBrand[] | undefined { + const entry = globalMemoryCache.get(countryCode) + if (entry == null) { + return undefined + } + + // Check if cache is still valid + const age = Date.now() - entry.timestamp + if (age > MEMORY_CACHE_TTL_MS) { + globalMemoryCache.delete(countryCode) + return undefined + } + + return Array.from(entry.brandsByProductId.values()) + }, + + getBrand( + countryCode: string, + productId: number + ): PhazeGiftCardBrand | undefined { + const entry = globalMemoryCache.get(countryCode) + if (entry == null) { + return undefined + } + + // Check if cache is still valid + const age = Date.now() - entry.timestamp + if (age > MEMORY_CACHE_TTL_MS) { + globalMemoryCache.delete(countryCode) + return undefined + } + + return entry.brandsByProductId.get(productId) + }, + + hasFullDetails(countryCode: string, productId: number): boolean { + const entry = globalMemoryCache.get(countryCode) + return entry?.fullDetailProductIds.has(productId) ?? false + }, + + setBrands( + countryCode: string, + brands: PhazeGiftCardBrand[], + fullDetailProductIds?: Set + ): void { + const existingEntry = globalMemoryCache.get(countryCode) + + // Build new brandsByProductId map + const brandsByProductId = new Map() + for (const brand of brands) { + // Don't overwrite existing brands that have full details + const existingBrand = existingEntry?.brandsByProductId.get( + brand.productId + ) + const existingHasFullDetails = + existingEntry?.fullDetailProductIds.has(brand.productId) ?? false + + if (existingBrand != null && existingHasFullDetails) { + brandsByProductId.set(brand.productId, existingBrand) + } else { + brandsByProductId.set(brand.productId, brand) + } + } + + // Merge fullDetailProductIds + const mergedFullDetails = new Set( + existingEntry?.fullDetailProductIds + ) + if (fullDetailProductIds != null) { + for (const id of fullDetailProductIds) { + mergedFullDetails.add(id) + } + } + + globalMemoryCache.set(countryCode, { + timestamp: Date.now(), + brandsByProductId, + fullDetailProductIds: mergedFullDetails + }) + }, + + setBrandWithFullDetails( + countryCode: string, + brand: PhazeGiftCardBrand + ): void { + const entry = globalMemoryCache.get(countryCode) + if (entry == null) { + // Create new entry with just this brand + globalMemoryCache.set(countryCode, { + timestamp: Date.now(), + brandsByProductId: new Map([[brand.productId, brand]]), + fullDetailProductIds: new Set([brand.productId]) + }) + } else { + // Update existing entry + entry.brandsByProductId.set(brand.productId, brand) + entry.fullDetailProductIds.add(brand.productId) + entry.timestamp = Date.now() + } + }, + + async loadFromDisk( + countryCode: string + ): Promise { + if (globalDisklet == null) return undefined + + try { + const filename = getCacheFilename(countryCode) + const text = await globalDisklet.getText(filename) + const cacheFile = asPhazeGiftCardCacheFile(JSON.parse(text)) + + // Check version compatibility + if (cacheFile.version !== CACHE_VERSION) { + debugLog('phaze', 'Cache version mismatch, ignoring disk cache') + return undefined + } + + // Check if disk cache is still valid + if (Date.now() - cacheFile.timestamp > DISK_CACHE_TTL_MS) { + debugLog('phaze', 'Disk cache expired for', countryCode) + return undefined + } + + debugLog( + 'phaze', + 'Loaded', + cacheFile.brands.length, + 'brands from disk for', + countryCode + ) + + // Populate memory cache + const brandsByProductId = new Map() + for (const brand of cacheFile.brands) { + brandsByProductId.set(brand.productId, brand) + } + + globalMemoryCache.set(countryCode, { + timestamp: cacheFile.timestamp, + brandsByProductId, + fullDetailProductIds: new Set(cacheFile.fullDetailProductIds) + }) + + return cacheFile.brands + } catch (err: unknown) { + // File doesn't exist or parse error - that's fine + return undefined + } + }, + + async saveToDisk(countryCode: string): Promise { + if (globalDisklet == null) return + + const entry = globalMemoryCache.get(countryCode) + if (entry == null) return + + try { + const cacheFile: PhazeGiftCardCacheFile = { + version: CACHE_VERSION, + timestamp: entry.timestamp, + countryCode, + brands: Array.from(entry.brandsByProductId.values()), + fullDetailProductIds: Array.from(entry.fullDetailProductIds) + } + const filename = getCacheFilename(countryCode) + await globalDisklet.setText(filename, JSON.stringify(cacheFile)) + debugLog( + 'phaze', + 'Saved', + entry.brandsByProductId.size, + 'brands to disk for', + countryCode + ) + } catch (err: unknown) { + debugLog('phaze', 'Failed to save to disk:', err) + } + }, + + clear(countryCode?: string): void { + if (countryCode != null) { + globalMemoryCache.delete(countryCode) + } else { + globalMemoryCache.clear() + } + } + } +} diff --git a/src/plugins/gift-cards/phazeGiftCardOrderStore.ts b/src/plugins/gift-cards/phazeGiftCardOrderStore.ts new file mode 100644 index 00000000000..18e6fd3badc --- /dev/null +++ b/src/plugins/gift-cards/phazeGiftCardOrderStore.ts @@ -0,0 +1,220 @@ +import { asJSON } from 'cleaners' +import type { EdgeAccount } from 'edge-core-js' +import * as React from 'react' +import { makeEvent } from 'yavent' + +import { useSelector } from '../../types/reactRedux' +import { + asPhazeOrderAugments, + type PhazeDisplayOrder, + type PhazeOrderAugment, + type PhazeOrderAugments, + type PhazeOrderStatusItem +} from './phazeGiftCardTypes' + +// dataStore keys - uses encrypted storage to protect privacy +const STORE_ID = 'phaze' +const AUGMENTS_KEY = 'order-augments' + +// --------------------------------------------------------------------------- +// Event-based reactive state +// --------------------------------------------------------------------------- + +let cachedAugments: PhazeOrderAugments = {} +let cachedAccountId: string | null = null +const [watchAugments, emitAugments] = makeEvent() +watchAugments(augments => { + cachedAugments = augments +}) + +/** + * Hook for components to reactively subscribe to augment changes. + */ +export function usePhazeOrderAugments(): PhazeOrderAugments { + const accountId = useSelector(state => state.core.account.id) + + // Prevent leaking cached augments between user sessions: + const didSwitchAccount = + cachedAccountId != null && cachedAccountId !== accountId + if (didSwitchAccount) { + cachedAccountId = accountId + cachedAugments = {} + } + + const [augments, setAugments] = React.useState(cachedAugments) + React.useEffect(() => watchAugments(setAugments), []) + + // Push the cleared cache to all subscribers on account switch: + React.useEffect(() => { + if (didSwitchAccount) emitAugments({}) + }, [accountId, didSwitchAccount]) + return augments +} + +/** + * Get augment for a specific order (from cache) + */ +export function getOrderAugment( + orderId: string +): PhazeOrderAugment | undefined { + return cachedAugments[orderId] +} + +// --------------------------------------------------------------------------- +// DataStore operations (encrypted storage) +// --------------------------------------------------------------------------- + +/** + * Load augments from dataStore and emit to subscribers. + * Call this on account login to initialize the cache. + */ +export async function refreshPhazeAugmentsCache( + account: EdgeAccount +): Promise { + // Clear existing cache immediately if the account changed: + if (cachedAccountId != null && cachedAccountId !== account.id) { + cachedAugments = {} + emitAugments({}) + } + cachedAccountId = account.id + + const augments = await loadAugmentsFromStore(account) + emitAugments(augments) + return augments +} + +/** + * Internal: Read augments from dataStore + */ +async function loadAugmentsFromStore( + account: EdgeAccount +): Promise { + try { + const text = await account.dataStore.getItem(STORE_ID, AUGMENTS_KEY) + return asJSON(asPhazeOrderAugments)(text) + } catch (err: unknown) { + return {} + } +} + +/** + * Internal: Write augments to dataStore + */ +async function saveAugmentsToStore( + account: EdgeAccount, + augments: PhazeOrderAugments +): Promise { + await account.dataStore.setItem( + STORE_ID, + AUGMENTS_KEY, + JSON.stringify(augments) + ) +} + +// --------------------------------------------------------------------------- +// Augment CRUD +// --------------------------------------------------------------------------- + +/** + * Save or update an augment for an order. + * Uses explicit key checks so undefined can clear existing values (e.g., redeemedDate). + */ +export async function saveOrderAugment( + account: EdgeAccount, + orderId: string, + augment: Partial +): Promise { + cachedAccountId = account.id + const existing = cachedAugments[orderId] ?? {} + + // Merge existing with new values. Use 'in' check so explicitly passing + // undefined (e.g., to clear redeemedDate) works correctly. + const updated: PhazeOrderAugment = { + walletId: 'walletId' in augment ? augment.walletId : existing.walletId, + tokenId: 'tokenId' in augment ? augment.tokenId : existing.tokenId, + txid: 'txid' in augment ? augment.txid : existing.txid, + brandName: 'brandName' in augment ? augment.brandName : existing.brandName, + brandImage: + 'brandImage' in augment ? augment.brandImage : existing.brandImage, + fiatAmount: + 'fiatAmount' in augment ? augment.fiatAmount : existing.fiatAmount, + fiatCurrency: + 'fiatCurrency' in augment ? augment.fiatCurrency : existing.fiatCurrency, + redeemedDate: + 'redeemedDate' in augment ? augment.redeemedDate : existing.redeemedDate + } + + const newAugments = { ...cachedAugments, [orderId]: updated } + await saveAugmentsToStore(account, newAugments) + emitAugments(newAugments) +} + +// --------------------------------------------------------------------------- +// Display order helpers +// --------------------------------------------------------------------------- + +/** Function to look up brand image by productId */ +export type BrandImageLookup = (productId: number) => string | undefined + +/** + * Merge Phaze API order data with local augments to create display order. + * Uses brandLookup to find images when augment doesn't have one. + */ +export function mergeOrderWithAugment( + apiOrder: PhazeOrderStatusItem, + augments: PhazeOrderAugments, + brandLookup?: BrandImageLookup +): PhazeDisplayOrder { + const augment = augments[apiOrder.quoteId] + + // Extract brand info from first cart item, falling back to augment for + // pending orders where API may not have full data yet + const firstCartItem = apiOrder.cart[0] + const brandName = + firstCartItem?.productName ?? augment?.brandName ?? 'Gift Card' + const fiatAmount = firstCartItem?.faceValue ?? augment?.fiatAmount ?? 0 + const fiatCurrency = + firstCartItem?.voucherCurrency ?? augment?.fiatCurrency ?? 'USD' + + // Collect all vouchers from all cart items + const vouchers = apiOrder.cart.flatMap(item => item.vouchers ?? []) + + // Get brand image: prefer augment, fall back to brand cache lookup + let brandImage = augment?.brandImage ?? '' + if ( + brandImage === '' && + brandLookup != null && + firstCartItem?.productId != null + ) { + const productIdNum = Number(firstCartItem.productId) + brandImage = brandLookup(productIdNum) ?? '' + } + + return { + quoteId: apiOrder.quoteId, + status: apiOrder.status, + brandName, + brandImage, + fiatAmount, + fiatCurrency, + vouchers, + walletId: augment?.walletId, + tokenId: augment?.tokenId, + txid: augment?.txid, + redeemedDate: augment?.redeemedDate + } +} + +/** + * Merge list of API orders with augments. + * @param brandLookup Optional function to look up brand images by productId + */ +export function mergeOrdersWithAugments( + apiOrders: PhazeOrderStatusItem[], + augments: PhazeOrderAugments, + brandLookup?: BrandImageLookup +): PhazeDisplayOrder[] { + return apiOrders.map(order => + mergeOrderWithAugment(order, augments, brandLookup) + ) +} diff --git a/src/plugins/gift-cards/phazeGiftCardProvider.ts b/src/plugins/gift-cards/phazeGiftCardProvider.ts new file mode 100644 index 00000000000..1c1d0781267 --- /dev/null +++ b/src/plugins/gift-cards/phazeGiftCardProvider.ts @@ -0,0 +1,503 @@ +import { asArray, asMaybe } from 'cleaners' +import type { EdgeAccount } from 'edge-core-js' + +import { debugLog } from '../../util/logger' +import { makeUuid } from '../../util/rnUtils' +import { + makePhazeApi, + MARKET_LISTING_FIELDS, + type PhazeApi, + type PhazeApiConfig +} from './phazeApi' +import { + makePhazeGiftCardCache, + type PhazeGiftCardCache +} from './phazeGiftCardCache' +import { saveOrderAugment } from './phazeGiftCardOrderStore' +import { + asPhazeUser, + cleanBrandName, + type PhazeCreateOrderRequest, + type PhazeGiftCardBrand, + type PhazeGiftCardsResponse, + type PhazeOrderStatusResponse, + type PhazeRegisterUserRequest, + type PhazeRegisterUserResponse, + type PhazeTokensResponse, + type PhazeUser +} from './phazeGiftCardTypes' + +// dataStore keys - encrypted storage for privacy +const STORE_ID = 'phaze' +const IDENTITIES_KEY = 'identities' + +// Cleaner for identity storage (array of PhazeUser with uniqueId) +interface StoredIdentity extends PhazeUser { + uniqueId: string +} +const asStoredIdentities = asArray(asPhazeUser) + +/** + * Clean a brand object by stripping trailing currency symbols from the name. + */ +const cleanBrand = (brand: PhazeGiftCardBrand): PhazeGiftCardBrand => ({ + ...brand, + brandName: cleanBrandName(brand.brandName) +}) + +export interface PhazeGiftCardProvider { + setUserApiKey: (userApiKey: string | undefined) => void + + /** + * Ensure a Phaze user exists for this Edge account. + * For full accounts: Auto-generates and registers if no identity exists. + * For light accounts: Returns false (feature is gated). + * Returns true if user is ready, false otherwise. + */ + ensureUser: (account: EdgeAccount) => Promise + + /** + * List all Phaze identities stored for this account. + * Used for aggregating orders across multiple devices/identities. + */ + listIdentities: (account: EdgeAccount) => Promise + + /** Get underlying API instance (for direct API calls) */ + getApi: () => PhazeApi + + /** Get cache instance (for direct cache access) */ + getCache: () => PhazeGiftCardCache + + getTokens: () => Promise + + // --------------------------------------------------------------------------- + // Brand fetching - smart methods + // --------------------------------------------------------------------------- + + /** + * Get brands for market listing display. Uses minimal fields for fast loading. + * Stores brands in cache for later lookup. + */ + getMarketBrands: (countryCode: string) => Promise + + /** + * Get full brand details by productId. + * Returns cached brand if already fetched with full details. + * Otherwise, fetches full details from API. + */ + getBrandDetails: ( + countryCode: string, + productId: number + ) => Promise + + /** + * Get a cached brand by productId (no fetch). + * Returns undefined if brand is not in cache. + */ + getCachedBrand: ( + countryCode: string, + productId: number + ) => PhazeGiftCardBrand | undefined + + /** + * Store a brand in the cache (for seeding cache from navigation params). + */ + storeBrand: (countryCode: string, brand: PhazeGiftCardBrand) => void + + // --------------------------------------------------------------------------- + // Order methods + // --------------------------------------------------------------------------- + + /** + * Fetch order status from Phaze API (for current userApiKey). + */ + getOrderStatus: (params?: { + quoteId?: string + currentPage?: number + }) => Promise + + /** + * Fetch orders from ALL identities stored for this account. + * Used in GiftCardListScene to aggregate orders across multi-device scenarios. + */ + getAllOrdersFromAllIdentities: ( + account: EdgeAccount + ) => Promise + + /** + * Create an order quote with Phaze API. + * Returns the API response - does NOT persist anything locally. + */ + createOrder: ( + body: PhazeCreateOrderRequest + ) => ReturnType + + /** + * Save order augment after broadcast (tx link + brand/amount info). + * This is the only local persistence for orders. + */ + saveOrderAugment: ( + account: EdgeAccount, + orderId: string, + augment: { + walletId: string + tokenId: string | null + txid: string + brandName: string + brandImage: string + fiatAmount: number + fiatCurrency: string + } + ) => Promise + + // --------------------------------------------------------------------------- + // Legacy methods (still used for specific cases) + // --------------------------------------------------------------------------- + + getGiftCards: (params: { + countryCode: string + currentPage?: number + perPage?: number + brandName?: string + }) => Promise + getFullGiftCards: (params: { + countryCode: string + /** Comma-separated list of fields to return (reduces payload size) */ + fields?: string + /** Filter expression (e.g., "categories=pets") */ + filter?: string + }) => Promise + getUserByEmail: (email: string) => Promise + registerUser: ( + body: PhazeRegisterUserRequest + ) => Promise + /** + * Get or create a user. Tries to lookup by email first; if not found, + * registers a new user. Handles multi-device scenarios seamlessly. + */ + getOrCreateUser: ( + body: PhazeRegisterUserRequest + ) => Promise +} + +export const makePhazeGiftCardProvider = ( + config: PhazeApiConfig, + account: EdgeAccount +): PhazeGiftCardProvider => { + const api = makePhazeApi(config) + const cache = makePhazeGiftCardCache() + + /** + * Load all stored identities from encrypted dataStore. + * Multiple identities is an edge case (multi-device before sync completes). + */ + const loadIdentities = async ( + account: EdgeAccount + ): Promise => { + try { + const text = await account.dataStore.getItem(STORE_ID, IDENTITIES_KEY) + const parsed = asMaybe(asStoredIdentities)(JSON.parse(text)) + // Add uniqueId if missing (migration from older format) + return (parsed ?? []).map((identity, index) => ({ + ...identity, + uniqueId: (identity as StoredIdentity).uniqueId ?? `legacy-${index}` + })) + } catch { + return [] + } + } + + /** + * Save identities to encrypted dataStore. + */ + const saveIdentities = async ( + account: EdgeAccount, + identities: StoredIdentity[] + ): Promise => { + await account.dataStore.setItem( + STORE_ID, + IDENTITIES_KEY, + JSON.stringify(identities) + ) + } + + return { + setUserApiKey: userApiKey => { + api.setUserApiKey(userApiKey) + }, + + async ensureUser(account) { + // Light accounts cannot use gift cards + if (account.username == null) { + debugLog('phaze', 'Light account - gift cards not available') + return false + } + + // Check for existing identities. Uses the first identity found for purchases/orders. + // Multiple identities is an edge case (multi-device before sync completes) - + // new orders simply go to whichever identity is active. + // Order VIEWING aggregates all identities via getAllOrdersFromAllIdentities(). + const identities = await loadIdentities(account) + + for (const identity of identities) { + if (identity.userApiKey != null) { + api.setUserApiKey(identity.userApiKey) + debugLog('phaze', 'Using existing identity:', identity.uniqueId) + return true + } + } + + // No existing identity found - auto-generate one + debugLog('phaze', 'No identity found, auto-generating...') + + // Generate unique email prefix + const uniqueId = await makeUuid() + const email = `${uniqueId}@edge.app` + + // Auto-generate registration data + const firstName = 'Edgeuser' + const lastName = account.username + + try { + // Register with Phaze API (or get existing if email already registered) + const response = await api.registerUser({ + email, + firstName, + lastName + }) + + const userApiKey = response.data.userApiKey + if (userApiKey == null) { + debugLog('phaze', 'Registration succeeded but no userApiKey returned') + return false + } + + // Save to identities array in encrypted dataStore + const newIdentity: StoredIdentity = { + ...response.data, + uniqueId + } + await saveIdentities(account, [...identities, newIdentity]) + + api.setUserApiKey(userApiKey) + debugLog('phaze', 'Auto-registered and saved identity:', uniqueId) + return true + } catch (err: unknown) { + debugLog('phaze', 'Auto-registration failed:', err) + return false + } + }, + + async listIdentities(account) { + return await loadIdentities(account) + }, + + getApi: () => api, + getCache: () => cache, + + getTokens: async () => { + return await api.getTokens() + }, + + // --------------------------------------------------------------------------- + // Brand fetching - smart methods + // --------------------------------------------------------------------------- + + async getMarketBrands(countryCode: string) { + // Fetch all brands with minimal fields for fast market display + const response = await api.getFullGiftCards({ + countryCode, + fields: MARKET_LISTING_FIELDS + }) + + // Clean brands + const cleanedBrands = response.brands.map(cleanBrand) + + // Store in cache (preserves existing full-detail brands) + cache.setBrands(countryCode, cleanedBrands) + + // Persist to disk + cache.saveToDisk(countryCode).catch((err: unknown) => { + debugLog('phaze', 'Failed to persist brand cache:', err) + }) + + return cleanedBrands + }, + + async getBrandDetails(countryCode: string, productId: number) { + // Check if we already have full details in cache + if (cache.hasFullDetails(countryCode, productId)) { + const cached = cache.getBrand(countryCode, productId) + if (cached != null) { + debugLog('phaze', 'Using cached full brand details for:', productId) + return cached + } + } + + // Get cached brand for name lookup + const cached = cache.getBrand(countryCode, productId) + const brandName = cached?.brandName + if (brandName == null) { + debugLog( + 'phaze', + 'Brand not in cache, cannot fetch details:', + productId + ) + return undefined + } + + // Fetch full details for this brand by name + debugLog('phaze', 'Fetching full brand details for:', brandName) + const response = await api.getGiftCards({ + countryCode, + brandName, + perPage: 1 + }) + + if (response.brands.length > 0) { + const fullBrand = cleanBrand(response.brands[0]) + cache.setBrandWithFullDetails(countryCode, fullBrand) + + // Persist to disk + cache.saveToDisk(countryCode).catch((err: unknown) => { + debugLog('phaze', 'Failed to persist brand cache:', err) + }) + + return fullBrand + } + + return cached + }, + + getCachedBrand(countryCode: string, productId: number) { + return cache.getBrand(countryCode, productId) + }, + + storeBrand(countryCode: string, brand: PhazeGiftCardBrand) { + // Only store if not already in cache (don't overwrite full details) + if (cache.getBrand(countryCode, brand.productId) == null) { + const cleaned = cleanBrand(brand) + cache.setBrands(countryCode, [cleaned]) + } + }, + + // --------------------------------------------------------------------------- + // Order methods + // --------------------------------------------------------------------------- + + async getOrderStatus(params = {}) { + return await api.getOrderStatus(params) + }, + + async getAllOrdersFromAllIdentities(account) { + const identities = await this.listIdentities(account) + const allOrders: PhazeOrderStatusResponse['data'] = [] + const seenQuoteIds = new Set() + + // Save current userApiKey to restore later + const currentKey = api.getUserApiKey() + + for (const identity of identities) { + if (identity.userApiKey == null) { + continue + } + + try { + // Temporarily set the API key for this identity + api.setUserApiKey(identity.userApiKey) + const response = await api.getOrderStatus({}) + + // Add unique orders (avoid duplicates if somehow shared) + for (const order of response.data) { + if (!seenQuoteIds.has(order.quoteId)) { + seenQuoteIds.add(order.quoteId) + allOrders.push(order) + } + } + } catch (err: unknown) { + // Log error but continue with other identities + debugLog('phaze', 'Error fetching orders for identity:', err) + } + } + + // Restore original userApiKey + api.setUserApiKey(currentKey) + + return allOrders + }, + + async createOrder(body) { + return await api.createOrder(body) + }, + + async saveOrderAugment(account, orderId, augment) { + await saveOrderAugment(account, orderId, { + walletId: augment.walletId, + tokenId: augment.tokenId ?? undefined, + txid: augment.txid, + brandName: augment.brandName, + brandImage: augment.brandImage, + fiatAmount: augment.fiatAmount, + fiatCurrency: augment.fiatCurrency + }) + }, + + // --------------------------------------------------------------------------- + // Legacy methods + // --------------------------------------------------------------------------- + + getGiftCards: async params => { + return await api.getGiftCards(params) + }, + getFullGiftCards: async params => { + return await api.getFullGiftCards(params) + }, + getUserByEmail: async email => { + try { + const response = await api.getUserByEmail(email) + const userApiKey = response.data.userApiKey + if (userApiKey != null) api.setUserApiKey(userApiKey) + return response.data + } catch (err: unknown) { + // 401 "Partner user not found." means user doesn't exist + if ( + err instanceof Error && + err.message.includes('Partner user not found') + ) { + return undefined + } + throw err + } + }, + registerUser: async body => { + const response = await api.registerUser(body) + const userApiKey = response.data.userApiKey + if (userApiKey != null) api.setUserApiKey(userApiKey) + return response + }, + getOrCreateUser: async body => { + // First, try to lookup existing user by email + try { + const existingUser = await api.getUserByEmail(body.email) + const userApiKey = existingUser.data.userApiKey + if (userApiKey != null) api.setUserApiKey(userApiKey) + return existingUser + } catch (err: unknown) { + // 401 "Partner user not found." means user doesn't exist - proceed to register + if ( + !( + err instanceof Error && + err.message.includes('Partner user not found') + ) + ) { + throw err + } + } + // User doesn't exist, register them + const response = await api.registerUser(body) + const userApiKey = response.data.userApiKey + if (userApiKey != null) api.setUserApiKey(userApiKey) + return response + } + } +} diff --git a/src/plugins/gift-cards/phazeGiftCardTypes.ts b/src/plugins/gift-cards/phazeGiftCardTypes.ts new file mode 100644 index 00000000000..921fb643739 --- /dev/null +++ b/src/plugins/gift-cards/phazeGiftCardTypes.ts @@ -0,0 +1,409 @@ +import { + asArray, + asBoolean, + asDate, + asEither, + asNumber, + asObject, + asOptional, + asString, + asValue, + type Cleaner +} from 'cleaners' + +import { FIAT_CODES_SYMBOLS } from '../../constants/WalletAndCurrencyConstants' + +// Build regex to match trailing currency symbols and amounts from FIAT_CODES_SYMBOLS +// Exclude symbols containing ASCII letters to avoid matching word endings +// (e.g., 'm' for TMT, 'kr' for SEK/NOK could match "Amazon.com", "Flickr") +const HAS_ASCII_LETTER = /[A-Za-z]/ +const CURRENCY_SYMBOLS = [...new Set(Object.values(FIAT_CODES_SYMBOLS))] + .filter(s => s.length > 0 && !HAS_ASCII_LETTER.test(s)) + .map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) // Escape regex special chars + .join('|') +// Match patterns like " $", " $50", " $50-$2000", " €100", etc. +// Require whitespace before the symbol to avoid matching word endings +const CURRENCY_AMOUNT_SUFFIX_REGEX = new RegExp( + `\\s+(${CURRENCY_SYMBOLS})\\d*(?:\\s*-\\s*(${CURRENCY_SYMBOLS})?\\d+)?$` +) + +/** + * Strip trailing currency symbols and amounts from brand name. + * Handles patterns like "Amazon $", "Royal Caribbean $50-$2000", etc. + */ +export const cleanBrandName = (name: string): string => + name.replace(CURRENCY_AMOUNT_SUFFIX_REGEX, '').trim() + +/** + * Cleaner that accepts either a number or a numeric string and returns a number. + * The Phaze API inconsistently returns some fields as either type (e.g., quoteExpiry). + */ +const asNumberOrNumericString: Cleaner = asEither( + asNumber, + (raw: unknown): number => { + const str = asString(raw) + const num = Number(str) + if (isNaN(num)) { + throw new TypeError(`Expected a numeric string, got "${str}"`) + } + return num + } +) + +/** + * Cleaner for denominations array that deduplicates values. + * The Phaze API sometimes returns duplicate denominations. + */ +const asUniqueDenominations: Cleaner = (raw: unknown): number[] => { + const arr = asArray(asNumber)(raw) + return [...new Set(arr)] +} + +// --------------------------------------------------------------------------- +// Init / Auth +// --------------------------------------------------------------------------- + +export const asPhazeInitOptions = asObject({ + baseUrl: asOptional(asString, ''), + apiKey: asString, + /** + * User API key returned by Register User. Optional on first-run when the user + * is not yet registered. + * TODO: Make this required once the partner guarantees the key will exist on + * every request. Currently, this only shows on first-run when the user is not + * yet registered. + */ + userApiKey: asOptional(asString) +}) +export type PhazeInitOptions = ReturnType + +// --------------------------------------------------------------------------- +// /crypto/tokens +// --------------------------------------------------------------------------- + +export const asPhazeToken = asObject({ + symbol: asString, + name: asString, + chainId: asNumber, + networkType: asString, + address: asString, + type: asString, + caip19: asString, + minimumAmount: asNumber, + minimumAmountInUSD: asNumber +}) +export type PhazeToken = ReturnType + +export const asPhazeTokensResponse = asObject({ + tokens: asArray(asPhazeToken) +}) +export type PhazeTokensResponse = ReturnType + +// Country list (simple array of country names) +export const asPhazeCountryList = asArray(asString) +export type PhazeCountryList = ReturnType + +// --------------------------------------------------------------------------- +// /crypto/user (Register User) +// --------------------------------------------------------------------------- + +/** + * Request body for POST /crypto/user + */ +export interface PhazeRegisterUserRequest { + email: string + firstName: string + lastName: string +} + +export const asPhazeUser = asObject({ + id: asNumber, + email: asString, + firstName: asString, + lastName: asString, + userApiKey: asOptional(asString), + balance: asString, // API always returns as string, e.g. "0.00" + balanceCurrency: asString +}) +export type PhazeUser = ReturnType + +export const asPhazeRegisterUserResponse = asObject({ + data: asPhazeUser +}) +export type PhazeRegisterUserResponse = ReturnType< + typeof asPhazeRegisterUserResponse +> + +// --------------------------------------------------------------------------- +// /gift-cards/:country +// --------------------------------------------------------------------------- + +// valueRestrictions can be empty {} for fixed-denomination cards +export const asPhazeValueRestrictions = asObject({ + maxVal: asOptional(asNumber), + minVal: asOptional(asNumber) +}) +export type PhazeValueRestrictions = ReturnType + +/** + * Gift card brand data from Phaze API. + * Required fields are needed for market listing display. + * Optional fields are only returned when fetching full brand details. + */ +export const asPhazeGiftCardBrand = asObject({ + // Required fields for market listing + brandName: asString, + countryName: asString, + currency: asString, + denominations: asUniqueDenominations, // Empty when valueRestrictions has min/max + valueRestrictions: asPhazeValueRestrictions, + productId: asNumber, + productImage: asString, + categories: asArray(asString), + + // Optional fields - only fetched for purchase scene + productDescription: asOptional(asString), // HTML + termsAndConditions: asOptional(asString), // HTML + howToUse: asOptional(asString), // HTML + expiryAndValidity: asOptional(asString), + discount: asOptional(asNumber), + deliveryFeeInPercentage: asOptional(asNumber), + deliveryFlatFee: asOptional(asNumber), + deliveryFlatFeeCurrency: asOptional(asString) +}) +export type PhazeGiftCardBrand = ReturnType + +export const asPhazeGiftCardsResponse = asObject({ + country: asString, + countryCode: asString, + brands: asArray(asPhazeGiftCardBrand), + currentPage: asNumber, + totalCount: asNumber +}) +export type PhazeGiftCardsResponse = ReturnType + +// --------------------------------------------------------------------------- +// /crypto/order (Create Quote/Order) +// --------------------------------------------------------------------------- + +/** + * Cart item used in POST /crypto/order + */ +export interface PhazeCartItemRequest { + /** Generated client-side with uuidv4 */ + orderId: string + /** Amount to purchase in fiat (TODO: USD? Or is it in the currency of the + * gift card?) */ + price: number + /** Partner product ID from gift card listing */ + productId: number +} + +/** + * Request body for POST /crypto/order + */ +export interface PhazeCreateOrderRequest { + /** Use CAIP-19 identifier from /crypto/tokens */ + tokenIdentifier: string + cart: PhazeCartItemRequest[] +} + +export const asPhazeCartItem = asObject({ + orderId: asString, + productId: asString, + productPrice: asNumber, + deliveryState: asString +}) +export type PhazeCartItem = ReturnType + +/** + * Order status values - shared between create and status responses + */ +export const asPhazeOrderStatusValue = asEither( + asValue('complete'), + asValue('pending'), + asValue('processing'), + asValue('expired') +) +export type PhazeOrderStatusValue = ReturnType + +export const asPhazeCreateOrderResponse = asObject({ + externalUserId: asString, + quoteId: asString, + status: asPhazeOrderStatusValue, + deliveryAddress: asString, + tokenIdentifier: asString, + quantity: asNumber, + amountInUSD: asNumber, + quoteExpiry: asNumberOrNumericString, + receivedQuantity: asNumber, + cart: asArray(asPhazeCartItem) +}) +export type PhazeCreateOrderResponse = ReturnType< + typeof asPhazeCreateOrderResponse +> + +// --------------------------------------------------------------------------- +// /crypto/orders/status (Order Status) +// --------------------------------------------------------------------------- + +/** + * Voucher code returned when order is complete + */ +export const asPhazeVoucher = asObject({ + url: asString, + code: asString, + validityDate: asString, + voucherCurrency: asString, + faceValue: asNumber +}) +export type PhazeVoucher = ReturnType + +/** + * Cart item delivery status values + */ +export const asPhazeCartItemStatus = asEither( + asValue('processed'), + asValue('pending'), + asValue('failed') +) +export type PhazeCartItemStatus = ReturnType + +/** + * Cart item in a completed/processing order - has vouchers and delivery status + * Note: Many fields are optional to handle incomplete data from older orders + */ +export const asPhazeCompletedCartItem = asObject({ + id: asOptional(asNumber), + orderId: asOptional(asString), + productId: asOptional(asString), + productName: asOptional(asString), + status: asOptional(asPhazeCartItemStatus), + faceValue: asOptional(asNumber), + voucherCurrency: asOptional(asString), + vouchers: asOptional(asArray(asPhazeVoucher)), + // Additional fields we may use + externalUserId: asOptional(asString), + voucherDiscountPercent: asOptional(asNumber), + baseCurrency: asOptional(asString), + commission: asOptional(asNumber), + created_at: asOptional(asString), + updated_at: asOptional(asString) +}) +export type PhazeCompletedCartItem = ReturnType + +/** + * Order status response - used when polling for completion + */ +export const asPhazeOrderStatusItem = asObject({ + externalUserId: asString, + quoteId: asString, + status: asPhazeOrderStatusValue, + deliveryAddress: asString, + tokenIdentifier: asString, + quantity: asNumber, + amountInUSD: asNumber, + quoteExpiry: asNumberOrNumericString, + receivedQuantity: asNumber, + cart: asArray(asPhazeCompletedCartItem) +}) +export type PhazeOrderStatusItem = ReturnType + +export const asPhazeOrderStatusResponse = asObject({ + data: asArray(asPhazeOrderStatusItem), + totalCount: asNumber +}) +export type PhazeOrderStatusResponse = ReturnType< + typeof asPhazeOrderStatusResponse +> + +// --------------------------------------------------------------------------- +// Common error envelope (observed) +// --------------------------------------------------------------------------- + +export const asPhazeError = asObject({ + error: asString, + httpStatusCode: asOptional(asNumber) +}) +export type PhazeError = ReturnType + +// Misc helpers for request headers we commonly send +export const asPhazeHeaders = asObject({ + apiKey: asString, + userApiKey: asOptional(asString), // See TODO in asPhazeInitOptions + publicKey: asOptional(asString), + signature: asOptional(asString), + acceptJson: asOptional(asBoolean, true) +}) +export type PhazeHeaders = ReturnType + +// --------------------------------------------------------------------------- +// Local Order Augments (minimal data we persist to augment Phaze API data) +// --------------------------------------------------------------------------- + +/** + * Minimal augmentation data stored per order. Phaze API drives display; + * this only stores what Phaze doesn't know: transaction link, user-set flags, + * and brand image (not in order status response). + */ +export const asPhazeOrderAugment = asObject({ + // Transaction link for navigation to tx details + walletId: asOptional(asString), + tokenId: asOptional(asString), // null for native, string for tokens + txid: asOptional(asString), + + // Brand/amount info (needed for pending cards before API has full data) + brandName: asOptional(asString), + brandImage: asOptional(asString), + fiatAmount: asOptional(asNumber), + fiatCurrency: asOptional(asString), + + // User-set timestamp when card was marked as used/archived (no API for this) + redeemedDate: asOptional(asDate) +}) +export type PhazeOrderAugment = ReturnType + +/** + * Map of orderId -> augment data. Stored as single JSON file. + */ +export const asPhazeOrderAugments = ( + raw: unknown +): Record => { + if (typeof raw !== 'object' || raw == null) return {} + const result: Record = {} + for (const [key, value] of Object.entries(raw)) { + try { + result[key] = asPhazeOrderAugment(value) + } catch { + // Skip invalid entries + } + } + return result +} +export type PhazeOrderAugments = ReturnType + +// --------------------------------------------------------------------------- +// Display Order (Phaze API data merged with augments) +// --------------------------------------------------------------------------- + +/** + * Combined order data for display: Phaze API data + local augments. + * This is what the UI components receive. + */ +export interface PhazeDisplayOrder { + // From Phaze API (PhazeOrderStatusItem) + quoteId: string + status: PhazeOrderStatusValue + // From Phaze API cart items + brandName: string + brandImage: string + fiatAmount: number + fiatCurrency: string + vouchers: PhazeVoucher[] + + // From local augments + walletId?: string + tokenId?: string + txid?: string + redeemedDate?: Date +} diff --git a/src/plugins/gift-cards/phazeOrderPollingService.ts b/src/plugins/gift-cards/phazeOrderPollingService.ts new file mode 100644 index 00000000000..9d2d52b477d --- /dev/null +++ b/src/plugins/gift-cards/phazeOrderPollingService.ts @@ -0,0 +1,196 @@ +import type { EdgeAccount, EdgeTxActionGiftCard } from 'edge-core-js' + +import { debugLog } from '../../util/logger' +import { makePeriodicTask, type PeriodicTask } from '../../util/PeriodicTask' +import { makePhazeApi } from './phazeApi' +import { + getOrderAugment, + refreshPhazeAugmentsCache +} from './phazeGiftCardOrderStore' +import type { PhazeVoucher } from './phazeGiftCardTypes' + +const POLL_INTERVAL_MS = 10000 // 10 seconds + +interface PhazeOrderPollingConfig { + baseUrl: string + apiKey: string + /** + * User API keys for all identities to poll. + * Multiple identities is an edge case (multi-device before sync completes) + * but we poll all to ensure voucher updates for all orders. + */ + userApiKeys: string[] +} + +interface PhazeOrderPollingService { + /** + * Start polling pending orders for this account. + * Loads augments cache before first poll to ensure tx links are available. + */ + start: () => Promise + /** + * Stop polling (call on logout) + */ + stop: () => void + /** + * Manually trigger a poll (e.g., when returning to orders screen) + */ + pollNow: () => Promise +} + +/** + * Create a polling service that monitors pending gift card orders + * and updates transaction savedAction when vouchers are received. + * + * This service: + * - Fetches orders directly from Phaze API (source of truth) + * - Polls ALL user identities (handles edge case of multiple identities) + * - Updates tx.savedAction with redemption details when vouchers arrive + * - Does NOT write to disklet (augments are written at purchase time only) + */ +export function makePhazeOrderPollingService( + account: EdgeAccount, + config: PhazeOrderPollingConfig +): PhazeOrderPollingService { + const { baseUrl, apiKey, userApiKeys } = config + let task: PeriodicTask | null = null + let isPolling = false + + const pollPendingOrders = async (): Promise => { + if (isPolling) return // Prevent overlapping polls + isPolling = true + + try { + // Poll orders from ALL identities. Multiple identities can occur when + // user purchases on multiple devices before sync - we handle all to + // ensure voucher updates work regardless of which device created the order. + for (const userApiKey of userApiKeys) { + const api = makePhazeApi({ baseUrl, apiKey, userApiKey }) + + try { + const statusResponse = await api.getOrderStatus({}) + const pendingOrders = statusResponse.data.filter( + order => order.status === 'pending' || order.status === 'processing' + ) + const completedOrders = statusResponse.data.filter( + order => order.status === 'complete' + ) + + if (pendingOrders.length > 0 || completedOrders.length > 0) { + debugLog( + 'phaze', + `Polling: ${pendingOrders.length} pending, ${completedOrders.length} complete` + ) + } + + // Process completed orders - update tx.savedAction with vouchers + for (const order of completedOrders) { + await updateTxSavedAction(account, order.quoteId, order.cart) + } + } catch (err: unknown) { + // Log but continue with other identities + debugLog('phaze', 'Error polling identity:', err) + } + } + } finally { + isPolling = false + } + } + + /** + * Update transaction's savedAction with redemption details. + * Only updates if the tx doesn't already have redemption info. + */ + const updateTxSavedAction = async ( + account: EdgeAccount, + orderId: string, + cart: Array<{ vouchers?: PhazeVoucher[] }> + ): Promise => { + // Get augment to find tx link + const augment = getOrderAugment(orderId) + if (augment?.walletId == null || augment.txid == null) { + // No tx link, nothing to update + return + } + + // Collect vouchers from cart + const vouchers = cart.flatMap(item => item.vouchers ?? []) + if (vouchers.length === 0) { + return + } + + const wallet = account.currencyWallets[augment.walletId] + if (wallet == null) { + return + } + + try { + // Find the transaction + const txs = await wallet.getTransactions({ + tokenId: augment.tokenId ?? null + }) + const tx = txs.find(t => t.txid === augment.txid) + + if (tx?.savedAction == null || tx.savedAction.actionType !== 'giftCard') { + return + } + + const currentAction = tx.savedAction + + // Skip if already has redemption info + if (currentAction.redemption?.code != null) { + return + } + + const updatedAction: EdgeTxActionGiftCard = { + ...currentAction, + redemption: { + code: vouchers[0]?.code, + url: vouchers[0]?.url + } + } + + await wallet.saveTxAction({ + txid: augment.txid, + tokenId: augment.tokenId ?? null, + assetAction: { assetActionType: 'giftCard' }, + savedAction: updatedAction + }) + + debugLog('phaze', `Updated transaction savedAction for ${augment.txid}`) + } catch (err: unknown) { + debugLog('phaze', 'Error updating transaction savedAction:', err) + } + } + + return { + async start() { + if (task != null) return // Already running + + debugLog('phaze', 'Starting order polling service') + + // Initialize augments cache from disk before first poll + // to ensure getOrderAugment() finds existing augments + await refreshPhazeAugmentsCache(account).catch(() => {}) + + // Create periodic task for polling + task = makePeriodicTask(pollPendingOrders, POLL_INTERVAL_MS, { + onError: () => {} + }) + // Start immediately, then poll periodically + task.start({ wait: false }) + }, + + stop() { + if (task != null) { + debugLog('phaze', 'Stopping order polling service') + task.stop() + task = null + } + }, + + async pollNow() { + await pollPendingOrders() + } + } +} From 6856eb9f207daf19c8d7b04005395eae53f9b4ab Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:05:20 -0800 Subject: [PATCH 04/14] Add gift card hooks - useBrand: Lazy-load full brand details on demand - useGiftCardProvider: Provider instance management --- src/hooks/useGiftCardProvider.ts | 43 +++++++++++++++++ src/hooks/usePhazeBrand.ts | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/hooks/useGiftCardProvider.ts create mode 100644 src/hooks/usePhazeBrand.ts diff --git a/src/hooks/useGiftCardProvider.ts b/src/hooks/useGiftCardProvider.ts new file mode 100644 index 00000000000..b2e6a12dc6c --- /dev/null +++ b/src/hooks/useGiftCardProvider.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' +import type { EdgeAccount } from 'edge-core-js' + +import { + makePhazeGiftCardProvider, + type PhazeGiftCardProvider +} from '../plugins/gift-cards/phazeGiftCardProvider' + +interface UseGiftCardProviderOptions { + account: EdgeAccount + apiKey: string + baseUrl: string + publicKey?: string +} + +export function useGiftCardProvider(options: UseGiftCardProviderOptions): { + provider: PhazeGiftCardProvider | null + isReady: boolean +} { + const { account, apiKey, baseUrl, publicKey } = options + + const { data: provider = null, isSuccess } = useQuery({ + queryKey: ['phazeProvider', account?.id, apiKey, baseUrl], + queryFn: async () => { + const instance = makePhazeGiftCardProvider( + { + baseUrl, + apiKey, + publicKey + }, + account + ) + // Attach persisted userApiKey if present: + await instance.ensureUser(account) + return instance + }, + enabled: account != null && apiKey !== '' && baseUrl !== '', + staleTime: Infinity, // Provider instance doesn't need to be refetched + gcTime: 300000 + }) + + return { provider, isReady: isSuccess } +} diff --git a/src/hooks/usePhazeBrand.ts b/src/hooks/usePhazeBrand.ts new file mode 100644 index 00000000000..f0174b79bae --- /dev/null +++ b/src/hooks/usePhazeBrand.ts @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query' + +import type { PhazeGiftCardProvider } from '../plugins/gift-cards/phazeGiftCardProvider' +import type { PhazeGiftCardBrand } from '../plugins/gift-cards/phazeGiftCardTypes' +import { useSelector } from '../types/reactRedux' + +interface UseBrandResult { + /** The brand data. May be partial initially, full after loading completes. */ + brand: PhazeGiftCardBrand + /** True while fetching full brand details. */ + isLoading: boolean + /** Error if fetching failed. */ + error: Error | null +} + +/** + * Hook to get full brand details for a gift card. + * + * Takes a partial brand (from market listing) and ensures full details are + * loaded. Shows shimmer-friendly loading state while fetching. + * + * @param provider - The Phaze gift card provider instance + * @param initialBrand - The brand from navigation params (may have limited fields) + * @returns The brand with full details, loading state, and any error + */ +export const usePhazeBrand = ( + provider: PhazeGiftCardProvider | null | undefined, + initialBrand: PhazeGiftCardBrand +): UseBrandResult => { + const countryCode = useSelector(state => state.ui.settings.countryCode) + + // Pre-seed cache if provider is ready + if (provider != null && countryCode !== '') { + provider.storeBrand(countryCode, initialBrand) + } + + const needsFetch = initialBrand.productDescription === undefined + + const { + data: fetchedBrand, + isLoading, + error + } = useQuery({ + queryKey: [ + 'phazeBrand', + countryCode, + initialBrand.productId, + provider != null + ], + queryFn: async () => { + if (provider == null) { + throw new Error('Provider not ready') + } + const fullBrand = await provider.getBrandDetails( + countryCode, + initialBrand.productId + ) + // If fetch returns nothing, use initial brand with empty description + // so shimmer doesn't show forever + return ( + fullBrand ?? { + ...initialBrand, + productDescription: '' + } + ) + }, + enabled: needsFetch && provider != null && countryCode !== '', + staleTime: 5 * 60 * 1000, // Brand details don't change often + gcTime: 10 * 60 * 1000 + }) + + // Return fetched brand if available, otherwise initial brand + const brand = fetchedBrand ?? initialBrand + + return { + brand, + isLoading: needsFetch && isLoading, + error: error instanceof Error ? error : null + } +} From afe2167acc87658327c112e6455c7c9a289d779c Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:05:43 -0800 Subject: [PATCH 05/14] Add Phaze order polling and login integration - PhazeActions: Start/stop order polling service - LoginActions: Hook polling to login/logout lifecycle --- src/actions/LoginActions.tsx | 1 + .../gift-cards/phazeOrderPollingService.ts | 196 ------------------ 2 files changed, 1 insertion(+), 196 deletions(-) delete mode 100644 src/plugins/gift-cards/phazeOrderPollingService.ts diff --git a/src/actions/LoginActions.tsx b/src/actions/LoginActions.tsx index a2e1071dd10..3bdc11e5300 100644 --- a/src/actions/LoginActions.tsx +++ b/src/actions/LoginActions.tsx @@ -333,6 +333,7 @@ export function logoutRequest( const { account } = state.core Keyboard.dismiss() Airship.clear() + dispatch({ type: 'LOGOUT' }) if (typeof account.logout === 'function') await account.logout() const rootNavigation = getRootNavigation(navigation) diff --git a/src/plugins/gift-cards/phazeOrderPollingService.ts b/src/plugins/gift-cards/phazeOrderPollingService.ts deleted file mode 100644 index 9d2d52b477d..00000000000 --- a/src/plugins/gift-cards/phazeOrderPollingService.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { EdgeAccount, EdgeTxActionGiftCard } from 'edge-core-js' - -import { debugLog } from '../../util/logger' -import { makePeriodicTask, type PeriodicTask } from '../../util/PeriodicTask' -import { makePhazeApi } from './phazeApi' -import { - getOrderAugment, - refreshPhazeAugmentsCache -} from './phazeGiftCardOrderStore' -import type { PhazeVoucher } from './phazeGiftCardTypes' - -const POLL_INTERVAL_MS = 10000 // 10 seconds - -interface PhazeOrderPollingConfig { - baseUrl: string - apiKey: string - /** - * User API keys for all identities to poll. - * Multiple identities is an edge case (multi-device before sync completes) - * but we poll all to ensure voucher updates for all orders. - */ - userApiKeys: string[] -} - -interface PhazeOrderPollingService { - /** - * Start polling pending orders for this account. - * Loads augments cache before first poll to ensure tx links are available. - */ - start: () => Promise - /** - * Stop polling (call on logout) - */ - stop: () => void - /** - * Manually trigger a poll (e.g., when returning to orders screen) - */ - pollNow: () => Promise -} - -/** - * Create a polling service that monitors pending gift card orders - * and updates transaction savedAction when vouchers are received. - * - * This service: - * - Fetches orders directly from Phaze API (source of truth) - * - Polls ALL user identities (handles edge case of multiple identities) - * - Updates tx.savedAction with redemption details when vouchers arrive - * - Does NOT write to disklet (augments are written at purchase time only) - */ -export function makePhazeOrderPollingService( - account: EdgeAccount, - config: PhazeOrderPollingConfig -): PhazeOrderPollingService { - const { baseUrl, apiKey, userApiKeys } = config - let task: PeriodicTask | null = null - let isPolling = false - - const pollPendingOrders = async (): Promise => { - if (isPolling) return // Prevent overlapping polls - isPolling = true - - try { - // Poll orders from ALL identities. Multiple identities can occur when - // user purchases on multiple devices before sync - we handle all to - // ensure voucher updates work regardless of which device created the order. - for (const userApiKey of userApiKeys) { - const api = makePhazeApi({ baseUrl, apiKey, userApiKey }) - - try { - const statusResponse = await api.getOrderStatus({}) - const pendingOrders = statusResponse.data.filter( - order => order.status === 'pending' || order.status === 'processing' - ) - const completedOrders = statusResponse.data.filter( - order => order.status === 'complete' - ) - - if (pendingOrders.length > 0 || completedOrders.length > 0) { - debugLog( - 'phaze', - `Polling: ${pendingOrders.length} pending, ${completedOrders.length} complete` - ) - } - - // Process completed orders - update tx.savedAction with vouchers - for (const order of completedOrders) { - await updateTxSavedAction(account, order.quoteId, order.cart) - } - } catch (err: unknown) { - // Log but continue with other identities - debugLog('phaze', 'Error polling identity:', err) - } - } - } finally { - isPolling = false - } - } - - /** - * Update transaction's savedAction with redemption details. - * Only updates if the tx doesn't already have redemption info. - */ - const updateTxSavedAction = async ( - account: EdgeAccount, - orderId: string, - cart: Array<{ vouchers?: PhazeVoucher[] }> - ): Promise => { - // Get augment to find tx link - const augment = getOrderAugment(orderId) - if (augment?.walletId == null || augment.txid == null) { - // No tx link, nothing to update - return - } - - // Collect vouchers from cart - const vouchers = cart.flatMap(item => item.vouchers ?? []) - if (vouchers.length === 0) { - return - } - - const wallet = account.currencyWallets[augment.walletId] - if (wallet == null) { - return - } - - try { - // Find the transaction - const txs = await wallet.getTransactions({ - tokenId: augment.tokenId ?? null - }) - const tx = txs.find(t => t.txid === augment.txid) - - if (tx?.savedAction == null || tx.savedAction.actionType !== 'giftCard') { - return - } - - const currentAction = tx.savedAction - - // Skip if already has redemption info - if (currentAction.redemption?.code != null) { - return - } - - const updatedAction: EdgeTxActionGiftCard = { - ...currentAction, - redemption: { - code: vouchers[0]?.code, - url: vouchers[0]?.url - } - } - - await wallet.saveTxAction({ - txid: augment.txid, - tokenId: augment.tokenId ?? null, - assetAction: { assetActionType: 'giftCard' }, - savedAction: updatedAction - }) - - debugLog('phaze', `Updated transaction savedAction for ${augment.txid}`) - } catch (err: unknown) { - debugLog('phaze', 'Error updating transaction savedAction:', err) - } - } - - return { - async start() { - if (task != null) return // Already running - - debugLog('phaze', 'Starting order polling service') - - // Initialize augments cache from disk before first poll - // to ensure getOrderAugment() finds existing augments - await refreshPhazeAugmentsCache(account).catch(() => {}) - - // Create periodic task for polling - task = makePeriodicTask(pollPendingOrders, POLL_INTERVAL_MS, { - onError: () => {} - }) - // Start immediately, then poll periodically - task.start({ wait: false }) - }, - - stop() { - if (task != null) { - debugLog('phaze', 'Stopping order polling service') - task.stop() - task = null - } - }, - - async pollNow() { - await pollPendingOrders() - } - } -} From 5cf43f2b570e2dc0b72184171e42db818c4d0127 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:05:31 -0800 Subject: [PATCH 06/14] Add gift card locale strings UI text for market, purchase, and list scenes --- src/locales/en_US.ts | 51 +++++++++++++++++++++++++++++++++++ src/locales/strings/enUS.json | 40 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 617988d33c9..d15ece3e6c5 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -84,6 +84,7 @@ const strings = { drawer_sweep_private_key: 'Sweep Private Key', drawer_fio_names: 'FIO Names', drawer_fio_requests: 'FIO Requests', + drawer_gift_cards: 'Gift Cards', network_alert_title: 'No Internet connection', fio_network_alert_text: 'FIO functionality requires internet connection.', @@ -736,6 +737,7 @@ const strings = { string_to_capitalize: 'To', string_show_balance: 'Show Balance', string_amount: 'Amount', + string_value: 'Value', string_tap_next_for_quote: 'Tap "Next" for Quote', string_tap_to_edit: 'Tap to edit', string_rate: 'Rate', @@ -1388,6 +1390,7 @@ const strings = { string_expiration: 'Expiration', export_transaction_error: 'Start date should be earlier than the end date', export_transaction_export_error: 'No transactions in the date range chosen', + string_all: 'All', string_allow: 'Allow', string_deny: 'Deny', string_wallet_balance: 'Wallet Balance', @@ -1858,6 +1861,54 @@ const strings = { // #endregion GuiPlugins + // #region Gift Cards + + gift_card: 'Gift Cards', + gift_card_list_no_cards: 'No Gift Cards', + gift_card_list_purchase_new_button: 'Purchase New', + title_gift_card_market: 'Gift Card Marketplace', + title_gift_card_select: 'Select Gift Cards', + title_gift_card_purchase: 'Purchase Gift Card', + search_gift_cards: 'Search Gift Cards', + gift_card_select_amount: 'Select Amount', + gift_card_enter_amount: 'Enter Amount', + gift_card_selected_amount: 'Selected Amount', + gift_card_fixed_amounts: 'Fixed Amounts', + gift_card_pay_from_wallet: 'Pay From Wallet', + gift_card_brand: 'Brand', + gift_card_label: 'Gift Card', + gift_card_pay_amount: 'Pay Amount', + gift_card_purchase_success: 'Gift card purchase submitted!', + gift_card_redeem: 'Redeem', + gift_card_code_copied: 'Security code copied to clipboard', + gift_card_recipient_name: 'Phaze Gift Card', + gift_card_redeem_visit: 'Visit redemption page', + gift_card_go_to_transaction: 'Go to Transaction', + gift_card_mark_as_redeemed: 'Mark as Redeemed', + gift_card_mark_redeemed_prompt: 'Mark gift card as redeemed?', + gift_card_security_code: 'Security Code', + gift_card_how_it_works: 'How it Works', + gift_card_how_it_works_body: + 'Purchase your card with crypto and receive a redemption code upon completion. The redemption code can be found in the transaction details where you can copy and paste the code for use.', + gift_card_terms_and_conditions: 'Terms and Conditions', + gift_card_terms_and_conditions_body: + 'By purchasing a gift card, you are agreeing to the terms and conditions that apply. {{link}}Read the terms and conditions here.{{/link}}', + gift_card_slider_terms: + 'By sliding to confirm, you are agreeing to the {{link}}gift card terms and conditions{{/link}}.', + gift_card_more_options: 'Browse more gift cards', + gift_card_network_error: + 'Unable to load gift cards. Please check your network connection.', + gift_card_minimum_warning_title: 'Below Minimum', + gift_card_minimum_warning_header: + 'The selected amount is below the minimum for %s.', + gift_card_minimum_warning_footer: + 'Please select a different payment method or increase your purchase amount to at least %s.', + gift_card_redeemed_cards: 'Redeemed Cards', + gift_card_unmark_as_redeemed: 'Unmark as Redeemed', + gift_card_active_cards: 'Active Cards', + + // #endregion + // #region Light Account backup_account: 'Back Up Account', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 6dd15164287..13f368d8498 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -46,6 +46,7 @@ "drawer_sweep_private_key": "Sweep Private Key", "drawer_fio_names": "FIO Names", "drawer_fio_requests": "FIO Requests", + "drawer_gift_cards": "Gift Cards", "network_alert_title": "No Internet connection", "fio_network_alert_text": "FIO functionality requires internet connection.", "fio_address_choose_label": "Choose a FIO Crypto Handle", @@ -572,6 +573,7 @@ "string_to_capitalize": "To", "string_show_balance": "Show Balance", "string_amount": "Amount", + "string_value": "Value", "string_tap_next_for_quote": "Tap \"Next\" for Quote", "string_tap_to_edit": "Tap to edit", "string_rate": "Rate", @@ -1093,6 +1095,7 @@ "string_expiration": "Expiration", "export_transaction_error": "Start date should be earlier than the end date", "export_transaction_export_error": "No transactions in the date range chosen", + "string_all": "All", "string_allow": "Allow", "string_deny": "Deny", "string_wallet_balance": "Wallet Balance", @@ -1438,6 +1441,43 @@ "otc_confirmation_message": "Thank you! You will be contacted in the next 24 hours to complete your request.", "otc_email_error_message_1s": "There was an error opening your email app. Please email %1$s directly to facilitate this transaction.", "otc_wire_required_message": "OTC transactions are done through bank wire transfers. Can your bank send wire transfers?", + "gift_card": "Gift Cards", + "gift_card_list_no_cards": "No Gift Cards", + "gift_card_list_purchase_new_button": "Purchase New", + "title_gift_card_market": "Gift Card Marketplace", + "title_gift_card_select": "Select Gift Cards", + "title_gift_card_purchase": "Purchase Gift Card", + "search_gift_cards": "Search Gift Cards", + "gift_card_select_amount": "Select Amount", + "gift_card_enter_amount": "Enter Amount", + "gift_card_selected_amount": "Selected Amount", + "gift_card_fixed_amounts": "Fixed Amounts", + "gift_card_pay_from_wallet": "Pay From Wallet", + "gift_card_brand": "Brand", + "gift_card_label": "Gift Card", + "gift_card_pay_amount": "Pay Amount", + "gift_card_purchase_success": "Gift card purchase submitted!", + "gift_card_redeem": "Redeem", + "gift_card_code_copied": "Security code copied to clipboard", + "gift_card_recipient_name": "Phaze Gift Card", + "gift_card_redeem_visit": "Visit redemption page", + "gift_card_go_to_transaction": "Go to Transaction", + "gift_card_mark_as_redeemed": "Mark as Redeemed", + "gift_card_mark_redeemed_prompt": "Mark gift card as redeemed?", + "gift_card_security_code": "Security Code", + "gift_card_how_it_works": "How it Works", + "gift_card_how_it_works_body": "Purchase your card with crypto and receive a redemption code upon completion. The redemption code can be found in the transaction details where you can copy and paste the code for use.", + "gift_card_terms_and_conditions": "Terms and Conditions", + "gift_card_terms_and_conditions_body": "By purchasing a gift card, you are agreeing to the terms and conditions that apply. {{link}}Read the terms and conditions here.{{/link}}", + "gift_card_slider_terms": "By sliding to confirm, you are agreeing to the {{link}}gift card terms and conditions{{/link}}.", + "gift_card_more_options": "Browse more gift cards", + "gift_card_network_error": "Unable to load gift cards. Please check your network connection.", + "gift_card_minimum_warning_title": "Below Minimum", + "gift_card_minimum_warning_header": "The selected amount is below the minimum for %s.", + "gift_card_minimum_warning_footer": "Please select a different payment method or increase your purchase amount to at least %s.", + "gift_card_redeemed_cards": "Redeemed Cards", + "gift_card_unmark_as_redeemed": "Unmark as Redeemed", + "gift_card_active_cards": "Active Cards", "backup_account": "Back Up Account", "backup_delete_confirm_message": "Are you sure you want to delete this account without backing up first? You will NOT be able to recover wallets and transactions for this account!", "backup_info_message": "Create a username and password to create a full account and secure your funds. No personal information is required", From 7fa2430c563922453a60cc35757c29ae16788605 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 6 Nov 2025 14:55:46 -0800 Subject: [PATCH 07/14] Factor out `RegionButton` from `RampCreateScene` --- src/components/buttons/RegionButton.tsx | 75 +++++++++++++++++++++++ src/components/scenes/RampCreateScene.tsx | 45 +------------- 2 files changed, 77 insertions(+), 43 deletions(-) create mode 100644 src/components/buttons/RegionButton.tsx diff --git a/src/components/buttons/RegionButton.tsx b/src/components/buttons/RegionButton.tsx new file mode 100644 index 00000000000..d70b8ea05fd --- /dev/null +++ b/src/components/buttons/RegionButton.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import FastImage from 'react-native-fast-image' + +import { FLAG_LOGO_URL } from '../../constants/CdnConstants' +import { COUNTRY_CODES } from '../../constants/CountryConstants' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { useSelector } from '../../types/reactRedux' +import { cacheStyles, useTheme } from '../services/ThemeContext' +import { PillButton } from './PillButton' + +interface Props { + onPress: () => void | Promise +} + +export const RegionButton: React.FC = props => { + const { onPress } = props + const theme = useTheme() + const styles = getStyles(theme) + + const { countryCode, stateProvinceCode } = useSelector( + state => state.ui.settings + ) + + const countryData = React.useMemo( + () => COUNTRY_CODES.find(c => c['alpha-2'] === countryCode), + [countryCode] + ) + + const label = React.useMemo(() => { + if (countryCode === '' || countryData == null) { + return lstrings.buy_sell_crypto_select_country_button + } + if (stateProvinceCode != null && countryData.stateProvinces != null) { + const stateProvince = countryData.stateProvinces.find( + sp => sp['alpha-2'] === stateProvinceCode + ) + if (stateProvince != null) { + return `${stateProvince.name}, ${countryData['alpha-3']}` + } + } + return countryData.name + }, [countryCode, countryData, stateProvinceCode]) + + const flagUri = React.useMemo(() => { + if (countryData == null) return null + const logoName = + countryData.filename ?? countryData.name.toLowerCase().replace(' ', '-') + return `${FLAG_LOGO_URL}/${logoName}.png` + }, [countryData]) + + const icon = useHandler(() => { + return flagUri != null ? ( + + ) : null + }) + + return ( + + ) +} + +const getStyles = cacheStyles((theme: ReturnType) => ({ + flagIconSmall: { + width: theme.rem(1), + height: theme.rem(1), + borderRadius: theme.rem(0.75) + } +})) diff --git a/src/components/scenes/RampCreateScene.tsx b/src/components/scenes/RampCreateScene.tsx index 4fcaa985535..88fea503c0a 100644 --- a/src/components/scenes/RampCreateScene.tsx +++ b/src/components/scenes/RampCreateScene.tsx @@ -18,7 +18,6 @@ import { setRampCryptoSelection, setRampFiatCurrencyCode } from '../../actions/SettingsActions' -import { FLAG_LOGO_URL } from '../../constants/CdnConstants' import { COUNTRY_CODES, FIAT_COUNTRY } from '../../constants/CountryConstants' import { getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' import { useHandler } from '../../hooks/useHandler' @@ -58,7 +57,7 @@ import { import { DropdownInputButton } from '../buttons/DropdownInputButton' import { EdgeButton } from '../buttons/EdgeButton' import { KavButtons } from '../buttons/KavButtons' -import { PillButton } from '../buttons/PillButton' +import { RegionButton } from '../buttons/RegionButton' import { AlertCardUi4 } from '../cards/AlertCard' import { ErrorCard, I18nError } from '../cards/ErrorCard' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' @@ -208,31 +207,6 @@ export const RampCreateScene: React.FC = (props: Props) => { direction }) - const getRegionText = (): string => { - if (countryCode === '' || countryData == null) { - return lstrings.buy_sell_crypto_select_country_button - } - - if (stateProvinceCode != null && countryData.stateProvinces != null) { - const stateProvince = countryData.stateProvinces.find( - sp => sp['alpha-2'] === stateProvinceCode - ) - if (stateProvince != null) { - return `${stateProvince.name}, ${countryData['alpha-3']}` - } - } - - return countryData.name - } - - const flagUri = - countryData != null - ? `${FLAG_LOGO_URL}/${ - countryData.filename ?? - countryData.name.toLowerCase().replace(' ', '-') - }.png` - : null - // Compute fiat flag URL for selected fiat currency code const selectedFiatFlagUri = React.useMemo(() => { const info = FIAT_COUNTRY[selectedFiatCurrencyCode?.toUpperCase() ?? ''] @@ -826,22 +800,7 @@ export const RampCreateScene: React.FC = (props: Props) => { > - flagUri != null ? ( - - ) : null - } - label={getRegionText()} - onPress={handleRegionSelect} - /> - } + headerTitleChildren={} > {/* Amount Inputs */} {/* Top Input (Fiat) */} From 9b69d7fdb190fa7810c7f36c6bcd12cf50c894ed Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Fri, 19 Dec 2025 11:56:03 -0800 Subject: [PATCH 08/14] Add supporting UI changes for gift cards - ThemedIcons: Add GiftCardIcon and GiftIcon - WebViewModal: Support HTML content with external link handling - SideMenu: Add gift card navigation entry - Button/layout component styling updates - Fix lint errors in files removed from exceptions list --- eslint.config.mjs | 10 +- .../CountryListModal.test.tsx.snap | 2742 +++++++++-------- .../__snapshots__/HelpModal.test.tsx.snap | 1260 ++++---- ...reateWalletSelectCryptoScene.test.tsx.snap | 1 + ...FioConnectWalletConfirmScene.test.tsx.snap | 11 +- .../SwapConfirmationScene.test.tsx.snap | 139 +- .../SwapCreateScene.test.tsx.snap | 10 +- src/actions/CountryListActions.tsx | 31 +- .../buttons/DropdownInputButton.tsx | 7 +- src/components/buttons/PillButton.tsx | 49 +- src/components/buttons/RegionButton.tsx | 64 +- src/components/cards/PaymentOptionCard.tsx | 2 +- src/components/common/SceneWrapper.tsx | 23 +- src/components/icons/ThemedIcons.tsx | 18 + src/components/layout/SceneContainer.tsx | 89 +- src/components/modals/WebViewModal.tsx | 92 +- src/components/scenes/DuressPinScene.tsx | 10 +- src/components/scenes/RampCreateScene.tsx | 15 +- src/components/scenes/RampKycFormScene.tsx | 1 - src/components/scenes/RampPendingScene.tsx | 2 +- .../scenes/Staking/StakeOptionsScene.tsx | 37 +- src/components/scenes/SwapCreateScene.tsx | 2 +- src/components/themed/SceneFooterWrapper.tsx | 8 +- src/components/themed/SideMenu.tsx | 16 +- 24 files changed, 2450 insertions(+), 2189 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 493141bc769..c2893822153 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -82,7 +82,7 @@ export default [ 'scripts/updateVersion.ts', 'src/actions/BackupModalActions.tsx', 'src/actions/CategoriesActions.ts', - 'src/actions/CountryListActions.tsx', + 'src/actions/CreateWalletActions.tsx', 'src/actions/DeviceSettingsActions.ts', @@ -164,7 +164,7 @@ export default [ 'src/components/keyboard/KavButton.tsx', 'src/components/layout/Peek.tsx', - 'src/components/layout/SceneContainer.tsx', + 'src/components/modals/AccelerateTxModal.tsx', 'src/components/modals/AddressModal.tsx', 'src/components/modals/AirshipFullScreenSpinner.tsx', @@ -204,7 +204,7 @@ export default [ 'src/components/modals/WalletListSortModal.tsx', 'src/components/modals/WcSmartContractModal.tsx', - 'src/components/modals/WebViewModal.tsx', + 'src/components/navigation/AlertDropdown.tsx', 'src/components/navigation/BackButton.tsx', 'src/components/navigation/CurrencySettingsTitle.tsx', @@ -255,7 +255,6 @@ export default [ 'src/components/scenes/DefaultFiatSettingScene.tsx', 'src/components/scenes/DuressModeHowToScene.tsx', 'src/components/scenes/DuressModeSettingScene.tsx', - 'src/components/scenes/DuressPinScene.tsx', 'src/components/scenes/EditTokenScene.tsx', 'src/components/scenes/ExtraTabScene.tsx', @@ -301,9 +300,7 @@ export default [ 'src/components/scenes/SpendingLimitsScene.tsx', 'src/components/scenes/Staking/EarnScene.tsx', - 'src/components/scenes/Staking/StakeOptionsScene.tsx', - 'src/components/scenes/SwapCreateScene.tsx', 'src/components/scenes/SwapProcessingScene.tsx', 'src/components/scenes/SwapSettingsScene.tsx', 'src/components/scenes/SwapSuccessScene.tsx', @@ -372,7 +369,6 @@ export default [ 'src/components/themed/ModalParts.tsx', 'src/components/themed/PinDots.tsx', - 'src/components/themed/SceneFooterWrapper.tsx', 'src/components/themed/SceneHeader.tsx', 'src/components/themed/SearchFooter.tsx', diff --git a/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap index d58d9c1170d..cba78e695ca 100644 --- a/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap @@ -2943,168 +2943,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - United States of America - - + + + + - US - + + United States of America + + + US + + @@ -3114,168 +3116,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Afghanistan - - + + + + - AF - + + Afghanistan + + + AF + + @@ -3285,168 +3289,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Åland Islands - - + + + + - AX - + + Åland Islands + + + AX + + @@ -3456,168 +3462,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Albania - - + + + + - AL - + + Albania + + + AL + + @@ -3627,168 +3635,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Algeria - - + + + + - DZ - + + Algeria + + + DZ + + @@ -3797,169 +3807,171 @@ exports[`CountryListModal should render with a country list 1`] = ` onFocusCapture={[Function]} onLayout={[Function]} style={null} - > - + > + - - - - - + /> - - American Samoa - - + + + + - AS - + + American Samoa + + + AS + + @@ -3969,168 +3981,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Andorra - - + + + + - AD - + + Andorra + + + AD + + @@ -4140,168 +4154,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Angola - - + + + + - AO - + + Angola + + + AO + + @@ -4311,168 +4327,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Anguilla - - + + + + - AI - + + Anguilla + + + AI + + @@ -4482,168 +4500,170 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - + - - - - - + /> - - Antigua and Barbuda - - + + + + - AG - + + Antigua and Barbuda + + + AG + + diff --git a/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap index c4833e3af64..5d57e0ff871 100644 --- a/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap @@ -342,773 +342,783 @@ exports[`HelpModal should render with loading props 1`] = ` } > - + - - -  - - + /> - - Knowledge Base - - +  + + + - Commonly asked questions - + + Knowledge Base + + + Commonly asked questions + + - + - - -  - - + /> - - Live Chat - - +  + + + - Quick help from a live agent - + + Live Chat + + + Quick help from a live agent + + - + - - -  - - + /> - - Submit a Support Ticket - - +  + + + - Troubleshooting and technical support - + + Submit a Support Ticket + + + Troubleshooting and technical support + + - + - - -  - - + /> - - Call for Assistance - - +  + + + - Our agents are also available by phone - + + Call for Assistance + + + Our agents are also available by phone + + - + - - -  - - + /> - - Visit Official Site - - +  + + + - Learn more about Edge - + + Visit Official Site + + + Learn more about Edge + + diff --git a/src/__tests__/scenes/__snapshots__/CreateWalletSelectCryptoScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/CreateWalletSelectCryptoScene.test.tsx.snap index 5bdfd6eeecf..754a6244f7a 100644 --- a/src/__tests__/scenes/__snapshots__/CreateWalletSelectCryptoScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/CreateWalletSelectCryptoScene.test.tsx.snap @@ -2293,6 +2293,7 @@ exports[`CreateWalletSelectCrypto should render with loading props 1`] = ` ] } nativeID="10" + onLayout={[Function]} style={ [ { diff --git a/src/__tests__/scenes/__snapshots__/FioConnectWalletConfirmScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/FioConnectWalletConfirmScene.test.tsx.snap index 730f9f50dbd..4d716a7ff42 100644 --- a/src/__tests__/scenes/__snapshots__/FioConnectWalletConfirmScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/FioConnectWalletConfirmScene.test.tsx.snap @@ -357,15 +357,10 @@ exports[`FioConnectWalletConfirm should render with loading props 1`] = ` diff --git a/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap index ab3a3f33d1a..6dbf122dcf9 100644 --- a/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap @@ -273,97 +273,92 @@ exports[`SwapConfirmationScene should render with loading props 1`] = ` - - - Exchange - - - + > + Exchange + + + + diff --git a/src/actions/CountryListActions.tsx b/src/actions/CountryListActions.tsx index 8f71a527290..f9acbad57ee 100644 --- a/src/actions/CountryListActions.tsx +++ b/src/actions/CountryListActions.tsx @@ -55,7 +55,9 @@ export const checkAndSetRegion = (props: { /** * Opens a country list modal, then a state province modal if needed. - * If skipCountry is set, only a state province modal is shown + * If skipCountry is set, only a state province modal is shown. + * If skipStateProvince is set, no state province modal is shown (useful for + * flows that only need country, like gift cards). */ export const showCountrySelectionModal = (props: { @@ -65,9 +67,18 @@ export const showCountrySelectionModal = /** Set to true to select stateProvinceCode only */ skipCountry?: boolean + + /** Set to true to skip state/province selection entirely */ + skipStateProvince?: boolean }): ThunkAction> => async (dispatch, getState) => { - const { account, countryCode, stateProvinceCode, skipCountry } = props + const { + account, + countryCode, + stateProvinceCode, + skipCountry, + skipStateProvince + } = props let selectedCountryCode: string = countryCode if (skipCountry !== true) { @@ -85,7 +96,9 @@ export const showCountrySelectionModal = if (country == null) throw new Error('Invalid country code') const { stateProvinces, name } = country let selectedStateProvince: string | undefined - if (stateProvinces != null) { + + // Only prompt for state/province if not skipped and country has them + if (skipStateProvince !== true && stateProvinces != null) { // This country has states/provinces. Show picker for that const previousStateProvince = stateProvinces.some( sp => sp['alpha-2'] === stateProvinceCode @@ -107,16 +120,24 @@ export const showCountrySelectionModal = return } } + const syncedSettings = await readSyncedSettings(account) + // When skipStateProvince is true and country didn't change, preserve + // existing stateProvinceCode. If country changed, clear it since the + // old state is no longer valid for the new country. + const newStateProvinceCode = + skipStateProvince === true && selectedCountryCode === countryCode + ? stateProvinceCode + : selectedStateProvince const updatedSettings: SyncedAccountSettings = { ...syncedSettings, countryCode: selectedCountryCode, - stateProvinceCode: selectedStateProvince + stateProvinceCode: newStateProvinceCode } dispatch( updateOneSetting({ countryCode: selectedCountryCode, - stateProvinceCode: selectedStateProvince + stateProvinceCode: newStateProvinceCode }) ) await writeSyncedSettings(account, updatedSettings) diff --git a/src/components/buttons/DropdownInputButton.tsx b/src/components/buttons/DropdownInputButton.tsx index 98dd15ae399..97953019c04 100644 --- a/src/components/buttons/DropdownInputButton.tsx +++ b/src/components/buttons/DropdownInputButton.tsx @@ -6,7 +6,7 @@ import { cacheStyles, useTheme } from '../services/ThemeContext' export interface DropdownInputButtonProps { children: React.ReactNode - onPress: () => void | Promise + onPress?: () => void | Promise testID?: string } @@ -21,10 +21,13 @@ export const DropdownInputButton: React.FC = ( {children} - + {onPress != null ? ( + + ) : null} ) } diff --git a/src/components/buttons/PillButton.tsx b/src/components/buttons/PillButton.tsx index 13d586d6a5a..29c38e64d54 100644 --- a/src/components/buttons/PillButton.tsx +++ b/src/components/buttons/PillButton.tsx @@ -7,16 +7,18 @@ import { useLayoutStyle } from '../../hooks/useLayoutStyle' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' -import { ChevronRightIcon } from '../icons/ThemedIcons' +import { ChevronDownIcon, ChevronRightIcon } from '../icons/ThemedIcons' import { cacheStyles, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' export interface PillButtonProps extends LayoutStyleProps { - label: string + label?: string onPress: () => void | Promise icon?: () => React.ReactElement | null disabled?: boolean - chevron?: boolean + children?: React.ReactNode + chevronDown?: boolean + chevronRight?: boolean } export const PillButton: React.FC = ( @@ -27,7 +29,9 @@ export const PillButton: React.FC = ( onPress, icon, disabled = false, - chevron = false, + children, + chevronDown = false, + chevronRight = false, ...marginProps } = props const marginStyle = useLayoutStyle(marginProps) @@ -51,17 +55,27 @@ export const PillButton: React.FC = ( start={theme.secondaryButtonColorStart} /> {icon == null ? null : icon()} - - {label} - - {!chevron ? null : ( - + {label == null || label === '' ? null : ( + + {label} + )} + {children} + {chevronDown ? ( + + ) : null} + {chevronRight ? ( + + ) : null} ) } @@ -86,5 +100,12 @@ const getStyles = cacheStyles((theme: ReturnType) => ({ lineHeight: theme.rem(1.5), flexShrink: 1, minWidth: 0 + }, + chevronDown: { + // Fudge factor to combat optical illusion of a triangle inside of a round + // container not appearing evenly centered + marginLeft: -theme.rem(0.25), + marginRight: -theme.rem(0.25), + top: 1 } })) diff --git a/src/components/buttons/RegionButton.tsx b/src/components/buttons/RegionButton.tsx index d70b8ea05fd..a6c4edf69d0 100644 --- a/src/components/buttons/RegionButton.tsx +++ b/src/components/buttons/RegionButton.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { View } from 'react-native' import FastImage from 'react-native-fast-image' import { FLAG_LOGO_URL } from '../../constants/CdnConstants' @@ -13,7 +14,61 @@ interface Props { onPress: () => void | Promise } -export const RegionButton: React.FC = props => { +/** + * Displays just the country flag. For use in flows that only need country + * selection (e.g., gift cards). + */ +export const CountryButton: React.FC = props => { + const { onPress } = props + const theme = useTheme() + const styles = getStyles(theme) + + const { countryCode } = useSelector(state => state.ui.settings) + + const countryData = React.useMemo( + () => COUNTRY_CODES.find(c => c['alpha-2'] === countryCode), + [countryCode] + ) + + const flagUri = React.useMemo(() => { + if (countryData == null) return null + const logoName = + countryData.filename ?? + countryData.name.toLowerCase().replaceAll(' ', '-') + return `${FLAG_LOGO_URL}/${logoName}.png` + }, [countryData]) + + const icon = useHandler(() => { + return flagUri != null ? ( + + + + ) : null + }) + + // Show placeholder text if no country selected, otherwise icon-only with chevron + const hasCountry = countryCode !== '' && countryData != null + const label = hasCountry + ? undefined + : lstrings.buy_sell_crypto_select_country_button + + return ( + + ) +} + +/** + * Displays the country flag with state/province and country name. + * For use in flows that need full region selection (e.g., ramps). + */ +export const CountryStateButton: React.FC = props => { const { onPress } = props const theme = useTheme() const styles = getStyles(theme) @@ -45,7 +100,8 @@ export const RegionButton: React.FC = props => { const flagUri = React.useMemo(() => { if (countryData == null) return null const logoName = - countryData.filename ?? countryData.name.toLowerCase().replace(' ', '-') + countryData.filename ?? + countryData.name.toLowerCase().replaceAll(' ', '-') return `${FLAG_LOGO_URL}/${logoName}.png` }, [countryData]) @@ -67,6 +123,10 @@ export const RegionButton: React.FC = props => { } const getStyles = cacheStyles((theme: ReturnType) => ({ + iconContainer: { + height: theme.rem(1.5), + justifyContent: 'center' + }, flagIconSmall: { width: theme.rem(1), height: theme.rem(1), diff --git a/src/components/cards/PaymentOptionCard.tsx b/src/components/cards/PaymentOptionCard.tsx index 747f8d31180..dfa98ecbe61 100644 --- a/src/components/cards/PaymentOptionCard.tsx +++ b/src/components/cards/PaymentOptionCard.tsx @@ -71,7 +71,7 @@ export const PaymentOptionCard: React.FC = (props: Props) => { } label={props.partner?.displayName ?? ''} onPress={props.onProviderPress} - chevron + chevronDown /> )} diff --git a/src/components/common/SceneWrapper.tsx b/src/components/common/SceneWrapper.tsx index b8b41b68695..a484d35d953 100644 --- a/src/components/common/SceneWrapper.tsx +++ b/src/components/common/SceneWrapper.tsx @@ -171,6 +171,9 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement { const navigation = useNavigation() const isIos = Platform.OS === 'ios' + // Track dock height for content padding when dockProps is used + const [dockHeight, setDockHeight] = useState(0) + // We need to track this state in the JS thread because insets are not shared values const [isKeyboardOpen, setIsKeyboardOpen] = useState(false) useKeyboardHandler({ @@ -240,6 +243,12 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement { // Ignore inset bottom when keyboard is open because it is rendered behind it const maybeInsetBottom = !isKeyboardOpen || !avoidKeyboard ? safeAreaInsets.bottom : 0 + // Include dock height in bottom inset when dock is visible (not keyboard-only or keyboard is open) + const keyboardVisibleOnlyDock = dockProps?.keyboardVisibleOnly ?? true + const maybeDockHeight = + dockProps != null && (!keyboardVisibleOnlyDock || isKeyboardOpen) + ? dockHeight + : 0 const insets: EdgeInsets = useMemo( () => ({ top: hasHeader ? headerBarHeight : safeAreaInsets.top, @@ -248,13 +257,15 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement { maybeInsetBottom + maybeNotificationHeight + maybeTabBarHeight + - footerHeight, + footerHeight + + maybeDockHeight, left: safeAreaInsets.left }), [ footerHeight, hasHeader, headerBarHeight, + maybeDockHeight, maybeInsetBottom, maybeNotificationHeight, maybeTabBarHeight, @@ -328,7 +339,12 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement { }, [children, sceneWrapperInfo]) // Build Dock View element - const keyboardVisibleOnlyDoc = dockProps?.keyboardVisibleOnly ?? true + const handleDockLayout = React.useCallback( + (event: { nativeEvent: { layout: { height: number } } }) => { + setDockHeight(event.nativeEvent.layout.height) + }, + [] + ) const dockBaseStyle = useMemo( () => ({ position: 'absolute' as const, @@ -366,9 +382,10 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement { return { bottom } }) const shouldShowDock = - dockProps != null && (!keyboardVisibleOnlyDoc || isKeyboardVisibleDock) + dockProps != null && (!keyboardVisibleOnlyDock || isKeyboardVisibleDock) const dockElement = !shouldShowDock ? null : ( = (props: Props) => { - const { children, headerTitle, headerTitleChildren, ...sceneContainerProps } = - props + const { children, headerTitle, headerTitleChildren, undoInsetStyle } = props + + const theme = useTheme() + const styles = getStyles(theme) + + const contentInsets = React.useMemo( + () => ({ + ...undoInsetStyle, + flex: 1, + marginTop: 0, + // Built-in padding if we're not using undoInsetStyle + paddingHorizontal: + undoInsetStyle == null ? theme.rem(DEFAULT_MARGIN_REM) : 0, + paddingBottom: undoInsetStyle == null ? theme.rem(DEFAULT_MARGIN_REM) : 0 + }), + [theme, undoInsetStyle] + ) return ( - + <> {headerTitle != null ? ( - - {headerTitleChildren} - + + + {headerTitle} + {headerTitleChildren} + + + ) : null} - {children} - + {children} + ) } -interface SceneContainerViewProps { - expand?: boolean - undoTop?: boolean - undoRight?: boolean - undoBottom?: boolean - undoLeft?: boolean - undoInsetStyle?: UndoInsetStyle -} -const SceneContainerView = styled(View)( - theme => - ({ expand, undoTop, undoRight, undoBottom, undoLeft, undoInsetStyle }) => ({ - flex: expand === true ? 1 : undefined, - paddingTop: theme.rem(0.5), - paddingRight: theme.rem(0.5), - paddingBottom: theme.rem(0.5), - paddingLeft: theme.rem(0.5), - marginTop: undoTop === true ? undoInsetStyle?.marginTop : undefined, - marginRight: undoRight === true ? undoInsetStyle?.marginRight : undefined, - marginBottom: - undoBottom === true ? undoInsetStyle?.marginBottom : undefined, - marginLeft: undoLeft === true ? undoInsetStyle?.marginLeft : undefined - }) -) +const getStyles = cacheStyles((theme: Theme) => ({ + headerContainer: { + justifyContent: 'center', + overflow: 'visible', + paddingLeft: theme.rem(DEFAULT_MARGIN_REM) + }, + title: { + fontSize: theme.rem(1.2), + fontFamily: theme.fontFaceMedium + }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + marginHorizontal: theme.rem(DEFAULT_MARGIN_REM), + marginBottom: theme.rem(DEFAULT_MARGIN_REM) + } +})) diff --git a/src/components/modals/WebViewModal.tsx b/src/components/modals/WebViewModal.tsx index 2802b1cd0c6..5e044d3affc 100644 --- a/src/components/modals/WebViewModal.tsx +++ b/src/components/modals/WebViewModal.tsx @@ -1,9 +1,11 @@ import * as React from 'react' +import { Linking } from 'react-native' import type { AirshipBridge } from 'react-native-airship' -import { WebView } from 'react-native-webview' +import { WebView, type WebViewNavigation } from 'react-native-webview' +import { useHandler } from '../../hooks/useHandler' import { Airship } from '../services/AirshipInstance' -import { useTheme } from '../services/ThemeContext' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeModal } from './EdgeModal' export async function showWebViewModal( @@ -15,29 +17,103 @@ export async function showWebViewModal( )) } +/** Show a modal with HTML content rendered in a WebView */ +export async function showHtmlModal( + title: string, + html: string +): Promise { + await Airship.show(bridge => ( + + )) +} + interface Props { bridge: AirshipBridge title: string - uri: string + uri?: string + html?: string } -export const WebViewModal = (props: Props) => { - const { bridge, title, uri } = props +export const WebViewModal: React.FC = props => { + const { bridge, title, uri, html } = props const webviewRef = React.useRef(null) const theme = useTheme() + const styles = getStyles(theme) - const handleClose = () => { + const handleClose = (): void => { props.bridge.resolve() } + // Open external links in the device browser + const handleShouldStartLoad = useHandler( + (event: WebViewNavigation): boolean => { + const { url } = event + // Allow initial load and about:blank, but open http(s) links externally + if (url.startsWith('http://') || url.startsWith('https://')) { + // For HTML content, the initial load uses a data URI or about:blank + // so any http(s) navigation is a link click + if (html != null) { + Linking.openURL(url).catch(() => {}) + return false + } + } + return true + } + ) + + // Build source - either URI or HTML with dark theme styling + const source = React.useMemo(() => { + if (uri != null) { + return { uri } + } + if (html != null) { + const styledHtml = ` + + + + + + + ${html} + + ` + return { html: styledHtml } + } + return { html: '' } + }, [uri, html, theme]) + return ( ) } + +const getStyles = cacheStyles((theme: Theme) => ({ + webView: { + marginTop: theme.rem(0.5), + backgroundColor: 'transparent' + } +})) diff --git a/src/components/scenes/DuressPinScene.tsx b/src/components/scenes/DuressPinScene.tsx index 7280bc45fb1..c41ba4d79a3 100644 --- a/src/components/scenes/DuressPinScene.tsx +++ b/src/components/scenes/DuressPinScene.tsx @@ -19,7 +19,7 @@ import { DigitInput, MAX_PIN_LENGTH } from './inputs/DigitInput' interface Props extends EdgeAppSceneProps<'duressPin'> {} -export const DuressPinScene = (props: Props) => { +export const DuressPinScene: React.FC = (props: Props) => { const { navigation } = props const theme = useTheme() const styles = getStyles(theme) @@ -29,7 +29,7 @@ export const DuressPinScene = (props: Props) => { const [pin, setPin] = React.useState('') const isValidPin = pin.length === MAX_PIN_LENGTH - const handleComplete = () => { + const handleComplete = useHandler(() => { if (!isValidPin) return account .checkPin(pin) @@ -47,10 +47,10 @@ export const DuressPinScene = (props: Props) => { showToast(lstrings.duress_mode_set_pin_success) navigation.navigate('duressModeSetting') }) - .catch(err => { + .catch((err: unknown) => { showError(err) }) - } + }) const handleChangePin = useHandler((newPin: string) => { // Change pin only when input are numbers @@ -66,7 +66,7 @@ export const DuressPinScene = (props: Props) => { return ( - + = (props: Props) => { const countryData = COUNTRY_CODES.find(c => c['alpha-2'] === countryCode) - // Determine whether to show the region selection scene variant + // Determine whether to show the region selection scene variant. + // Show if: no country, invalid country, or country requires state but none selected + const countryRequiresState = countryData?.stateProvinces != null const shouldShowRegionSelect = - initialRegionCode == null && (countryCode === '' || countryData == null) + initialRegionCode == null && + (countryCode === '' || + countryData == null || + (countryRequiresState && stateProvinceCode == null)) // Get ramp plugins const { data: rampPluginArray = [], isLoading: isPluginsLoading } = @@ -800,7 +805,9 @@ export const RampCreateScene: React.FC = (props: Props) => { > } + headerTitleChildren={ + + } > {/* Amount Inputs */} {/* Top Input (Fiat) */} diff --git a/src/components/scenes/RampKycFormScene.tsx b/src/components/scenes/RampKycFormScene.tsx index d952fb04389..ee00121c05b 100644 --- a/src/components/scenes/RampKycFormScene.tsx +++ b/src/components/scenes/RampKycFormScene.tsx @@ -192,7 +192,6 @@ export const RampKycFormScene = React.memo((props: Props) => { = props => { return ( - + {error != null ? ( diff --git a/src/components/scenes/Staking/StakeOptionsScene.tsx b/src/components/scenes/Staking/StakeOptionsScene.tsx index 0f4e8558ca2..4ca4f3c38df 100644 --- a/src/components/scenes/Staking/StakeOptionsScene.tsx +++ b/src/components/scenes/Staking/StakeOptionsScene.tsx @@ -30,7 +30,6 @@ import { SceneContainer } from '../../layout/SceneContainer' import { Space } from '../../layout/Space' import { useTheme } from '../../services/ThemeContext' import { EdgeText } from '../../themed/EdgeText' -import { SceneHeaderUi4 } from '../../themed/SceneHeaderUi4' interface Props extends EdgeAppSceneProps<'stakeOptions'> { wallet: EdgeCurrencyWallet @@ -41,7 +40,7 @@ export interface StakeOptionsParams { walletId: string } -const StakeOptionsSceneComponent = (props: Props) => { +const StakeOptionsSceneComponent: React.FC = props => { const { navigation, route, wallet } = props const { tokenId } = route.params const [stakePlugins = []] = useAsyncValue( @@ -73,7 +72,7 @@ const StakeOptionsSceneComponent = (props: Props) => { // Handlers // - const handleStakeOptionPress = (stakePolicy: StakePolicy) => { + const handleStakeOptionPress = (stakePolicy: StakePolicy): void => { const { stakePolicyId } = stakePolicy const stakePlugin = getPluginFromPolicyId(stakePlugins, stakePolicyId, { pluginId @@ -90,7 +89,11 @@ const StakeOptionsSceneComponent = (props: Props) => { // Renders // - const renderOptions = ({ item: stakePolicy }: { item: StakePolicy }) => { + const renderOptions = ({ + item: stakePolicy + }: { + item: StakePolicy + }): React.ReactElement => { const primaryText = getPolicyAssetName(stakePolicy, 'stakeAssets') const secondaryText = getPolicyTitleName(stakePolicy, countryCode) const key = [primaryText, secondaryText].join() @@ -143,27 +146,21 @@ const StakeOptionsSceneComponent = (props: Props) => { overrideDots={theme.backgroundDots.assetOverrideDots} > {({ undoInsetStyle, insetStyle }) => ( - + - {/* TODO: Decide if our design language accepts scene headers within - the scroll area of a scene. If so, we must make the SceneContainer - component implement FlatList components. This is a one-off - until then. */} - - - {lstrings.stake_select_options} - - + + {lstrings.stake_select_options} + } keyExtractor={(stakePolicy: StakePolicy) => stakePolicy.stakePolicyId diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 13130513eeb..f42ec8d90b1 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -535,7 +535,7 @@ export const SwapCreateScene: React.FC = props => { }} > {({ isKeyboardOpen }) => ( - + {fromWallet == null ? ( void } -export const SceneFooterWrapper = (props: SceneFooterProps) => { +export const SceneFooterWrapper = ( + props: SceneFooterProps +): React.JSX.Element | null => { const { children, noBackgroundBlur = false, @@ -69,7 +71,7 @@ export const SceneFooterWrapper = (props: SceneFooterProps) => { insetBottom={maybeInsetBottom} onLayout={handleLayoutOnce} > - {noBackgroundBlur ? null : } + {noBackgroundBlur ? null : } {children} ) diff --git a/src/components/themed/SideMenu.tsx b/src/components/themed/SideMenu.tsx index 49efe9ba753..b837f40c13a 100644 --- a/src/components/themed/SideMenu.tsx +++ b/src/components/themed/SideMenu.tsx @@ -27,7 +27,10 @@ import FontAwesome5Icon from 'react-native-vector-icons/FontAwesome5' import Ionicons from 'react-native-vector-icons/Ionicons' import { sprintf } from 'sprintf-js' -import { showBackupModal } from '../../actions/BackupModalActions' +import { + checkAndShowLightBackupModal, + showBackupModal +} from '../../actions/BackupModalActions' import { launchDeepLink } from '../../actions/DeepLinkingActions' import { useNotifCount } from '../../actions/LocalSettingsActions' import { getRootNavigation, logoutRequest } from '../../actions/LoginActions' @@ -308,6 +311,17 @@ export function SideMenuComponent(props: Props): React.ReactElement { iconNameFontAwesome: 'chart-line', title: lstrings.title_markets }, + { + handlePress: () => { + 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' }) + }, + iconNameFontAwesome: 'gift', + title: lstrings.drawer_gift_cards + }, ...(ENV.BETA_FEATURES ? [ { From d938ef2ee3474e47bf747c96397d19f8bc6cc605 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:04:31 -0800 Subject: [PATCH 09/14] Add embossedTextShadow theme property Mimics raised/embossed text styling on physical credit cards. Used for gift card display components. --- src/theme/variables/edgeDark.ts | 11 +++++++++++ src/theme/variables/edgeLight.ts | 11 +++++++++++ src/theme/variables/testDark.ts | 11 +++++++++++ src/theme/variables/testLight.ts | 11 +++++++++++ src/types/Theme.ts | 3 +++ 5 files changed, 47 insertions(+) diff --git a/src/theme/variables/edgeDark.ts b/src/theme/variables/edgeDark.ts index a8efc45fdda..88daf0662e7 100644 --- a/src/theme/variables/edgeDark.ts +++ b/src/theme/variables/edgeDark.ts @@ -58,6 +58,7 @@ const palette = { blackOp35: 'rgba(0, 0, 0, .25)', blackOp50: 'rgba(0, 0, 0, .5)', blackOp70: 'rgba(0, 0, 0, .7)', + blackOp80: 'rgba(0, 0, 0, .8)', whiteOp05: 'rgba(255, 255, 255, .05)', whiteOp10: 'rgba(255, 255, 255, .1)', @@ -327,6 +328,16 @@ export const edgeDark: Theme = { textShadowRadius: 3 }, + // Mimics raised/embossed text on physical credit cards + embossedTextShadow: { + textShadowColor: palette.blackOp80, + textShadowOffset: { + width: 1, + height: 1 + }, + textShadowRadius: 3 + }, + tabBarBackground: [palette.transparent, palette.transparent], tabBarBackgroundStart: { x: 0, y: 0.5 }, tabBarBackgroundEnd: { x: 0, y: 1 }, diff --git a/src/theme/variables/edgeLight.ts b/src/theme/variables/edgeLight.ts index 5b098aad92b..87554d48bea 100644 --- a/src/theme/variables/edgeLight.ts +++ b/src/theme/variables/edgeLight.ts @@ -51,6 +51,7 @@ const palette = { blackOp25: 'rgba(0, 0, 0, .25)', blackOp50: 'rgba(0, 0, 0, .5)', + blackOp80: 'rgba(0, 0, 0, .8)', blackOp10: 'rgba(0, 0, 0, .1)', grayOp80: 'rgba(135, 147, 158, .8)', whiteOp05: 'rgba(255, 255, 255, 0.05)', @@ -280,6 +281,16 @@ export const edgeLight: Theme = { textShadowRadius: 3 }, + // Mimics raised/embossed text on physical credit cards + embossedTextShadow: { + textShadowColor: palette.blackOp80, + textShadowOffset: { + width: 1, + height: 1 + }, + textShadowRadius: 3 + }, + tabBarBackground: [palette.white, palette.white], tabBarBackgroundStart: { x: 0, y: 0 }, tabBarBackgroundEnd: { x: 1, y: 1 }, diff --git a/src/theme/variables/testDark.ts b/src/theme/variables/testDark.ts index 375ac61ad3d..d6b11d4bfe0 100644 --- a/src/theme/variables/testDark.ts +++ b/src/theme/variables/testDark.ts @@ -54,6 +54,7 @@ const palette = { blackOp25: 'rgba(0, 0, 0, .25)', blackOp50: 'rgba(0, 0, 0, .5)', + blackOp80: 'rgba(0, 0, 0, .8)', whiteOp05: 'rgba(255, 255, 255, .05)', whiteOp10: 'rgba(255, 255, 255, .1)', @@ -321,6 +322,16 @@ export const testDark: Theme = { textShadowRadius: 3 }, + // Mimics raised/embossed text on physical credit cards + embossedTextShadow: { + textShadowColor: palette.blackOp80, + textShadowOffset: { + width: 1, + height: 1 + }, + textShadowRadius: 3 + }, + tabBarBackground: [palette.blackOp25, palette.blackOp50], tabBarBackgroundStart: { x: 0, y: 0.5 }, tabBarBackgroundEnd: { x: 0, y: 1 }, diff --git a/src/theme/variables/testLight.ts b/src/theme/variables/testLight.ts index 92567762374..a00bb546507 100644 --- a/src/theme/variables/testLight.ts +++ b/src/theme/variables/testLight.ts @@ -51,6 +51,7 @@ const palette = { blackOp25: 'rgba(0, 0, 0, .25)', blackOp50: 'rgba(0, 0, 0, .5)', + blackOp80: 'rgba(0, 0, 0, .8)', blackOp10: 'rgba(0, 0, 0, .1)', grayOp80: 'rgba(135, 147, 158, .8)', whiteOp05: 'rgba(255, 255, 255, 0.05)', @@ -280,6 +281,16 @@ export const testLight: Theme = { textShadowRadius: 3 }, + // Mimics raised/embossed text on physical credit cards + embossedTextShadow: { + textShadowColor: palette.blackOp80, + textShadowOffset: { + width: 1, + height: 1 + }, + textShadowRadius: 3 + }, + tabBarBackground: [palette.white, palette.white], tabBarBackgroundStart: { x: 0, y: 0 }, tabBarBackgroundEnd: { x: 1, y: 1 }, diff --git a/src/types/Theme.ts b/src/types/Theme.ts index 768e397f7a6..f0e78b8fe1b 100644 --- a/src/types/Theme.ts +++ b/src/types/Theme.ts @@ -271,6 +271,9 @@ export interface Theme { cardBorderRadius: number cardTextShadow: TextShadowParams // For added contrast against complex card backgrounds + // Mimics raised/embossed text on physical credit cards + embossedTextShadow: TextShadowParams + tabBarBackground: string[] tabBarBackgroundStart: GradientCoords tabBarBackgroundEnd: GradientCoords From 7caa73cd0dacc5ee5db9f45dbf6535008ab4e7fc Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:04:42 -0800 Subject: [PATCH 10/14] Add disabled state to SelectableRow When onPress is undefined, row renders dimmed and non-interactive. Used for conditionally disabled gift card menu options. --- .../CountryListModal.test.tsx.snap | 2740 ++++++++--------- .../__snapshots__/HelpModal.test.tsx.snap | 1260 ++++---- src/components/themed/SelectableRow.tsx | 72 +- 3 files changed, 2039 insertions(+), 2033 deletions(-) diff --git a/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap index cba78e695ca..d58d9c1170d 100644 --- a/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/CountryListModal.test.tsx.snap @@ -2943,170 +2943,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - United States of America - - - US - - + US + @@ -3116,170 +3114,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Afghanistan - - - AF - - + AF + @@ -3289,170 +3285,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Åland Islands - - - AX - - + AX + @@ -3462,170 +3456,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Albania - - - AL - - + AL + @@ -3635,170 +3627,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Algeria - - - DZ - - + DZ + @@ -3808,170 +3798,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - American Samoa - - - AS - - + AS + @@ -3981,170 +3969,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Andorra - - - AD - - + AD + @@ -4154,170 +4140,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Angola - - - AO - - + AO + @@ -4327,170 +4311,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Anguilla - - - AI - - + AI + @@ -4500,170 +4482,168 @@ exports[`CountryListModal should render with a country list 1`] = ` onLayout={[Function]} style={null} > - - + + + > + + + + - - - - - - + - - Antigua and Barbuda - - - AG - - + AG + diff --git a/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap index 5d57e0ff871..c4833e3af64 100644 --- a/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/HelpModal.test.tsx.snap @@ -342,783 +342,773 @@ exports[`HelpModal should render with loading props 1`] = ` } > - - + + + > + +  + + - - -  - - - + - - Knowledge Base - - - Commonly asked questions - - + Commonly asked questions + - - + + + > + +  + + - - -  - - - + - - Live Chat - - - Quick help from a live agent - - + Quick help from a live agent + - - + + + > + +  + + - - -  - - - + - - Submit a Support Ticket - - - Troubleshooting and technical support - - + Troubleshooting and technical support + - - + + + > + +  + + - - -  - - - + - - Call for Assistance - - - Our agents are also available by phone - - + Our agents are also available by phone + - - + + + > + +  + + - - -  - - - + - - Visit Official Site - - - Learn more about Edge - - + Learn more about Edge + diff --git a/src/components/themed/SelectableRow.tsx b/src/components/themed/SelectableRow.tsx index 7b0208d268b..5344005700a 100644 --- a/src/components/themed/SelectableRow.tsx +++ b/src/components/themed/SelectableRow.tsx @@ -1,12 +1,19 @@ import * as React from 'react' -import { View } from 'react-native' +import { type StyleProp, View, type ViewStyle } from 'react-native' +import { + fixSides, + mapSides, + sidesToMargin, + sidesToPadding +} from '../../util/sides' import { EdgeCard } from '../cards/EdgeCard' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from './EdgeText' interface Props { - onPress: () => void | Promise + /** When undefined, the row is dimmed and non-interactive */ + onPress?: () => void | Promise title: string | React.ReactNode subTitle?: string @@ -34,24 +41,46 @@ export const SelectableRow = (props: Props) => { const theme = useTheme() const styles = getStyles(theme) + const isDisabled = onPress == null + + // Row content (shared between enabled and disabled states) + const rowContent = ( + + {/* HACK: Keeping the iconContainer instead of CardUi4's built-in icon prop because the prop's behavior is inconsistent in legacy use cases */} + {icon} + + {title} + {subTitle != null ? ( + + {subTitle} + + ) : null} + + + ) + + // Disabled: Use simple View mimicking EdgeCard appearance (avoids extra wrapper) + if (isDisabled) { + const margin = sidesToMargin(mapSides(fixSides(marginRem, 0.5), theme.rem)) + const padding = sidesToPadding( + mapSides(fixSides(marginRem, 0.5), theme.rem) + ) + const disabledStyle: StyleProp = [ + styles.disabledCard, + margin, + padding + ] + return {rowContent} + } + + // Enabled: Use EdgeCard for full interactivity return ( - - {/* HACK: Keeping the iconContainer instead of CardUi4's built-in icon prop because the prop's behavior is inconsistent in legacy use cases */} - {icon} - - {title} - {subTitle ? ( - - {subTitle} - - ) : null} - - + {rowContent} ) } @@ -76,5 +105,12 @@ const getStyles = cacheStyles((theme: Theme) => ({ color: theme.secondaryText, fontSize: theme.rem(0.75), marginTop: theme.rem(0.25) + }, + // Mimics EdgeCard appearance for disabled state + disabledCard: { + borderRadius: theme.cardBorderRadius, + backgroundColor: theme.cardBaseColor, + alignSelf: 'stretch', + opacity: 0.3 } })) From e42d2ee44c17b8a86266ce8879d823772bcf84bb Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:06:17 -0800 Subject: [PATCH 11/14] Add gift card UI components - GiftCardDisplayCard: Full card view with PIN, status, shimmer states - GiftCardDetailsCard: Compact card details - GiftCardTile: Grid/list item for market browsing - CircularBrandIcon: Rounded brand logo - GiftCardAmountModal: Denomination picker - GiftCardMenuModal: Card options (redeem, view tx) - GiftCardSearchModal: Brand search with filtering --- src/components/cards/GiftCardDetailsCard.tsx | 72 +++++ src/components/cards/GiftCardDisplayCard.tsx | 243 +++++++++++++++++ src/components/cards/GiftCardTile.tsx | 103 +++++++ src/components/common/CircularBrandIcon.tsx | 64 +++++ src/components/modals/GiftCardAmountModal.tsx | 228 ++++++++++++++++ src/components/modals/GiftCardMenuModal.tsx | 149 ++++++++++ src/components/modals/GiftCardSearchModal.tsx | 257 ++++++++++++++++++ src/locales/en_US.ts | 2 + src/locales/strings/enUS.json | 2 + 9 files changed, 1120 insertions(+) create mode 100644 src/components/cards/GiftCardDetailsCard.tsx create mode 100644 src/components/cards/GiftCardDisplayCard.tsx create mode 100644 src/components/cards/GiftCardTile.tsx create mode 100644 src/components/common/CircularBrandIcon.tsx create mode 100644 src/components/modals/GiftCardAmountModal.tsx create mode 100644 src/components/modals/GiftCardMenuModal.tsx create mode 100644 src/components/modals/GiftCardSearchModal.tsx diff --git a/src/components/cards/GiftCardDetailsCard.tsx b/src/components/cards/GiftCardDetailsCard.tsx new file mode 100644 index 00000000000..8f8a082f2be --- /dev/null +++ b/src/components/cards/GiftCardDetailsCard.tsx @@ -0,0 +1,72 @@ +import type { EdgeTxActionGiftCard } from 'edge-core-js' +import * as React from 'react' +import { Linking } from 'react-native' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { removeIsoPrefix } from '../../util/utils' +import { CircularBrandIcon } from '../common/CircularBrandIcon' +import { EdgeRow } from '../rows/EdgeRow' +import { EdgeText } from '../themed/EdgeText' +import { EdgeCard } from './EdgeCard' + +interface Props { + action: EdgeTxActionGiftCard +} + +/** + * Displays gift card details including brand, amount, and redemption code + * in TransactionDetailsScene for gift card purchases. + */ +export const GiftCardDetailsCard: React.FC = ({ action }) => { + const { card, redemption } = action + + const handleRedeemPress = useHandler(() => { + if (redemption?.url != null) { + Linking.openURL(redemption.url).catch(() => {}) + } + }) + + const brandIcon = React.useMemo( + () => + card.imageUrl != null && card.imageUrl !== '' ? ( + + ) : null, + [card.imageUrl] + ) + + // Format fiat amount with currency + const fiatCurrency = removeIsoPrefix(card.fiatCurrencyCode) + const amountDisplay = `${card.fiatAmount} ${fiatCurrency}` + + return ( + + + {card.name} + + + + + {redemption?.code != null ? ( + + ) : null} + + {redemption?.url != null ? ( + + ) : null} + + ) +} diff --git a/src/components/cards/GiftCardDisplayCard.tsx b/src/components/cards/GiftCardDisplayCard.tsx new file mode 100644 index 00000000000..130992f65eb --- /dev/null +++ b/src/components/cards/GiftCardDisplayCard.tsx @@ -0,0 +1,243 @@ +import Clipboard from '@react-native-clipboard/clipboard' +import * as React from 'react' +import { View } from 'react-native' +import FastImage from 'react-native-fast-image' + +import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import type { PhazeDisplayOrder } from '../../plugins/gift-cards/phazeGiftCardTypes' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { + ChevronRightIcon, + CopyIcon, + DotsThreeVerticalIcon +} from '../icons/ThemedIcons' +import { Shimmer } from '../progress-indicators/Shimmer' +import { showToast } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' + +// Zoom factor to crop out edge artifacts from source images. One size fits most +// - differs per source image, but better than nothing. +const ZOOM_FACTOR = 1.025 + +/** + * Card display states: + * - pending: Order broadcasted but voucher not yet received + * - available: Voucher received, ready to redeem + * - redeemed: User has marked as redeemed + */ +export type GiftCardStatus = 'pending' | 'available' | 'redeemed' + +interface Props { + order: PhazeDisplayOrder + /** Display state of the card */ + status: GiftCardStatus + onMenuPress: () => void + /** Called when user taps redeem and completes viewing (webview closes) */ + onRedeemComplete?: () => void +} + +/** + * Displays a gift card in a physical card-like format (1.6:1 aspect ratio). + * Shows brand image as background with amount, brand name, security code, + * and redemption link overlaid. + */ +export const GiftCardDisplayCard: React.FC = props => { + const { order, status, onMenuPress, 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 + const cardContainerStyle = + status === 'redeemed' + ? [styles.cardContainer, styles.dimmedCard] + : styles.cardContainer + + // Format amount with fiat symbol + const fiatSymbol = getFiatSymbol(order.fiatCurrency) + const formattedAmount = `${fiatSymbol} ${order.fiatAmount}` + + // Copy security code to clipboard + const handleCopyCode = useHandler(() => { + if (code != null) { + Clipboard.setString(code) + showToast(lstrings.gift_card_code_copied) + } + }) + + // Copy code and trigger redemption flow + const handleRedeem = useHandler(() => { + if (code != null) { + Clipboard.setString(code) + showToast(lstrings.gift_card_code_copied) + } + + // Notify parent to handle redemption (open webview, then prompt) + if (onRedeemComplete != null) { + onRedeemComplete() + } + }) + + return ( + + {/* Brand image background */} + + + {/* Content overlay */} + + {/* Top row: Amount + Brand name (left) + Menu icon (right) */} + + {formattedAmount} + + + + + + + {order.brandName} + + + + {/* Spacer for center area */} + + + {/* Bottom row: Security code (left) + Redeem/Pending (right) */} + + {code != null ? ( + + {code} + + + ) : ( + + )} + + {status === 'pending' ? ( + + {lstrings.fragment_wallet_unconfirmed} + + ) : status === 'available' && redemptionUrl != null ? ( + + + {lstrings.gift_card_redeem} + + + + ) : null} + + + + {/* Shimmer overlay for pending state */} + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + cardContainer: { + aspectRatio: 1.6, + borderRadius: theme.cardBorderRadius, + overflow: 'hidden', + position: 'relative' + }, + dimmedCard: { + opacity: 0.5 + }, + cardImage: { + position: 'absolute', + // Slightly larger than container to crop edge artifacts + width: `${ZOOM_FACTOR * 100}%`, + height: `${ZOOM_FACTOR * 100}%`, + // Center the oversized image + left: `${((1 - ZOOM_FACTOR) / 2) * 100}%`, + top: `${((1 - ZOOM_FACTOR) / 2) * 100}%` + }, + cardOverlay: { + flex: 1, + padding: theme.rem(0.75), + justifyContent: 'space-between' + }, + // Top row + topRow1: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + topRow2: { + flexDirection: 'row', + alignItems: 'flex-start' + }, + amountText: { + fontSize: theme.rem(1.25), + fontFamily: theme.fontFaceBold, + ...theme.embossedTextShadow + }, + brandNameText: { + fontSize: theme.rem(1), + ...theme.embossedTextShadow + }, + embossedShadow: theme.embossedTextShadow, + // Center row (spacer) + centerRow: { + flex: 1 + }, + // Bottom row + bottomRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end' + }, + codeContainer: { + flexDirection: 'row', + alignItems: 'center' + }, + codeValue: { + fontSize: theme.rem(0.875), + fontFamily: theme.fontFaceMedium, + marginRight: theme.rem(0.5), + ...theme.embossedTextShadow + }, + redeemContainer: { + flexDirection: 'row', + alignItems: 'center' + }, + redeemText: { + color: theme.iconTappable, + fontSize: theme.rem(0.875), + fontFamily: theme.fontFaceMedium, + ...theme.embossedTextShadow + }, + pendingText: { + color: theme.deactivatedText, + fontSize: theme.rem(0.875), + fontFamily: theme.fontFaceMedium, + ...theme.embossedTextShadow + } +})) diff --git a/src/components/cards/GiftCardTile.tsx b/src/components/cards/GiftCardTile.tsx new file mode 100644 index 00000000000..f2d52f0541d --- /dev/null +++ b/src/components/cards/GiftCardTile.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { + type DimensionValue, + StyleSheet, + View, + type ViewStyle +} from 'react-native' +import FastImage from 'react-native-fast-image' + +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { EdgeCard } from './EdgeCard' + +// Zoom factor to crop out edge artifacts from source images +const ZOOM_FACTOR = 1.05 + +interface Props { + brandName: string + priceRange: string + imageUrl: string + onPress: () => void +} + +// Semi-transparent gradient to darken behind text for visibility +const OVERLAY_GRADIENT = { + colors: ['rgba(0,0,0,0.3)', 'rgba(0,0,0,0.5)'], + start: { x: 0, y: 0 }, + end: { x: 0, y: 1 } +} + +// Style for the zoomed image container to crop out edge artifacts +const zoomedContainerStyle: ViewStyle = { + position: 'absolute', + width: `${ZOOM_FACTOR * 100}%` as DimensionValue, + height: `${ZOOM_FACTOR * 100}%` as DimensionValue, + top: `${((ZOOM_FACTOR - 1) / 2) * -100}%` as DimensionValue, + left: `${((ZOOM_FACTOR - 1) / 2) * -100}%` as DimensionValue +} + +/** + * Gift card tile displaying brand image, name, and price range. + * Square aspect ratio with image filling vertically. + */ +export const GiftCardTile: React.FC = props => { + const { brandName, priceRange, imageUrl, onPress } = props + + const theme = useTheme() + const styles = getStyles(theme) + + const imageBackground = + imageUrl !== '' ? ( + + + + ) : null + + return ( + + + + + {brandName} + + + {priceRange} + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + squareContainer: { + aspectRatio: 1, + width: '100%' + }, + titleText: { + marginBottom: theme.rem(0.5) + }, + footerText: { + fontSize: theme.rem(0.75), + ...theme.cardTextShadow + }, + contentContainer: { + flex: 1, + justifyContent: 'space-between', + margin: theme.rem(0.5) + } +})) diff --git a/src/components/common/CircularBrandIcon.tsx b/src/components/common/CircularBrandIcon.tsx new file mode 100644 index 00000000000..e029db4d38d --- /dev/null +++ b/src/components/common/CircularBrandIcon.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import { View } from 'react-native' +import FastImage from 'react-native-fast-image' + +import { fixSides, mapSides, sidesToMargin } from '../../util/sides' +import { useTheme } from '../services/ThemeContext' + +// Zoom factor to crop out edge artifacts from source images. One size fits most +// - differs per source image, but better than nothing. +const ZOOM_FACTOR = 1.01 + +interface Props { + imageUrl: string + /** Size in rem units. Default is 2 */ + sizeRem?: number + /** Margin in rem units */ + marginRem?: number | number[] +} + +/** + * A circular icon with border, commonly used for brand logos. + */ +export const CircularBrandIcon: React.FC = props => { + const { imageUrl, sizeRem = 2, marginRem } = props + const theme = useTheme() + const size = theme.rem(sizeRem) + + const marginStyle = sidesToMargin(mapSides(fixSides(marginRem, 0), theme.rem)) + + const containerStyle = React.useMemo( + () => ({ + height: size, + width: size, + borderRadius: size / 2, + alignItems: 'center' as const, + justifyContent: 'center' as const, + overflow: 'hidden' as const, + borderWidth: theme.cardBorder, + borderColor: theme.cardBorderColor, + ...marginStyle + }), + [size, theme, marginStyle] + ) + + // Make image larger than container to zoom in and hide edge artifacts + const imageSize = size * ZOOM_FACTOR + const imageStyle = React.useMemo( + () => ({ + width: imageSize, + height: imageSize + }), + [imageSize] + ) + + return ( + + + + ) +} diff --git a/src/components/modals/GiftCardAmountModal.tsx b/src/components/modals/GiftCardAmountModal.tsx new file mode 100644 index 00000000000..e90abc016e8 --- /dev/null +++ b/src/components/modals/GiftCardAmountModal.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' +import { FlatList, type ListRenderItem, View } from 'react-native' +import type { AirshipBridge } from 'react-native-airship' + +import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' +import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { CircularBrandIcon } from '../common/CircularBrandIcon' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { SectionHeader } from '../common/SectionHeader' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { EdgeModal } from './EdgeModal' + +export interface GiftCardAmountItem { + brandName: string + productImage: string + amount: number + currency: string + isMinimum?: boolean + isMaximum?: boolean +} + +export interface GiftCardAmountResult { + amount: number + currency: string +} + +interface Props { + bridge: AirshipBridge + brandName: string + productImage: string + currency: string + denominations: number[] + selectedAmount?: number +} + +export function GiftCardAmountModal(props: Props): React.ReactElement { + const { + bridge, + brandName, + productImage, + currency, + denominations, + selectedAmount = denominations[0] + } = props + const theme = useTheme() + const styles = getStyles(theme) + + const fiatSymbol = getFiatSymbol(currency) + + // Sort denominations (deduplication done at API layer) + const sortedDenominations = React.useMemo( + () => [...denominations].sort((a, b) => a - b), + [denominations] + ) + + const minAmount = sortedDenominations[0] + const maxAmount = sortedDenominations[sortedDenominations.length - 1] + + const handleCancel = useHandler(() => { + bridge.resolve(undefined) + }) + + const handleAmountSelect = useHandler((amount: number) => { + bridge.resolve({ amount, currency }) + }) + + const handleConfirm = useHandler(() => { + bridge.resolve({ amount: selectedAmount, currency }) + }) + + // Build list items for fixed amounts section (excluding selected amount) + const fixedAmountItems: GiftCardAmountItem[] = React.useMemo( + () => + sortedDenominations + .filter(amount => amount !== selectedAmount) + .map(amount => ({ + brandName, + productImage, + amount, + currency, + isMinimum: amount === minAmount, + isMaximum: amount === maxAmount + })), + [ + sortedDenominations, + selectedAmount, + brandName, + productImage, + currency, + minAmount, + maxAmount + ] + ) + + const renderAmountRow: ListRenderItem = React.useCallback( + ({ item }) => { + const handlePress = (): void => { + handleAmountSelect(item.amount) + } + + // Determine label suffix + let labelSuffix = '' + if (item.isMinimum === true) { + labelSuffix = lstrings.gift_card_minimum + } else if (item.isMaximum === true) { + labelSuffix = lstrings.gift_card_maximum + } + + return ( + + + + + {item.brandName} + + {labelSuffix !== '' ? ( + {labelSuffix} + ) : null} + + + {`${fiatSymbol}${item.amount} ${item.currency}`} + + + ) + }, + [handleAmountSelect, styles, fiatSymbol] + ) + + const keyExtractor = React.useCallback( + (item: GiftCardAmountItem): string => String(item.amount), + [] + ) + + // Selected amount item for the top section + const selectedItem: GiftCardAmountItem = { + brandName, + productImage, + amount: selectedAmount, + currency, + isMinimum: selectedAmount === minAmount, + isMaximum: selectedAmount === maxAmount + } + + return ( + + + + + + + + {brandName} + + {selectedItem.isMinimum === true ? ( + + {lstrings.gift_card_minimum} + + ) : selectedItem.isMaximum === true ? ( + + {lstrings.gift_card_maximum} + + ) : null} + + + {`${fiatSymbol}${selectedAmount} ${currency}`} + + + + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + selectedContainer: { + marginBottom: theme.rem(0.5) + }, + list: { + flexGrow: 0, + flexShrink: 1 + }, + listContent: { + paddingBottom: theme.rem(1) + }, + amountRow: { + flexDirection: 'row', + alignItems: 'center', + padding: theme.rem(0.5), + marginTop: theme.rem(0.5), + marginLeft: theme.rem(1), + backgroundColor: theme.tileBackground, + borderRadius: theme.rem(0.5) + }, + amountTextContainer: { + flex: 1, + marginLeft: theme.rem(0.75) + }, + amountBrandName: { + fontSize: theme.rem(1), + color: theme.primaryText + }, + amountLabel: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, + amountValue: { + fontSize: theme.rem(1), + color: theme.primaryText, + marginLeft: theme.rem(0.5) + } +})) diff --git a/src/components/modals/GiftCardMenuModal.tsx b/src/components/modals/GiftCardMenuModal.tsx new file mode 100644 index 00000000000..a2b2374c230 --- /dev/null +++ b/src/components/modals/GiftCardMenuModal.tsx @@ -0,0 +1,149 @@ +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 { 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 { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SelectableRow } from '../themed/SelectableRow' +import { EdgeModal } from './EdgeModal' + +export type GiftCardMenuResult = + | { type: 'goToTransaction'; transaction: EdgeTransaction; walletId: string } + | { type: 'markAsRedeemed' } + | { type: 'unmarkAsRedeemed' } + | undefined + +interface Props { + bridge: AirshipBridge + order: PhazeDisplayOrder + isRedeemed?: boolean +} + +export const GiftCardMenuModal: React.FC = props => { + const { bridge, order, isRedeemed = false } = props + const theme = useTheme() + const styles = getStyles(theme) + + const account = useSelector(state => state.core.account) + const currencyWallets = useWatch(account, 'currencyWallets') + + const [isLoadingTx, setIsLoadingTx] = React.useState(false) + const [transaction, setTransaction] = React.useState( + null + ) + const [txSearchComplete, setTxSearchComplete] = React.useState(false) + + // Get wallet and check sync status + const wallet: EdgeCurrencyWallet | undefined = + order.walletId != null ? currencyWallets[order.walletId] : undefined + const isSynced = wallet?.syncRatio === 1 + + // Look up the transaction when modal opens + React.useEffect(() => { + const findTransaction = async (): Promise => { + if (wallet == null || order.txid == null) { + setTxSearchComplete(true) + return + } + + setIsLoadingTx(true) + try { + const txs = await wallet.getTransactions({ + tokenId: order.tokenId ?? null + }) + const tx = txs.find(t => t.txid === order.txid) + setTransaction(tx ?? null) + } catch { + // Transaction lookup failed + setTransaction(null) + } finally { + setIsLoadingTx(false) + setTxSearchComplete(true) + } + } + + findTransaction().catch(() => {}) + }, [wallet, order.txid, order.tokenId]) + + const handleCancel = useHandler(() => { + bridge.resolve(undefined) + }) + + const handleGoToTransaction = useHandler(() => { + if (transaction != null && order.walletId != null) { + bridge.resolve({ + type: 'goToTransaction', + transaction, + walletId: order.walletId + }) + } + }) + + const handleToggleRedeemed = useHandler(() => { + bridge.resolve({ + type: isRedeemed ? 'unmarkAsRedeemed' : 'markAsRedeemed' + }) + }) + + // Determine "Go to Transaction" state + const hasTx = transaction != null + const canNavigate = hasTx && order.walletId != null + const goToTxDisabled = !canNavigate && (isSynced || txSearchComplete) + const goToTxLoading = isLoadingTx || (!txSearchComplete && !isSynced) + + const iconSize = theme.rem(1.5) + const iconColor = theme.iconTappable + + // Disable entire row when loading or tx unavailable + const txRowDisabled = goToTxLoading || goToTxDisabled + + return ( + + + + + ) : ( + + + + ) + } + /> + + + + } + /> + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + iconContainer: { + width: theme.rem(2.5), + height: theme.rem(2.5), + alignItems: 'center', + justifyContent: 'center' + } +})) diff --git a/src/components/modals/GiftCardSearchModal.tsx b/src/components/modals/GiftCardSearchModal.tsx new file mode 100644 index 00000000000..5b7a8f85ff2 --- /dev/null +++ b/src/components/modals/GiftCardSearchModal.tsx @@ -0,0 +1,257 @@ +import * as React from 'react' +import { FlatList, type ListRenderItem, ScrollView, View } from 'react-native' +import type { AirshipBridge } from 'react-native-airship' + +import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { EdgeCard } from '../cards/EdgeCard' +import { CircularBrandIcon } from '../common/CircularBrandIcon' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { ModalFilledTextInput } from '../themed/FilledTextInput' +import { EdgeModal } from './EdgeModal' + +const CATEGORY_ALL = 'Popular' + +/** + * Normalizes a category string by converting spaces to hyphens and lowercasing. + * This deduplicates "food delivery" and "food-delivery" into "food-delivery". + */ +export const normalizeCategory = (category: string): string => { + return category.toLowerCase().replace(/\s+/g, '-') +} + +/** + * Normalizes an array of categories, removing duplicates after normalization. + * Returns unique normalized categories. + */ +export const normalizeCategories = (categories: string[]): string[] => { + const seen = new Set() + const result: string[] = [] + for (const cat of categories) { + const normalized = normalizeCategory(cat) + if (!seen.has(normalized)) { + seen.add(normalized) + result.push(normalized) + } + } + return result +} + +export interface GiftCardBrandItem { + brandName: string + productId: number + productImage: string + categories: string[] +} + +export interface GiftCardSearchResult { + brand: GiftCardBrandItem +} + +interface Props { + bridge: AirshipBridge + brands: GiftCardBrandItem[] + categories: string[] +} + +export function GiftCardSearchModal(props: Props): React.ReactElement { + const { bridge, brands, categories } = props + const theme = useTheme() + const styles = getStyles(theme) + + const [query, setQuery] = React.useState('') + const [selectedCategory, setSelectedCategory] = React.useState(CATEGORY_ALL) + + // Build category list with "Popular" first, normalizing and deduplicating + const categoryList = React.useMemo(() => { + // Normalize all categories and deduplicate + const normalizedSet = new Set() + for (const cat of categories) { + const normalized = normalizeCategory(cat) + if (normalized !== normalizeCategory(CATEGORY_ALL)) { + normalizedSet.add(normalized) + } + } + return [CATEGORY_ALL, ...Array.from(normalizedSet).sort()] + }, [categories]) + + // Filter brands by search query and selected category + const filteredBrands = React.useMemo(() => { + let filtered = brands + + // Filter by category (unless "Popular" is selected, which shows all) + if (selectedCategory !== CATEGORY_ALL) { + filtered = filtered.filter(brand => + brand.categories.some( + cat => normalizeCategory(cat) === selectedCategory + ) + ) + } + + // Filter by search query + if (query.trim() !== '') { + const lowerQuery = query.toLowerCase() + filtered = filtered.filter(brand => + brand.brandName.toLowerCase().includes(lowerQuery) + ) + } + + return filtered + }, [brands, query, selectedCategory]) + + const handleCancel = useHandler(() => { + bridge.resolve(undefined) + }) + + const handleBrandPress = useHandler((brand: GiftCardBrandItem) => { + bridge.resolve({ brand }) + }) + + const handleCategoryPress = useHandler((category: string) => { + setSelectedCategory(category) + }) + + const renderBrandRow: ListRenderItem = React.useCallback( + ({ item }) => { + const handlePress = (): void => { + handleBrandPress(item) + } + + // Normalize and deduplicate categories, then join as comma-delimited + const displayCategories = normalizeCategories(item.categories).join(', ') + + return ( + } + onPress={handlePress} + > + + + {item.brandName} + + + {displayCategories} + + + + ) + }, + [handleBrandPress, styles] + ) + + const keyExtractor = React.useCallback( + (item: GiftCardBrandItem): string => String(item.productId), + [] + ) + + return ( + + + + {categoryList.map(category => { + const isSelected = selectedCategory === category + return ( + { + handleCategoryPress(category) + }} + > + + {category} + + + ) + })} + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + categoryScrollView: { + flexGrow: 0, + flexShrink: 0, + marginTop: theme.rem(0.5) + }, + categoryContainer: { + paddingHorizontal: theme.rem(0.25), + paddingBottom: theme.rem(0.5) + }, + categoryButton: { + paddingHorizontal: theme.rem(0.5), + paddingVertical: theme.rem(0.25) + }, + categoryText: { + fontSize: theme.rem(0.875), + color: theme.primaryText + }, + categoryTextSelected: { + fontSize: theme.rem(0.875), + color: theme.iconTappable + }, + list: { + flexGrow: 0, + flexShrink: 1 + }, + listContent: { + paddingTop: theme.rem(0.5) + }, + brandRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: theme.rem(0.5), + paddingHorizontal: theme.rem(0.5), + marginBottom: theme.rem(0.25), + backgroundColor: theme.tileBackground, + borderRadius: theme.rem(0.5) + }, + brandTextContainer: { + flexGrow: 1, + flexShrink: 1, + marginLeft: theme.rem(0.5) + }, + brandName: { + fontSize: theme.rem(1), + color: theme.primaryText + }, + brandCategory: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + } +})) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index d15ece3e6c5..450b8c273fa 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1872,6 +1872,8 @@ const strings = { search_gift_cards: 'Search Gift Cards', gift_card_select_amount: 'Select Amount', gift_card_enter_amount: 'Enter Amount', + gift_card_minimum: 'Minimum', + gift_card_maximum: 'Maximum', gift_card_selected_amount: 'Selected Amount', gift_card_fixed_amounts: 'Fixed Amounts', gift_card_pay_from_wallet: 'Pay From Wallet', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 13f368d8498..562b44879c8 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1450,6 +1450,8 @@ "search_gift_cards": "Search Gift Cards", "gift_card_select_amount": "Select Amount", "gift_card_enter_amount": "Enter Amount", + "gift_card_minimum": "Minimum", + "gift_card_maximum": "Maximum", "gift_card_selected_amount": "Selected Amount", "gift_card_fixed_amounts": "Fixed Amounts", "gift_card_pay_from_wallet": "Pay From Wallet", From ddefea01e71a8d6c673743bb81a792de918b4ebf Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:04:06 -0800 Subject: [PATCH 12/14] Add react-native-render-html dependency Required for gift card purchase scene HTML content rendering --- ios/Podfile.lock | 6 ++ package.json | 1 + yarn.lock | 217 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 218 insertions(+), 6 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 50af5b14ff2..ee97cee0995 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1670,6 +1670,8 @@ PODS: - MnemonicSwift (~> 2.2) - React-Core - SQLite.swift/standalone (~> 0.14) + - react-native-render-html (6.3.4): + - React-Core - react-native-safari-view (1.0.0): - React - react-native-safe-area-context (5.6.1): @@ -2940,6 +2942,7 @@ DEPENDENCIES: - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-performance (from `../node_modules/react-native-performance`) - react-native-piratechain (from `../node_modules/react-native-piratechain`) + - react-native-render-html (from `../node_modules/react-native-render-html`) - react-native-safari-view (from `../node_modules/react-native-safari-view`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-webview (from `../node_modules/react-native-webview`) @@ -3188,6 +3191,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-performance" react-native-piratechain: :path: "../node_modules/react-native-piratechain" + react-native-render-html: + :path: "../node_modules/react-native-render-html" react-native-safari-view: :path: "../node_modules/react-native-safari-view" react-native-safe-area-context: @@ -3410,6 +3415,7 @@ SPEC CHECKSUMS: react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-performance: f0471c84eda0f6625bd42a1f515b1b216f284b12 react-native-piratechain: b1a2e627232b583e0060b50db340becb64e3cde1 + react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd react-native-safari-view: 07dc856a2663fef31eaca6beb79b111b8f6cf1f2 react-native-safe-area-context: 83e0ac3d023997de1c2e035af907cc4dc05f718c react-native-webview: 69c118d283fccfbc4fca0cd680e036ff3bf188fa diff --git a/package.json b/package.json index 1c003eea744..8b61c296dbe 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "react-native-permissions": "^4.1.5", "react-native-piratechain": "^0.5.19", "react-native-reanimated": "^4.1.3", + "react-native-render-html": "^6.3.4", "react-native-reorderable-list": "^0.5.0", "react-native-safari-view": "^2.1.0", "react-native-safe-area-context": "^5.6.1", diff --git a/yarn.lock b/yarn.lock index 36c6d2e0fec..bbda8149357 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1063,7 +1063,20 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0", "@babel/traverse@^7.7.0": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.0.tgz#518aa113359b062042379e333db18380b537e34b" + integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.0" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.0" + debug "^4.3.1" + +"@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0", "@babel/traverse@^7.7.0": version "7.28.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.0.tgz#518aa113359b062042379e333db18380b537e34b" integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== @@ -3221,6 +3234,16 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jsamr/counter-style@^2.0.1": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@jsamr/counter-style/-/counter-style-2.0.2.tgz#6f08cfa98e1f0416dc1d7f2d8ac38a8cdb004c5d" + integrity sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ== + +"@jsamr/react-native-li@^2.3.0": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz#12a5b5f6e3971cec77b96bee58104eed0ae9314a" + integrity sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w== + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -3361,6 +3384,28 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.9.0" +"@native-html/css-processor@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@native-html/css-processor/-/css-processor-1.11.0.tgz#27d02e5123b0849f4986d44060ba3f235a15f552" + integrity sha512-NnhBEbJX5M2gBGltPKOetiLlKhNf3OHdRafc8//e2ZQxXN8JaSW/Hy8cm94pnIckQxwaMKxrtaNT3x4ZcffoNQ== + dependencies: + css-to-react-native "^3.0.0" + csstype "^3.0.8" + +"@native-html/transient-render-engine@11.2.3": + version "11.2.3" + resolved "https://registry.yarnpkg.com/@native-html/transient-render-engine/-/transient-render-engine-11.2.3.tgz#e4de0e7c8c023224a2dc27f3bd2b30d3984d94a4" + integrity sha512-zXwgA3gPUEmFs3I3syfnvDvS6WiUHXEE6jY09OBzK+trq7wkweOSFWIoyXiGkbXrozGYG0KY90YgPyr8Tg8Uyg== + dependencies: + "@native-html/css-processor" "1.11.0" + "@types/ramda" "^0.27.44" + csstype "^3.0.9" + domelementtype "^2.2.0" + domhandler "^4.2.2" + domutils "^2.8.0" + htmlparser2 "^7.1.2" + ramda "^0.27.2" + "@noble/curves@1.1.0", "@noble/curves@~1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" @@ -5804,6 +5849,13 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== +"@types/ramda@^0.27.40", "@types/ramda@^0.27.44": + version "0.27.66" + resolved "https://registry.yarnpkg.com/@types/ramda/-/ramda-0.27.66.tgz#f1a23d13b0087d806a62e3ff941e5e59b3318999" + integrity sha512-i2YW+E2U6NfMt3dp0RxNcejox+bxJUNDjB7BpYuRuoHIzv5juPHkJkNgcUOu+YSQEmaWu8cnAo/8r63C0NnuVA== + dependencies: + ts-toolbelt "^6.15.1" + "@types/range-parser@*": version "1.2.4" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" @@ -5941,6 +5993,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/urijs@^1.19.15": + version "1.19.26" + resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.26.tgz#500fc9912e0ba01d635480970bdc9ba0f45d7bc6" + integrity sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg== + "@types/url-parse@^1.4.8": version "1.4.8" resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.8.tgz#c3825047efbca1295b7f1646f38203d9145130d6" @@ -8116,6 +8173,11 @@ camelcase@^6.2.0, camelcase@^6.3.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +camelize@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" + integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== + caniuse-lite@^1.0.30001726: version "1.0.30001731" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz#277c07416ea4613ec564e5b0ffb47e7b60f32e2f" @@ -8177,6 +8239,16 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +character-entities-html4@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125" + integrity sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g== + +character-entities-legacy@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" + integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== + check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" @@ -8834,6 +8906,11 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg== + css-select@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" @@ -8845,6 +8922,15 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-to-react-native@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" + integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + css-tree@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" @@ -8886,6 +8972,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== +csstype@^3.0.8, csstype@^3.0.9: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + csv-stringify@*: version "6.2.0" resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-6.2.0.tgz#f89881e8f61293bf5af11f421266b5da7b744030" @@ -9280,6 +9371,15 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-serializer@^1.0.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.0" + entities "^2.0.0" + dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -9294,11 +9394,18 @@ dom-walk@^0.1.0: resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== -domelementtype@^2.3.0: +domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== +domhandler@^4.2.0, domhandler@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== + dependencies: + domelementtype "^2.2.0" + domhandler@^5.0.2, domhandler@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" @@ -9306,6 +9413,15 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" +domutils@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" @@ -9696,6 +9812,16 @@ enquirer@^2.3.6: ansi-colors "^4.1.1" strip-ansi "^6.0.1" +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +entities@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" + integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== + entities@^4.2.0, entities@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" @@ -11791,6 +11917,16 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +htmlparser2@^7.1.2: + version "7.2.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-7.2.0.tgz#8817cdea38bbc324392a90b1990908e81a65f5a5" + integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.2" + domutils "^2.8.0" + entities "^3.0.1" + http-cache-semantics@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" @@ -15336,6 +15472,11 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== +postcss-value-parser@^4.0.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + postcss@~8.4.32: version "8.4.49" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" @@ -15520,7 +15661,7 @@ prompts@^2.3.2, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.7, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -15733,6 +15874,11 @@ radix3@^1.1.0: resolved "https://registry.yarnpkg.com/radix3/-/radix3-1.1.0.tgz#9745df67a49c522e94a33d0a93cf743f104b6e0d" integrity sha512-pNsHDxbGORSvuSScqNJ+3Km6QAVqk8CfsCBIEoDgpqLrkD2f3QM4I7d1ozJJ172OmIcoUcerZaNWqtLkRXTV3A== +ramda@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.2.tgz#84463226f7f36dc33592f6f4ed6374c48306c3f1" + integrity sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.0.6, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -16052,6 +16198,21 @@ react-native-reanimated@^4.1.3: react-native-is-edge-to-edge "^1.2.1" semver "7.7.2" +react-native-render-html@^6.3.4: + version "6.3.4" + resolved "https://registry.yarnpkg.com/react-native-render-html/-/react-native-render-html-6.3.4.tgz#01684897bed2de84829e540a1dbb3a7bdf9d0e57" + integrity sha512-H2jSMzZjidE+Wo3qCWPUMU1nm98Vs2SGCvQCz/i6xf0P3Y9uVtG/b0sDbG/cYFir2mSYBYCIlS1Dv0WC1LjYig== + dependencies: + "@jsamr/counter-style" "^2.0.1" + "@jsamr/react-native-li" "^2.3.0" + "@native-html/transient-render-engine" "11.2.3" + "@types/ramda" "^0.27.40" + "@types/urijs" "^1.19.15" + prop-types "^15.5.7" + ramda "^0.27.2" + stringify-entities "^3.1.0" + urijs "^1.19.6" + react-native-reorderable-list@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/react-native-reorderable-list/-/react-native-reorderable-list-0.5.0.tgz#5f85360d68988fdd350cea720b0201f413329101" @@ -17608,7 +17769,16 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17699,6 +17869,15 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +stringify-entities@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-3.1.0.tgz#b8d3feac256d9ffcc9fa1fefdcf3ca70576ee903" + integrity sha512-3FP+jGMmMV/ffZs86MoghGqAoqXAdxLrJP4GUdrDN1aIScYih5tuIO3eF4To5AJZ79KDZ8Fpdy7QJnK8SsL1Vg== + dependencies: + character-entities-html4 "^1.0.0" + character-entities-legacy "^1.0.0" + xtend "^4.0.0" + stringify-object@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" @@ -17708,7 +17887,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -17722,6 +17901,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.0, strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -18303,6 +18489,11 @@ ts-proto@^1.162.1: ts-poet "^6.5.0" ts-proto-descriptors "1.15.0" +ts-toolbelt@^6.15.1: + version "6.15.5" + resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz#cb3b43ed725cb63644782c64fbcad7d8f28c0a83" + integrity sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A== + tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -18700,6 +18891,11 @@ urijs@1.19.1: resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.1.tgz#5b0ff530c0cbde8386f6342235ba5ca6e995d25a" integrity sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg== +urijs@^1.19.6: + version "1.19.11" + resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.11.tgz#204b0d6b605ae80bea54bea39280cdb7c9f923cc" + integrity sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ== + url-parse@^1.4.1, url-parse@^1.5.2, url-parse@^1.5.3: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -19430,7 +19626,7 @@ wordwrapjs@^4.0.0: reduce-flatten "^2.0.0" typical "^5.2.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19448,6 +19644,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 8d056681698be8e6783bb1afcd035b338bb690e8 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:06:29 -0800 Subject: [PATCH 13/14] Add gift card scenes and navigation - GiftCardListScene: View purchased cards (active/redeemed) - GiftCardMarketScene: Browse available brands with categories - GiftCardPurchaseScene: Select denomination and pay with crypto - Register routes in navigation stack --- src/components/Main.tsx | 19 + src/components/scenes/GiftCardListScene.tsx | 444 ++++++++++ src/components/scenes/GiftCardMarketScene.tsx | 615 +++++++++++++ .../scenes/GiftCardPurchaseScene.tsx | 812 ++++++++++++++++++ src/components/scenes/HomeScene.tsx | 3 +- src/types/routerTypes.tsx | 4 + 6 files changed, 1895 insertions(+), 2 deletions(-) create mode 100644 src/components/scenes/GiftCardListScene.tsx create mode 100644 src/components/scenes/GiftCardMarketScene.tsx create mode 100644 src/components/scenes/GiftCardPurchaseScene.tsx diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 3fb3e4101ed..ca556b37dd9 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -95,6 +95,9 @@ 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 { GiftCardListScene as GiftCardListSceneComponent } from './scenes/GiftCardListScene' +import { GiftCardMarketScene as GiftCardMarketSceneComponent } from './scenes/GiftCardMarketScene' +import { GiftCardPurchaseScene as GiftCardPurchaseSceneComponent } from './scenes/GiftCardPurchaseScene' import { BuyScene as BuySceneComponent, SellScene as SellSceneComponent @@ -238,6 +241,9 @@ const FioStakingChangeScene = ifLoggedIn(FioStakingChangeSceneComponent) const FioStakingOverviewScene = ifLoggedIn(FioStakingOverviewSceneComponent) const GuiPluginViewScene = ifLoggedIn(GuiPluginViewSceneComponent) const HomeScene = ifLoggedIn(HomeSceneComponent) +const GiftCardListScene = ifLoggedIn(GiftCardListSceneComponent) +const GiftCardMarketScene = ifLoggedIn(GiftCardMarketSceneComponent) +const GiftCardPurchaseScene = ifLoggedIn(GiftCardPurchaseSceneComponent) const LoanCloseScene = ifLoggedIn(LoanCloseSceneComponent) const LoanCreateConfirmationScene = ifLoggedIn( LoanCreateConfirmationSceneComponent @@ -934,6 +940,19 @@ const EdgeAppStack: React.FC = () => { name="fioStakingOverview" component={FioStakingOverviewScene} /> + + + ( + + fromParams={params => params.brand.brandName} + /> + ) + }} + /> {} + +/** + * Module-level cache to persist order data across scene mounts. + * Keyed by account ID to prevent data leaking between user sessions. + */ +let cachedAccountId: string | null = null +let cachedApiOrders: PhazeOrderStatusItem[] = [] +let cachedActiveOrders: PhazeDisplayOrder[] = [] +let cachedRedeemedOrders: PhazeDisplayOrder[] = [] +/** Necessary to ensure if the user truly has zero gift cards, we don't show loading every time. */ +let hasLoadedOnce = false +/** Track if brands have been loaded at least once (brands change infrequently) */ +let hasFetchedBrands = false + +/** Clear module-level cache (called when account changes) */ +const clearOrderCache = (): void => { + cachedApiOrders = [] + cachedActiveOrders = [] + cachedRedeemedOrders = [] + hasLoadedOnce = false + hasFetchedBrands = false +} + +/** List of purchased gift cards */ +export const GiftCardListScene: React.FC = (props: Props) => { + const { navigation } = props + const theme = useTheme() + const styles = getStyles(theme) + const dispatch = useDispatch() + + const account = useSelector(state => state.core.account) + const { countryCode, stateProvinceCode } = useSelector( + state => state.ui.settings + ) + + // Clear cache if account changed (prevents data leaking between users) + if (cachedAccountId !== account.id) { + clearOrderCache() + cachedAccountId = account.id + } + + // Get Phaze provider for API access + const phazeConfig = (ENV.PLUGIN_API_KEYS as Record) + ?.phaze as { apiKey?: string; baseUrl?: string } | undefined + const { provider, isReady } = useGiftCardProvider({ + account, + apiKey: phazeConfig?.apiKey ?? '', + baseUrl: phazeConfig?.baseUrl ?? '' + }) + + // Get augments from synced storage + const augments = usePhazeOrderAugments() + + // Orders from Phaze API merged with augments - separate active and redeemed + // Initialize from module-level cache to avoid flash of empty state on re-mount + const [activeOrders, setActiveOrders] = + React.useState(cachedActiveOrders) + const [redeemedOrders, setRedeemedOrders] = + React.useState(cachedRedeemedOrders) + // Only show loading on very first load; subsequent mounts refresh silently + const [isLoading, setIsLoading] = React.useState(!hasLoadedOnce) + + // Footer height for floating button + const [footerHeight, setFooterHeight] = React.useState() + + // Brand lookup function using provider cache + const brandLookup = React.useCallback( + (productId: number): string | undefined => { + const brand = provider?.getCachedBrand(countryCode, productId) + return brand?.productImage + }, + [countryCode, provider] + ) + + // Apply augments to cached API orders (no API call) + const applyAugments = React.useCallback( + (apiOrders: typeof cachedApiOrders) => { + const merged = mergeOrdersWithAugments(apiOrders, augments, brandLookup) + + // Show all orders that have augments (we purchased them) or have vouchers + const relevantOrders = merged.filter( + order => order.vouchers.length > 0 || order.txid != null + ) + + // Separate active and redeemed + const active = relevantOrders.filter(order => order.redeemedDate == null) + const redeemed = relevantOrders.filter( + order => order.redeemedDate != null + ) + + cachedActiveOrders = active + cachedRedeemedOrders = redeemed + setActiveOrders(active) + setRedeemedOrders(redeemed) + }, + [augments, brandLookup] + ) + + // Fetch orders from API (full refresh) + const loadOrdersFromApi = React.useCallback( + async (includeBrands: boolean): Promise => { + debugLog('phaze', 'loadOrdersFromApi called, isReady:', isReady) + if (provider == null || !isReady) { + debugLog('phaze', 'Provider not ready, skipping') + return false + } + + try { + // Aggregate orders from all identities + const allOrders = await provider.getAllOrdersFromAllIdentities(account) + debugLog('phaze', 'Got', allOrders.length, 'orders from API') + cachedApiOrders = allOrders + + // Only fetch brands on first load (they change infrequently) + let didFetchBrands = false + if (includeBrands) { + await provider.getMarketBrands(countryCode) + didFetchBrands = true + } + + applyAugments(allOrders) + return didFetchBrands + } catch (err: unknown) { + debugLog('phaze', 'Error loading orders:', err) + setActiveOrders([]) + setRedeemedOrders([]) + return false + } finally { + setIsLoading(false) + hasLoadedOnce = true + } + }, + [account, provider, isReady, countryCode, applyAugments] + ) + + // Re-apply augments when they change (no API call needed) + React.useEffect(() => { + if (cachedApiOrders.length > 0) { + applyAugments(cachedApiOrders) + } + }, [augments, applyAugments]) + + // Load augments on mount + useAsyncEffect( + async () => { + await refreshPhazeAugmentsCache(account) + }, + [], + 'GiftCardListScene:refreshAugments' + ) + + // Reload orders when scene comes into focus, then poll periodically + // to detect when pending orders receive their vouchers + useFocusEffect( + React.useCallback(() => { + // First load: fetch both brands and orders + // Subsequent loads: only fetch orders (brands change infrequently) + const includeBrands = !hasFetchedBrands + loadOrdersFromApi(includeBrands) + .then(didFetchBrands => { + if (didFetchBrands) hasFetchedBrands = true + }) + .catch(() => {}) + + // Poll every 10 seconds while focused (orders only, not brands) + const task = makePeriodicTask( + async () => { + await loadOrdersFromApi(false) + }, + 10000, + { onError: () => {} } + ) + task.start() + + return () => { + task.stop() + } + }, [loadOrdersFromApi]) + ) + + const handlePurchaseNew = useHandler(async () => { + // Provider auto-registers user if needed via ensureUser() + // Ensure country is set: + let nextCountryCode = countryCode + if (nextCountryCode === '') { + await dispatch( + showCountrySelectionModal({ + account, + countryCode: '', + stateProvinceCode + }) + ) + // Re-read from synced settings to determine if user actually selected: + const synced = await readSyncedSettings(account) + nextCountryCode = synced.countryCode ?? '' + } + // Only navigate if we have a country code selected: + if (nextCountryCode !== '') { + navigation.navigate('giftCardMarket') + } + }) + + // Show menu modal for an order + const handleMenuPress = useHandler( + async (order: PhazeDisplayOrder, isRedeemed: boolean) => { + const result = await Airship.show(bridge => ( + + )) + + if (result == null) return + + if (result.type === 'goToTransaction') { + navigation.navigate('transactionDetails', { + edgeTransaction: result.transaction, + walletId: result.walletId + }) + } else if (result.type === 'markAsRedeemed') { + await saveOrderAugment(account, order.quoteId, { + redeemedDate: new Date() + }) + } else if (result.type === 'unmarkAsRedeemed') { + await saveOrderAugment(account, order.quoteId, { + redeemedDate: undefined + }) + } + } + ) + + // Handle redeem flow: open URL, then prompt to mark as redeemed + const handleRedeemComplete = useHandler(async (order: PhazeDisplayOrder) => { + const redemptionUrl = order.vouchers?.[0]?.url + if (redemptionUrl == null) return + + // Open redemption URL in webview + await showWebViewModal(order.brandName, redemptionUrl) + + // After webview closes, ask if they want to mark as redeemed + const result = await Airship.show<'yes' | 'no' | undefined>(bridge => ( + + )) + + if (result === 'yes') { + await saveOrderAugment(account, order.quoteId, { + redeemedDate: new Date() + }) + } + }) + + const handleFooterLayoutHeight = useHandler((height: number) => { + setFooterHeight(height) + }) + + /** + * Derive card status from order data: + * - pending: Broadcasted but no voucher yet + * - available: Has voucher, not yet redeemed + * - redeemed: User marked as redeemed + */ + const getCardStatus = React.useCallback( + (order: PhazeDisplayOrder): GiftCardStatus => { + if (order.redeemedDate != null) return 'redeemed' + if (order.vouchers.length === 0) return 'pending' + return 'available' + }, + [] + ) + + const renderCard = (order: PhazeDisplayOrder): React.ReactElement => { + const status = getCardStatus(order) + return ( + + { + handleMenuPress(order, status === 'redeemed').catch(() => {}) + }} + onRedeemComplete={ + status !== 'available' + ? undefined + : () => { + handleRedeemComplete(order).catch(() => {}) + } + } + /> + + ) + } + + const renderFooter: FooterRender = React.useCallback( + sceneWrapperInfo => { + return ( + + + + ) + }, + [handleFooterLayoutHeight, handlePurchaseNew] + ) + + const hasNoCards = activeOrders.length === 0 && redeemedOrders.length === 0 + + return ( + + {({ insetStyle, undoInsetStyle }) => ( + + + {isLoading ? ( + + ) : hasNoCards ? ( + {lstrings.gift_card_list_no_cards} + ) : ( + <> + {/* Active Cards Section */} + {activeOrders.map(order => renderCard(order))} + + {/* Redeemed Cards Section */} + {redeemedOrders.length > 0 && ( + <> + + + {lstrings.gift_card_redeemed_cards} + + + + {redeemedOrders.map(order => renderCard(order))} + + )} + + )} + + + )} + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + scrollView: { + flex: 1 + }, + scrollContent: { + flexGrow: 1 + }, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center' + }, + cardContainer: { + marginTop: theme.rem(0.75) + }, + sectionHeader: { + marginTop: theme.rem(1) + }, + sectionHeaderTitle: { + fontSize: theme.rem(1.2), + fontFamily: theme.fontFaceMedium, + marginBottom: theme.rem(0.5) + } +})) diff --git a/src/components/scenes/GiftCardMarketScene.tsx b/src/components/scenes/GiftCardMarketScene.tsx new file mode 100644 index 00000000000..38edd6f2783 --- /dev/null +++ b/src/components/scenes/GiftCardMarketScene.tsx @@ -0,0 +1,615 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import type { ListRenderItem } from 'react-native' +import { ScrollView, StyleSheet, View } from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import Animated from 'react-native-reanimated' + +import { showCountrySelectionModal } from '../../actions/CountryListActions' +import { EDGE_CONTENT_SERVER_URI } from '../../constants/CdnConstants' +import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' +import { guiPlugins } from '../../constants/plugins/GuiPlugins' +import { ENV } from '../../env' +import { useGiftCardProvider } from '../../hooks/useGiftCardProvider' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { getCachedBrandsSync } from '../../plugins/gift-cards/phazeGiftCardCache' +import type { PhazeGiftCardBrand } from '../../plugins/gift-cards/phazeGiftCardTypes' +import type { FooterRender } from '../../state/SceneFooterState' +import { useSceneScrollHandler } from '../../state/SceneScrollState' +import { useDispatch, useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { debugLog } from '../../util/logger' +import { CountryButton } from '../buttons/RegionButton' +import { EdgeCard } from '../cards/EdgeCard' +import { GiftCardTile } from '../cards/GiftCardTile' +import { CircularBrandIcon } from '../common/CircularBrandIcon' +import { EdgeAnim } from '../common/EdgeAnim' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { SceneWrapper } from '../common/SceneWrapper' +import { GridIcon, ListIcon } from '../icons/ThemedIcons' +import { SceneContainer } from '../layout/SceneContainer' +import { normalizeCategory } from '../modals/GiftCardSearchModal' +import { FillLoader } from '../progress-indicators/FillLoader' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from '../themed/EdgeText' +import { SearchFooter } from '../themed/SearchFooter' + +type ViewMode = 'grid' | 'list' + +// Internal constant for "All" category comparison - display uses lstrings.string_all +const CATEGORY_ALL = 'All' + +/** + * Formats a normalized category for display: + * - Replaces dashes with " & " + * - Capitalizes first letter of each word + */ +const formatCategoryDisplay = (category: string): string => { + return category + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' & ') +} + +interface MarketItem { + brandName: string + priceRange: string + productId: number + productImage: string + categories: string[] + isBitrefill?: boolean +} + +/** + * Formats a price range string from brand data. + * - Variable range: shows "min - max" format + * - Fixed denominations: shows comma-separated list + */ +const formatPriceRange = (brand: PhazeGiftCardBrand): string => { + const { currency, valueRestrictions, denominations } = brand + const { minVal, maxVal } = valueRestrictions + + // Variable range - show min to max + if (minVal != null && maxVal != null) { + return `${minVal} - ${maxVal} ${currency}` + } + + // Fixed denominations - show comma-separated list + if (denominations.length > 0) { + const sorted = [...denominations].sort((a, b) => a - b) + return `${sorted.join(', ')} ${currency}` + } + + return currency +} + +// Bitrefill partner option shown at end of results +const BITREFILL_ITEM: MarketItem = { + brandName: 'Bitrefill', + priceRange: lstrings.gift_card_more_options, + productId: -999, + productImage: `${EDGE_CONTENT_SERVER_URI}/bitrefill.png`, + categories: [], + isBitrefill: true +} + +interface Props extends EdgeAppSceneProps<'giftCardMarket'> {} + +export const GiftCardMarketScene: React.FC = props => { + const { navigation } = props + const theme = useTheme() + const styles = getStyles(theme) + const dispatch = useDispatch() + + // Get user's current country code (specific selector to avoid re-renders on other setting changes) + const countryCode = useSelector(state => state.ui.settings.countryCode) + const account = useSelector(state => state.core.account) + + // Provider (requires API key configured) + const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze + const { provider, isReady } = useGiftCardProvider({ + account, + apiKey: phazeConfig?.apiKey ?? '', + baseUrl: phazeConfig?.baseUrl ?? '' + }) + + // Cache for gift card brands (accessed via provider) + const cache = provider?.getCache() + + // Initialize items from memory cache synchronously to avoid flash of loader + // Use getCachedBrandsSync since provider is null on initial render + const [items, setItems] = React.useState(() => { + const cached = getCachedBrandsSync(countryCode) + if (cached != null) { + return cached.map(brand => ({ + brandName: brand.brandName, + priceRange: formatPriceRange(brand), + productId: brand.productId, + productImage: brand.productImage, + categories: brand.categories + })) + } + return null + }) + const [allCategories, setAllCategories] = React.useState(() => { + const cached = getCachedBrandsSync(countryCode) + if (cached != null) { + const categorySet = new Set() + for (const brand of cached) { + for (const category of brand.categories) { + categorySet.add(normalizeCategory(category)) + } + } + return Array.from(categorySet).sort() + } + return [] + }) + + // Search state + const [searchText, setSearchText] = React.useState('') + const [isSearching, setIsSearching] = React.useState(false) + const [footerHeight, setFooterHeight] = React.useState() + + // Category filter state + const [selectedCategory, setSelectedCategory] = React.useState(CATEGORY_ALL) + + // View mode state (grid or list) + const [viewMode, setViewMode] = React.useState('grid') + + const handleScroll = useSceneScrollHandler() + + // Helper to map brand response to MarketItem + const mapBrandsToItems = React.useCallback( + (brands: PhazeGiftCardBrand[]): MarketItem[] => + brands.map(brand => ({ + brandName: brand.brandName, + priceRange: formatPriceRange(brand), + productId: brand.productId, + productImage: brand.productImage, + categories: brand.categories + })), + [] + ) + + // Extract unique normalized categories from brands + const extractCategories = React.useCallback( + (brands: PhazeGiftCardBrand[]): string[] => { + const categorySet = new Set() + for (const brand of brands) { + for (const category of brand.categories) { + categorySet.add(normalizeCategory(category)) + } + } + return Array.from(categorySet).sort() + }, + [] + ) + + // Helper to update UI state from brands + const updateFromBrands = React.useCallback( + (brands: PhazeGiftCardBrand[]): void => { + setItems(mapBrandsToItems(brands)) + setAllCategories(extractCategories(brands)) + }, + [extractCategories, mapBrandsToItems] + ) + + // If the user changes country while on this scene, clear the current list so + // we don't briefly show stale brands from the previous country: + const prevCountryCodeRef = React.useRef(null) + React.useEffect(() => { + // Skip initial mount so we don't wipe synchronous cache state: + if ( + prevCountryCodeRef.current != null && + prevCountryCodeRef.current !== countryCode + ) { + setItems(null) + setAllCategories([]) + setSelectedCategory(CATEGORY_ALL) + setSearchText('') + setIsSearching(false) + } + prevCountryCodeRef.current = countryCode + }, [countryCode]) + + // Fetch brands. Initial data comes from synchronous cache read in useState + const { data: apiBrands } = useQuery({ + queryKey: ['phazeBrands', countryCode, isReady], + queryFn: async () => { + if (provider == null || cache == null) { + throw new Error('Provider not ready') + } + + // 1. Try disk cache first (for cold start) + const diskCached = await cache.loadFromDisk(countryCode) + if (diskCached != null) { + debugLog('phaze', 'Using disk cache:', diskCached.length, 'brands') + // Return disk cache immediately, but continue to fetch fresh data + } + + // 2. Fetch all brands with minimal fields (fast) → immediate display + debugLog('phaze', 'Fetching all gift cards for:', countryCode) + const allBrands = await provider.getMarketBrands(countryCode) + debugLog('phaze', 'Got', allBrands.length, 'brands for display') + + // 3. Background: Fetch all brands with full data (for purchase scene) + debugLog('phaze', 'Fetching full brand data in background...') + provider + .getFullGiftCards({ countryCode }) + .then(fullResponse => { + debugLog( + 'phaze', + 'Got', + fullResponse.brands.length, + 'brands with full details' + ) + const fullDetailIds = new Set( + fullResponse.brands.map(b => b.productId) + ) + cache.setBrands(countryCode, fullResponse.brands, fullDetailIds) + cache.saveToDisk(countryCode).catch(() => {}) + }) + .catch(() => {}) + + return allBrands + }, + enabled: isReady && provider != null && countryCode !== '', + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, + retry: 1 + }) + + // Update items when API data arrives + React.useEffect(() => { + if (apiBrands != null) { + updateFromBrands(apiBrands) + } + }, [apiBrands, updateFromBrands]) + + // Build category list with "All" first, then alphabetized categories + const categoryList = React.useMemo(() => { + const normalizedSet = new Set() + for (const cat of allCategories) { + normalizedSet.add(normalizeCategory(cat)) + } + return [CATEGORY_ALL, ...Array.from(normalizedSet).sort()] + }, [allCategories]) + + // Filter items by search text and category + const filteredItems = React.useMemo(() => { + if (items == null) return null + + let filtered = items + + // Filter by category (unless "All" is selected, which shows all) + if (selectedCategory !== CATEGORY_ALL) { + filtered = filtered.filter(item => + item.categories.some(cat => normalizeCategory(cat) === selectedCategory) + ) + } + + // Filter by search text + if (searchText.trim() !== '') { + const lowerQuery = searchText.toLowerCase() + filtered = filtered.filter(item => + item.brandName.toLowerCase().includes(lowerQuery) + ) + } + + return filtered + }, [items, searchText, selectedCategory]) + + const handleItemPress = useHandler((item: MarketItem) => { + if (provider == null) return + const brand = provider.getCachedBrand(countryCode, item.productId) + if (brand == null) { + debugLog('phaze', 'Brand not found for productId:', item.productId) + return + } + debugLog('phaze', 'Navigating to purchase for:', item.brandName) + navigation.navigate('giftCardPurchase', { brand }) + }) + + const handleBitrefillPress = useHandler(() => { + navigation.navigate('pluginView', { + plugin: guiPlugins.bitrefill + } as any) + }) + + const handleCategoryPress = useHandler((category: string) => { + setSelectedCategory(category) + }) + + const handleToggleViewMode = useHandler(() => { + setViewMode(prev => (prev === 'grid' ? 'list' : 'grid')) + }) + + const handleStartSearching = useHandler(() => { + setIsSearching(true) + }) + + const handleDoneSearching = useHandler(() => { + setSearchText('') + setIsSearching(false) + }) + + const handleChangeText = useHandler((value: string) => { + setSearchText(value) + }) + + const handleFooterLayoutHeight = useHandler((height: number) => { + setFooterHeight(height) + }) + + const renderGridItem: ListRenderItem = React.useCallback( + ({ item }) => { + const handlePress = (): void => { + if (item.isBitrefill === true) { + handleBitrefillPress() + } else { + handleItemPress(item) + } + } + return ( + + + + ) + }, + [handleBitrefillPress, handleItemPress, styles.tileContainer] + ) + + const renderListItem: ListRenderItem = React.useCallback( + ({ item }) => { + const handlePress = (): void => { + if (item.isBitrefill === true) { + handleBitrefillPress() + } else { + handleItemPress(item) + } + } + + return ( + } + onPress={handlePress} + > + + + {item.brandName} + + + {item.priceRange} + + + + ) + }, + [ + handleBitrefillPress, + handleItemPress, + styles.listBrandName, + styles.listPriceRange, + styles.listTextContainer + ] + ) + + const renderItem = viewMode === 'grid' ? renderGridItem : renderListItem + + const keyExtractor = React.useCallback( + (item: MarketItem, index: number): string => `${item.productId}-${index}`, + [] + ) + + const handleRegionSelect = useHandler(() => { + dispatch( + showCountrySelectionModal({ + account, + countryCode, + skipStateProvince: true + }) + ).catch(() => {}) + }) + + const renderFooter: FooterRender = React.useCallback( + sceneWrapperInfo => { + return ( + + ) + }, + [ + handleChangeText, + handleDoneSearching, + handleFooterLayoutHeight, + handleStartSearching, + isSearching, + searchText + ] + ) + + // Build list data: filtered items + Bitrefill option at end + const listData = React.useMemo(() => { + return [...(filteredItems ?? []), BITREFILL_ITEM] + }, [filteredItems]) + + return ( + + {({ insetStyle, undoInsetStyle }) => ( + } + > + {items == null ? ( + + ) : ( + <> + + {categoryList.length > 1 ? ( + + {categoryList.map((category, index) => { + const isSelected = selectedCategory === category + const displayName = + category === CATEGORY_ALL + ? lstrings.string_all + : formatCategoryDisplay(category) + return ( + + { + handleCategoryPress(category) + }} + > + + {displayName} + + + + ) + })} + + ) : ( + + )} + + + {viewMode === 'grid' ? ( + + ) : ( + + )} + + + + + )} + + )} + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + categoryRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: theme.rem(0.5) + }, + categoryScrollView: { + flexGrow: 1, + flexShrink: 1 + }, + categoryContainer: { + paddingRight: theme.rem(1) + }, + categoryButton: { + paddingHorizontal: theme.rem(0.5), + paddingVertical: theme.rem(0.25) + }, + categoryText: { + color: theme.primaryText + }, + categoryTextSelected: { + fontSize: theme.rem(0.875), + color: theme.iconTappable + }, + viewToggleButton: { + width: theme.rem(2), + height: theme.rem(2), + borderRadius: theme.rem(1), + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0 + }, + viewToggleGradient: { + ...StyleSheet.absoluteFillObject, + borderRadius: theme.rem(1) + }, + tileContainer: { + width: '50%' + }, + // List view styles + listTextContainer: { + flexGrow: 1, + flexShrink: 1, + marginLeft: theme.rem(0.5) + }, + listBrandName: { + fontSize: theme.rem(1), + color: theme.primaryText + }, + listPriceRange: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + } +})) diff --git a/src/components/scenes/GiftCardPurchaseScene.tsx b/src/components/scenes/GiftCardPurchaseScene.tsx new file mode 100644 index 00000000000..087a2a21cf6 --- /dev/null +++ b/src/components/scenes/GiftCardPurchaseScene.tsx @@ -0,0 +1,812 @@ +import { useQuery } from '@tanstack/react-query' +import { ceil, mul } from 'biggystring' +import type { EdgeTransaction, EdgeTxActionGiftCard } from 'edge-core-js' +import * as React from 'react' +import { + type DimensionValue, + StyleSheet, + useWindowDimensions, + View, + type ViewStyle +} from 'react-native' +import FastImage from 'react-native-fast-image' +import RenderHtml from 'react-native-render-html' +import Ionicons from 'react-native-vector-icons/Ionicons' +import { sprintf } from 'sprintf-js' +import { v4 as uuidv4 } from 'uuid' + +import { ENV } from '../../env' +import { useGiftCardProvider } from '../../hooks/useGiftCardProvider' +import { useHandler } from '../../hooks/useHandler' +import { usePhazeBrand } from '../../hooks/usePhazeBrand' +import { lstrings } from '../../locales/strings' +import type { + PhazeCreateOrderResponse, + PhazeGiftCardBrand, + PhazeToken +} from '../../plugins/gift-cards/phazeGiftCardTypes' +import { useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' +import type { EdgeAsset } from '../../types/types' +import { caip19ToEdgeAsset } from '../../util/caip19Utils' +import { debugLog } from '../../util/logger' +import { parseLinkedText } from '../../util/parseLinkedText' +import { DECIMAL_PRECISION } from '../../util/utils' +import { DropdownInputButton } from '../buttons/DropdownInputButton' +import { KavButtons } from '../buttons/KavButtons' +import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' +import { EdgeAnim } from '../common/EdgeAnim' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { SceneWrapper } from '../common/SceneWrapper' +import { SectionHeader } from '../common/SectionHeader' +import { SceneContainer } from '../layout/SceneContainer' +import { + GiftCardAmountModal, + type GiftCardAmountResult +} from '../modals/GiftCardAmountModal' +import { + WalletListModal, + type WalletListResult +} from '../modals/WalletListModal' +import { showHtmlModal } from '../modals/WebViewModal' +import { ShimmerCard } from '../progress-indicators/ShimmerCard' +import { Airship, showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText, Paragraph } from '../themed/EdgeText' +import { FilledTextInput } from '../themed/FilledTextInput' + +/** Create a consistent key for an EdgeAsset */ +const assetKey = (asset: EdgeAsset): string => + `${asset.pluginId}:${asset.tokenId ?? 'native'}` + +// Zoom factor to crop out edge artifacts from source images +const ZOOM_FACTOR = 1.03 + +// Style for the zoomed image container +const zoomedContainerStyle: ViewStyle = { + position: 'absolute', + width: `${ZOOM_FACTOR * 100}%` as DimensionValue, + height: `${ZOOM_FACTOR * 100}%` as DimensionValue, + top: `${((ZOOM_FACTOR - 1) / 2) * -100}%` as DimensionValue, + left: `${((ZOOM_FACTOR - 1) / 2) * -100}%` as DimensionValue +} + +export interface GiftCardPurchaseParams { + brand: PhazeGiftCardBrand +} + +interface Props extends EdgeAppSceneProps<'giftCardPurchase'> {} + +export const GiftCardPurchaseScene: React.FC = props => { + const { navigation, route } = props + const { brand: initialBrand } = route.params + const theme = useTheme() + const styles = getStyles(theme) + const { width: screenWidth } = useWindowDimensions() + + const account = useSelector(state => state.core.account) + + // Provider (requires API key configured) + const phazeConfig = (ENV.PLUGIN_API_KEYS as Record) + ?.phaze as { apiKey?: string; baseUrl?: string } | undefined + const { provider, isReady } = useGiftCardProvider({ + account, + apiKey: phazeConfig?.apiKey ?? '', + baseUrl: phazeConfig?.baseUrl ?? '' + }) + + // Fetch full brand details if needed (may have limited fields from market) + const { brand } = usePhazeBrand(provider, initialBrand) + + // State for loading indicator during order creation + const [isCreatingOrder, setIsCreatingOrder] = React.useState(false) + + // Store pending order for onDone callback + const pendingOrderRef = React.useRef(null) + + // State for collapsible cards + const [howItWorksExpanded, setHowItWorksExpanded] = React.useState(false) + const [termsExpanded, setTermsExpanded] = React.useState(false) + + // Token minimums map: assetKey -> PhazeToken (includes caip19, minimums) + const tokenMinimumsRef = React.useRef>(new Map()) + + // Warning state for minimum amount violations + const [minimumWarning, setMinimumWarning] = React.useState<{ + header: string + footer: string + } | null>(null) + + // Fetch allowed tokens from Phaze API + const { data: tokenQueryResult } = useQuery({ + queryKey: ['phazeTokens', account?.id, isReady], + queryFn: async () => { + if (provider == null) { + throw new Error('Provider not ready') + } + + const tokensResponse = await provider.getTokens() + + // Convert CAIP-19 identifiers to EdgeAsset format and build token map + // Key by asset (pluginId:tokenId) so we can look up the original caip19 later + const assets: EdgeAsset[] = [] + const tokenMap = new Map() + for (const token of tokensResponse.tokens) { + const asset = caip19ToEdgeAsset(account, token.caip19) + if (asset != null) { + assets.push(asset) + tokenMap.set(assetKey(asset), token) + } + } + + debugLog( + 'phaze', + 'Loaded', + assets.length, + 'supported assets from', + tokensResponse.tokens.length, + 'tokens' + ) + return { assets, tokenMap } + }, + enabled: isReady && provider != null, + staleTime: 5 * 60 * 1000, // 5 minutes - tokens don't change often + gcTime: 10 * 60 * 1000 + }) + + // 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 + React.useEffect(() => { + if (tokenQueryResult?.tokenMap != null) { + tokenMinimumsRef.current = tokenQueryResult.tokenMap + } + }, [tokenQueryResult]) + + // Determine if this is fixed denominations or variable range + const sortedDenominations = React.useMemo( + () => [...brand.denominations].sort((a, b) => a - b), + [brand.denominations] + ) + const hasFixedDenominations = sortedDenominations.length > 0 + + // For variable range, get min/max from valueRestrictions + const minVal = brand.valueRestrictions?.minVal ?? 0 + const maxVal = brand.valueRestrictions?.maxVal ?? 0 + const hasVariableRange = !hasFixedDenominations && maxVal > 0 + + // Amount state - for fixed denoms, default to minimum; for variable, start empty + const [selectedAmount, setSelectedAmount] = React.useState< + number | undefined + >(hasFixedDenominations ? sortedDenominations[0] : undefined) + const [amountText, setAmountText] = React.useState( + hasFixedDenominations ? String(sortedDenominations[0]) : '' + ) + + // Update selection when denominations become available (e.g., after brand fetch) + React.useEffect(() => { + if (hasFixedDenominations && selectedAmount == null) { + const minDenom = sortedDenominations[0] + setSelectedAmount(minDenom) + setAmountText(String(minDenom)) + } + }, [hasFixedDenominations, sortedDenominations, selectedAmount]) + + // Handle amount text change for variable range + const handleAmountChange = useHandler((text: string) => { + // Clear minimum warning when user modifies amount + setMinimumWarning(null) + + // Only allow numbers and decimal point + const cleaned = text.replace(/[^0-9.]/g, '') + setAmountText(cleaned) + + const parsed = parseFloat(cleaned) + // Always sync selectedAmount with the parsed value (even if invalid). + // Validation is handled by isAmountValid which checks the range. + if (!isNaN(parsed)) { + setSelectedAmount(parsed) + } else { + setSelectedAmount(undefined) + } + }) + + // Handle MAX button press + const handleMaxPress = useHandler(() => { + if (hasVariableRange) { + setMinimumWarning(null) + setAmountText(String(maxVal)) + setSelectedAmount(maxVal) + } + }) + + // Toggle handlers for collapsible cards + const handleHowItWorksToggle = useHandler(() => { + setHowItWorksExpanded(!howItWorksExpanded) + }) + + const handleTermsToggle = useHandler(() => { + setTermsExpanded(!termsExpanded) + }) + + // Open full terms and conditions in a modal + const handleTermsLinkPress = useHandler(() => { + if (brand.termsAndConditions == null) return + showHtmlModal( + lstrings.gift_card_terms_and_conditions, + brand.termsAndConditions + ).catch(() => {}) + }) + + // Handle amount row press for fixed denominations + const handleAmountPress = useHandler(async () => { + if (!hasFixedDenominations) { + return + } + + const result = await Airship.show( + bridge => ( + + ) + ) + + if (result != null) { + // Clear minimum warning when user modifies amount + setMinimumWarning(null) + setSelectedAmount(result.amount) + setAmountText(String(result.amount)) + } + }) + + const handleNextPress = useHandler(async () => { + if (selectedAmount == null || provider == null || !isReady) { + return + } + + // Show wallet selection modal with only supported assets + const walletResult = await Airship.show(bridge => ( + + )) + + if (walletResult?.type !== 'wallet') { + return + } + + const { walletId, tokenId } = walletResult + const wallet = account.currencyWallets[walletId] + + if (wallet == null) { + showError(new Error('Wallet not found')) + return + } + + // Get token info using original caip19 from Phaze API (preserves checksum case) + const selectedAsset: EdgeAsset = { + pluginId: wallet.currencyInfo.pluginId, + tokenId + } + const tokenInfo = tokenMinimumsRef.current.get(assetKey(selectedAsset)) + + if (tokenInfo == null) { + showError(new Error('Unsupported cryptocurrency for gift card purchase')) + return + } + + const caip19 = tokenInfo.caip19 + + // Check minimum amount for selected token + if (selectedAmount < tokenInfo.minimumAmountInUSD) { + const currencyCode = + tokenId != null + ? account.currencyConfig[wallet.currencyInfo.pluginId]?.allTokens[ + tokenId + ]?.currencyCode ?? wallet.currencyInfo.currencyCode + : wallet.currencyInfo.currencyCode + + setMinimumWarning({ + header: sprintf( + lstrings.gift_card_minimum_warning_header, + currencyCode + ), + footer: sprintf( + lstrings.gift_card_minimum_warning_footer, + `$${tokenInfo.minimumAmountInUSD.toFixed(2)} USD` + ) + }) + return + } + + debugLog('phaze', 'Creating order:', { + brandName: brand.brandName, + productId: brand.productId, + amount: selectedAmount, + currency: brand.currency, + tokenIdentifier: caip19 + }) + + setIsCreatingOrder(true) + + try { + // Create order with Phaze API + const orderResponse = await provider.createOrder({ + tokenIdentifier: caip19, + cart: [ + { + orderId: uuidv4(), + price: selectedAmount, + productId: brand.productId + } + ] + }) + + debugLog('phaze', 'Order created:', { + quoteId: orderResponse.quoteId, + deliveryAddress: orderResponse.deliveryAddress, + quantity: orderResponse.quantity, + amountInUSD: orderResponse.amountInUSD, + quoteExpiry: orderResponse.quoteExpiry + }) + + // Store the order for the onDone callback + pendingOrderRef.current = orderResponse + + // Convert quantity to native amount (crypto amount to pay) + // The quantity is in the token's standard units (e.g., BTC, ETH) + const currencyCode = + tokenId != null + ? account.currencyConfig[wallet.currencyInfo.pluginId]?.allTokens[ + tokenId + ]?.currencyCode ?? wallet.currencyInfo.currencyCode + : wallet.currencyInfo.currencyCode + + const multiplier = + tokenId != null + ? account.currencyConfig[wallet.currencyInfo.pluginId]?.allTokens[ + tokenId + ]?.denominations[0]?.multiplier ?? '1' + : wallet.currencyInfo.denominations[0]?.multiplier ?? '1' + + // quantity from API is in decimal units, convert to native + 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) + const isoExpireDate = expiryDate.toISOString() + + // Navigate to SendScene2 + navigation.navigate('send2', { + walletId, + tokenId, + spendInfo: { + tokenId, + spendTargets: [ + { + publicAddress: orderResponse.deliveryAddress, + nativeAmount + } + ], + metadata: { + name: lstrings.gift_card_recipient_name, + // Store quoteId in notes for linking in TransactionDetailsScene + notes: `Phaze gift card purchase - ${brand.brandName} ${selectedAmount} ${brand.currency}\nQuoteId: ${orderResponse.quoteId}` + } + }, + lockTilesMap: { + address: true, + amount: true, + wallet: true + }, + hiddenFeaturesMap: { + address: true, + fioAddressSelect: true + }, + infoTiles: [ + { label: lstrings.gift_card_brand, value: brand.brandName }, + { + label: lstrings.string_amount, + value: `${selectedAmount} ${brand.currency}` + }, + { + label: lstrings.gift_card_pay_amount, + value: `${orderResponse.quantity} ${currencyCode}` + } + ], + sliderTopNode: ( + + {parseLinkedText( + lstrings.gift_card_slider_terms, + handleTermsLinkPress, + styles.termsLink + )} + + ), + isoExpireDate, + onDone: async (error: Error | null, tx?: EdgeTransaction) => { + if (error != null) { + debugLog('phaze', 'Transaction error:', error) + return + } + if (tx != null && pendingOrderRef.current != null) { + debugLog('phaze', 'Transaction successful:', tx.txid) + + const order = pendingOrderRef.current + + // Save the gift card action to the transaction (synced via edge-core) + const savedAction: EdgeTxActionGiftCard = { + actionType: 'giftCard', + orderId: order.quoteId, + provider: { + providerId: 'phaze', + displayName: 'Phaze' + }, + card: { + name: brand.brandName, + imageUrl: brand.productImage, + fiatAmount: String(selectedAmount), + fiatCurrencyCode: `iso:${brand.currency}` + } + // redemption is populated later by polling service + } + + await wallet.saveTxAction({ + txid: tx.txid, + tokenId, + assetAction: { assetActionType: 'giftCard' }, + savedAction + }) + + // Save order augment (tx link + brand/amount info for list scene) + await provider.saveOrderAugment(account, order.quoteId, { + walletId, + tokenId, + txid: tx.txid, + brandName: brand.brandName, + brandImage: brand.productImage, + fiatAmount: selectedAmount, + fiatCurrency: brand.currency + }) + + // Navigate to gift card list to see the pending order + navigation.navigate('giftCardList') + } + } + }) + } catch (err: unknown) { + debugLog('phaze', 'Order creation error:', err) + + // Check for minimum amount error from API + const errorMessage = err instanceof Error ? err.message : '' + const minimumMatch = /Minimum cart cost should be above: ([\d.]+)/.exec( + errorMessage + ) + + if (minimumMatch != null) { + const minimumUSD = parseFloat(minimumMatch[1]) + setMinimumWarning({ + header: sprintf( + lstrings.gift_card_minimum_warning_header, + 'this cryptocurrency' + ), + footer: sprintf( + lstrings.gift_card_minimum_warning_footer, + `$${minimumUSD.toFixed(2)} USD` + ) + }) + } else { + showError(err) + } + } finally { + setIsCreatingOrder(false) + } + }) + + // Validation for variable range + const isAmountValid = + selectedAmount != null && + (hasFixedDenominations || + (selectedAmount >= minVal && selectedAmount <= maxVal)) + + // Section title based on type + const sectionTitle = hasFixedDenominations + ? lstrings.gift_card_select_amount + : lstrings.gift_card_enter_amount + + // Base style for RenderHtml (root font settings) to make it appear like + // EdgeText + const htmlBaseStyle = React.useMemo( + () => ({ + color: theme.primaryText, + fontSize: theme.rem(1), + lineHeight: theme.rem(1.25), + fontWeight: '300' as const + }), + [theme] + ) + + // Tag-specific overrides for RenderHtml + const htmlTagsStyles = React.useMemo( + () => ({ + p: { + marginTop: 0, + marginBottom: theme.rem(0.5) + }, + a: { + color: theme.iconTappable + } + }), + [theme] + ) + + // Default text props to disable accessibility font scaling + const defaultTextProps = React.useMemo( + () => ({ + allowFontScaling: false + }), + [] + ) + + // Content width for RenderHtml (accounting for card padding) + const htmlContentWidth = screenWidth - theme.rem(3) + + return ( + + ) + }} + scroll + > + + {/* Hero brand image - rectangular full-width display (differs from + CircularBrandIcon which is circular with border for list items) */} + + + + + + + + + + + + {hasFixedDenominations ? ( + // Fixed denominations - tappable row that opens modal (if multiple options) + + + 1 + ? handleAmountPress + : undefined + } + > + + + {lstrings.string_value} + + + {selectedAmount != null + ? `${selectedAmount} ${brand.currency}` + : '—'} + + + + {sortedDenominations.length > 1 ? ( + { + setMinimumWarning(null) + const maxDenom = + sortedDenominations[sortedDenominations.length - 1] + setSelectedAmount(maxDenom) + setAmountText(String(maxDenom)) + }} + > + + {lstrings.string_max_cap} + + + ) : null} + + + ) : ( + // Variable range - editable text input + + + + + {lstrings.string_max_cap} + + + + )} + + + {/* Minimum Amount Warning */} + {minimumWarning != null ? ( + + ) : null} + + {/* Product Description Card */} + {brand.productDescription == null ? ( + // Shimmer while loading + + ) : brand.productDescription !== '' ? ( + + + + ) : null} + + {/* How it Works - Collapsible Card */} + + + + {lstrings.gift_card_how_it_works} + + + + {howItWorksExpanded ? ( + + {lstrings.gift_card_how_it_works_body} + + ) : null} + + + {/* Terms and Conditions - Collapsible Card */} + + + + {lstrings.gift_card_terms_and_conditions} + + + + {termsExpanded ? ( + + {parseLinkedText( + lstrings.gift_card_terms_and_conditions_body, + handleTermsLinkPress, + styles.termsLink + )} + + ) : null} + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + flex: 1, + padding: theme.rem(0.5) + }, + brandImageContainer: { + aspectRatio: 1.6, + borderRadius: theme.cardBorderRadius, + overflow: 'hidden', + marginHorizontal: theme.rem(0.5) + }, + // Input label (shared between fixed and variable) + inputLabel: { + fontSize: theme.rem(0.75), + color: theme.textInputPlaceholderColor, + marginBottom: theme.rem(0.25) + }, + inputContainer: { + paddingVertical: theme.rem(0.75), + paddingHorizontal: theme.rem(2) + }, + // Fixed denomination container + fixedAmountContainer: { + marginTop: theme.rem(0.5), + alignItems: 'center' + }, + fixedAmountInner: { + alignItems: 'flex-end' + }, + fixedAmountContent: {}, + amountValue: { + fontSize: theme.rem(1.5), + fontFamily: theme.fontFaceMedium + }, + // Variable amount input + variableAmountContainer: { + marginTop: theme.rem(0.5) + }, + maxButton: { + alignSelf: 'flex-end', + marginVertical: theme.rem(0.5) + }, + maxButtonText: { + color: theme.iconTappable, + fontFamily: theme.fontFaceMedium + }, + // Collapsible card styles + collapsibleHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.rem(0.5) + }, + collapsibleTitle: { + fontSize: theme.rem(1), + fontFamily: theme.fontFaceMedium + }, + collapsibleBody: { + marginTop: theme.rem(0.75), + fontSize: theme.rem(0.75) + }, + sliderTermsText: { + textAlign: 'center', + fontSize: theme.rem(0.75), + color: theme.secondaryText, + marginBottom: theme.rem(0.5) + }, + termsLink: { + color: theme.iconTappable + } +})) diff --git a/src/components/scenes/HomeScene.tsx b/src/components/scenes/HomeScene.tsx index 16826d283c0..01a37b8fdcb 100644 --- a/src/components/scenes/HomeScene.tsx +++ b/src/components/scenes/HomeScene.tsx @@ -6,7 +6,6 @@ import Animated from 'react-native-reanimated' import { useSafeAreaFrame } from 'react-native-safe-area-context' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' -import { guiPlugins } from '../../constants/plugins/GuiPlugins' import { ENV } from '../../env' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' @@ -113,7 +112,7 @@ export const HomeScene: React.FC = props => { navigation.navigate('swapTab') }) const handleSpendPress = useHandler(() => { - navigation.navigate('pluginView', { plugin: guiPlugins.bitrefill }) + navigation.navigate('edgeAppStack', { screen: 'giftCardMarket' }) }) const handleViewAssetsPress = useHandler(() => { navigation.navigate('edgeTabs', { diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index 816acbe872f..a9c9d7bca5f 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -35,6 +35,7 @@ import type { FioSentRequestDetailsParams } from '../components/scenes/Fio/FioSe import type { FioStakingChangeParams } from '../components/scenes/Fio/FioStakingChangeScene' import type { FioStakingOverviewParams } from '../components/scenes/Fio/FioStakingOverviewScene' import type { GettingStartedParams } from '../components/scenes/GettingStartedScene' +import type { GiftCardPurchaseParams } from '../components/scenes/GiftCardPurchaseScene' import type { GuiPluginListParams } from '../components/scenes/GuiPluginListScene' import type { PluginViewParams } from '../components/scenes/GuiPluginViewScene' import type { LoanCloseParams } from '../components/scenes/Loans/LoanCloseScene' @@ -204,6 +205,9 @@ export type EdgeAppStackParamList = {} & { fioSentRequestDetails: FioSentRequestDetailsParams fioStakingChange: FioStakingChangeParams fioStakingOverview: FioStakingOverviewParams + giftCardList: undefined + giftCardMarket: undefined + giftCardPurchase: GiftCardPurchaseParams loanClose: LoanCloseParams loanCreate: LoanCreateParams loanCreateConfirmation: LoanCreateConfirmationParams From 4c8d85ef24418160777dc68ff609db0da5e5bbf4 Mon Sep 17 00:00:00 2001 From: Jon Tzeng Date: Thu, 18 Dec 2025 21:06:42 -0800 Subject: [PATCH 14/14] Add gift card transaction integration - Display gift cards in TransactionDetailsScene - Add giftCard action type handling in CategoriesActions - Add sliderTopNode prop to SendScene2 for purchase flow - Add Phaze merchant contact info --- .../TransactionDetailsScene.test.tsx.snap | 136 +++++++++++++----- src/actions/CategoriesActions.ts | 10 ++ src/components/scenes/SendScene2.tsx | 4 + .../scenes/TransactionDetailsScene.tsx | 19 ++- src/constants/MerchantContacts.ts | 4 + 5 files changed, 136 insertions(+), 37 deletions(-) diff --git a/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap index 04ce071b11b..1bcef38aaf2 100644 --- a/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap @@ -228,15 +228,10 @@ exports[`TransactionDetailsScene should render 1`] = ` @@ -497,6 +492,45 @@ exports[`TransactionDetailsScene should render 1`] = ` + @@ -3170,7 +3199,7 @@ exports[`TransactionDetailsScene should render with negative nativeAmount and fi "reduceMotionV": "system", } } - nativeID="11" + nativeID="12" > + + // Custom React node rendered directly above the slider + sliderTopNode?: React.ReactNode fioPendingRequest?: FioRequest onBack?: () => void onDone?: ( @@ -201,6 +203,7 @@ const SendComponent = (props: Props): React.ReactElement => { minNativeAmount: initMinNativeAmount, openCamera = false, infoTiles, + sliderTopNode, lockTilesMap = {}, hiddenFeaturesMap = {}, onDone, @@ -1755,6 +1758,7 @@ const SendComponent = (props: Props): React.ReactElement => { {renderPendingTransactionWarning()} {renderError()} + {sliderTopNode} = props => { const thumbnailPath = useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] + + // Check if this is a gift card transaction + const giftCardAction = + action != null && action.actionType === 'giftCard' ? action : undefined const iconSource = React.useMemo( () => ({ uri: thumbnailPath }), [thumbnailPath] @@ -309,8 +314,9 @@ export const TransactionDetailsComponent: React.FC = props => { walletId }) } - } catch (err: any) { - if (err?.message === 'transaction underpriced') { + } catch (err: unknown) { + const message = err instanceof Error ? err.message : undefined + if (message === 'transaction underpriced') { const newAcceleratedTx = await wallet.accelerate(acceleratedTx) setAcceleratedTx(newAcceleratedTx) showError( @@ -472,7 +478,8 @@ export const TransactionDetailsComponent: React.FC = props => { backgroundColors[0] = scaledColor } - const fiatAction = action?.actionType === 'fiat' ? action : undefined + const fiatAction = + action != null && action.actionType === 'fiat' ? action : undefined return ( = props => { + + {giftCardAction == null ? null : ( + + )} + + diff --git a/src/constants/MerchantContacts.ts b/src/constants/MerchantContacts.ts index 53e3980ff7f..39c19e11a58 100644 --- a/src/constants/MerchantContacts.ts +++ b/src/constants/MerchantContacts.ts @@ -125,5 +125,9 @@ export const MERCHANT_CONTACTS: MerchantContact[] = [ { displayName: 'Rango Exchange', thumbnailPath: `${EDGE_CONTENT_SERVER_URI}/rango.png` + }, + { + displayName: 'Phaze Gift Card', + thumbnailPath: `${EDGE_CONTENT_SERVER_URI}/phaze.png` } ]