From 002ac9c144eac97e080625f0d75662c34067c472 Mon Sep 17 00:00:00 2001 From: isra el Date: Fri, 13 Mar 2026 21:54:00 +0300 Subject: [PATCH] chore(web): show update app version cta --- web/.env.example | 1 + .../dashboard/(components)/device-list.tsx | 64 ++++++- .../(components)/update-app-helpers.ts | 169 ++++++++++++++++++ .../(components)/update-app-modal.tsx | 160 +++++++++++++++++ .../update-app-notification-bar.tsx | 64 +++++++ web/app/(app)/dashboard/layout.tsx | 5 +- 6 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 web/app/(app)/dashboard/(components)/update-app-helpers.ts create mode 100644 web/app/(app)/dashboard/(components)/update-app-modal.tsx create mode 100644 web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx diff --git a/web/.env.example b/web/.env.example index 2604758..23023d1 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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= diff --git a/web/app/(app)/dashboard/(components)/device-list.tsx b/web/app/(app)/dashboard/(components)/device-list.tsx index 24f1c76..6ed6b88 100644 --- a/web/app/(app)/dashboard/(components)/device-list.tsx +++ b/web/app/(app)/dashboard/(components)/device-list.tsx @@ -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() {

{formatDeviceName(device)}

- - {device.enabled ? 'Enabled' : 'Disabled'} - +
+ {isDeviceOutdated(device as DeviceVersionCandidate) && ( + + Update available + + )} + + {device.enabled ? 'Enabled' : 'Disabled'} + +
@@ -116,6 +132,11 @@ export default function DeviceList() {
-
+
+ App version:{' '} + {getDeviceVersionCode(device as DeviceVersionCandidate) ?? + 'unknown'} +
Registered at:{' '} {new Date(device.createdAt).toLocaleString('en-US', { @@ -124,6 +145,31 @@ export default function DeviceList() { })}
+ {isDeviceOutdated(device as DeviceVersionCandidate) && ( +
+

+ This device is behind the latest supported version{' '} + + {latestAppVersionCode} + + . +

+ +
+ )} diff --git a/web/app/(app)/dashboard/(components)/update-app-helpers.ts b/web/app/(app)/dashboard/(components)/update-app-helpers.ts new file mode 100644 index 0000000..16bcddd --- /dev/null +++ b/web/app/(app)/dashboard/(components)/update-app-helpers.ts @@ -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, + } +} diff --git a/web/app/(app)/dashboard/(components)/update-app-modal.tsx b/web/app/(app)/dashboard/(components)/update-app-modal.tsx new file mode 100644 index 0000000..96955b9 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/update-app-modal.tsx @@ -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 ( + + + +
+ +
+ + You are using an older version of the textbee mobile app + + + {deviceSummary}{' '} + is ready for an update. Install version {latestAppVersionCode} to get + improved reliability, bug fixes, and more. + +
+ +
+
+ +
+

Update highlights

+

Improved reliability, bug fixes, and more.

+
+
+
+ +
+

Recommended action

+

Download the latest Android app build and update your device.

+
+
+
+ + + +
+ + +
+
+
+
+ ) +} diff --git a/web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx b/web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx new file mode 100644 index 0000000..2c0ef14 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx @@ -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 ( + + + +
+ {summary} {verb} running an older app + version. Update to version {latestAppVersionCode} for improved + reliability, bug fixes, and more. +
+ +
+
+ ) +} diff --git a/web/app/(app)/dashboard/layout.tsx b/web/app/(app)/dashboard/layout.tsx index b71e4ad..e0e6cf7 100644 --- a/web/app/(app)/dashboard/layout.tsx +++ b/web/app/(app)/dashboard/layout.tsx @@ -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 */}
+ @@ -93,6 +95,7 @@ export default function DashboardLayout({
+
) }