chore(web): show update app version cta

This commit is contained in:
isra el
2026-03-13 21:54:00 +03:00
parent 87006c226f
commit 002ac9c144
6 changed files with 453 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001/api/v1
NEXT_PUBLIC_LATEST_APP_VERSION_CODE=17
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_TAWKTO_EMBED_URL=

View File

@@ -3,14 +3,20 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Smartphone, Battery, Signal, Copy } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { Routes } from '@/config/routes'
import { useQuery } from '@tanstack/react-query'
import { Skeleton } from '@/components/ui/skeleton'
import { formatDeviceName } from '@/lib/utils'
import {
DeviceVersionCandidate,
getDeviceVersionCode,
isDeviceOutdated,
latestAppVersionCode,
} from './update-app-helpers'
export default function DeviceList() {
const { toast } = useToast()
@@ -86,14 +92,24 @@ export default function DeviceList() {
<h3 className='font-semibold text-sm'>
{formatDeviceName(device)}
</h3>
<Badge
variant={
device.status === 'online' ? 'default' : 'secondary'
}
className='text-xs'
>
{device.enabled ? 'Enabled' : 'Disabled'}
</Badge>
<div className='flex items-center gap-2'>
{isDeviceOutdated(device as DeviceVersionCandidate) && (
<Badge
variant='outline'
className='border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300'
>
Update available
</Badge>
)}
<Badge
variant={
device.status === 'online' ? 'default' : 'secondary'
}
className='text-xs'
>
{device.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className='flex items-center space-x-2 mt-1'>
<code className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs'>
@@ -116,6 +132,11 @@ export default function DeviceList() {
<div className='flex items-center'>
<Signal className='h-3 w-3 mr-1' />-
</div>
<div>
App version:{' '}
{getDeviceVersionCode(device as DeviceVersionCandidate) ??
'unknown'}
</div>
<div>
Registered at:{' '}
{new Date(device.createdAt).toLocaleString('en-US', {
@@ -124,6 +145,31 @@ export default function DeviceList() {
})}
</div>
</div>
{isDeviceOutdated(device as DeviceVersionCandidate) && (
<div className='mt-3 flex items-center justify-between gap-2 rounded-lg border border-brand-100 bg-brand-50/60 px-3 py-2 dark:border-brand-900/50 dark:bg-brand-950/20'>
<p className='text-xs text-muted-foreground'>
This device is behind the latest supported version{' '}
<span className='font-medium text-foreground'>
{latestAppVersionCode}
</span>
.
</p>
<Button
variant='outline'
size='sm'
asChild
className='shrink-0'
>
<a
href={Routes.downloadAndroidApp}
target='_blank'
rel='noreferrer'
>
Update app
</a>
</Button>
</div>
)}
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,169 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
export type DeviceVersionCandidate = {
_id: string
brand: string
model: string
name?: string | null
appVersionCode?: number | null
appVersionInfo?: {
versionCode?: number | null
} | null
}
export const DEFAULT_LATEST_APP_VERSION_CODE = 17
export const UPDATE_APP_REMIND_LATER_MS = 6 * 60 * 60 * 1000
export const UPDATE_APP_DONT_ASK_AGAIN_MS = 30 * 24 * 60 * 60 * 1000
const UPDATE_APP_SNOOZE_KEY = 'update_app_prompt_snooze_until'
const UPDATE_APP_SNOOZE_EVENT = 'update-app-prompt-snooze-changed'
const envLatestVersionCode = Number.parseInt(
process.env.NEXT_PUBLIC_LATEST_APP_VERSION_CODE?.trim() ?? '',
10
)
export const latestAppVersionCode =
Number.isFinite(envLatestVersionCode) && envLatestVersionCode > 0
? envLatestVersionCode
: DEFAULT_LATEST_APP_VERSION_CODE
export function getDeviceVersionCode(device: DeviceVersionCandidate) {
const heartbeatVersionCode = device.appVersionInfo?.versionCode
if (typeof heartbeatVersionCode === 'number') {
return heartbeatVersionCode
}
return typeof device.appVersionCode === 'number' ? device.appVersionCode : null
}
export function isDeviceOutdated(
device: DeviceVersionCandidate,
latestVersionCode = latestAppVersionCode
) {
const deviceVersionCode = getDeviceVersionCode(device)
if (deviceVersionCode === null) {
return false
}
return deviceVersionCode < latestVersionCode
}
export function getOutdatedDevices(
devices: DeviceVersionCandidate[] | undefined,
latestVersionCode = latestAppVersionCode
) {
if (!devices?.length) {
return []
}
return devices.filter((device) => isDeviceOutdated(device, latestVersionCode))
}
export function summarizeOutdatedDeviceNames(
devices: DeviceVersionCandidate[],
formatDeviceName: (device: DeviceVersionCandidate) => string,
visibleCount = 3
) {
const visibleDevices = devices.slice(0, visibleCount).map(formatDeviceName)
const remainingCount = Math.max(devices.length - visibleDevices.length, 0)
if (visibleDevices.length === 0) {
return ''
}
if (visibleDevices.length === 1) {
return visibleDevices[0]
}
if (visibleDevices.length === 2) {
return remainingCount > 0
? `${visibleDevices[0]}, ${visibleDevices[1]} and ${remainingCount} more device${
remainingCount > 1 ? 's' : ''
}`
: `${visibleDevices[0]} and ${visibleDevices[1]}`
}
const namedPrefix = `${visibleDevices[0]}, ${visibleDevices[1]} and ${visibleDevices[2]}`
return remainingCount > 0
? `${namedPrefix} and ${remainingCount} more device${
remainingCount > 1 ? 's' : ''
}`
: namedPrefix
}
function readUpdatePromptSnoozeUntil() {
if (typeof window === 'undefined') {
return 0
}
const value = window.localStorage.getItem(UPDATE_APP_SNOOZE_KEY)
const parsedValue = value ? Number.parseInt(value, 10) : 0
return Number.isFinite(parsedValue) ? parsedValue : 0
}
function emitUpdatePromptSnoozeChanged() {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new Event(UPDATE_APP_SNOOZE_EVENT))
}
export function setUpdatePromptSnooze(durationMs: number) {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(
UPDATE_APP_SNOOZE_KEY,
(Date.now() + durationMs).toString()
)
emitUpdatePromptSnoozeChanged()
}
export function useUpdatePromptSnooze() {
const [snoozeUntil, setSnoozeUntil] = useState(0)
const refreshSnoozeUntil = useCallback(() => {
setSnoozeUntil(readUpdatePromptSnoozeUntil())
}, [])
useEffect(() => {
refreshSnoozeUntil()
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshSnoozeUntil()
}
}
window.addEventListener('storage', refreshSnoozeUntil)
window.addEventListener(
UPDATE_APP_SNOOZE_EVENT,
refreshSnoozeUntil as EventListener
)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('storage', refreshSnoozeUntil)
window.removeEventListener(
UPDATE_APP_SNOOZE_EVENT,
refreshSnoozeUntil as EventListener
)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [refreshSnoozeUntil])
return {
isSnoozed: snoozeUntil > Date.now(),
snoozeUntil,
refreshSnoozeUntil,
}
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import { Download, Sparkles, Smartphone } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ApiEndpoints } from '@/config/api'
import { Routes } from '@/config/routes'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { formatDeviceName } from '@/lib/utils'
import {
DeviceVersionCandidate,
UPDATE_APP_DONT_ASK_AGAIN_MS,
UPDATE_APP_REMIND_LATER_MS,
getOutdatedDevices,
latestAppVersionCode,
setUpdatePromptSnooze,
summarizeOutdatedDeviceNames,
useUpdatePromptSnooze,
} from './update-app-helpers'
export default function UpdateAppModal() {
const [isOpen, setIsOpen] = useState(false)
const ignoreNextOpenChangeRef = useRef(false)
const { isSnoozed } = useUpdatePromptSnooze()
const { data: devicesResponse, isLoading, error } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
const outdatedDevices = useMemo(
() =>
getOutdatedDevices(
(devicesResponse?.data ?? []) as DeviceVersionCandidate[]
),
[devicesResponse?.data]
)
const primaryOutdatedDevice = outdatedDevices[0]
useEffect(() => {
if (isLoading || error || isSnoozed || outdatedDevices.length === 0) {
setIsOpen(false)
return
}
if (isOpen) {
return
}
const timer = window.setTimeout(() => {
setIsOpen(true)
}, 900)
return () => window.clearTimeout(timer)
}, [error, isLoading, isOpen, isSnoozed, outdatedDevices.length])
const closeWithSnooze = (durationMs: number) => {
setUpdatePromptSnooze(durationMs)
ignoreNextOpenChangeRef.current = true
setIsOpen(false)
}
const handleOpenChange = (open: boolean) => {
if (!open && isOpen) {
if (ignoreNextOpenChangeRef.current) {
ignoreNextOpenChangeRef.current = false
} else {
setUpdatePromptSnooze(UPDATE_APP_REMIND_LATER_MS)
}
}
setIsOpen(open)
}
if (!primaryOutdatedDevice || error) {
return null
}
const deviceSummary = summarizeOutdatedDeviceNames(
outdatedDevices,
formatDeviceName
)
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg border-brand-200 dark:border-brand-800'>
<DialogHeader>
<div className='mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/30 dark:text-brand-300'>
<Smartphone className='h-6 w-6' />
</div>
<DialogTitle className='text-center text-2xl'>
You are using an older version of the textbee mobile app
</DialogTitle>
<DialogDescription className='text-center text-sm sm:text-base'>
<span className='font-medium text-foreground'>{deviceSummary}</span>{' '}
is ready for an update. Install version {latestAppVersionCode} to get
improved reliability, bug fixes, and more.
</DialogDescription>
</DialogHeader>
<div className='space-y-3 rounded-xl border border-brand-100 bg-brand-50/60 p-4 dark:border-brand-900/50 dark:bg-brand-950/20'>
<div className='flex items-start gap-3'>
<Sparkles className='mt-0.5 h-4 w-4 text-brand-500' />
<div className='space-y-1 text-sm text-muted-foreground'>
<p className='font-medium text-foreground'>Update highlights</p>
<p>Improved reliability, bug fixes, and more.</p>
</div>
</div>
<div className='flex items-start gap-3'>
<Download className='mt-0.5 h-4 w-4 text-brand-500' />
<div className='space-y-1 text-sm text-muted-foreground'>
<p className='font-medium text-foreground'>Recommended action</p>
<p>Download the latest Android app build and update your device.</p>
</div>
</div>
</div>
<DialogFooter className='flex-col gap-2 sm:flex-col'>
<Button asChild className='w-full'>
<Link href={Routes.downloadAndroidApp}>Update now</Link>
</Button>
<div className='flex w-full items-center justify-between gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => closeWithSnooze(UPDATE_APP_REMIND_LATER_MS)}
className='text-muted-foreground'
>
Remind later
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => closeWithSnooze(UPDATE_APP_DONT_ASK_AGAIN_MS)}
className='text-muted-foreground hover:text-destructive'
>
Don&apos;t ask again
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,64 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import { ArrowUpRight, BellRing } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ApiEndpoints } from '@/config/api'
import { Routes } from '@/config/routes'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { formatDeviceName } from '@/lib/utils'
import {
DeviceVersionCandidate,
getOutdatedDevices,
latestAppVersionCode,
summarizeOutdatedDeviceNames,
} from './update-app-helpers'
export default function UpdateAppNotificationBar() {
const { data: devicesResponse, isLoading, error } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
const outdatedDevices = useMemo(
() =>
getOutdatedDevices(
(devicesResponse?.data ?? []) as DeviceVersionCandidate[]
),
[devicesResponse?.data]
)
const primaryOutdatedDevice = outdatedDevices[0]
if (isLoading || error || !primaryOutdatedDevice) {
return null
}
const summary = summarizeOutdatedDeviceNames(outdatedDevices, formatDeviceName)
const verb = outdatedDevices.length > 1 ? 'are' : 'is'
return (
<Alert className='sticky top-4 z-20 border-brand-200 bg-brand-50/95 text-brand-950 shadow-sm backdrop-blur dark:border-brand-800 dark:bg-brand-950/90 dark:text-brand-50'>
<BellRing className='h-4 w-4 text-brand-600 dark:text-brand-300' />
<AlertDescription className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
<div className='pr-2 text-sm'>
<span className='font-medium'>{summary}</span> {verb} running an older app
version. Update to version {latestAppVersionCode} for improved
reliability, bug fixes, and more.
</div>
<Button asChild size='sm' className='w-full md:w-auto shrink-0'>
<Link href={Routes.downloadAndroidApp}>
Update app
<ArrowUpRight className='ml-1 h-4 w-4' />
</Link>
</Button>
</AlertDescription>
</Alert>
)
}

View File

@@ -5,8 +5,9 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import AccountDeletionAlert from './(components)/account-deletion-alert'
import UpgradeToProAlert from './(components)/upgrade-to-pro-alert'
import UpdateAppModal from './(components)/update-app-modal'
import UpdateAppNotificationBar from './(components)/update-app-notification-bar'
import VerifyEmailAlert from './(components)/verify-email-alert'
import BlackFridayModal from './(components)/black-friday-modal'
import { SurveyModal } from '@/components/shared/survey-modal'
export default function DashboardLayout({
@@ -51,6 +52,7 @@ export default function DashboardLayout({
{/* Main content with left padding to account for fixed sidebar */}
<main className='flex-1 min-w-0 overflow-auto md:ml-24'>
<div className='space-y-2 p-4'>
<UpdateAppNotificationBar />
<VerifyEmailAlert />
<AccountDeletionAlert />
<UpgradeToProAlert />
@@ -93,6 +95,7 @@ export default function DashboardLayout({
<div className='h-16 md:hidden'></div>
<SurveyModal />
<UpdateAppModal />
</div>
)
}