refactor: centralize data fetching into typed react-query hooks

Introduce a typed data layer under lib/api (query-key factory, response
types, feature hooks) plus shared lib/format and lib/status helpers, and
migrate the canonical dashboard consumers (subscription-info, overview,
device-list, api-keys) onto it, deleting their duplicated inline queries,
mutations and formatters.

List hooks keep the raw { data: [] } envelope in the cache and unwrap
per-observer with react-query `select`, so shared keys like ['devices']
stay compatible with the not-yet-migrated components that still read the
raw shape (avoids a cache-shape collision surfaced by the dashboard e2e).

Adds unit tests for the formatters and the hooks (against MSW). Build,
19 unit tests, and 2 e2e all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-11 01:36:16 +03:00
co-authored by Claude Opus 4.8
parent 89f2f78706
commit 497a14074a
13 changed files with 569 additions and 265 deletions
+70 -102
View File
@@ -21,9 +21,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import {
useApiKeys,
useDeleteApiKey,
useRenameApiKey,
useRevokeApiKey,
} from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import GenerateApiKey, {
type GenerateApiKeyHandle,
@@ -42,7 +45,6 @@ type ApiKeyRow = {
export default function ApiKeys() {
const addApiKeyRef = useRef<GenerateApiKeyHandle>(null)
const queryClient = useQueryClient()
const [selectedKey, setSelectedKey] = useState<ApiKeyRow | null>(null)
const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false)
@@ -56,100 +58,69 @@ export default function ApiKeys() {
const { toast } = useToast()
const {
isPending,
error,
data: apiKeys,
} = useQuery({
queryKey: ['apiKeys', 'active'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.listApiKeys('active'))
.then((res) => res.data),
})
const { isPending, error, data: apiKeys } = useApiKeys('active')
const {
data: revokedKeysData,
isPending: isRevokedPending,
} = useQuery({
queryKey: ['apiKeys', 'revoked'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.listApiKeys('revoked'))
.then((res) => res.data),
enabled: isRevokedModalOpen,
})
const { data: revokedKeysData, isPending: isRevokedPending } = useApiKeys(
'revoked',
{ enabled: isRevokedModalOpen }
)
const {
mutate: revokeApiKey,
isPending: isRevokingApiKey,
error: revokeApiKeyError,
} = useMutation({
mutationFn: (id: string) =>
httpBrowserClient.post(ApiEndpoints.auth.revokeApiKey(id)),
onSuccess: () => {
setIsRevokeDialogOpen(false)
toast({
title: `API key "${selectedKey?.apiKey}" has been revoked`,
})
void queryClient.invalidateQueries({ queryKey: ['apiKeys'] })
},
onError: () => {
toast({
variant: 'destructive',
title: 'Error revoking API key',
description: revokeApiKeyError?.message,
})
},
})
const { mutate: revokeApiKey, isPending: isRevokingApiKey } = useRevokeApiKey()
const { mutate: deleteRevokedApiKey, isPending: isDeletingRevokedApiKey } =
useDeleteApiKey()
const { mutate: renameApiKey, isPending: isRenamingApiKey } =
useRenameApiKey()
const {
mutate: deleteRevokedApiKey,
isPending: isDeletingRevokedApiKey,
error: deleteApiKeyError,
} = useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.auth.deleteApiKey(id)),
onSuccess: () => {
setIsConfirmDeleteRevokedOpen(false)
setRevokedKeyToDelete(null)
toast({
title: 'API key removed',
})
void queryClient.invalidateQueries({ queryKey: ['apiKeys'] })
},
onError: () => {
toast({
variant: 'destructive',
title: 'Error deleting API key',
description: deleteApiKeyError?.message,
})
},
})
const {
mutate: renameApiKey,
isPending: isRenamingApiKey,
error: renameApiKeyError,
} = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
httpBrowserClient.patch(ApiEndpoints.auth.renameApiKey(id), { name }),
onSuccess: () => {
setIsRenameDialogOpen(false)
toast({
title: `API key renamed to "${newKeyName}"`,
})
void queryClient.invalidateQueries({ queryKey: ['apiKeys', 'active'] })
},
onError: () => {
toast({
variant: 'destructive',
title: 'Error renaming API key',
description: renameApiKeyError?.message,
})
},
})
const handleRevokeApiKey = (id: string) =>
revokeApiKey(id, {
onSuccess: () => {
setIsRevokeDialogOpen(false)
toast({ title: `API key "${selectedKey?.apiKey}" has been revoked` })
},
onError: (err) => {
toast({
variant: 'destructive',
title: 'Error revoking API key',
description: err?.message,
})
},
})
const revokedList = revokedKeysData?.data as ApiKeyRow[] | undefined
const handleDeleteRevokedApiKey = (id: string) =>
deleteRevokedApiKey(id, {
onSuccess: () => {
setIsConfirmDeleteRevokedOpen(false)
setRevokedKeyToDelete(null)
toast({ title: 'API key removed' })
},
onError: (err) => {
toast({
variant: 'destructive',
title: 'Error deleting API key',
description: err?.message,
})
},
})
const handleRenameApiKey = (id: string, name: string) =>
renameApiKey(
{ id, name },
{
onSuccess: () => {
setIsRenameDialogOpen(false)
toast({ title: `API key renamed to "${newKeyName}"` })
},
onError: (err) => {
toast({
variant: 'destructive',
title: 'Error renaming API key',
description: err?.message,
})
},
}
)
const revokedList = revokedKeysData as ApiKeyRow[] | undefined
return (
<>
@@ -210,13 +181,13 @@ export default function ApiKeys() {
</div>
)}
{!isPending && !error && apiKeys?.data?.length === 0 && (
{!isPending && !error && apiKeys?.length === 0 && (
<div className='flex justify-center items-center h-full'>
<div>No API keys found</div>
</div>
)}
{apiKeys?.data?.map((apiKey: ApiKeyRow) => (
{apiKeys?.map((apiKey: ApiKeyRow) => (
<Card key={apiKey._id} className='border-0 shadow-none'>
<CardContent className='flex items-center p-3'>
<Key className='h-6 w-6 mr-3' />
@@ -319,7 +290,7 @@ export default function ApiKeys() {
</Button>
<Button
variant='destructive'
onClick={() => revokeApiKey(selectedKey?._id)}
onClick={() => selectedKey?._id && handleRevokeApiKey(selectedKey._id)}
disabled={isRevokingApiKey}
>
{isRevokingApiKey ? (
@@ -425,7 +396,7 @@ export default function ApiKeys() {
variant='destructive'
onClick={() =>
revokedKeyToDelete &&
deleteRevokedApiKey(revokedKeyToDelete._id)
handleDeleteRevokedApiKey(revokedKeyToDelete._id)
}
disabled={isDeletingRevokedApiKey}
>
@@ -462,10 +433,7 @@ export default function ApiKeys() {
</Button>
<Button
onClick={() =>
renameApiKey({
id: selectedKey?._id,
name: newKeyName?.trim(),
})
handleRenameApiKey(selectedKey?._id, newKeyName?.trim())
}
disabled={isRenamingApiKey || !newKeyName?.trim()}
>
@@ -16,10 +16,8 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { Routes } from '@/config/routes'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useDeleteDevice, useDevices, useSubscription } from '@/lib/api'
import { useRef, useState } from 'react'
import {
Dialog,
@@ -60,65 +58,42 @@ export default function DeviceList() {
const [devicePendingDelete, setDevicePendingDelete] =
useState<DeviceRow | null>(null)
const { toast } = useToast()
const queryClient = useQueryClient()
const {
isPending,
error,
data: devices,
} = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
// select: (res) => res.data,
})
const { isPending, error, data: devices } = useDevices()
const { data: currentSubscription } = useQuery({
queryKey: ['currentSubscription'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
.then((res) => res.data),
})
const { data: currentSubscription } = useSubscription()
// -1 (or missing) means unlimited; only enabled devices count toward the limit
const deviceLimit = currentSubscription?.usage?.deviceLimit ?? -1
const activeDeviceCount =
devices?.data?.filter((device) => device.enabled).length ?? 0
devices?.filter((device) => device.enabled).length ?? 0
const isDeviceLimitReached =
deviceLimit !== -1 && !isPending && activeDeviceCount >= deviceLimit
const isApproachingDeviceLimit =
deviceLimit >= 2 && !isPending && activeDeviceCount === deviceLimit - 1
const {
mutate: deleteDevice,
isPending: isDeletingDevice,
} = useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.gateway.deleteDevice(id)),
onSuccess: () => {
setDevicePendingDelete(null)
toast({
title: 'Device removed',
})
void queryClient.invalidateQueries({ queryKey: ['devices'] })
},
onError: (err: unknown) => {
const message =
err &&
typeof err === 'object' &&
'message' in err &&
typeof (err as { message: unknown }).message === 'string'
? (err as { message: string }).message
: 'Something went wrong'
toast({
variant: 'destructive',
title: 'Error removing device',
description: message,
})
},
})
const { mutate: deleteDevice, isPending: isDeletingDevice } = useDeleteDevice()
const handleDeleteDevice = (id: string) =>
deleteDevice(id, {
onSuccess: () => {
setDevicePendingDelete(null)
toast({ title: 'Device removed' })
},
onError: (err: unknown) => {
const message =
err &&
typeof err === 'object' &&
'message' in err &&
typeof (err as { message: unknown }).message === 'string'
? (err as { message: string }).message
: 'Something went wrong'
toast({
variant: 'destructive',
title: 'Error removing device',
description: message,
})
},
})
const handleCopyId = (id: string) => {
navigator.clipboard.writeText(id)
@@ -216,13 +191,13 @@ export default function DeviceList() {
</div>
)}
{!isPending && !error && devices?.data?.length === 0 && (
{!isPending && !error && devices?.length === 0 && (
<div className='flex justify-center items-center h-full'>
<div>No devices found</div>
</div>
)}
{devices?.data?.map((device) => (
{devices?.map((device) => (
<Card key={device._id} className='border-0 shadow-none'>
<CardContent className='flex items-center gap-1 p-3'>
<Smartphone className='h-6 w-6 mr-2 shrink-0' />
@@ -430,7 +405,7 @@ export default function DeviceList() {
variant='destructive'
onClick={() =>
devicePendingDelete &&
deleteDevice(devicePendingDelete._id)
handleDeleteDevice(devicePendingDelete._id)
}
disabled={isDeletingDevice}
>
@@ -3,9 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart3, Smartphone, Key, MessageSquare, TrendingUp } from 'lucide-react'
import GetStartedCard from './get-started'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import { useGatewayStats } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
// import GetStartedCard from "@/components/get-started-card";
@@ -32,13 +30,7 @@ export const StatCard = ({ title, value, icon: Icon, description }) => {
}
export default function Overview() {
const { data: stats } = useQuery({
queryKey: ['stats'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getStats())
.then((res) => res.data?.data),
})
const { data: stats } = useGatewayStats()
return (
<div className='space-y-6'>
@@ -4,9 +4,15 @@ import { useEffect } from 'react'
import { Calendar, Check, Info, Sparkles } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useQuery } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useCurrentUser, useSubscription } from '@/lib/api'
import {
formatLimit,
formatPrice,
formatDate,
getBillingInterval,
titleCaseStatus,
} from '@/lib/format'
import { subscriptionStatusTone, usageMeterColor } from '@/lib/status'
import { polarCustomerPortalRequestUrl } from '@/config/external-links'
import Link from 'next/link'
import {
@@ -18,12 +24,6 @@ import {
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const formatLimit = (value: number | null | undefined) => {
if (value === -1) return 'Unlimited'
if (value == null) return '0'
return value.toLocaleString()
}
type Meter = {
used: number
remaining: number
@@ -54,12 +54,7 @@ function LimitTile({
meter,
}: LimitTileProps) {
const isUnlimited = effectiveValue === -1
const meterColor =
meter && meter.percentage >= 100
? 'bg-red-500'
: meter && meter.percentage >= 80
? 'bg-amber-500'
: 'bg-green-500'
const meterColor = meter ? usageMeterColor(meter.percentage) : 'bg-green-500'
return (
<div
@@ -170,42 +165,9 @@ export default function SubscriptionInfo() {
data: currentSubscription,
isLoading: isLoadingSubscription,
error: subscriptionError,
} = useQuery({
queryKey: ['currentSubscription'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
.then((res) => res.data),
})
} = useSubscription()
const { data: currentUser } = useQuery({
queryKey: ['currentUser'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
.then((res) => res.data?.data),
})
// Format price with currency symbol
const formatPrice = (
amount: number | null | undefined,
currency: string | null | undefined
) => {
if (amount == null || currency == null) return 'Free'
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase() || 'USD',
minimumFractionDigits: 2,
})
return formatter.format(amount / 100)
}
const getBillingInterval = (interval: string | null | undefined) => {
if (!interval) return ''
return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly'
}
const { data: currentUser } = useCurrentUser()
if (isLoadingSubscription)
return (
@@ -336,38 +298,24 @@ export default function SubscriptionInfo() {
</div>
</div>
<div
className={`flex items-center px-2 py-0.5 rounded-full ${
currentSubscription?.status === 'active'
? 'bg-green-50 dark:bg-green-900/30'
: currentSubscription?.status === 'past_due'
? 'bg-amber-50 dark:bg-amber-900/30'
: 'bg-gray-50 dark:bg-gray-800/50'
}`}
className={cn(
'flex items-center px-2 py-0.5 rounded-full',
subscriptionStatusTone(currentSubscription?.status).bg
)}
>
<Check
className={`h-3 w-3 mr-1 ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}
className={cn(
'h-3 w-3 mr-1',
subscriptionStatusTone(currentSubscription?.status).text
)}
/>
<span
className={`text-xs font-medium ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}
className={cn(
'text-xs font-medium',
subscriptionStatusTone(currentSubscription?.status).text
)}
>
{currentSubscription?.status
? currentSubscription.status
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
: 'Active'}
{titleCaseStatus(currentSubscription?.status) || 'Active'}
</span>
</div>
</div>
@@ -380,15 +328,7 @@ export default function SubscriptionInfo() {
Start Date
</p>
<p className='text-xs font-medium text-gray-900 dark:text-white'>
{currentSubscription?.subscriptionStartDate
? new Date(
currentSubscription?.subscriptionStartDate
).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: 'N/A'}
{formatDate(currentSubscription?.subscriptionStartDate)}
</p>
</div>
</div>
@@ -400,15 +340,7 @@ export default function SubscriptionInfo() {
Next Payment
</p>
<p className='text-xs font-medium text-gray-900 dark:text-white'>
{currentSubscription?.currentPeriodEnd
? new Date(
currentSubscription?.currentPeriodEnd
).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: 'N/A'}
{formatDate(currentSubscription?.currentPeriodEnd)}
</p>
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { TestProviders } from '@/test/render'
import { mockDevices, mockSubscription, mockUser } from '@/test/fixtures'
import { useCurrentUser, useDevices, useSubscription } from './hooks'
// Verifies the typed hooks talk to the mocked API and unwrap the various
// response envelopes correctly. No real backend is contacted (MSW).
const wrapper = TestProviders
describe('data hooks', () => {
it('useCurrentUser unwraps res.data.data', async () => {
const { result } = renderHook(() => useCurrentUser(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.email).toBe(mockUser.email)
})
it('useSubscription returns the raw body', async () => {
const { result } = renderHook(() => useSubscription(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.plan?.name).toBe(mockSubscription.plan.name)
})
it('useDevices unwraps to the device array', async () => {
const { result } = renderHook(() => useDevices(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toHaveLength(mockDevices.length)
expect(result.current.data?.[0]._id).toBe(mockDevices[0]._id)
})
})
+158
View File
@@ -0,0 +1,158 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
} from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { queryKeys } from './query-keys'
import type {
ApiKey,
ApiKeyStatusFilter,
Device,
GatewayStats,
Plan,
Subscription,
User,
} from './types'
// Most endpoints wrap their payload as { data: ... }; a few (subscription)
// return the object directly. These helpers keep the unwrapping in one place.
const unwrapData = <T>(res: { data: { data: T } }) => res.data.data
const unwrapBody = <T>(res: { data: T }) => res.data
type QueryOpts<T> = Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>
// List endpoints return { data: T[] }. Legacy (not-yet-migrated) components
// cache that raw envelope under the same query key, so these hooks must keep
// the raw shape in the cache and unwrap per-observer via `select` to avoid a
// cache-shape collision on shared keys like ['devices'] and ['webhooks'].
type ListEnvelope<T> = { data: T[] }
type ListQueryOpts<T> = Omit<
UseQueryOptions<ListEnvelope<T>, Error, T[]>,
'queryKey' | 'queryFn' | 'select'
>
const selectList = <T>(raw: ListEnvelope<T> | undefined): T[] => raw?.data ?? []
// ---------- account ----------
export function useCurrentUser(options?: QueryOpts<User>) {
return useQuery({
queryKey: queryKeys.currentUser,
queryFn: () =>
httpBrowserClient.get(ApiEndpoints.auth.whoAmI()).then(unwrapData<User>),
...options,
})
}
// ---------- billing ----------
export function useSubscription(options?: QueryOpts<Subscription>) {
return useQuery({
queryKey: queryKeys.subscription,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
.then(unwrapBody<Subscription>),
...options,
})
}
export function useBillingPlans(options?: ListQueryOpts<Plan>) {
return useQuery({
queryKey: queryKeys.billingPlans,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.plans())
.then((r) => r.data as ListEnvelope<Plan>),
select: selectList<Plan>,
...options,
})
}
// ---------- gateway ----------
export function useGatewayStats(options?: QueryOpts<GatewayStats>) {
return useQuery({
queryKey: queryKeys.stats,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getStats())
.then(unwrapData<GatewayStats>),
...options,
})
}
export function useDevices(options?: ListQueryOpts<Device>) {
return useQuery({
queryKey: queryKeys.devices,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((r) => r.data as ListEnvelope<Device>),
select: selectList<Device>,
...options,
})
}
export function useDeleteDevice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.gateway.deleteDevice(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.devices })
},
})
}
// ---------- api keys ----------
export function useApiKeys(
status: ApiKeyStatusFilter = 'active',
options?: ListQueryOpts<ApiKey>
) {
return useQuery({
queryKey: queryKeys.apiKeys(status),
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.listApiKeys(status))
.then((r) => r.data as ListEnvelope<ApiKey>),
select: selectList<ApiKey>,
...options,
})
}
export function useRevokeApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.post(ApiEndpoints.auth.revokeApiKey(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['apiKeys'] })
},
})
}
export function useDeleteApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.auth.deleteApiKey(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['apiKeys'] })
},
})
}
export function useRenameApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
httpBrowserClient.patch(ApiEndpoints.auth.renameApiKey(id), { name }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.apiKeys('active') })
},
})
}
+3
View File
@@ -0,0 +1,3 @@
export * from './types'
export * from './query-keys'
export * from './hooks'
+20
View File
@@ -0,0 +1,20 @@
import type { ApiKeyStatusFilter } from './types'
// Single source of truth for react-query cache keys. Values intentionally match
// the ad-hoc string keys used across the app before this refactor (e.g.
// ['devices'], ['currentSubscription'], ['apiKeys', 'active']) so migrated and
// not-yet-migrated components still share the same cache entries.
export const queryKeys = {
currentUser: ['currentUser'] as const,
subscription: ['currentSubscription'] as const,
stats: ['stats'] as const,
devices: ['devices'] as const,
webhooks: ['webhooks'] as const,
billingPlans: ['billingPlans'] as const,
apiKeys: (status: ApiKeyStatusFilter = 'active') =>
['apiKeys', status] as const,
deviceMessages: (deviceId: string, filters?: Record<string, unknown>) =>
filters
? (['messages', deviceId, filters] as const)
: (['messages', deviceId] as const),
}
+85
View File
@@ -0,0 +1,85 @@
// Shared response types for the backend API. Kept permissive (most fields
// optional) to match the loosely-typed backend payloads and the project's
// non-strict TS config, while still giving components real autocomplete.
export interface User {
_id?: string
id?: string
name?: string
email?: string
phone?: string
role?: string
avatar?: string | null
emailVerifiedAt?: string | null
onboardingCompletedAt?: string | null
}
export interface GatewayStats {
totalSentSMSCount?: number
totalReceivedSMSCount?: number
totalDeviceCount?: number
totalApiKeyCount?: number
}
export interface Device {
_id: string
brand?: string
model?: string
enabled?: boolean
status?: string
batteryLevel?: number
appVersionCode?: number
createdAt?: string
}
export interface ApiKey {
_id: string
apiKey: string
name?: string
status?: 'active' | 'revoked'
lastUsedAt?: string | null
createdAt?: string
}
export interface Plan {
name?: string
dailyLimit?: number
monthlyLimit?: number
bulkSendLimit?: number
deviceLimit?: number
amount?: number
currency?: string
recurringInterval?: string
}
export interface SubscriptionUsage {
dailyLimit?: number
monthlyLimit?: number
bulkSendLimit?: number
deviceLimit?: number
processedSmsToday?: number
processedSmsLastMonth?: number
dailyRemaining?: number
monthlyRemaining?: number
dailyUsagePercentage?: number
monthlyUsagePercentage?: number
}
export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | string
export interface Subscription {
plan?: Plan
usage?: SubscriptionUsage
status?: SubscriptionStatus
amount?: number
currency?: string
recurringInterval?: string
subscriptionStartDate?: string
currentPeriodEnd?: string
customDailyLimit?: number | null
customMonthlyLimit?: number | null
customBulkSendLimit?: number | null
customDeviceLimit?: number | null
}
export type ApiKeyStatusFilter = 'active' | 'revoked' | 'all'
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
formatDate,
formatLimit,
formatPrice,
getBillingInterval,
titleCaseStatus,
} from './format'
describe('formatLimit', () => {
it('renders -1 as Unlimited', () => {
expect(formatLimit(-1)).toBe('Unlimited')
})
it('renders null/undefined as 0', () => {
expect(formatLimit(null)).toBe('0')
expect(formatLimit(undefined)).toBe('0')
})
it('thousands-separates numbers', () => {
expect(formatLimit(100000)).toBe('100,000')
})
})
describe('formatPrice', () => {
it('formats cents into a currency string', () => {
expect(formatPrice(1900, 'usd')).toBe('$19.00')
})
it('returns Free when amount or currency is missing', () => {
expect(formatPrice(null, null)).toBe('Free')
expect(formatPrice(1900, null)).toBe('Free')
})
})
describe('getBillingInterval', () => {
it('maps month to monthly and anything else to yearly', () => {
expect(getBillingInterval('month')).toBe('monthly')
expect(getBillingInterval('year')).toBe('yearly')
expect(getBillingInterval(null)).toBe('')
})
})
describe('formatDate', () => {
it('returns N/A for empty values', () => {
expect(formatDate(null)).toBe('N/A')
expect(formatDate(undefined)).toBe('N/A')
})
it('formats an ISO date', () => {
expect(formatDate('2026-08-01T00:00:00.000Z')).toMatch(/2026/)
})
})
describe('titleCaseStatus', () => {
it('title-cases underscore separated statuses', () => {
expect(titleCaseStatus('past_due')).toBe('Past Due')
expect(titleCaseStatus('active')).toBe('Active')
expect(titleCaseStatus(null)).toBe('')
})
})
+47
View File
@@ -0,0 +1,47 @@
// Shared formatting helpers. These were previously duplicated inline across
// subscription-info, device-list, overview and others.
// A plan limit of -1 means unlimited; null/undefined render as 0.
export function formatLimit(value: number | null | undefined): string {
if (value === -1) return 'Unlimited'
if (value == null) return '0'
return value.toLocaleString()
}
// Amounts come from the backend in minor units (cents). Null amount = Free.
export function formatPrice(
amount: number | null | undefined,
currency: string | null | undefined
): string {
if (amount == null || currency == null) return 'Free'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase() || 'USD',
minimumFractionDigits: 2,
}).format(amount / 100)
}
export function getBillingInterval(
interval: string | null | undefined
): string {
if (!interval) return ''
return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly'
}
export function formatDate(value: string | number | Date | null | undefined) {
if (!value) return 'N/A'
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
// "past_due" -> "Past Due"
export function titleCaseStatus(status: string | null | undefined): string {
if (!status) return ''
return status
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
+37
View File
@@ -0,0 +1,37 @@
import type { SubscriptionStatus } from '@/lib/api/types'
// Subscription status colors, previously repeated across subscription-info's
// badge, icon and text. Returns semantic tone classes for text + background.
export type StatusTone = {
text: string
bg: string
}
export function subscriptionStatusTone(
status: SubscriptionStatus | null | undefined
): StatusTone {
switch (status) {
case 'active':
return {
text: 'text-green-600 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-900/30',
}
case 'past_due':
return {
text: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-50 dark:bg-amber-900/30',
}
default:
return {
text: 'text-muted-foreground',
bg: 'bg-muted',
}
}
}
// Usage meter color by percentage: green under 80, amber 80-99, red at 100+.
export function usageMeterColor(percentage: number): string {
if (percentage >= 100) return 'bg-red-500'
if (percentage >= 80) return 'bg-amber-500'
return 'bg-green-500'
}
+3 -3
View File
@@ -11,11 +11,11 @@ export function cn(...inputs: ClassValue[]) {
* @returns Formatted string like "Brand Model" or "Brand Model (Custom Name)"
*/
export function formatDeviceName(device: {
brand: string
model: string
brand?: string
model?: string
name?: string | null
}): string {
const baseName = `${device.brand} ${device.model}`
const baseName = `${device.brand ?? ''} ${device.model ?? ''}`.trim()
if (device.name && device.name.trim() !== '' && device.name !== baseName) {
return `${baseName} (${device.name})`