feat: Show PWA install button on mobile landing page (#99)

## 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 |
|--------|--------|
| <img width="1082" height="2402" alt="localhost_8000_(Pixel 7)"
src="https://github.com/user-attachments/assets/bd42c24a-8a82-4f42-9f5f-c13bd5505836"
/> | <img width="1170" height="2532" alt="localhost_8000_(iPhone 12
Pro)"
src="https://github.com/user-attachments/assets/c4ec136f-46f1-44a5-a96a-d7afd41c6015"
/> |
This commit is contained in:
Víctor Falcón 2026-02-07 09:33:12 +01:00 committed by GitHub
parent 444c81c5aa
commit abc71daa7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 193 additions and 18 deletions

View File

@ -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 (
<Button
onClick={() => {
if (canInstall) {
promptInstall();
}
}}
disabled={!canInstall}
className="text-shadow h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
>
<DownloadIcon className="size-5" />
Install App
</Button>
);
}
if (platform === 'ios') {
return (
<>
<Button
onClick={() => setShowIosDialog(true)}
className="text-shadow h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
>
<DownloadIcon className="size-5" />
Install App
</Button>
<Dialog open={showIosDialog} onOpenChange={setShowIosDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Install Whisper Money</DialogTitle>
<DialogDescription>
Add the app to your home screen for the best
experience.
</DialogDescription>
</DialogHeader>
<ol className="flex flex-col gap-5 py-2">
<li className="flex items-center gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<ShareIcon className="size-5 text-zinc-600 dark:text-zinc-400" />
</span>
<span className="text-sm">
Tap the{' '}
<strong className="font-semibold">
Share
</strong>{' '}
button in your browser toolbar
</span>
</li>
<li className="flex items-center gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<PlusSquareIcon className="size-5 text-zinc-600 dark:text-zinc-400" />
</span>
<span className="text-sm">
Tap{' '}
<strong className="font-semibold">
Add to Home Screen
</strong>
</span>
</li>
</ol>
<Button
variant="secondary"
className="mt-2 w-full"
onClick={() => setShowIosDialog(false)}
>
Got it
</Button>
</DialogContent>
</Dialog>
</>
);
}
return null;
}

View File

@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react';
type Platform = 'android' | 'ios' | null;
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
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<Platform>(detectPlatform);
const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(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<boolean> => {
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,
};
}

View File

@ -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<SharedData>().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.
</p>
<div className="flex w-full max-w-lg flex-col gap-4">
<div className="flex w-full flex-row gap-4">
<Link
href="/register"
className="w-full"
>
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
Get Started
</Button>
</Link>
<Link href="/login?demo=1">
<Button
variant={'secondary'}
size={'lg'}
className="h-14"
{isMobile ? (
<InstallAppButton />
) : (
<div className="flex w-full flex-row gap-4">
<Link
href="/register"
className="w-full"
>
Check Demo
</Button>
</Link>
</div>
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
Get Started
</Button>
</Link>
<Link href="/login?demo=1">
<Button
variant={'secondary'}
size={'lg'}
className="h-14"
>
Check Demo
</Button>
</Link>
</div>
)}
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
Your data is yours alone. Sign up to get
started.