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/CLAUDE.md b/CLAUDE.md index ec1fc69523..e86a7939f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,12 +96,39 @@ features/{name}/ - **Feature flags**: `apps/expo/config.ts` — `featureFlags` object, default new flags to `false` - **Animations**: React Native Reanimated 4 -### Web Apps (apps/guides, apps/landing) +### Web Apps (apps/guides, apps/landing, apps/trails) - Radix UI + Shadcn components, Tailwind CSS - TanStack React Query for data fetching - Zod for form validation +### API Client (`@packrat/api-client`) + +Use `createApiClient` from `@packrat/api-client` for all PackRat API calls in web apps. **Never write manual fetch wrappers for PackRat API endpoints.** + +```ts +// apps//lib/apiClient.ts +import { createApiClient } from '@packrat/api-client'; +import { clearTokens, clearUser, getAccessToken, getRefreshToken, setTokens } from './auth'; + +export const apiClient = createApiClient({ + baseUrl: typeof window !== 'undefined' ? window.location.origin : '', + auth: { + getAccessToken, + getRefreshToken, + onAccessTokenRefreshed: (token) => { /* persist new access token */ }, + onRefreshTokenRefreshed: (token) => { /* persist new refresh token */ }, + onNeedsReauth: () => { clearTokens(); clearUser(); }, + }, +}); +``` + +- `baseUrl` should be the same origin when routing through a CF Worker proxy (so rate limiting applies); use `EXPO_PUBLIC_API_URL` for the Expo app +- `AuthHooks` wires your platform's token storage — the package is transport-only +- The client handles 401 → refresh → retry automatically; `onNeedsReauth` fires only when refresh itself fails +- Call via Treaty path syntax: `apiClient.auth.login.post(...)`, `apiClient.trails.search.get({ query: { q } })` +- Responses are `{ data, error, status }` — check `if (error || !data)` before using `data` + ## Private Package Auth `@packrat-ai/nativewindui` is hosted on GitHub Packages. `bunfig.toml` resolves the scope using `$PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN`. Bun auto-loads `.env.local` before running `install`, so the simplest setup is to put the token there alongside your other secrets. diff --git a/apps/admin/app/dashboard/catalog/page.tsx b/apps/admin/app/dashboard/catalog/page.tsx index bb8d43642f..710468d3f5 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,16 @@ 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'; +import Image from 'next/image'; + +const PAGE_SIZE = 50; function TableSkeleton() { return ( @@ -32,30 +38,72 @@ 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() }); }, }); + const thumbUrl = item.images?.[0] ?? null; + return ( -
-

{item.name}

- {item.brand &&

{item.brand}

} +
+ {thumbUrl ? ( + + ) : ( +
+ )} +
+
+

{item.name}

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

+ {item.description} +

