mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
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:
co-authored by
Claude Opus 4.8
parent
89f2f78706
commit
497a14074a
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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') })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './query-keys'
|
||||
export * from './hooks'
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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
@@ -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})`
|
||||
|
||||
Reference in New Issue
Block a user