From 3c3389024ea5b4a93ed580dc723b5d71ea0f300d Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 9 Nov 2026 10:43:56 +1100 Subject: [PATCH 1/2] feat: link the native app betas from the landing page and web app - Landing: small beta section (iOS TestFlight, Android two-step opt-in) and an updated install FAQ - Balances: dismissible card picked by user agent after mount; dismissal kept in localStorage - Download App drawer: beta links above the add-to-home-screen instructions - Android stays hidden everywhere until ANDROID_BETA_GROUP_URL is set --- src/components/AppBeta.tsx | 181 +++++++++++++++++++++++++++++++ src/components/InstallApp.tsx | 50 +++++---- src/components/Site/blocks.tsx | 10 +- src/components/Site/constants.ts | 8 ++ src/pages/balances.tsx | 2 + src/pages/index.tsx | 65 ++++++++++- 6 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 src/components/AppBeta.tsx diff --git a/src/components/AppBeta.tsx b/src/components/AppBeta.tsx new file mode 100644 index 0000000..821d96c --- /dev/null +++ b/src/components/AppBeta.tsx @@ -0,0 +1,181 @@ +import { Smartphone, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { + ANDROID_BETA_GROUP_URL, + ANDROID_BETA_OPT_IN_URL, + IOS_BETA_URL, +} from '~/components/Site/constants'; +import { Button } from '~/components/ui/button'; +import { cn } from '~/lib/utils'; + +export type DevicePlatform = 'ios' | 'android' | 'desktop'; + +const DISMISS_KEY = 'splitpro:app-beta-card-dismissed'; + +const detectPlatform = (): DevicePlatform => { + const ua = navigator.userAgent; + if (/android/i.test(ua)) { + return 'android'; + } + // iPadOS reports a Mac user agent, so a touch screen is what gives it away. + if (/iphone|ipad|ipod/i.test(ua) || (/macintosh/i.test(ua) && 1 < navigator.maxTouchPoints)) { + return 'ios'; + } + return 'desktop'; +}; + +/** The device platform, or null until mounted, so the server render and first client render match. */ +export const useDevicePlatform = () => { + const [platform, setPlatform] = useState(null); + + useEffect(() => { + setPlatform(detectPlatform()); + }, []); + + return platform; +}; + +const BetaLink: React.FC<{ href: string; children: React.ReactNode; className?: string }> = ({ + href, + children, + className, +}) => ( + + {children} + +); + +const IosBeta: React.FC = () => ( +
+ +

Needs Apple's free TestFlight app.

+
+); + +const AndroidBetaSteps: React.FC<{ groupUrl: string }> = ({ groupUrl }) => ( +
    +
  1. + Join the testers group +
  2. +
  3. + Tap "Become a tester" on the{' '} + opt-in page +
  4. +
+); + +/** Dismissible card on the Balances page pointing at the native app beta for this device. */ +export const AppBetaCard: React.FC<{ className?: string }> = ({ className }) => { + const platform = useDevicePlatform(); + // Hidden until we've read localStorage, so it never flashes for people who dismissed it. + const [dismissed, setDismissed] = useState(true); + + useEffect(() => { + try { + setDismissed('1' === localStorage.getItem(DISMISS_KEY)); + } catch { + setDismissed(false); + } + }, []); + + const dismiss = useCallback(() => { + setDismissed(true); + try { + localStorage.setItem(DISMISS_KEY, '1'); + } catch { + // Private mode or storage disabled: it's hidden until the next reload, which is fine. + } + }, []); + + if (null === platform || dismissed) { + return null; + } + + const androidGroupUrl = ANDROID_BETA_GROUP_URL; + + if ('android' === platform && null === androidGroupUrl) { + return null; + } + + const closeButton = ( + + ); + + if ('desktop' === platform) { + return ( +
+ {null !== androidGroupUrl ? ( +

+ The phone apps are in beta: iPhone ยท Android:{' '} + join the group, then{' '} + opt in +

+ ) : ( +

+ The iPhone app is in beta. Join on TestFlight +

+ )} + {closeButton} +
+ ); + } + + return ( +
+ +
+
+
+

+ The {'ios' === platform ? 'iPhone' : 'Android'} app is in beta +

+

Try it early and tell me what breaks.

+
+ {closeButton} +
+ {'ios' === platform ? : null} + {'android' === platform && null !== androidGroupUrl ? ( + + ) : null} +
+
+ ); +}; + +/** Beta links for the "Download App" drawer: every platform that has a beta open. */ +export const AppBetaLinks: React.FC = () => { + const androidGroupUrl = ANDROID_BETA_GROUP_URL; + + return ( +
+
+

iPhone and iPad

+ +
+ {null !== androidGroupUrl ? ( +
+

Android

+ +
+ ) : null} +
+ ); +}; diff --git a/src/components/InstallApp.tsx b/src/components/InstallApp.tsx index ba1a907..290a742 100644 --- a/src/components/InstallApp.tsx +++ b/src/components/InstallApp.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Button } from './ui/button'; import { Download } from 'lucide-react'; import { AppDrawer } from './ui/drawer'; +import { AppBetaLinks } from './AppBeta'; const InstallApp: React.FC = () => { const [isStandalone, setIsStandalone] = React.useState(false); @@ -44,29 +45,36 @@ const InstallApp: React.FC = () => { shouldCloseOnAction >
-

You can download SplitPro as a PWA to your home screen

+
+

The native app is in beta. Try it early and tell me what breaks.

+ +
-

- If you are using iOS, checkout this{' '} - - video - -

+
+

Or add SplitPro to your home screen from the browser.

-

- If you are using Android, checkout this{' '} - - Video - -

+

+ If you are using iOS, checkout this{' '} + + video + +

+ +

+ If you are using Android, checkout this{' '} + + Video + +

+
diff --git a/src/components/Site/blocks.tsx b/src/components/Site/blocks.tsx index 59dc9ad..822a527 100644 --- a/src/components/Site/blocks.tsx +++ b/src/components/Site/blocks.tsx @@ -22,9 +22,12 @@ export const PrimaryLink: React.FC<{ href: string; children: React.ReactNode; className?: string; -}> = ({ href, children, className }) => ( + /** Opens in a new tab. */ + external?: boolean; +}> = ({ href, children, className, external }) => ( = ({ href, children, className }) => ( + /** Opens in a new tab. */ + external?: boolean; +}> = ({ href, children, className, external }) => ( { function shareWithFriends() { @@ -54,6 +55,7 @@ const BalancePage: NextPageWithUser = () => { } > +
{balanceQuery.data?.youOwe.length ? ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index a58ea59..0104700 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -7,11 +7,19 @@ import { Import, Repeat, SlidersHorizontal, + Smartphone, Users, } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; -import { GITHUB_URL, QUICK_SPLIT_PATH, SITE_URL } from '~/components/Site/constants'; +import { + ANDROID_BETA_GROUP_URL, + ANDROID_BETA_OPT_IN_URL, + GITHUB_URL, + IOS_BETA_URL, + QUICK_SPLIT_PATH, + SITE_URL, +} from '~/components/Site/constants'; import { CtaBand, Faq, @@ -31,8 +39,11 @@ const FAQS: FaqItem[] = [ }, { question: 'Do I need to install an app?', - answer: - 'No. SplitPro runs in your browser. On your phone you can add it to your home screen, where it opens like any other app and can send you notifications.', + answer: `No. SplitPro runs in your browser, and on your phone you can add it to your home screen. ${ + null !== ANDROID_BETA_GROUP_URL + ? 'Native iPhone and Android apps are in beta too.' + : 'A native iPhone app is in beta too, with Android coming soon.' + }`, }, { question: 'Do my friends need an account?', @@ -89,6 +100,7 @@ export default function Home() { +
@@ -224,6 +236,53 @@ const Showcase = () => (
); +const AppBeta = () => { + const androidGroupUrl = ANDROID_BETA_GROUP_URL; + + return ( +
+
+
+

+ + Beta +

+

+ {null !== androidGroupUrl + ? 'SplitPro for iPhone and Android is in beta' + : 'SplitPro for iPhone is in beta'} +

+

+ The native app is still rough around the edges. Try it early and tell me what breaks. +

+
+
+
+ + Join the iOS beta + +

Needs Apple's free TestFlight app.

+
+ {null !== androidGroupUrl ? ( +
+

Android, in two steps

+
+ + 1. Join the testers group + + + 2. Become a tester + +
+

Use the same Google account for both.

+
+ ) : null} +
+
+
+ ); +}; + const QuickSplitPromo = () => (
From 645ea33fcb20bd0036c01db9d319e827ef454033 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 9 Nov 2026 13:35:10 +1100 Subject: [PATCH 2/2] fix: remember beta card dismissal per device and variant --- src/components/AppBeta.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/AppBeta.tsx b/src/components/AppBeta.tsx index 821d96c..9cf1337 100644 --- a/src/components/AppBeta.tsx +++ b/src/components/AppBeta.tsx @@ -10,7 +10,9 @@ import { cn } from '~/lib/utils'; export type DevicePlatform = 'ios' | 'android' | 'desktop'; -const DISMISS_KEY = 'splitpro:app-beta-card-dismissed'; +// One key per card variant, so dismissing the iPhone-only version doesn't hide the Android one later +const dismissKey = (platform: DevicePlatform) => + `splitpro:app-beta-card-dismissed:${platform}:${null === ANDROID_BETA_GROUP_URL ? 'ios' : 'all'}`; const detectPlatform = (): DevicePlatform => { const ua = navigator.userAgent; @@ -80,21 +82,27 @@ export const AppBetaCard: React.FC<{ className?: string }> = ({ className }) => const [dismissed, setDismissed] = useState(true); useEffect(() => { + if (null === platform) { + return; + } try { - setDismissed('1' === localStorage.getItem(DISMISS_KEY)); + setDismissed('1' === localStorage.getItem(dismissKey(platform))); } catch { setDismissed(false); } - }, []); + }, [platform]); const dismiss = useCallback(() => { setDismissed(true); + if (null === platform) { + return; + } try { - localStorage.setItem(DISMISS_KEY, '1'); + localStorage.setItem(dismissKey(platform), '1'); } catch { // Private mode or storage disabled: it's hidden until the next reload, which is fine. } - }, []); + }, [platform]); if (null === platform || dismissed) { return null;