From abc71daa7e65b386eb30ba9672c8288016665a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 7 Feb 2026 09:33:12 +0100 Subject: [PATCH] feat: Show PWA install button on mobile landing page (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Detects Android and iOS visitors on the landing page hero section - Replaces the **Get Started** and **Check Demo** buttons with a single **Install App** button on mobile - **Android**: Captures the `beforeinstallprompt` event and triggers the native PWA installation prompt when tapped - **iOS**: Opens a dialog with step-by-step instructions (tap Share icon → Add to Home Screen) - Desktop visitors see the original buttons unchanged ## New files - `resources/js/hooks/use-pwa-install.ts` — Hook that detects platform (Android/iOS), captures the `beforeinstallprompt` event, and exposes `promptInstall()` - `resources/js/components/landing/install-app-button.tsx` — Install button component with iOS instructions dialog ## Screenshots | Android & iOS | iOS Instructions | |--------|--------| | localhost_8000_(Pixel 7) | localhost_8000_(iPhone 12
Pro) | --- .../components/landing/install-app-button.tsx | 95 +++++++++++++++++++ resources/js/hooks/use-pwa-install.ts | 73 ++++++++++++++ resources/js/pages/welcome.tsx | 43 +++++---- 3 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 resources/js/components/landing/install-app-button.tsx create mode 100644 resources/js/hooks/use-pwa-install.ts diff --git a/resources/js/components/landing/install-app-button.tsx b/resources/js/components/landing/install-app-button.tsx new file mode 100644 index 00000000..4c0e0846 --- /dev/null +++ b/resources/js/components/landing/install-app-button.tsx @@ -0,0 +1,95 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { usePwaInstall } from '@/hooks/use-pwa-install'; +import { DownloadIcon, PlusSquareIcon, ShareIcon } from 'lucide-react'; +import { useState } from 'react'; + +export default function InstallAppButton() { + const { platform, canInstall, promptInstall } = usePwaInstall(); + const [showIosDialog, setShowIosDialog] = useState(false); + + if (platform === 'android') { + return ( + + ); + } + + if (platform === 'ios') { + return ( + <> + + + + + + Install Whisper Money + + Add the app to your home screen for the best + experience. + + + +
    +
  1. + + + + + Tap the{' '} + + Share + {' '} + button in your browser toolbar + +
  2. +
  3. + + + + + Tap{' '} + + Add to Home Screen + + +
  4. +
+ + +
+
+ + ); + } + + return null; +} diff --git a/resources/js/hooks/use-pwa-install.ts b/resources/js/hooks/use-pwa-install.ts new file mode 100644 index 00000000..a1b2b0cb --- /dev/null +++ b/resources/js/hooks/use-pwa-install.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type Platform = 'android' | 'ios' | null; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +function detectPlatform(): Platform { + if (typeof navigator === 'undefined') { + return null; + } + + const ua = navigator.userAgent; + + if (/android/i.test(ua)) { + return 'android'; + } + + if ( + /iPad|iPhone|iPod/.test(ua) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ) { + return 'ios'; + } + + return null; +} + +export function usePwaInstall() { + const [platform] = useState(detectPlatform); + const deferredPromptRef = useRef(null); + const [canInstall, setCanInstall] = useState(false); + + useEffect(() => { + if (platform !== 'android') { + return; + } + + const handler = (e: Event) => { + e.preventDefault(); + deferredPromptRef.current = e as BeforeInstallPromptEvent; + setCanInstall(true); + }; + + window.addEventListener('beforeinstallprompt', handler); + + return () => window.removeEventListener('beforeinstallprompt', handler); + }, [platform]); + + const promptInstall = useCallback(async (): Promise => { + const prompt = deferredPromptRef.current; + + if (!prompt) { + return false; + } + + await prompt.prompt(); + const { outcome } = await prompt.userChoice; + deferredPromptRef.current = null; + setCanInstall(false); + + return outcome === 'accepted'; + }, []); + + return { + platform, + isMobile: platform !== null, + canInstall, + promptInstall, + }; +} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index 519e8a7f..e6b6c3c2 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -1,4 +1,5 @@ import EncryptionVideoPlayer from '@/components/landing/encryption-video-player'; +import InstallAppButton from '@/components/landing/install-app-button'; import Header from '@/components/partials/header'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; @@ -8,6 +9,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { usePwaInstall } from '@/hooks/use-pwa-install'; import { LEAD_FUNNEL_EVENT_UUID } from '@/lib/constants'; import { trackEvent } from '@/lib/track-event'; import { cn } from '@/lib/utils'; @@ -160,6 +162,7 @@ export default function Welcome({ usePage().props; const planEntries = Object.entries(pricing.plans); const visitTrackedRef = useRef(false); + const { isMobile } = usePwaInstall(); const [isPwa] = useState(() => { if (typeof window === 'undefined') { @@ -303,25 +306,29 @@ export default function Welcome({ secure.

-
- - - - - - -
+ + + + + +
+ )}

Your data is yours alone. Sign up to get started.