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:
parent
444c81c5aa
commit
abc71daa7e
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue