supermemory/apps/web/components/pwa-install-prompt.tsx

429 lines
11 KiB
TypeScript

"use client"
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, 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
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
isAndroid: boolean
isSafari: boolean
isChrome: boolean
}
let memoryDismissed = false
function getDeviceInfo(): DeviceInfo {
if (typeof window === "undefined")
return { isIOS: false, isAndroid: false, isSafari: false, isChrome: false }
const ua = navigator.userAgent
const isIOS =
/iPad|iPhone|iPod/.test(ua) ||
(/Macintosh/.test(ua) && navigator.maxTouchPoints > 1)
const isAndroid = /Android/.test(ua)
const isSafari =
/Safari/.test(ua) && !/CriOS|FxiOS|Chrome|Chromium|Edg|OPR|Opera/.test(ua)
const isChrome = /Chrome/.test(ua) && !/Edg|OPR|Opera/.test(ua)
return { isIOS, isAndroid, isSafari, isChrome }
}
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
if (memoryDismissed) 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 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{" "}
<ShareIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
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{" "}
<ShareIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
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{" "}
<MoreVertIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
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{" "}
<MoreVertIcon className="inline-block size-3.5 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
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<DeviceInfo>({
isIOS: false,
isAndroid: false,
isSafari: false,
isChrome: false,
})
const [nativePrompt, setNativePrompt] =
useState<BeforeInstallPromptEvent | null>(null)
useEffect(() => {
const info = getDeviceInfo()
setDevice(info)
const handleBeforeInstall = (e: Event) => {
e.preventDefault()
setNativePrompt(e as BeforeInstallPromptEvent)
}
window.addEventListener("beforeinstallprompt", handleBeforeInstall)
const isMobile = info.isIOS || info.isAndroid
let timer: ReturnType<typeof setTimeout> | undefined
if (isMobile && !isStandalone() && !isDismissed()) {
timer = setTimeout(() => setShow(true), 1500)
}
return () => {
if (timer) clearTimeout(timer)
window.removeEventListener("beforeinstallprompt", handleBeforeInstall)
}
}, [])
const dismiss = useCallback(() => {
setShow(false)
memoryDismissed = true
try {
localStorage.setItem(PWA_DISMISS_KEY, Date.now().toString())
} catch {}
}, [])
const handleInstall = useCallback(async () => {
if (nativePrompt) {
nativePrompt.prompt()
await nativePrompt.userChoice
setNativePrompt(null)
}
dismiss()
}, [nativePrompt, dismiss])
const openShareMenu = useCallback(async () => {
try {
if (typeof navigator !== "undefined" && "share" in navigator) {
await navigator.share({
title: "Supermemory",
url: window.location.href,
})
}
} catch {}
dismiss()
}, [dismiss])
const canNativeInstall = !!nativePrompt && device.isAndroid && device.isChrome
const canShare = typeof navigator !== "undefined" && "share" in navigator
const steps = buildSteps(device)
return (
<Drawer open={show} onOpenChange={(open) => !open && dismiss()}>
<DrawerContent
className={cn(
"flex flex-col gap-0 border-none bg-[#1B1F24] p-0",
"max-h-[92svh] overflow-hidden",
"[&>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(),
)}
>
<DrawerTitle className="sr-only">Install Supermemory</DrawerTitle>
<div
className={cn(
"flex flex-col gap-4 px-4 pt-3",
"pb-[max(1rem,env(safe-area-inset-bottom))]",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Image
src="/android-chrome-512x512.png"
alt=""
width={48}
height={48}
className="size-12 shrink-0"
priority
/>
<div className="min-w-0">
<h2
className={cn(
"text-[17px] font-semibold text-[#FAFAFA] leading-tight",
dmSans125ClassName(),
)}
>
Install Supermemory
</h2>
<p
className={cn(
"text-[13px] text-[#737373] leading-snug mt-0.5",
dmSans125ClassName(),
)}
>
Your memories, one tap away.
</p>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss install prompt"
className={cn(
"size-7 shrink-0 flex items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
INSET,
)}
>
<XIcon className="size-3.5 text-[#737373]" />
</button>
</div>
<div
className={cn(
"rounded-[14px] bg-[#14161A] p-5 flex flex-col gap-5",
CARD_INSET,
)}
>
<div className="grid grid-cols-2 gap-x-5 gap-y-2.5">
{BENEFITS.map((text) => (
<div key={text} className="flex items-start gap-2">
<Check className="size-4 shrink-0 text-[#4BA0FA] mt-0.5" />
<span
className={cn(
"text-[13px] text-[#E4E4E7] leading-snug",
dmSans125ClassName(),
)}
>
{text}
</span>
</div>
))}
</div>
<div className="h-px bg-white/[0.06]" />
<ol className="flex min-w-0 flex-col">
{steps.map((step, i) => (
<li key={step.title} className="flex min-w-0 gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
INSET,
)}
>
{i + 1}
</span>
{i < steps.length - 1 && (
<span className="w-px flex-1 bg-white/[0.10] my-1" />
)}
</div>
<div
className={cn(
"min-w-0 flex-1 space-y-0.5",
i < steps.length - 1 ? "pb-4" : "",
)}
>
<p
className={cn(
"text-[13px] font-semibold text-[#FAFAFA] leading-tight",
dmSans125ClassName(),
)}
>
{step.title}
</p>
<p
className={cn(
"text-[12px] leading-relaxed text-[#A1A1AA]",
dmSans125ClassName(),
)}
>
{step.description}
</p>
</div>
</li>
))}
</ol>
</div>
<Button
variant="insideOut"
onClick={
canNativeInstall
? handleInstall
: canShare
? openShareMenu
: dismiss
}
className={cn("h-12 w-full px-5 text-[15px]", dmSansClassName())}
>
{canNativeInstall
? "Install now"
: canShare
? "Add to Home Screen"
: "Got it"}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}
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>
)
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent
}
}