+ )} +
@@ -83,9 +131,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 +159,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 items = result?.data ?? []; const total = result?.total ?? 0; + const hasPrev = page > 0; + const hasNext = items.length === PAGE_SIZE; return (
@@ -132,7 +201,7 @@ export default function CatalogPage() {

- + {isError ? (

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

-

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

+
+

+ {items.length === 0 + ? `No items${q ? ` matching "${q}"` : ''}` + : `${(offset + 1).toLocaleString()}–${(offset + items.length).toLocaleString()} of ${total.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 d498628174..446dc6749a 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,13 +13,17 @@ 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 { cn } from 'admin-app/lib/utils'; -import { useSearchParams } from 'next/navigation'; -import { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import Image from 'next/image'; + +const PAGE_SIZE = 50; function TableSkeleton() { return ( @@ -43,28 +48,57 @@ function TableSkeleton() { function PackRow({ pack }: { pack: AdminPack }) { const queryClient = useQueryClient(); - const isDeleted = pack.deleted; const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deletePack(pack.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'packs'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.admin.packs.all() }); }, }); return ( - + -
-

{pack.name}

- {pack.description && ( -

{pack.description}

- )} - {isDeleted && ( -

- Deleted {pack.deletedAt ? formatDate(new Date(pack.deletedAt)) : ''} -

+
+ {pack.image ? ( + + ) : ( +
)} +
+
+

{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} + )} +
+ )} +
@@ -86,12 +120,20 @@ function PackRow({ pack }: { pack: AdminPack }) { - - {pack.createdAt ? formatDate(new Date(pack.createdAt)) : '—'} - +
+ + {pack.createdAt ? formatDate(new Date(pack.createdAt)) : '—'} + + {pack.updatedAt && pack.updatedAt !== pack.createdAt && ( +

+ upd {formatDate(new Date(pack.updatedAt))} +

+ )} +
- {!isDeleted && ( +
+ - )} +
); } export default function PacksPage() { - const searchParams = useSearchParams(); - const q = searchParams?.get('q') ?? undefined; - const [includeDeleted, setIncludeDeleted] = useState(false); + const { q, setSearch, page, setPage } = usePaginatedSearch(); + const offset = page * PAGE_SIZE; const { data: result, isLoading, isError, } = useQuery({ - queryKey: [...queryKeys.admin.packs(q), { includeDeleted }], - queryFn: () => getPacks({ q, includeDeleted }), + queryKey: queryKeys.admin.packs.list({ q: q || undefined, page }), + queryFn: () => getPacks({ q: q || undefined, limit: PAGE_SIZE, offset }), }); const packs = result?.data ?? []; const total = result?.total ?? 0; + const hasPrev = page > 0; + const hasNext = packs.length === PAGE_SIZE; return (
@@ -131,18 +174,7 @@ export default function PacksPage() {

-
- - -
+ {isError ? (

Failed to load packs. Check that the API is reachable. @@ -186,11 +218,34 @@ export default function PacksPage() {

-

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

+
+

+ {packs.length === 0 + ? `No packs${q ? ` matching "${q}"` : ''}` + : `${(offset + 1).toLocaleString()}–${(offset + packs.length).toLocaleString()} of ${total.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 7992c482ea..ccd14c2e4e 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: usersResult, isLoading: usersLoading } = useQuery({ - queryKey: queryKeys.admin.users(5), + queryKey: queryKeys.admin.users.list({ limit: 5 }), queryFn: () => getUsers({ limit: 5 }), }); const { data: packsResult, isLoading: packsLoading } = useQuery({ - queryKey: queryKeys.admin.packs(5), + queryKey: queryKeys.admin.packs.list({ limit: 5 }), queryFn: () => getPacks({ limit: 5 }), }); const { data: catalogResult, 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 4a62bb3f74..c28e6c33f9 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,13 +13,17 @@ 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 { type AdminUser, deleteUser, getUsers, restoreUser } from 'admin-app/lib/api'; +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 { cn } from 'admin-app/lib/utils'; -import { useSearchParams } from 'next/navigation'; -import { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import Image from 'next/image'; + +const PAGE_SIZE = 50; function TableSkeleton() { return ( @@ -33,8 +38,7 @@ function TableSkeleton() { - - +
))}
@@ -43,39 +47,43 @@ function TableSkeleton() { function UserRow({ user }: { user: AdminUser }) { const queryClient = useQueryClient(); - const isDeleted = !!user.deletedAt; const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deleteUser(user.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); - }, - }); - - const { mutateAsync: handleRestore } = useMutation({ - mutationFn: () => restoreUser(user.id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['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] ?? '?'} + +
)} - {isDeleted && ( -

- Deleted {user.deletedAt ? formatDate(new Date(user.deletedAt)) : ''} +

+

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

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

{user.email}

+ )} +
@@ -94,54 +102,50 @@ function UserRow({ user }: { user: AdminUser }) { - - {user.createdAt ? formatDate(new Date(user.createdAt)) : '—'} - - - - - {user.lastActiveAt ? formatDate(new Date(user.lastActiveAt)) : '—'} - +
+ + {user.createdAt ? formatDate(new Date(user.createdAt)) : '—'} + + {user.updatedAt && user.updatedAt !== user.createdAt && ( +

+ act {formatDate(new Date(user.updatedAt))} +

+ )} +
- {isDeleted ? ( - - ) : ( +
+ { await handleDelete(); }} /> - )} +
); } export default function UsersPage() { - const searchParams = useSearchParams(); - const q = searchParams?.get('q') ?? undefined; - const [includeDeleted, setIncludeDeleted] = useState(false); + const { q, setSearch, page, setPage } = usePaginatedSearch(); + const offset = page * PAGE_SIZE; const { data: result, isLoading, isError, } = useQuery({ - queryKey: [...queryKeys.admin.users(q), { includeDeleted }], - queryFn: () => getUsers({ q, includeDeleted }), + queryKey: queryKeys.admin.users.list({ q: q || undefined, page }), + queryFn: () => getUsers({ q: q || undefined, limit: PAGE_SIZE, offset }), }); const users = result?.data ?? []; const total = result?.total ?? 0; + const hasPrev = page > 0; + const hasNext = users.length === PAGE_SIZE; return (
@@ -152,18 +156,7 @@ export default function UsersPage() {

-
- - -
+ {isError ? (

Failed to load users. Check that the API is reachable. @@ -188,10 +181,7 @@ export default function UsersPage() { Joined - - Last Active - - + @@ -207,11 +197,34 @@ export default function UsersPage() {

-

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

+
+

+ {users.length === 0 + ? `No users${q ? ` matching "${q}"` : ''}` + : `${(offset + 1).toLocaleString()}–${(offset + users.length).toLocaleString()} of ${total.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 3726ac7874..96ce6ba4f7 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -3,7 +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 Script from 'next/script'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import type React from 'react'; import './globals.css'; @@ -34,17 +34,18 @@ export default function RootLayout({ - - - {children} - - -