feat: add PWA install instructions modal for mobile

This commit is contained in:
MaheshtheDev 2026-05-17 03:12:59 +00:00
parent efd25bbd27
commit 1ff25d6eeb
2 changed files with 284 additions and 0 deletions

View file

@ -2,12 +2,14 @@
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { NextAppResearchCta } from "@/components/next-app-research-cta"
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<EnsureWorkspace>{children}</EnsureWorkspace>
<NextAppResearchCta />
<PWAInstallPrompt />
</>
)
}

View file

@ -0,0 +1,282 @@
"use client"
import { useState, useEffect, useCallback } from "react"
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"
const PWA_DISMISS_KEY = "pwa-install-dismissed"
const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function getDeviceInfo() {
if (typeof window === "undefined") return { isIOS: false, isAndroid: false }
const ua = navigator.userAgent
const isIOS =
/iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
const isAndroid = /Android/.test(ua)
return { isIOS, isAndroid }
}
function isStandalone() {
if (typeof window === "undefined") return false
return (
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone ===
true
)
}
function isDismissed() {
if (typeof window === "undefined") return true
try {
const dismissed = localStorage.getItem(PWA_DISMISS_KEY)
if (!dismissed) return false
const timestamp = Number.parseInt(dismissed, 10)
if (Date.now() - timestamp < DISMISS_DURATION_MS) return true
localStorage.removeItem(PWA_DISMISS_KEY)
return false
} catch {
return false
}
}
const FEATURES = [
{ icon: Brain, label: "AI-powered memory" },
{ icon: Sparkles, label: "Chat with Nova" },
{ icon: Globe, label: "Access anywhere" },
]
export function PWAInstallPrompt() {
const [show, setShow] = useState(false)
const [device, setDevice] = useState<{ isIOS: boolean; isAndroid: boolean }>({
isIOS: false,
isAndroid: false,
})
useEffect(() => {
const info = getDeviceInfo()
setDevice(info)
const isMobile = info.isIOS || info.isAndroid
if (isMobile && !isStandalone() && !isDismissed()) {
const timer = setTimeout(() => setShow(true), 1500)
return () => clearTimeout(timer)
}
}, [])
const dismiss = useCallback(() => {
setShow(false)
try {
localStorage.setItem(PWA_DISMISS_KEY, Date.now().toString())
} catch {}
}, [])
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm"
onClick={dismiss}
>
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
className={cn(
"w-full max-w-lg bg-[#1B1F24] rounded-t-[22px] p-6 pb-8 flex flex-col gap-5",
dmSansClassName(),
)}
style={{
boxShadow:
"0 -4px 24px 0 rgba(0, 0, 0, 0.4), 0.5px 0.5px 0.5px 0 rgba(255, 255, 255, 0.08) inset",
}}
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="size-12 rounded-[12px] bg-[#0D121A] flex items-center justify-center border border-[rgba(115,115,115,0.15)]">
<GradientLogo className="size-7" />
</div>
<div>
<h2
className={cn(
"text-[18px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Supermemory
</h2>
<p className="text-[14px] text-[#737373] font-medium">
Your memories, wherever you are
</p>
</div>
</div>
<button
type="button"
onClick={dismiss}
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow:
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<XIcon className="size-4 text-[#737373]" />
<span className="sr-only">Close</span>
</button>
</div>
{/* Feature pills */}
<div className="flex gap-2">
{FEATURES.map((f) => (
<div
key={f.label}
className="flex-1 flex flex-col items-center gap-2 py-3 px-2 rounded-[12px] bg-[#0D121A] border border-[rgba(115,115,115,0.1)]"
>
<f.icon className="size-5 text-[#4BA0FA]" />
<span className="text-[12px] text-[#d0dae7] font-medium text-center leading-tight">
{f.label}
</span>
</div>
))}
</div>
{/* Install steps */}
<div className="flex flex-col gap-3">
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Install for a better experience
</p>
{device.isIOS ? <IOSSteps /> : <AndroidSteps />}
</div>
{/* Actions */}
<div className="flex flex-col gap-2 mt-1">
<button
type="button"
onClick={dismiss}
className={cn(
"w-full py-3 rounded-[12px] bg-[#2261CA] text-white text-[15px] font-semibold transition-colors hover:bg-[#1a4fa0]",
dmSansClassName(),
)}
>
Got it
</button>
<button
type="button"
onClick={dismiss}
className={cn(
"w-full py-2 text-[14px] text-[#737373] font-medium transition-colors hover:text-[#fafafa]",
dmSansClassName(),
)}
>
Not now
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function StepRow({
step,
children,
}: {
step: number
children: React.ReactNode
}) {
return (
<div className="flex items-center gap-3">
<span className="size-7 shrink-0 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center text-[13px] font-semibold text-[#4BA0FA]">
{step}
</span>
<span className="text-[14px] text-[#d0dae7]">{children}</span>
</div>
)
}
function IOSSteps() {
return (
<div className="flex flex-col gap-3">
<StepRow step={1}>
Tap the share button{" "}
<ShareIcon className="inline-block size-4 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
in Safari
</StepRow>
<StepRow step={2}>
Scroll down and tap{" "}
<strong className="text-[#fafafa]">Add to Home Screen</strong>
</StepRow>
<StepRow step={3}>
Tap <strong className="text-[#fafafa]">Add</strong> to install
</StepRow>
</div>
)
}
function AndroidSteps() {
return (
<div className="flex flex-col gap-3">
<StepRow step={1}>
Tap the menu button{" "}
<MoreVertIcon className="inline-block size-4 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
in Chrome
</StepRow>
<StepRow step={2}>
Tap <strong className="text-[#fafafa]">Add to Home screen</strong>
</StepRow>
<StepRow step={3}>
Tap <strong className="text-[#fafafa]">Install</strong> to confirm
</StepRow>
</div>
)
}
function ShareIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
)
}
function MoreVertIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
)
}