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 |
|--------|--------|
|
|
|
---
.../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 (
+ <>
+
+
+
+ >
+ );
+ }
+
+ 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.
-
-
-
-
-
-
+ ) : (
+
+
- Check Demo
-
-
-
+
+
+
+
+
+
+ )}
Your data is yours alone. Sign up to get
started.