From 7497269ddcf353110ffa7393198a41198ce78b5a Mon Sep 17 00:00:00 2001 From: Fabien Date: Mon, 9 Feb 2026 16:52:04 +0100 Subject: [PATCH] Handle deeplinks fallback mode When a deeplink is not handled by an app on the user device, the browser opens the /app page and attempts to open the cashtab extension, then the cashtab web wallet if the extension is not available or the user rejects the payment. This has been tested on a dev server by manually calling the URL like so: http://localhost:3000/app?address=ecash:qplv39yx80kdqejh4ag6c3t30aatj9mausupeyjzr7&amount=100.00 --- pages/_app.tsx | 3 +- pages/app/index.tsx | 96 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 pages/app/index.tsx diff --git a/pages/_app.tsx b/pages/_app.tsx index 9d0b965ac..485540cec 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -19,7 +19,8 @@ const AUTHORIZED_UNLOGGED_URLS = [ '/signin', '/signup', '/reset-password', - '/auth/reset-password' + '/auth/reset-password', + '/app' ] function App ({ Component, pageProps }: AppProps): React.ReactElement | null { diff --git a/pages/app/index.tsx b/pages/app/index.tsx new file mode 100644 index 000000000..9f6616855 --- /dev/null +++ b/pages/app/index.tsx @@ -0,0 +1,96 @@ +import React, { useEffect } from 'react' +import { GetServerSideProps } from 'next' +import { parseAddress } from 'utils/validators' + +const convertToBip21 = (queryParams: Record): string | null => { + // Extract and validate address parameter (required) + let parsedAddress: string + try { + const address = queryParams.address + if (typeof address !== 'string') { + return null + } + parsedAddress = parseAddress(address) + } catch { + return null + } + + // Build query string from all parameters except 'address' and 'b' + const queryParts: string[] = [] + for (const [key, value] of Object.entries(queryParams)) { + // Skip 'address' and 'b' parameters + if (key === 'address' || key === 'b') { + continue + } + + // Handle array values (take first element) or string values + const paramValue = Array.isArray(value) ? value[0] : value + if (paramValue !== undefined && paramValue !== '') { + queryParts.push(`${key}=${paramValue}`) + } + } + + // Construct BIP21 string + if (queryParts.length > 0) { + return `${parsedAddress}?${queryParts.join('&')}` + } + return parsedAddress +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const queryParams = context.query + + // Convert to BIP21 string (validates address internally) + const bip21String = convertToBip21(queryParams) + if (bip21String === null) { + context.res.statusCode = 400 + return { + props: { + error: 'Invalid PayButton URL.' + } + } + } + + return { + props: { + bip21String + } + } +} + +interface AppProps { + bip21String?: string + error?: string +} + +export default function App ({ bip21String, error }: AppProps): JSX.Element { + useEffect(() => { + if (error !== undefined || bip21String === undefined) { + return + } + + window.location.href = `https://cashtab.com/#/send?bip21=${bip21String}` + }, [bip21String, error]) + + if (error !== undefined) { + return ( +
+

Error

+

{error}

+
+ ) + } + + // bip21String should always be defined at that point, but this makes eslint + // happy and hardens the code a bit. + const cashtabRedirectUrl = typeof bip21String === 'string' + ? `https://cashtab.com/#/send?bip21=${bip21String}` + : 'https://cashtab.com' + + return ( +
+

Redirecting to Cashtab...

+

If you are not redirected, please click here.

+
+ ) +}