diff --git a/README.md b/README.md index 5593dcd..c5fc078 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ const Checkout = () => { variable_name: 'order_id', value: 'OID1234' } + ] }, onSuccess: (res) => console.log('Success:', res), @@ -124,6 +125,7 @@ const Checkout = () => { | `publicKey` | `string` | โ€” | Your Paystack public key | | `currency` | `string` | โ€” | Currency code (optional) | | `defaultChannels` | `string[]`| `['card']`| Payment channels | +| `deepLinkHosts` | `(string \| RegExp)[]` | `[]` | Extra hosts to hand off to the OS instead of loading in the WebView (see [Deep linking](#-deep-linking)) | | `debug` | `boolean` | `false` | Show debug logs | | `onGlobalSuccess` | `func` | โ€” | Called on all successful transactions | | `onGlobalCancel` | `func` | โ€” | Called on all cancelled transactions | @@ -185,6 +187,45 @@ const Checkout = () => { --- +## ๐Ÿ”— Deep linking + +Some payment channels need to hand off to a partner app. For example, the **Zap** channel renders a universal link to `https://joinzap.com/app/...` that should open the Zap app. + +WKWebView on iOS does **not** perform universal-link handoff or custom-scheme dispatch for navigations that originate inside the WebView, so tapping such a link would otherwise just load it as a web page. To fix this, the provider intercepts these navigations and forwards them to the OS via `Linking.openURL`, which triggers the universal-link handoff (iOS) or App Links / `Intent.ACTION_VIEW` dispatch (Android). + +`https://joinzap.com/app/` ships as a built-in default and **always applies** โ€” it can't be removed. Use the `deepLinkHosts` prop to add your own partner hosts on top of the defaults: + +```tsx + + + +``` + +Each entry is matched against the navigation URL. String entries match if the URL **starts with** the string; `RegExp` entries are matched with `RegExp.test(url)`. + +> โš ๏ธ Only add hosts that should leave the WebView. Do **not** add 3DS / issuer ACS challenge pages, bank-redirect flows, or `checkout.paystack.com` โ€” those must stay in the WebView for checkout to complete. + +### Returning to your app from Zap + +When the customer is sent to the Zap app, you can have Zap return to your app after the action completes by passing a `callback_url` in the transaction `metadata`. Set it to a URL (or deep link) that resolves back to your app: + +```tsx +popup.checkout({ + email: 'jane.doe@example.com', + amount: 5000, + metadata: { + callback_url: 'https://paystack.com', + }, + onSuccess: (res) => console.log('Success:', res), + onCancel: () => console.log('User cancelled'), +}); +``` + +--- + ## ๐Ÿงช Debugging Enable `debug={true}` on the `PaystackProvider` to get logs like: diff --git a/__tests__/index.test.tsx b/__tests__/index.test.tsx index 969c473..52c30ae 100644 --- a/__tests__/index.test.tsx +++ b/__tests__/index.test.tsx @@ -1,8 +1,9 @@ -import { validateParams, sanitize, generatePaystackParams } from '../development/utils'; -import { Alert } from 'react-native'; +import { validateParams, sanitize, generatePaystackParams, shouldHandleExternally, openExternalUrl } from '../development/utils'; +import { Alert, Linking } from 'react-native'; jest.mock('react-native', () => ({ - Alert: { alert: jest.fn() } + Alert: { alert: jest.fn() }, + Linking: { canOpenURL: jest.fn(), openURL: jest.fn() } })); describe('Paystack Utils', () => { @@ -84,4 +85,66 @@ describe('Paystack Utils', () => { expect(js).toContain("amount: 10000"); }); }); + + describe('shouldHandleExternally', () => { + it('matches a string host by prefix', () => { + expect( + shouldHandleExternally('https://joinzap.com/app/abc', ['https://joinzap.com/app/']) + ).toBe(true); + }); + + it('does not match when only part of the URL contains the prefix', () => { + expect( + shouldHandleExternally('https://evil.com/?u=https://joinzap.com/app/', ['https://joinzap.com/app/']) + ).toBe(false); + }); + + it('matches a RegExp host', () => { + expect( + shouldHandleExternally('mypartner://pay', [/^mypartner:\/\//]) + ).toBe(true); + }); + + it('returns false when no host matches', () => { + expect( + shouldHandleExternally('https://checkout.paystack.com/123', ['https://joinzap.com/app/']) + ).toBe(false); + }); + + it('returns false for an empty URL', () => { + expect(shouldHandleExternally('', ['https://joinzap.com/app/'])).toBe(false); + }); + }); + + describe('openExternalUrl', () => { + beforeEach(() => { + (Linking.canOpenURL as jest.Mock).mockReset(); + (Linking.openURL as jest.Mock).mockReset(); + }); + + it('opens the URL when an app can handle it', async () => { + (Linking.canOpenURL as jest.Mock).mockResolvedValue(true); + (Linking.openURL as jest.Mock).mockResolvedValue(undefined); + + await openExternalUrl('https://joinzap.com/app/abc'); + + expect(Linking.canOpenURL).toHaveBeenCalledWith('https://joinzap.com/app/abc'); + expect(Linking.openURL).toHaveBeenCalledWith('https://joinzap.com/app/abc'); + }); + + it('does not open the URL when no app can handle it', async () => { + (Linking.canOpenURL as jest.Mock).mockResolvedValue(false); + + await openExternalUrl('mypartner://pay'); + + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + it('swallows errors instead of throwing', async () => { + (Linking.canOpenURL as jest.Mock).mockRejectedValue(new Error('boom')); + + await expect(openExternalUrl('https://joinzap.com/app/abc')).resolves.toBeUndefined(); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/development/PaystackProvider.tsx b/development/PaystackProvider.tsx index c70d88c..6f13feb 100644 --- a/development/PaystackProvider.tsx +++ b/development/PaystackProvider.tsx @@ -1,14 +1,18 @@ import React, { createContext, useCallback, useMemo, useState } from 'react'; -import { Modal, ActivityIndicator, } from 'react-native'; +import { Modal, ActivityIndicator } from 'react-native'; import { WebView, WebViewMessageEvent } from 'react-native-webview'; import { SafeAreaView } from 'react-native-safe-area-context'; import { PaystackParams, PaystackProviderProps, } from './types'; -import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage } from './utils'; +import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage, shouldHandleExternally, openExternalUrl } from './utils'; import { styles } from './styles'; +export const DEFAULT_DEEP_LINK_HOSTS: string[] = [ + 'https://joinzap.com/app/', +]; + export const PaystackContext = createContext<{ popup: { checkout: (params: PaystackParams) => void; @@ -20,6 +24,7 @@ export const PaystackProvider: React.FC = ({ publicKey, currency, defaultChannels = ['card'], + deepLinkHosts = [], debug = false, children, onGlobalSuccess, @@ -31,6 +36,11 @@ export const PaystackProvider: React.FC = ({ const fallbackRef = useMemo(() => `ref_${Date.now()}`, []); + const resolvedDeepLinkHosts = useMemo( + () => [...DEFAULT_DEEP_LINK_HOSTS, ...deepLinkHosts], + [deepLinkHosts] + ); + const open = useCallback( (params: PaystackParams, selectedMethod: 'checkout' | 'newTransaction') => { if (debug) console.log(`[Paystack] Opening modal with method: ${selectedMethod}`); @@ -95,6 +105,15 @@ export const PaystackProvider: React.FC = ({ originWhitelist={["*"]} source={{ html: paystackHTML }} onMessage={handleMessage} + onShouldStartLoadWithRequest={(request) => { + const url = request.url ?? ''; + if (!shouldHandleExternally(url, resolvedDeepLinkHosts)) { + return true; + } + if (debug) console.log('[Paystack] Opening external/deep link via OS:', url); + void openExternalUrl(url, debug); + return false; + }} javaScriptEnabled domStorageEnabled startInLoadingState diff --git a/development/types.ts b/development/types.ts index eff687d..fa3fc73 100644 --- a/development/types.ts +++ b/development/types.ts @@ -12,6 +12,12 @@ export type PaystackProviderProps = { publicKey: string; currency?: Currency; defaultChannels?: PaymentChannels; + /** + * Additional hosts to hand off to the OS instead of loading inside the + * checkout WebView. Added to the built-in defaults, which always apply. + * See the "Deep linking" section of the README. + */ + deepLinkHosts?: Array; debug?: boolean; children: React.ReactNode; onGlobalSuccess?: (data: PaystackTransactionResponse) => void; diff --git a/development/utils.ts b/development/utils.ts index f404c3d..3f3e05d 100644 --- a/development/utils.ts +++ b/development/utils.ts @@ -1,5 +1,36 @@ -import { Alert } from 'react-native'; -import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types'; +import { Alert, Linking } from 'react-native'; +import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types'; + +/** + * Whether a navigation URL should be handed off to the OS instead of being + * loaded inside the checkout WebView. String matchers match by prefix; + * RegExp matchers use `.test(url)`. + */ +export const shouldHandleExternally = ( + url: string, + hosts: Array +): boolean => + !!url && + hosts.some((matcher) => + typeof matcher === 'string' ? url.indexOf(matcher) === 0 : matcher.test(url) + ); + +/** + * Hand a URL off to the OS. Checks `Linking.canOpenURL` first so we don't + * attempt to open a URL no installed app can handle, and swallows any error. + */ +export const openExternalUrl = async (url: string, debug = false): Promise => { + try { + const supported = await Linking.canOpenURL(url); + if (!supported) { + if (debug) console.log('[Paystack] No app can handle URL:', url); + return; + } + await Linking.openURL(url); + } catch (err) { + if (debug) console.log('[Paystack] Linking.openURL failed:', err); + } +}; export const validateParams = (params: PaystackParams, debug: boolean): boolean => { const errors: string[] = [];