mirror of
https://github.com/vernu/textbee.git
synced 2026-04-19 22:43:59 -04:00
chore(web): show update app version cta
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
169
web/app/(app)/dashboard/(components)/update-app-helpers.ts
Normal file
169
web/app/(app)/dashboard/(components)/update-app-helpers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
160
web/app/(app)/dashboard/(components)/update-app-modal.tsx
Normal file
160
web/app/(app)/dashboard/(components)/update-app-modal.tsx
Normal 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't ask again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user