From d109b21bdfe06d5b5c268fec0c189029b52669df Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Sun, 17 May 2026 23:32:35 +0000 Subject: [PATCH] chore: Polish PWA install modal (#961) - Replace hand-rolled motion.div sheet with Drawer from @ui/components - Use insideOut Button for primary CTA; standardize close button with INSET shadow recipe - Wrap content in recessed card (bg-#14161A + chrome-detail inset shadow) - Replace 3 generic feature pills with 2x2 Check-list of honest PWA benefits - Steps now use connected numbered circles with title + description (matches install-steps.tsx) - Swap GradientLogo for actual app icon (/android-chrome-512x512.png) - Add safe-area padding so CTA clears the home indicator - Parallel noun-phrase copy: Instant launch / Full-screen mode / Native-app feel / Stays signed in --- apps/web/components/pwa-install-prompt.tsx | 491 ++++++++++----------- 1 file changed, 239 insertions(+), 252 deletions(-) diff --git a/apps/web/components/pwa-install-prompt.tsx b/apps/web/components/pwa-install-prompt.tsx index b7776825..89bf3e4e 100644 --- a/apps/web/components/pwa-install-prompt.tsx +++ b/apps/web/components/pwa-install-prompt.tsx @@ -1,14 +1,19 @@ "use client" -import { useState, useEffect, useCallback, useRef } from "react" +import { useState, useEffect, useCallback, type ReactNode } from "react" +import Image from "next/image" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" -import { XIcon, Brain, Sparkles, Globe } from "lucide-react" -import { GradientLogo } from "@ui/assets/Logo" -import { AnimatePresence, motion } from "motion/react" +import { XIcon, Check } from "lucide-react" +import { Drawer, DrawerContent, DrawerTitle } from "@ui/components/drawer" +import { Button } from "@ui/components/button" const PWA_DISMISS_KEY = "pwa-install-dismissed" -const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000 + +const INSET = + "shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" +const CARD_INSET = "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]" type DeviceInfo = { isIOS: boolean @@ -17,7 +22,6 @@ type DeviceInfo = { isChrome: boolean } -/** In-memory fallback when localStorage is unavailable */ let memoryDismissed = false function getDeviceInfo(): DeviceInfo { @@ -28,13 +32,9 @@ function getDeviceInfo(): DeviceInfo { /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) const isAndroid = /Android/.test(ua) - - // Safari: contains "Safari" but not "CriOS", "FxiOS", "Chrome", "Edg", etc. const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|Chrome|Chromium|Edg|OPR|Opera/.test(ua) - // Chrome on Android: contains "Chrome" but not "Edg", "OPR", "Opera" const isChrome = /Chrome/.test(ua) && !/Edg|OPR|Opera/.test(ua) - return { isIOS, isAndroid, isSafari, isChrome } } @@ -62,12 +62,109 @@ function isDismissed() { } } -const FEATURES = [ - { icon: Brain, label: "AI-powered memory" }, - { icon: Sparkles, label: "Chat with Nova" }, - { icon: Globe, label: "Access anywhere" }, +const BENEFITS = [ + "Instant launch", + "Full-screen mode", + "Native-app feel", + "Stays signed in", ] +type Step = { + title: string + description: ReactNode +} + +function buildSteps(device: DeviceInfo): Step[] { + if (device.isIOS) { + if (device.isSafari) { + return [ + { + title: "Open the share menu", + description: ( + <> + Tap the share icon{" "} + {" "} + at the bottom of Safari. + + ), + }, + { + title: "Add to Home Screen", + description: + "Scroll down in the share sheet and tap Add to Home Screen.", + }, + { + title: "Confirm", + description: "Tap Add in the top right to install.", + }, + ] + } + return [ + { + title: "Open this page in Safari", + description: + "The install flow only works inside Safari on iPhone and iPad.", + }, + { + title: "Open the share menu", + description: ( + <> + Tap the share icon{" "} + {" "} + at the bottom. + + ), + }, + { + title: "Add to Home Screen", + description: "Choose Add to Home Screen and confirm.", + }, + ] + } + if (device.isChrome) { + return [ + { + title: "Open the Chrome menu", + description: ( + <> + Tap the menu icon{" "} + {" "} + in the top right. + + ), + }, + { + title: "Add to Home screen", + description: "Tap Add to Home screen from the menu.", + }, + { + title: "Confirm", + description: "Tap Install to finish.", + }, + ] + } + return [ + { + title: "Open this page in Chrome", + description: "Install works best from Chrome on Android.", + }, + { + title: "Open the Chrome menu", + description: ( + <> + Tap the menu icon{" "} + {" "} + in the top right. + + ), + }, + { + title: "Add to Home screen", + description: "Choose Add to Home screen and confirm.", + }, + ] +} + export function PWAInstallPrompt() { const [show, setShow] = useState(false) const [device, setDevice] = useState({ @@ -78,13 +175,11 @@ export function PWAInstallPrompt() { }) const [nativePrompt, setNativePrompt] = useState(null) - const panelRef = useRef(null) useEffect(() => { const info = getDeviceInfo() setDevice(info) - // Listen for the native install prompt (Chromium browsers on Android) const handleBeforeInstall = (e: Event) => { e.preventDefault() setNativePrompt(e as BeforeInstallPromptEvent) @@ -92,14 +187,12 @@ export function PWAInstallPrompt() { window.addEventListener("beforeinstallprompt", handleBeforeInstall) const isMobile = info.isIOS || info.isAndroid + let timer: ReturnType | undefined if (isMobile && !isStandalone() && !isDismissed()) { - const timer = setTimeout(() => setShow(true), 1500) - return () => { - clearTimeout(timer) - window.removeEventListener("beforeinstallprompt", handleBeforeInstall) - } + timer = setTimeout(() => setShow(true), 1500) } return () => { + if (timer) clearTimeout(timer) window.removeEventListener("beforeinstallprompt", handleBeforeInstall) } }, []) @@ -112,258 +205,156 @@ export function PWAInstallPrompt() { } catch {} }, []) - // Escape key handler - useEffect(() => { - if (!show) return - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") dismiss() - } - document.addEventListener("keydown", handler) - return () => document.removeEventListener("keydown", handler) - }, [show, dismiss]) - - // Focus the panel when shown for accessibility - useEffect(() => { - if (show && panelRef.current) { - panelRef.current.focus() - } - }, [show]) - const handleInstall = useCallback(async () => { if (nativePrompt) { nativePrompt.prompt() - const { outcome } = await nativePrompt.userChoice - if (outcome === "accepted") { - setShow(false) - } + await nativePrompt.userChoice setNativePrompt(null) } dismiss() }, [nativePrompt, dismiss]) - /** - * Determine which instructions to show: - * - iOS + Safari → standard iOS steps - * - iOS + non-Safari → "open in Safari" hint - * - Android + Chrome (with native prompt) → trigger native install - * - Android + Chrome (no native prompt) → manual Chrome steps - * - Android + non-Chrome → "open in Chrome" hint - */ - const renderSteps = () => { - if (device.isIOS) { - if (device.isSafari) return - return - } - // Android - if (device.isChrome) return - return - } + const canNativeInstall = !!nativePrompt && device.isAndroid && device.isChrome + const steps = buildSteps(device) return ( - - {show && ( - !open && dismiss()}> + div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1", + dmSansClassName(), + )} + > + Install Supermemory + +
- e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby="pwa-install-title" - tabIndex={-1} +
+
+ +
+

+ Install Supermemory +

+

+ Your memories, one tap away. +

+
+
+ +
+ +
- {/* Header */} -
-
-
- -
-
-

+ {BENEFITS.map((text) => ( +
+ + - Supermemory -

-

- Your memories, wherever you are -

-
-
- -
- - {/* Feature pills */} -
- {FEATURES.map((f) => ( -
- - - {f.label} + {text}
))}
- {/* Install steps */} -
-

- Install for a better experience -

- {renderSteps()} -
+
- {/* Action */} -
- -
- - - )} - - ) -} +
    + {steps.map((step, i) => ( +
  1. +
    + + {i + 1} + + {i < steps.length - 1 && ( + + )} +
    +
    +

    + {step.title} +

    +

    + {step.description} +

    +
    +
  2. + ))} +
+
-function StepRow({ - step, - children, -}: { - step: number - children: React.ReactNode -}) { - return ( -
- - {step} - - {children} -
- ) -} - -function IOSSteps() { - return ( -
- - Tap the share button{" "} - {" "} - in Safari - - - Scroll down and tap{" "} - Add to Home Screen - - - Tap Add to install - -
- ) -} - -function IOSNonSafariSteps() { - return ( -
- - Open this page in Safari - - - Tap the share button{" "} - - - - Tap Add to Home Screen - -
- ) -} - -function AndroidSteps() { - return ( -
- - Tap the menu button{" "} - {" "} - in Chrome - - - Tap Add to Home screen - - - Tap Install to confirm - -
- ) -} - -function AndroidNonChromeSteps() { - return ( -
- - Open this page in Chrome - - - Tap the menu button{" "} - - - - Tap Add to Home screen - -
+ +
+ + ) } @@ -403,10 +394,6 @@ function MoreVertIcon({ className }: { className?: string }) { ) } -/** - * Type for the `beforeinstallprompt` event (not yet in TS lib). - * @see https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent - */ interface BeforeInstallPromptEvent extends Event { prompt(): Promise userChoice: Promise<{ outcome: "accepted" | "dismissed" }>