diff --git a/app.config.ts b/app.config.ts index 02e585e49..eac8d4173 100644 --- a/app.config.ts +++ b/app.config.ts @@ -209,10 +209,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-build-properties', { ios: { - // 16.0 is the floor declared by OnramperReactNative.podspec - // (@onramper/onramper-react-native). CocoaPods fails resolution if the - // app target is lower, so this cannot go back below 16.0 while that - // dependency is installed. + // Was raised to 16.0 for OnramperReactNative's podspec floor. That + // package is gone, but the target is left where it is: lowering it is + // a product decision about which iOS versions we support, not a + // consequence of dropping a dependency. deploymentTarget: '16.0', useFrameworks: 'static', // Static frameworks with precompiled RN core have been flaky in EAS iOS builds. @@ -306,13 +306,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ // does not fail. See plugins/withMlkitVisionDependencies.js. './plugins/withMlkitVisionDependencies.js', // Sets SWIFT_ENABLE_EXPLICIT_MODULES=NO so the Swift pods (NitroModules, - // OnramperReactNative) don't fail the app target's "Emit Swift module" phase - // on Xcode 16+. Referenced by file path, not as '@onramper/onramper-react-native': - // the package ships app.plugin.js but omits it from its package.json "exports" - // map, so resolving it as a package subpath fails with - // ERR_PACKAGE_PATH_NOT_EXPORTED. Switch to the bare package name once - // onramper fixes that upstream. - './node_modules/@onramper/onramper-react-native/app.plugin.js', + // RCTSwiftUI) don't fail the app target's "Emit Swift module" phase on + // Xcode 16+. Vendored from @onramper/onramper-react-native's own plugin + // when that package was removed — the fix was never Onramper-specific, and + // NitroModules is still here via react-native-mmkv. + './plugins/withSwiftExplicitModulesDisabled.js', ], experiments: { typedRoutes: true, diff --git a/app/(protected)/(tabs)/savings.tsx b/app/(protected)/(tabs)/savings.tsx index 3b8cadae4..7ff1b30e5 100644 --- a/app/(protected)/(tabs)/savings.tsx +++ b/app/(protected)/(tabs)/savings.tsx @@ -171,11 +171,11 @@ function LegacySavings() { /> { - useDepositStore.getState().setDepositFromSolid(false); + useDepositStore.getState().setDepositFromSolid(true); }} /> diff --git a/app/(protected)/(tabs)/stocks/index.tsx b/app/(protected)/(tabs)/stocks/index.tsx index b9dbbd70c..e6ca74f0a 100644 --- a/app/(protected)/(tabs)/stocks/index.tsx +++ b/app/(protected)/(tabs)/stocks/index.tsx @@ -1,22 +1,23 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { ScrollView, View } from 'react-native'; -import { Redirect } from 'expo-router'; +import { Redirect, router, useLocalSearchParams } from 'expo-router'; import PageLayout from '@/components/PageLayout'; import BuyStockModal from '@/components/Stocks/BuyStockModal'; import SellStockModal from '@/components/Stocks/SellStockModal'; import { Holding, STOCKS } from '@/components/Stocks/stocksData'; import StocksDiscoverSection from '@/components/Stocks/StocksDiscoverSection'; -import { XStockToken } from '@/hooks/useXStocksTokens'; import StocksEmptyHoldings from '@/components/Stocks/StocksEmptyHoldings'; import StocksHoldingsList from '@/components/Stocks/StocksHoldingsList'; import StocksPendingStrip from '@/components/Stocks/StocksPendingStrip'; import StocksPortfolioCard from '@/components/Stocks/StocksPortfolioCard'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; +import { XSTOCKS_TOKENS } from '@/constants/xstocksTokens'; import { useDimension } from '@/hooks/useDimension'; import { useXStockHoldings } from '@/hooks/useXStockHoldings'; import { useXStockPrices } from '@/hooks/useXStockPrices'; +import { XStockToken } from '@/hooks/useXStocksTokens'; import { isProduction } from '@/lib/config'; // Stocks is an in-development feature: not accessible in production builds. @@ -33,7 +34,7 @@ function StocksPageContent() { const { isScreenMedium } = useDimension(); const scrollRef = useRef(null); - const { holdings } = useXStockHoldings(); + const { holdings, isLoading: isHoldingsLoading } = useXStockHoldings(); const hasHoldings = holdings.length > 0; const holdingTickers = holdings.map(h => h.ticker); const holdingPrices = useXStockPrices(holdingTickers); @@ -45,33 +46,106 @@ function StocksPageContent() { const [buyModalOpen, setBuyModalOpen] = useState(false); const [sellModalOpen, setSellModalOpen] = useState(false); const [selectedHolding, setSelectedHolding] = useState(null); + // Which stock the buy flow should open on. Null means "let the user pick". + const [buyToken, setBuyToken] = useState(null); + // True when the buy flow was opened by a deep link rather than by browsing + // this screen — leaving it should return the user where they came from + // instead of stranding them on a Stocks tab they never chose to open. + const [cameFromDeepLink, setCameFromDeepLink] = useState(false); + + // Open a trade straight from a `/stocks?ticker=TSLAx&action=sell` deep link + // (the Earn page uses it), then clear the params so closing the modal doesn't + // leave a link that reopens it on the next visit. + const { ticker: tickerParam, action: actionParam } = useLocalSearchParams<{ + ticker?: string; + action?: string; + }>(); + + useEffect(() => { + if (!tickerParam) return; + + const clearParams = () => router.setParams({ ticker: undefined, action: undefined }); + + if (actionParam === 'sell') { + const holding = holdings.find(h => h.ticker === tickerParam); + + // Holdings are read from chain and arrive after this screen mounts, so + // the params are held until they land rather than dropped on the floor. + if (!holding) { + if (!isHoldingsLoading) clearParams(); + return; + } + + setSelectedHolding(holding); + setSellModalOpen(true); + setCameFromDeepLink(true); + clearParams(); + return; + } + + const token = XSTOCKS_TOKENS.find(t => t.symbol === tickerParam); + if (token) { + setBuyToken(token); + setBuyModalOpen(true); + setCameFromDeepLink(true); + } + clearParams(); + }, [tickerParam, actionParam, holdings, isHoldingsLoading]); function handleBuyPress() { + setBuyToken(null); + setCameFromDeepLink(false); setBuyModalOpen(true); } function handleSellPress() { if (holdings.length > 0) { setSelectedHolding(holdings[0]); + setCameFromDeepLink(false); setSellModalOpen(true); } } - function handleStockPress(_token: XStockToken) { + function handleStockPress(token: XStockToken) { + setBuyToken(token); + setCameFromDeepLink(false); setBuyModalOpen(true); } + // Leaving a deep-linked buy flow returns to the screen that opened it (the + // Earn catalog); leaving one started here just closes the modal. + function handleBuyClose() { + setBuyModalOpen(false); + if (cameFromDeepLink && router.canGoBack()) { + setCameFromDeepLink(false); + router.back(); + } + } + function handleHoldingPress(holding: Holding) { setSelectedHolding(holding); + setCameFromDeepLink(false); setSellModalOpen(true); } + // Same as the buy flow: a deep-linked sale returns to the screen that opened + // it rather than stranding the user on a Stocks tab they never chose. + function handleSellClose() { + setSellModalOpen(false); + if (cameFromDeepLink && router.canGoBack()) { + setCameFromDeepLink(false); + router.back(); + } + } + function scrollToDiscover() { scrollRef.current?.scrollToEnd({ animated: true }); } const selectedStockPrice = selectedHolding - ? (holdingPrices[selectedHolding.ticker] ?? STOCKS.find(s => s.ticker === selectedHolding.ticker)?.price ?? 194.23) + ? (holdingPrices[selectedHolding.ticker] ?? + STOCKS.find(s => s.ticker === selectedHolding.ticker)?.price ?? + 194.23) : 194.23; if (isScreenMedium) { @@ -86,11 +160,12 @@ function StocksPageContent() { onStockPress={handleStockPress} onHoldingPress={handleHoldingPress} buyModalOpen={buyModalOpen} + buyToken={buyToken} sellModalOpen={sellModalOpen} selectedHolding={selectedHolding} selectedStockPrice={selectedStockPrice} - onBuyClose={() => setBuyModalOpen(false)} - onSellClose={() => setSellModalOpen(false)} + onBuyClose={handleBuyClose} + onSellClose={handleSellClose} /> ); } @@ -140,13 +215,22 @@ function StocksPageContent() { {/* Modals (rendered outside scroll) */} - setBuyModalOpen(false)} trigger={null} /> + {/* Keyed on the stock so picking a different one re-seeds the flow's + initial step instead of reusing the previous selection. */} + setSellModalOpen(false)} + onClose={handleSellClose} trigger={null} /> @@ -163,6 +247,7 @@ type DesktopLayoutProps = { onStockPress: (token: XStockToken) => void; onHoldingPress: (holding: Holding) => void; buyModalOpen: boolean; + buyToken: XStockToken | null; sellModalOpen: boolean; selectedHolding: Holding | null; selectedStockPrice: number; @@ -180,6 +265,7 @@ function DesktopLayout({ onStockPress, onHoldingPress, buyModalOpen, + buyToken, sellModalOpen, selectedHolding, selectedStockPrice, @@ -229,7 +315,14 @@ function DesktopLayout({ - + { + const { data: session, isPending, isError, refetch } = useOnramperWidget(); + const [isOpeningBrowser, setIsOpeningBrowser] = useState(false); + + /** + * Intercept our own scheme rather than letting the WebView try to navigate + * to it — it cannot, and the flow would stall on a failed load. + */ + const handleShouldStartLoad = useCallback( + (request: WebViewNavigation) => { + if (!request.url.startsWith(APP_SCHEME)) return true; + + onOutcome?.(request.url.includes('/failure') ? 'failure' : 'success'); + + return false; + }, + [onOutcome], + ); + + /** + * The escape hatch, and not an optional one. + * + * Google Pay and ACH lean on browser payment APIs a WebView often lacks, and + * they fail silently — a button that never appears, a flow that goes nowhere, + * no error and no event. Onramper has no visibility once the user is with a + * provider, so nothing tells us it happened. The same signed URL in a Custom + * Tab or SFSafariViewController has the APIs, and still carries the deep-link + * redirects, so the user lands back here. + */ + const openInBrowser = useCallback(async () => { + if (!session) return; + + setIsOpeningBrowser(true); + try { + await openBrowserAsync(session.url); + } catch (error) { + console.error('Failed to open the Onramper widget in a browser:', error); + } finally { + setIsOpeningBrowser(false); + } + }, [session]); + + if (isPending) return ; + if (isError || !session) { + return void refetch()} />; + } + + return ( + + + + + + void openInBrowser()} + disabled={isOpeningBrowser} + accessibilityRole="button" + > + Having trouble? Open in browser + + + ); +}; + +export default OnramperWidget; diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.tsx new file mode 100644 index 000000000..892646c51 --- /dev/null +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.tsx @@ -0,0 +1,3 @@ +// Platform default (bundler/TS): the iframe implementation. Metro resolves +// OnramperWidget.native.tsx on iOS and Android automatically. +export { default, OnramperWidget } from './OnramperWidget.web'; diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx new file mode 100644 index 000000000..e4560c5d4 --- /dev/null +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx @@ -0,0 +1,44 @@ +import useOnramperWidget from '@/hooks/useOnramperWidget'; + +import { + ONRAMPER_WIDGET_HEIGHT, + ONRAMPER_WIDGET_WIDTH, + OnramperWidgetError, + OnramperWidgetLoading, + type OnramperWidgetProps, +} from './OnramperWidgetStates'; + +/** + * Web: Onramper's hosted widget in an iframe. + * + * The `allow` list is not optional. Camera is what lets a provider capture ID + * documents inside the frame, and payment is what lets wallet flows run. A + * missing entry raises no error — the step just quietly does nothing, deep + * inside a provider's flow where we have no visibility at all. + */ +export const OnramperWidget = (_props: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(); + + if (isPending) return ; + if (isError || !session) { + return void refetch()} />; + } + + return ( +