diff --git a/.gitignore b/.gitignore index fba64347b4..b63f08dd25 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Git worktrees .worktrees/ +.worktrees diff --git a/apps/admin/app/dashboard/catalog/page.tsx b/apps/admin/app/dashboard/catalog/page.tsx index 8d6133bdd9..a4d3f89d1f 100644 --- a/apps/admin/app/dashboard/catalog/page.tsx +++ b/apps/admin/app/dashboard/catalog/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Badge } from '@packrat/web-ui/components/badge'; +import { Button } from '@packrat/web-ui/components/button'; import { Skeleton } from '@packrat/web-ui/components/skeleton'; import { Table, @@ -13,11 +14,15 @@ import { import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DeleteButton } from 'admin-app/components/delete-button'; import { EditCatalogDialog } from 'admin-app/components/edit-catalog-dialog'; +import { RawObjectDialog } from 'admin-app/components/raw-object-dialog'; import { SearchInput } from 'admin-app/components/search-input'; +import { usePaginatedSearch } from 'admin-app/hooks/use-paginated-search'; import { type AdminCatalogItem, deleteCatalogItem, getCatalogItems } from 'admin-app/lib/api'; import { formatDate } from 'admin-app/lib/date'; import { queryKeys } from 'admin-app/lib/queryKeys'; -import { useSearchParams } from 'next/navigation'; +import { ChevronLeft, ChevronRight, ExternalLink, Star } from 'lucide-react'; + +const PAGE_SIZE = 50; function TableSkeleton() { return ( @@ -32,21 +37,28 @@ function TableSkeleton() { + - + ))} ); } +function availabilityColor(availability: string | null) { + if (availability === 'InStock') return 'text-green-500'; + if (availability === 'OutOfStock') return 'text-destructive'; + return 'text-muted-foreground'; +} + function CatalogRow({ item }: { item: AdminCatalogItem }) { const queryClient = useQueryClient(); const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deleteCatalogItem(item.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog() }); + queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog.all() }); }, }); @@ -54,8 +66,26 @@ function CatalogRow({ item }: { item: AdminCatalogItem }) {
-

{item.name}

- {item.brand &&

{item.brand}

} +
+

{item.name}

+ {item.productUrl && ( + + + + )} +
+
+ {item.brand && {item.brand}} + {item.model && {item.model}} +
+ {item.description && ( +

{item.description}

+ )}
@@ -83,9 +113,27 @@ function CatalogRow({ item }: { item: AdminCatalogItem }) { - {item.price != null ? `$${item.price.toFixed(2)}` : '—'} + {item.price != null + ? `${item.currency && item.currency !== 'USD' ? `${item.currency} ` : '$'}${item.price.toFixed(2)}` + : '—'} + +
+ + {item.availability ?? '—'} + + {item.ratingValue != null && ( +
+ + + {item.ratingValue.toFixed(1)} + {item.reviewCount != null && ` (${item.reviewCount})`} + +
+ )} +
+
{item.createdAt ? formatDate(new Date(item.createdAt)) : '—'} @@ -93,6 +141,7 @@ function CatalogRow({ item }: { item: AdminCatalogItem }) {
+ getCatalogItems({ q }), + queryKey: queryKeys.admin.catalog.list({ q: q || undefined, page }), + queryFn: () => getCatalogItems({ q: q || undefined, limit: PAGE_SIZE, offset }), }); + const hasPrev = page > 0; + const hasNext = items.length === PAGE_SIZE; + return (
@@ -129,7 +181,7 @@ export default function CatalogPage() {

- + {isError ? (

Failed to load catalog. Check that the API is reachable. @@ -154,16 +206,19 @@ export default function CatalogPage() { Price + + Status + Added - + {items.length === 0 ? ( - + No catalog items found{q ? ` matching "${q}"` : ''}. @@ -173,10 +228,34 @@ export default function CatalogPage() {

-

- {items.length.toLocaleString()} item{items.length !== 1 ? 's' : ''} - {q ? ` matching "${q}"` : ''} -

+
+

+ {items.length === 0 + ? `No items${q ? ` matching "${q}"` : ''}` + : `${(offset + 1).toLocaleString()}–${(offset + items.length).toLocaleString()} items${q ? ` matching "${q}"` : ''}`} +

+
+ + Page {page + 1} + +
+
)}
diff --git a/apps/admin/app/dashboard/error.tsx b/apps/admin/app/dashboard/error.tsx new file mode 100644 index 0000000000..9b9e936a80 --- /dev/null +++ b/apps/admin/app/dashboard/error.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Button } from '@packrat/web-ui/components/button'; +import { useEffect } from 'react'; + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Dashboard error:', error); + }, [error]); + + return ( +
+

Failed to load

+

+ {error.message || 'Something went wrong loading this page.'} +

+ +
+ ); +} diff --git a/apps/admin/app/dashboard/layout.tsx b/apps/admin/app/dashboard/layout.tsx index c9ba4294b5..2692c9ddc4 100644 --- a/apps/admin/app/dashboard/layout.tsx +++ b/apps/admin/app/dashboard/layout.tsx @@ -1,17 +1,28 @@ +'use client'; + import { SidebarInset, SidebarProvider } from '@packrat/web-ui/components/sidebar'; import { AppSidebar } from 'admin-app/components/app-sidebar'; import { AuthGuard } from 'admin-app/components/auth-guard'; import { DashboardHeader } from 'admin-app/components/dashboard-header'; +import { ErrorFallback } from 'admin-app/components/error-fallback'; +import { usePathname } from 'next/navigation'; import type React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + return ( -
{children}
+
+ + {children} + +
diff --git a/apps/admin/app/dashboard/packs/page.tsx b/apps/admin/app/dashboard/packs/page.tsx index a2b826a5cb..f97d5e1aa9 100644 --- a/apps/admin/app/dashboard/packs/page.tsx +++ b/apps/admin/app/dashboard/packs/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Badge } from '@packrat/web-ui/components/badge'; +import { Button } from '@packrat/web-ui/components/button'; import { Skeleton } from '@packrat/web-ui/components/skeleton'; import { Table, @@ -12,11 +13,15 @@ import { } from '@packrat/web-ui/components/table'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DeleteButton } from 'admin-app/components/delete-button'; +import { RawObjectDialog } from 'admin-app/components/raw-object-dialog'; import { SearchInput } from 'admin-app/components/search-input'; +import { usePaginatedSearch } from 'admin-app/hooks/use-paginated-search'; import { type AdminPack, deletePack, getPacks } from 'admin-app/lib/api'; import { formatDate } from 'admin-app/lib/date'; import { queryKeys } from 'admin-app/lib/queryKeys'; -import { useSearchParams } from 'next/navigation'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +const PAGE_SIZE = 50; function TableSkeleton() { return ( @@ -45,7 +50,7 @@ function PackRow({ pack }: { pack: AdminPack }) { const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deletePack(pack.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.packs() }); + queryClient.invalidateQueries({ queryKey: queryKeys.admin.packs.all() }); }, }); @@ -53,10 +58,29 @@ function PackRow({ pack }: { pack: AdminPack }) {
-

{pack.name}

+
+

{pack.name}

+ {pack.isAIGenerated && ( + + AI + + )} +
{pack.description && (

{pack.description}

)} + {pack.tags && pack.tags.length > 0 && ( +
+ {pack.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {pack.tags.length > 3 && ( + +{pack.tags.length - 3} + )} +
+ )}
@@ -80,31 +104,37 @@ function PackRow({ pack }: { pack: AdminPack }) { - { - await handleDelete(); - }} - /> +
+ + { + await handleDelete(); + }} + /> +
); } export default function PacksPage() { - const searchParams = useSearchParams(); - const q = searchParams?.get('q') ?? undefined; + const { q, setSearch, page, setPage } = usePaginatedSearch(); + const offset = page * PAGE_SIZE; const { data: packs = [], isLoading, isError, } = useQuery({ - queryKey: queryKeys.admin.packs(q), - queryFn: () => getPacks({ q }), + queryKey: queryKeys.admin.packs.list({ q: q || undefined, page }), + queryFn: () => getPacks({ q: q || undefined, limit: PAGE_SIZE, offset }), }); + const hasPrev = page > 0; + const hasNext = packs.length === PAGE_SIZE; + return (
@@ -114,7 +144,7 @@ export default function PacksPage() {

- + {isError ? (

Failed to load packs. Check that the API is reachable. @@ -158,10 +188,34 @@ export default function PacksPage() {

-

- {packs.length.toLocaleString()} pack{packs.length !== 1 ? 's' : ''} - {q ? ` matching "${q}"` : ''} -

+
+

+ {packs.length === 0 + ? `No packs${q ? ` matching "${q}"` : ''}` + : `${(offset + 1).toLocaleString()}–${(offset + packs.length).toLocaleString()} packs${q ? ` matching "${q}"` : ''}`} +

+
+ + Page {page + 1} + +
+
)}
diff --git a/apps/admin/app/dashboard/page.tsx b/apps/admin/app/dashboard/page.tsx index d2543b8eae..8557f03995 100644 --- a/apps/admin/app/dashboard/page.tsx +++ b/apps/admin/app/dashboard/page.tsx @@ -48,22 +48,22 @@ function OverviewSkeleton() { export default function DashboardPage() { const { data: stats, isLoading: statsLoading } = useQuery({ - queryKey: queryKeys.admin.stats, + queryKey: queryKeys.admin.stats(), queryFn: getStats, }); const { data: users = [], isLoading: usersLoading } = useQuery({ - queryKey: queryKeys.admin.users(5), + queryKey: queryKeys.admin.users.list({ limit: 5 }), queryFn: () => getUsers({ limit: 5 }), }); const { data: packs = [], isLoading: packsLoading } = useQuery({ - queryKey: queryKeys.admin.packs(5), + queryKey: queryKeys.admin.packs.list({ limit: 5 }), queryFn: () => getPacks({ limit: 5 }), }); const { data: catalog = [], isLoading: catalogLoading } = useQuery({ - queryKey: queryKeys.admin.catalog(5), + queryKey: queryKeys.admin.catalog.list({ limit: 5 }), queryFn: () => getCatalogItems({ limit: 5 }), }); diff --git a/apps/admin/app/dashboard/users/page.tsx b/apps/admin/app/dashboard/users/page.tsx index 7dfaa86cfa..3d36c1b178 100644 --- a/apps/admin/app/dashboard/users/page.tsx +++ b/apps/admin/app/dashboard/users/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Badge } from '@packrat/web-ui/components/badge'; +import { Button } from '@packrat/web-ui/components/button'; import { Skeleton } from '@packrat/web-ui/components/skeleton'; import { Table, @@ -12,11 +13,16 @@ import { } from '@packrat/web-ui/components/table'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DeleteButton } from 'admin-app/components/delete-button'; +import { RawObjectDialog } from 'admin-app/components/raw-object-dialog'; import { SearchInput } from 'admin-app/components/search-input'; +import { usePaginatedSearch } from 'admin-app/hooks/use-paginated-search'; import { type AdminUser, deleteUser, getUsers } from 'admin-app/lib/api'; import { formatDate } from 'admin-app/lib/date'; import { queryKeys } from 'admin-app/lib/queryKeys'; -import { useSearchParams } from 'next/navigation'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import Image from 'next/image'; + +const PAGE_SIZE = 50; function TableSkeleton() { return ( @@ -31,7 +37,7 @@ function TableSkeleton() { - +
))} @@ -44,22 +50,39 @@ function UserRow({ user }: { user: AdminUser }) { const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deleteUser(user.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.users() }); + queryClient.invalidateQueries({ queryKey: queryKeys.admin.users.all() }); }, }); return ( -
-

- {user.firstName || user.lastName - ? [user.firstName, user.lastName].filter(Boolean).join(' ') - : user.email} -

- {(user.firstName || user.lastName) && ( -

{user.email}

+
+ {user.avatarUrl ? ( + + ) : ( +
+ + {user.firstName?.[0] ?? user.email[0] ?? '?'} + +
)} +
+

+ {user.firstName || user.lastName + ? [user.firstName, user.lastName].filter(Boolean).join(' ') + : user.email} +

+ {(user.firstName || user.lastName) && ( +

{user.email}

+ )} +
@@ -80,31 +103,37 @@ function UserRow({ user }: { user: AdminUser }) { - { - await handleDelete(); - }} - /> +
+ + { + await handleDelete(); + }} + /> +
); } export default function UsersPage() { - const searchParams = useSearchParams(); - const q = searchParams?.get('q') ?? undefined; + const { q, setSearch, page, setPage } = usePaginatedSearch(); + const offset = page * PAGE_SIZE; const { data: users = [], isLoading, isError, } = useQuery({ - queryKey: queryKeys.admin.users(q), - queryFn: () => getUsers({ q }), + queryKey: queryKeys.admin.users.list({ q: q || undefined, page }), + queryFn: () => getUsers({ q: q || undefined, limit: PAGE_SIZE, offset }), }); + const hasPrev = page > 0; + const hasNext = users.length === PAGE_SIZE; + return (
@@ -114,7 +143,7 @@ export default function UsersPage() {

- + {isError ? (

Failed to load users. Check that the API is reachable. @@ -139,7 +168,7 @@ export default function UsersPage() { Joined - + @@ -155,10 +184,34 @@ export default function UsersPage() {

-

- {users.length.toLocaleString()} user{users.length !== 1 ? 's' : ''} - {q ? ` matching "${q}"` : ''} -

+
+

+ {users.length === 0 + ? `No users${q ? ` matching "${q}"` : ''}` + : `${(offset + 1).toLocaleString()}–${(offset + users.length).toLocaleString()} users${q ? ` matching "${q}"` : ''}`} +

+
+ + Page {page + 1} + +
+
)}
diff --git a/apps/admin/app/error.tsx b/apps/admin/app/error.tsx new file mode 100644 index 0000000000..fa88e82241 --- /dev/null +++ b/apps/admin/app/error.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Button } from '@packrat/web-ui/components/button'; +import { useEffect } from 'react'; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('App error:', error); + }, [error]); + + return ( +
+

Something went wrong

+

+ {error.message || 'An unexpected error occurred.'} +

+ +
+ ); +} diff --git a/apps/admin/app/global-error.tsx b/apps/admin/app/global-error.tsx new file mode 100644 index 0000000000..1527c878c3 --- /dev/null +++ b/apps/admin/app/global-error.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Global error:', error); + }, [error]); + + return ( + + +

Something went wrong

+

+ An unexpected error occurred. +

+ + + + ); +} diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx index 532c0fff6f..3cc26dd33f 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -3,6 +3,7 @@ import { QueryProvider } from 'admin-app/components/query-provider'; import { ThemeProvider } from 'admin-app/components/theme-provider'; import type { Metadata } from 'next'; import { Mona_Sans as FontSans } from 'next/font/google'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import type React from 'react'; import './globals.css'; @@ -29,16 +30,18 @@ export default function RootLayout({ return ( - - - {children} - - + + + + {children} + + + ); diff --git a/apps/admin/components/analytics/catalog-analytics.tsx b/apps/admin/components/analytics/catalog-analytics.tsx index 73d2221f88..1b517624dc 100644 --- a/apps/admin/components/analytics/catalog-analytics.tsx +++ b/apps/admin/components/analytics/catalog-analytics.tsx @@ -1,6 +1,7 @@ 'use client'; import { Badge } from '@packrat/web-ui/components/badge'; +import { Button } from '@packrat/web-ui/components/button'; import { Card, CardContent, @@ -16,6 +17,8 @@ import { ChartTooltip, ChartTooltipContent, } from '@packrat/web-ui/components/chart'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { RawObjectDialog } from 'admin-app/components/raw-object-dialog'; import { useCatalogBrands, useCatalogEmbeddings, @@ -23,6 +26,9 @@ import { useCatalogOverview, useCatalogPrices, } from 'admin-app/hooks/use-catalog-analytics'; +import { resetStuckEtlJobs } from 'admin-app/lib/api'; +import { queryKeys } from 'admin-app/lib/queryKeys'; +import { RotateCcw } from 'lucide-react'; import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts'; const priceConfig: ChartConfig = { @@ -49,12 +55,25 @@ function statusBadgeVariant(status: string): 'default' | 'secondary' | 'destruct } export function CatalogAnalytics() { + const queryClient = useQueryClient(); const { data: overview } = useCatalogOverview(); const { data: brands } = useCatalogBrands(15); const { data: prices } = useCatalogPrices(); const { data: etl } = useCatalogEtl(15); const { data: embeddings } = useCatalogEmbeddings(); + const { + mutate: resetStuck, + isPending: isResetting, + isError: resetFailed, + data: resetResult, + } = useMutation({ + mutationFn: resetStuckEtlJobs, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.catalogAnalytics.etl.all() }); + }, + }); + const availConfig: ChartConfig = Object.fromEntries( (overview?.availability ?? []).map((a, i) => [ a.status ?? 'unknown', @@ -250,12 +269,35 @@ export function CatalogAnalytics() { {etl && ( - ETL Pipeline - - {etl.summary.totalRuns} total runs — {etl.summary.completed} completed,{' '} - {etl.summary.failed} failed — {etl.summary.totalItemsIngested.toLocaleString()}{' '} - items ingested - +
+
+ ETL Pipeline + + {etl.summary.totalRuns} total runs — {etl.summary.completed} completed,{' '} + {etl.summary.failed} failed —{' '} + {etl.summary.totalItemsIngested.toLocaleString()} items ingested + {resetFailed && — reset failed} + {!resetFailed && resetResult && resetResult.reset > 0 && ( + + — reset {resetResult.reset} stuck job{resetResult.reset !== 1 ? 's' : ''} + + )} + {!resetFailed && resetResult && resetResult.reset === 0 && ( + — no stuck jobs found + )} + +
+ +
@@ -266,8 +308,11 @@ export function CatalogAnalytics() { Status Processed Valid + Invalid Success % Started + Completed + @@ -285,12 +330,27 @@ export function CatalogAnalytics() { {job.totalValid?.toLocaleString() ?? '—'} + + {job.totalInvalid != null ? ( + 0 ? 'text-destructive' : ''}> + {job.totalInvalid.toLocaleString()} + + ) : ( + '—' + )} + {job.successRate != null ? `${job.successRate}%` : '—'} - + {new Date(job.startedAt).toLocaleDateString()} + + {job.completedAt ? new Date(job.completedAt).toLocaleDateString() : '—'} + + + + ))} diff --git a/apps/admin/components/edit-catalog-dialog.tsx b/apps/admin/components/edit-catalog-dialog.tsx index b35d414650..e03ff86c5f 100644 --- a/apps/admin/components/edit-catalog-dialog.tsx +++ b/apps/admin/components/edit-catalog-dialog.tsx @@ -29,7 +29,7 @@ export function EditCatalogDialog({ item }: EditCatalogDialogProps) { const { mutate, isPending } = useMutation({ mutationFn: (data: Parameters[1]) => updateCatalogItem(item.id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog() }); + queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog.all() }); setOpen(false); }, }); diff --git a/apps/admin/components/error-fallback.tsx b/apps/admin/components/error-fallback.tsx new file mode 100644 index 0000000000..91127ec1a6 --- /dev/null +++ b/apps/admin/components/error-fallback.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { Button } from '@packrat/web-ui/components/button'; +import { useEffect } from 'react'; +import type { FallbackProps } from 'react-error-boundary'; + +export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + useEffect(() => { + console.error('Dashboard error:', error); + }, [error]); + + return ( +
+

Something went wrong

+

An unexpected error occurred.

+ +
+ ); +} diff --git a/apps/admin/components/raw-object-dialog.tsx b/apps/admin/components/raw-object-dialog.tsx new file mode 100644 index 0000000000..023aa8922c --- /dev/null +++ b/apps/admin/components/raw-object-dialog.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Button } from '@packrat/web-ui/components/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@packrat/web-ui/components/dialog'; +import { Braces } from 'lucide-react'; + +interface RawObjectDialogProps { + label: string; + data: unknown; +} + +export function RawObjectDialog({ label, data }: RawObjectDialogProps) { + return ( + + + + + + + {label} + Raw JSON data for {label} + +
+
+            {JSON.stringify(data, null, 2)}
+          
+
+
+
+ ); +} diff --git a/apps/admin/components/search-input.tsx b/apps/admin/components/search-input.tsx index a6c383eff2..1074ec87b6 100644 --- a/apps/admin/components/search-input.tsx +++ b/apps/admin/components/search-input.tsx @@ -2,59 +2,38 @@ import { Input } from '@packrat/web-ui/components/input'; import { Search } from 'lucide-react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useCallback, useTransition } from 'react'; +import { parseAsString, useQueryState } from 'nuqs'; interface SearchInputProps { placeholder?: string; paramKey?: string; + onSearch?: (value: string) => void; } -// Inner component — must be inside because it calls useSearchParams() -function SearchInputInner({ placeholder = 'Search…', paramKey = 'q' }: SearchInputProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const [, startTransition] = useTransition(); - - const value = searchParams?.get(paramKey) ?? ''; - - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const next = new URLSearchParams(searchParams?.toString() ?? ''); - if (e.target.value) { - next.set(paramKey, e.target.value); - } else { - next.delete(paramKey); - } - startTransition(() => { - router.replace(`?${next.toString()}`, { scroll: false }); - }); - }, - [router, searchParams, paramKey], +export function SearchInput({ + placeholder = 'Search…', + paramKey = 'q', + onSearch, +}: SearchInputProps) { + const [value, setValue] = useQueryState( + paramKey, + parseAsString + .withDefault('') + .withOptions({ shallow: false, throttleMs: 300, clearOnDefault: true }), ); - return ( -
- - -
- ); -} + function handleChange(e: React.ChangeEvent) { + if (onSearch) { + onSearch(e.target.value); + } else { + void setValue(e.target.value || null); + } + } -// Fallback shown while the inner component suspends -function SearchInputFallback({ placeholder }: Pick) { return (
- +
); } - -export function SearchInput(props: SearchInputProps) { - return ( - }> - - - ); -} diff --git a/apps/admin/hooks/use-catalog-analytics.ts b/apps/admin/hooks/use-catalog-analytics.ts index 05408adffb..e3af5eb693 100644 --- a/apps/admin/hooks/use-catalog-analytics.ts +++ b/apps/admin/hooks/use-catalog-analytics.ts @@ -12,7 +12,7 @@ import { queryKeys } from 'admin-app/lib/queryKeys'; export function useCatalogOverview() { return useQuery({ - queryKey: queryKeys.catalogAnalytics.overview, + queryKey: queryKeys.catalogAnalytics.overview(), queryFn: () => getCatalogOverview(), }); } @@ -26,21 +26,21 @@ export function useCatalogBrands(limit = 20) { export function useCatalogPrices() { return useQuery({ - queryKey: queryKeys.catalogAnalytics.prices, + queryKey: queryKeys.catalogAnalytics.prices(), queryFn: () => getCatalogPrices(), }); } export function useCatalogEtl(limit = 20) { return useQuery({ - queryKey: queryKeys.catalogAnalytics.etl(limit), + queryKey: queryKeys.catalogAnalytics.etl.list(limit), queryFn: () => getCatalogEtl(limit), }); } export function useCatalogEmbeddings() { return useQuery({ - queryKey: queryKeys.catalogAnalytics.embeddings, + queryKey: queryKeys.catalogAnalytics.embeddings(), queryFn: () => getCatalogEmbeddings(), }); } diff --git a/apps/admin/hooks/use-paginated-search.ts b/apps/admin/hooks/use-paginated-search.ts new file mode 100644 index 0000000000..7cfbfa3c50 --- /dev/null +++ b/apps/admin/hooks/use-paginated-search.ts @@ -0,0 +1,26 @@ +'use client'; + +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { useCallback } from 'react'; + +const parsers = { + q: parseAsString.withDefault(''), + page: parseAsInteger.withDefault(0), +}; + +export function usePaginatedSearch() { + const [{ q, page }, setParams] = useQueryStates(parsers, { shallow: false }); + + // Setting q resets page atomically so users never land on a stale page. + const setSearch = useCallback( + (next: string) => setParams({ q: next || null, page: null }), + [setParams], + ); + + return { + q, + setSearch, + page: Math.max(0, page), + setPage: (next: number) => setParams({ page: next > 0 ? next : null }), + }; +} diff --git a/apps/admin/hooks/use-platform-analytics.ts b/apps/admin/hooks/use-platform-analytics.ts index a2721f43f4..68f889eea4 100644 --- a/apps/admin/hooks/use-platform-analytics.ts +++ b/apps/admin/hooks/use-platform-analytics.ts @@ -20,7 +20,7 @@ export function usePlatformActivity(period: 'day' | 'week' | 'month') { export function usePlatformBreakdown() { return useQuery({ - queryKey: queryKeys.platform.breakdown, + queryKey: queryKeys.platform.breakdown(), queryFn: () => getPlatformBreakdown(), }); } diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 3087a08894..2941f5f610 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -57,7 +57,9 @@ export interface AdminUser { lastName: string | null; role: string | null; emailVerified: boolean | null; + avatarUrl: string | null; createdAt: string | null; + updatedAt: string | null; } export function getUsers({ @@ -86,7 +88,11 @@ export interface AdminPack { description: string | null; category: string; isPublic: boolean | null; + isAIGenerated: boolean; + tags: string[] | null; + image: string | null; createdAt: string | null; + updatedAt: string | null; userEmail: string | null; } @@ -113,11 +119,27 @@ export function deletePack(id: string): Promise<{ success: boolean }> { export interface AdminCatalogItem { id: number; name: string; + description: string | null; categories: string[] | null; brand: string | null; + model: string | null; + sku: string | null; price: number | null; + currency: string | null; weight: number | null; weightUnit: string; + availability: string | null; + ratingValue: number | null; + reviewCount: number | null; + color: string | null; + size: string | null; + material: string | null; + seller: string | null; + productUrl: string | null; + images: string[] | null; + variants: Array<{ attribute: string; values: string[] }> | null; + techs: Record | null; + links: Array<{ title: string; url: string }> | null; createdAt: string | null; } @@ -246,3 +268,7 @@ export function getCatalogEtl(limit = 20): Promise { export function getCatalogEmbeddings(): Promise { return adminFetch('/analytics/catalog/embeddings'); } + +export function resetStuckEtlJobs(): Promise<{ reset: number; ids: string[] }> { + return adminFetch('/analytics/catalog/etl/reset-stuck', { method: 'POST' }); +} diff --git a/apps/admin/lib/cfAccess.ts b/apps/admin/lib/cfAccess.ts index aeffab874f..503ccefeaf 100644 --- a/apps/admin/lib/cfAccess.ts +++ b/apps/admin/lib/cfAccess.ts @@ -60,7 +60,7 @@ export async function isBehindCFAccess(): Promise { */ export function useCFAccessIdentity() { return useQuery({ - queryKey: queryKeys.cfAccessIdentity, + queryKey: queryKeys.cfAccessIdentity(), queryFn: getCFAccessIdentity, staleTime: Infinity, gcTime: Infinity, diff --git a/apps/admin/lib/queryKeys.ts b/apps/admin/lib/queryKeys.ts index cf2c9adceb..e19046e9e3 100644 --- a/apps/admin/lib/queryKeys.ts +++ b/apps/admin/lib/queryKeys.ts @@ -1,35 +1,52 @@ /** - * Centralised query key registry for the admin SPA. + * Query key factory following the TkDodo self-referencing pattern. + * Each level spreads its parent so invalidating a parent truly invalidates all children. * - * All useQuery / useInfiniteQuery / invalidateQueries call sites should - * reference these constants instead of inlining raw arrays. This makes - * key shape changes a one-place edit and prevents typo-driven cache misses. + * @see https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories */ export const queryKeys = { - /** CF Access identity — fetched once per page lifetime. */ - cfAccessIdentity: ['cf-access-identity'] as const, + cfAccessIdentity: () => ['cfAccessIdentity'] as const, - /** Admin entity queries (dashboard pages). */ admin: { - stats: ['admin', 'stats'] as const, - users: (limitOrQuery?: number | string) => ['admin', 'users', limitOrQuery] as const, - packs: (limitOrQuery?: number | string) => ['admin', 'packs', limitOrQuery] as const, - catalog: (limitOrQuery?: number | string) => ['admin', 'catalog', limitOrQuery] as const, + all: () => ['admin'] as const, + stats: () => [...queryKeys.admin.all(), 'stats'] as const, + + users: { + all: () => [...queryKeys.admin.all(), 'users'] as const, + list: (params?: { q?: string; page?: number; limit?: number }) => + [...queryKeys.admin.users.all(), params] as const, + }, + + packs: { + all: () => [...queryKeys.admin.all(), 'packs'] as const, + list: (params?: { q?: string; page?: number; limit?: number }) => + [...queryKeys.admin.packs.all(), params] as const, + }, + + catalog: { + all: () => [...queryKeys.admin.all(), 'catalog'] as const, + list: (params?: { q?: string; page?: number; limit?: number }) => + [...queryKeys.admin.catalog.all(), params] as const, + }, }, - /** Platform analytics queries. */ platform: { - growth: (period: string) => ['platform', 'growth', period] as const, - activity: (period: string) => ['platform', 'activity', period] as const, - breakdown: ['platform', 'breakdown'] as const, + all: () => ['platform'] as const, + growth: (period: string) => [...queryKeys.platform.all(), 'growth', period] as const, + activity: (period: string) => [...queryKeys.platform.all(), 'activity', period] as const, + breakdown: () => [...queryKeys.platform.all(), 'breakdown'] as const, }, - /** Catalog analytics queries. */ catalogAnalytics: { - overview: ['catalog', 'overview'] as const, - brands: (limit?: number) => ['catalog', 'brands', limit] as const, - prices: ['catalog', 'prices'] as const, - etl: (limit?: number) => ['catalog', 'etl', limit] as const, - embeddings: ['catalog', 'embeddings'] as const, + all: () => ['catalogAnalytics'] as const, + overview: () => [...queryKeys.catalogAnalytics.all(), 'overview'] as const, + brands: (limit?: number) => [...queryKeys.catalogAnalytics.all(), 'brands', limit] as const, + prices: () => [...queryKeys.catalogAnalytics.all(), 'prices'] as const, + embeddings: () => [...queryKeys.catalogAnalytics.all(), 'embeddings'] as const, + + etl: { + all: () => [...queryKeys.catalogAnalytics.all(), 'etl'] as const, + list: (limit?: number) => [...queryKeys.catalogAnalytics.etl.all(), limit] as const, + }, }, -} as const; +}; diff --git a/apps/admin/package.json b/apps/admin/package.json index 87c22f8003..739c3d5648 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -33,8 +33,10 @@ "lucide-react": "^1.8.0", "next": "^15.3.4", "next-themes": "^0.4.6", + "nuqs": "^2.8.9", "react": "catalog:", "react-dom": "catalog:", + "react-error-boundary": "^6.1.1", "recharts": "3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/bun.lock b/bun.lock index f8749fdf22..955293518a 100644 --- a/bun.lock +++ b/bun.lock @@ -43,8 +43,10 @@ "lucide-react": "^1.8.0", "next": "^15.3.4", "next-themes": "^0.4.6", + "nuqs": "^2.8.9", "react": "catalog:", "react-dom": "catalog:", + "react-error-boundary": "^6.1.1", "recharts": "3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -1741,7 +1743,7 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -3221,6 +3223,8 @@ "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], + "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], + "ob1": ["ob1@0.83.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-m/xZYkwcjo6UqLMrUICEB3iHk7Bjt3RSR7KXMi6Y1MO/kGkPhoRmfUDF6KAan3rLAZ7ABRqnQyKUTwaqZgUV4w=="], "object-assign": ["object-assign@4.0.1", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="], @@ -3433,6 +3437,8 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], @@ -4039,6 +4045,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], @@ -4217,6 +4225,8 @@ "@react-navigation/routers/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "@reduxjs/toolkit/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -4683,6 +4693,10 @@ "@manypkg/tools/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@react-native-ai/apple/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@react-native-ai/llama/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@react-native/dev-middleware/serve-static/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], diff --git a/packages/api/scripts/reset-stuck-etl-jobs.sql b/packages/api/scripts/reset-stuck-etl-jobs.sql deleted file mode 100644 index f5595b867e..0000000000 --- a/packages/api/scripts/reset-stuck-etl-jobs.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Reset ETL jobs stuck in 'running' state for more than 3 hours. --- 3h accounts for large first-time imports (~500K rows + embedding generation). --- Run manually when zombie jobs are detected. -UPDATE etl_jobs -SET status = 'failed', completed_at = NOW() -WHERE status = 'running' - AND started_at < NOW() - INTERVAL '3 hours'; diff --git a/packages/api/src/routes/admin/analytics/catalog.ts b/packages/api/src/routes/admin/analytics/catalog.ts index 40baab1c60..21aa098d0f 100644 --- a/packages/api/src/routes/admin/analytics/catalog.ts +++ b/packages/api/src/routes/admin/analytics/catalog.ts @@ -1,6 +1,6 @@ import { createDb } from '@packrat/api/db'; import { catalogItems, etlJobs } from '@packrat/api/db/schema'; -import { and, avg, count, desc, gt, isNotNull, max, min, sql } from 'drizzle-orm'; +import { and, avg, count, desc, eq, gt, isNotNull, lt, max, min, sql } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; @@ -242,4 +242,26 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) } }, { detail: { tags: ['Admin'], summary: 'Embedding coverage' } }, + ) + + .post( + '/etl/reset-stuck', + async () => { + const db = createDb(); + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000); + + try { + const reset = await db + .update(etlJobs) + .set({ status: 'failed', completedAt: new Date() }) + .where(and(eq(etlJobs.status, 'running'), lt(etlJobs.startedAt, threeHoursAgo))) + .returning(); + + return { reset: reset.length, ids: reset.map((r) => r.id) }; + } catch (error) { + console.error('ETL reset-stuck error:', error); + return status(500, { error: 'Failed to reset stuck jobs', code: 'ETL_RESET_STUCK_ERROR' }); + } + }, + { detail: { tags: ['Admin'], summary: 'Reset ETL jobs stuck in running state for >3 hours' } }, ); diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 06b8d15dab..4e7b58179b 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -203,7 +203,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) lastName: users.lastName, role: users.role, emailVerified: users.emailVerified, + avatarUrl: users.avatarUrl, createdAt: users.createdAt, + updatedAt: users.updatedAt, }) .from(users) .where( @@ -221,7 +223,8 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) return usersList.map((u) => ({ ...u, - createdAt: u.createdAt?.toISOString() || null, + createdAt: u.createdAt?.toISOString() ?? null, + updatedAt: u.updatedAt?.toISOString() ?? null, })); } catch (error) { console.error('Error fetching users:', error); @@ -254,7 +257,11 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) description: packs.description, category: packs.category, isPublic: packs.isPublic, + isAIGenerated: packs.isAIGenerated, + tags: packs.tags, + image: packs.image, createdAt: packs.createdAt, + updatedAt: packs.updatedAt, userEmail: users.email, }) .from(packs) @@ -278,7 +285,8 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) return packsList.map((p) => ({ ...p, - createdAt: p.createdAt?.toISOString() || null, + createdAt: p.createdAt?.toISOString() ?? null, + updatedAt: p.updatedAt?.toISOString() ?? null, })); } catch (error) { console.error('Error fetching packs:', error); @@ -308,11 +316,27 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) .select({ id: catalogItems.id, name: catalogItems.name, + description: catalogItems.description, categories: catalogItems.categories, brand: catalogItems.brand, + model: catalogItems.model, + sku: catalogItems.sku, price: catalogItems.price, + currency: catalogItems.currency, weight: catalogItems.weight, weightUnit: catalogItems.weightUnit, + availability: catalogItems.availability, + ratingValue: catalogItems.ratingValue, + reviewCount: catalogItems.reviewCount, + color: catalogItems.color, + size: catalogItems.size, + material: catalogItems.material, + seller: catalogItems.seller, + productUrl: catalogItems.productUrl, + images: catalogItems.images, + variants: catalogItems.variants, + techs: catalogItems.techs, + links: catalogItems.links, createdAt: catalogItems.createdAt, }) .from(catalogItems)