diff --git a/apps/admin/app/dashboard/trails/page.tsx b/apps/admin/app/dashboard/trails/page.tsx index f7c73ab163..e0b4bfd524 100644 --- a/apps/admin/app/dashboard/trails/page.tsx +++ b/apps/admin/app/dashboard/trails/page.tsx @@ -62,7 +62,7 @@ function TrailSearchSection({ const [activeSport, setActiveSport] = useState(''); const { data, isLoading, isError } = useQuery({ - queryKey: queryKeys.osm.search(activeQ, activeSport || undefined), + queryKey: queryKeys.osm.search({ q: activeQ, sport: activeSport || undefined }), queryFn: () => searchTrails({ q: activeQ, sport: activeSport || undefined }), enabled: activeQ.length > 0, }); diff --git a/apps/admin/components/analytics/catalog-analytics.tsx b/apps/admin/components/analytics/catalog-analytics.tsx index d4f5921342..3ca0fa8461 100644 --- a/apps/admin/components/analytics/catalog-analytics.tsx +++ b/apps/admin/components/analytics/catalog-analytics.tsx @@ -67,7 +67,7 @@ function statusBadgeVariant(status: string): 'default' | 'secondary' | 'destruct function EtlJobFailuresDialog({ jobId, totalInvalid }: { jobId: string; totalInvalid: number }) { const [open, setOpen] = useState(false); - const { data, isLoading } = useEtlJobFailures(jobId, { enabled: open }); + const { data, isLoading } = useEtlJobFailures({ jobId, opts: { enabled: open } }); return ( diff --git a/apps/admin/components/analytics/platform-analytics.tsx b/apps/admin/components/analytics/platform-analytics.tsx index b71faa893c..ce1f241a85 100644 --- a/apps/admin/components/analytics/platform-analytics.tsx +++ b/apps/admin/components/analytics/platform-analytics.tsx @@ -60,7 +60,7 @@ const BREAKDOWN_COLORS = [ type Period = 'day' | 'week' | 'month'; const PERIODS = ['day', 'week', 'month'] as const satisfies readonly Period[]; -function formatPeriodLabel(v: string, period: Period) { +function formatPeriodLabel({ v, period }: { v: string; period: Period }) { const d = new Date(v); if (period === 'day') return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); if (period === 'week') return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); @@ -122,7 +122,7 @@ export function PlatformAnalytics() { dataKey="period" tickLine={false} axisLine={false} - tickFormatter={(v: string) => formatPeriodLabel(v, period)} + tickFormatter={(v: string) => formatPeriodLabel({ v, period })} /> } /> @@ -177,7 +177,7 @@ export function PlatformAnalytics() { dataKey="period" tickLine={false} axisLine={false} - tickFormatter={(v: string) => formatPeriodLabel(v, period)} + tickFormatter={(v: string) => formatPeriodLabel({ v, period })} /> } /> diff --git a/apps/admin/components/edit-catalog-dialog.tsx b/apps/admin/components/edit-catalog-dialog.tsx index 7019e07d03..24cf1c16f3 100644 --- a/apps/admin/components/edit-catalog-dialog.tsx +++ b/apps/admin/components/edit-catalog-dialog.tsx @@ -12,7 +12,7 @@ import { import { Input } from '@packrat/web-ui/components/input'; import { Label } from '@packrat/web-ui/components/label'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { AdminCatalogItem } from 'admin-app/lib/api'; +import type { AdminCatalogItem, UpdateCatalogItemInput } from 'admin-app/lib/api'; import { updateCatalogItem } from 'admin-app/lib/api'; import { queryKeys } from 'admin-app/lib/queryKeys'; import { Pencil } from 'lucide-react'; @@ -27,7 +27,7 @@ export function EditCatalogDialog({ item }: EditCatalogDialogProps) { const queryClient = useQueryClient(); const { mutate, isPending } = useMutation({ - mutationFn: (data: Parameters[1]) => updateCatalogItem(item.id, data), + mutationFn: (data: UpdateCatalogItemInput) => updateCatalogItem({ id: item.id, body: data }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog.all() }); setOpen(false); diff --git a/apps/admin/hooks/use-catalog-analytics.ts b/apps/admin/hooks/use-catalog-analytics.ts index c45b990897..455c392c41 100644 --- a/apps/admin/hooks/use-catalog-analytics.ts +++ b/apps/admin/hooks/use-catalog-analytics.ts @@ -54,11 +54,17 @@ export function useEtlFailureSummary(limit = 20) { }); } -export function useEtlJobFailures(jobId: string, opts: { enabled?: boolean; limit?: number } = {}) { +export function useEtlJobFailures({ + jobId, + opts = {}, +}: { + jobId: string; + opts?: { enabled?: boolean; limit?: number }; +}) { const { enabled = false, limit = 50 } = opts; return useQuery({ - queryKey: queryKeys.catalogAnalytics.etl.jobFailures(jobId, limit), - queryFn: () => getEtlJobFailures(jobId, limit), + queryKey: queryKeys.catalogAnalytics.etl.jobFailures({ jobId, limit }), + queryFn: () => getEtlJobFailures({ jobId, limit }), enabled, }); } diff --git a/apps/admin/hooks/use-platform-analytics.ts b/apps/admin/hooks/use-platform-analytics.ts index 68f889eea4..de394708dc 100644 --- a/apps/admin/hooks/use-platform-analytics.ts +++ b/apps/admin/hooks/use-platform-analytics.ts @@ -7,14 +7,14 @@ import { queryKeys } from 'admin-app/lib/queryKeys'; export function usePlatformGrowth(period: 'day' | 'week' | 'month') { return useQuery({ queryKey: queryKeys.platform.growth(period), - queryFn: () => getPlatformGrowth(period), + queryFn: () => getPlatformGrowth({ period }), }); } export function usePlatformActivity(period: 'day' | 'week' | 'month') { return useQuery({ queryKey: queryKeys.platform.activity(period), - queryFn: () => getPlatformActivity(period), + queryFn: () => getPlatformActivity({ period }), }); } diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index f5d7810585..a67a16efb2 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -29,7 +29,13 @@ import { adminEnv } from './env'; const API_BASE = adminEnv.NEXT_PUBLIC_API_URL; // Injects admin auth header and redirects to /login on 401. -const adminFetcher = async (input: RequestInfo | URL, init?: RequestInit): Promise => { +const adminFetcher = async ({ + input, + init, +}: { + input: RequestInfo | URL; + init?: RequestInit; +}): Promise => { const authHeader = getAuthHeader(); const headers = new Headers(init?.headers); headers.set('Content-Type', 'application/json'); @@ -46,18 +52,24 @@ const adminFetcher = async (input: RequestInfo | URL, init?: RequestInit): Promi // Pre-drilled into .api.admin so call sites write `adminClient.stats.get()`. const adminClient = treaty(API_BASE, { - fetcher: adminFetcher as unknown as typeof fetch, + fetcher: ((input, init) => adminFetcher({ input, init })) as unknown as typeof fetch, parseDate: false, }).api.admin; -function throwOnError(error: { value?: unknown } | null, fallback = 'Admin API error'): never { +function throwOnError({ + error, + fallback = 'Admin API error', +}: { + error: { value?: unknown } | null; + fallback?: string; +}): never { const val = error?.value; const msg = isObject(val) && 'error' in val ? String((val as { error: unknown }).error) : fallback; throw new Error(msg); } -function unwrap(data: T | null | undefined, name: string): T { +function unwrap({ data, name }: { data: T | null | undefined; name: string }): T { if (data == null) throw new Error(`Admin API returned no data for ${name}`); return data; } @@ -68,8 +80,8 @@ export type { AdminStats }; export async function getStats(): Promise { const { data, error } = await adminClient.stats.get(); - if (error) throwOnError(error); - return unwrap(data, 'stats'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'stats' }); } // ─── Users ──────────────────────────────────────────────────────────────────── @@ -101,29 +113,32 @@ export async function getUsers({ const { data, error } = await adminClient['users-list'].get({ query: { limit, offset, q }, }); - if (error) throwOnError(error); - return unwrap(data, 'users'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'users' }); } export async function deleteUser(id: string): Promise<{ success: boolean }> { const { data, error } = await adminClient.users({ id }).delete(); - if (error) throwOnError(error); - return unwrap(data, 'deleteUser'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'deleteUser' }); } -export async function hardDeleteUser( - id: string, - reason: string, -): Promise<{ success: boolean; purged: boolean }> { +export async function hardDeleteUser({ + id, + reason, +}: { + id: string; + reason: string; +}): Promise<{ success: boolean; purged: boolean }> { const { data, error } = await adminClient.users({ id }).hard.delete({ reason }); - if (error) throwOnError(error); - return unwrap(data, 'hardDeleteUser'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'hardDeleteUser' }); } export async function restoreUser(id: string): Promise<{ success: boolean }> { const { data, error } = await adminClient.users({ id }).restore.post(); - if (error) throwOnError(error); - return unwrap(data, 'restoreUser'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'restoreUser' }); } // ─── Packs ──────────────────────────────────────────────────────────────────── @@ -144,14 +159,14 @@ export async function getPacks({ const { data, error } = await adminClient['packs-list'].get({ query: { limit, offset, q, includeDeleted }, }); - if (error) throwOnError(error); - return unwrap(data, 'packs'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'packs' }); } export async function deletePack(id: string): Promise<{ success: boolean }> { const { data, error } = await adminClient.packs({ id }).delete(); - if (error) throwOnError(error); - return unwrap(data, 'deletePack'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'deletePack' }); } // ─── Catalog Items ──────────────────────────────────────────────────────────── @@ -180,23 +195,26 @@ export async function getCatalogItems({ const { data, error } = await adminClient['catalog-list'].get({ query: { limit, offset, q }, }); - if (error) throwOnError(error); - return unwrap(data, 'catalog'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'catalog' }); } export async function deleteCatalogItem(id: number): Promise<{ success: boolean }> { const { data, error } = await adminClient.catalog({ id: String(id) }).delete(); - if (error) throwOnError(error); - return unwrap(data, 'deleteCatalogItem'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'deleteCatalogItem' }); } -export async function updateCatalogItem( - id: number, - body: UpdateCatalogItemInput, -): Promise<{ id: number; name: string }> { +export async function updateCatalogItem({ + id, + body, +}: { + id: number; + body: UpdateCatalogItemInput; +}): Promise<{ id: number; name: string }> { const { data, error } = await adminClient.catalog({ id: String(id) }).patch(body); - if (error) throwOnError(error); - return unwrap(data, 'updateCatalogItem'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'updateCatalogItem' }); } // ─── Analytics — Platform ───────────────────────────────────────────────────── @@ -204,32 +222,38 @@ export async function updateCatalogItem( export type { GrowthPoint, ActivityPoint, BreakdownItem, ActiveUsers }; export type AnalyticsPeriod = 'day' | 'week' | 'month'; -export async function getPlatformGrowth( - period: AnalyticsPeriod, +export async function getPlatformGrowth({ + period, range = 12, -): Promise { +}: { + period: AnalyticsPeriod; + range?: number; +}): Promise { const { data, error } = await adminClient.analytics.platform.growth.get({ query: { period, range }, }); - if (error) throwOnError(error); - return unwrap(data, 'platformGrowth'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'platformGrowth' }); } -export async function getPlatformActivity( - period: AnalyticsPeriod, +export async function getPlatformActivity({ + period, range = 12, -): Promise { +}: { + period: AnalyticsPeriod; + range?: number; +}): Promise { const { data, error } = await adminClient.analytics.platform.activity.get({ query: { period, range }, }); - if (error) throwOnError(error); - return unwrap(data, 'platformActivity'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'platformActivity' }); } export async function getPlatformBreakdown(): Promise { const { data, error } = await adminClient.analytics.platform.breakdown.get(); - if (error) throwOnError(error); - return unwrap(data, 'platformBreakdown'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'platformBreakdown' }); } // ─── Analytics — Catalog ───────────────────────────────────────────────────── @@ -238,36 +262,36 @@ export type { CatalogOverview, BrandRow, PriceBucket, EtlJob, EtlResponse, Embed export async function getCatalogOverview(): Promise { const { data, error } = await adminClient.analytics.catalog.overview.get(); - if (error) throwOnError(error); - return unwrap(data, 'catalogOverview'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'catalogOverview' }); } export async function getCatalogBrands(limit = 20): Promise { const { data, error } = await adminClient.analytics.catalog.brands.get({ query: { limit }, }); - if (error) throwOnError(error); - return unwrap(data, 'catalogBrands'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'catalogBrands' }); } export async function getCatalogPrices(): Promise { const { data, error } = await adminClient.analytics.catalog.prices.get(); - if (error) throwOnError(error); - return unwrap(data, 'catalogPrices'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'catalogPrices' }); } export async function getCatalogEtl(limit = 20): Promise { const { data, error } = await adminClient.analytics.catalog.etl.get({ query: { limit }, }); - if (error) throwOnError(error); - return unwrap(data, 'catalogEtl'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'catalogEtl' }); } export async function getCatalogEmbeddings(): Promise { const { data, error } = await adminClient.analytics.catalog.embeddings.get(); - if (error) throwOnError(error); - return unwrap(data, 'catalogEmbeddings'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'catalogEmbeddings' }); } // ─── Admin Trails ───────────────────────────────────────────────────────────── @@ -291,20 +315,20 @@ export async function searchTrails({ const { data, error } = await adminClient.trails.search.get({ query: { q, sport, limit, offset }, }); - if (error) throwOnError(error); - return unwrap(data, 'searchTrails'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'searchTrails' }); } export async function getTrailGeometry(osmId: string): Promise { const { data, error } = await adminClient.trails({ osmId }).geometry.get(); - if (error) throwOnError(error); - return unwrap(data, 'trailGeometry'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'trailGeometry' }); } export async function getAdminTrail(osmId: string): Promise { const { data, error } = await adminClient.trails({ osmId }).get(); - if (error) throwOnError(error); - return unwrap(data, 'adminTrail'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'adminTrail' }); } export async function getTrailConditions({ @@ -321,40 +345,53 @@ export async function getTrailConditions({ const { data, error } = await adminClient.trails.conditions.get({ query: { q, limit, offset, includeDeleted }, }); - if (error) throwOnError(error); - return unwrap(data, 'trailConditions'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'trailConditions' }); } export async function deleteTrailCondition(reportId: string): Promise<{ success: boolean }> { const { data, error } = await adminClient.trails.conditions({ reportId }).delete(); - if (error) throwOnError(error); - return unwrap(data, 'deleteTrailCondition'); + if (error) throwOnError({ error }); + return unwrap({ data, name: 'deleteTrailCondition' }); } -async function adminFetch(path: string, init?: RequestInit): Promise { - const res = await adminFetcher(`${API_BASE}/api/admin${path}`, init); - if (!res.ok) throw new Error(`Admin API error: ${res.status}`); +async function adminFetch({ path, init }: { path: string; init?: RequestInit }): Promise { + const res = await adminFetcher({ input: `${API_BASE}/api/admin${path}`, init }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? `Admin API error: ${res.status}`); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return res.json(); } export function resetStuckEtlJobs(): Promise<{ reset: number; ids: string[] }> { - return adminFetch('/analytics/catalog/etl/reset-stuck', { method: 'POST' }); + return adminFetch({ path: '/analytics/catalog/etl/reset-stuck', init: { method: 'POST' } }); } export type { EtlFailureSummary, EtlJobFailures }; export function getEtlFailureSummary(limit = 20): Promise { - return adminFetch(`/analytics/catalog/etl/failure-summary?limit=${limit}`); + return adminFetch({ path: `/analytics/catalog/etl/failure-summary?limit=${limit}` }); } -export function getEtlJobFailures(jobId: string, limit = 50): Promise { - return adminFetch(`/analytics/catalog/etl/${encodeURIComponent(jobId)}/failures?limit=${limit}`); +export function getEtlJobFailures({ + jobId, + limit = 50, +}: { + jobId: string; + limit?: number; +}): Promise { + return adminFetch({ + path: `/analytics/catalog/etl/${encodeURIComponent(jobId)}/failures?limit=${limit}`, + }); } export function retryEtlJob( jobId: string, ): Promise<{ success: boolean; newJobId: string; objectKey: string }> { - return adminFetch(`/analytics/catalog/etl/${encodeURIComponent(jobId)}/retry`, { - method: 'POST', + return adminFetch({ + path: `/analytics/catalog/etl/${encodeURIComponent(jobId)}/retry`, + init: { method: 'POST' }, }); } diff --git a/apps/admin/lib/auth.ts b/apps/admin/lib/auth.ts index 76dfee73d5..349d1927a4 100644 --- a/apps/admin/lib/auth.ts +++ b/apps/admin/lib/auth.ts @@ -9,7 +9,7 @@ export function getStoredToken(): string | null { /** Persist a short-lived admin JWT for the session. */ export function storeToken(token: string): void { - safeSessionStorage.setItem(TOKEN_KEY, token); + safeSessionStorage.setItem({ key: TOKEN_KEY, value: token }); } /** Remove the token (logout). */ diff --git a/apps/admin/lib/queryKeys.ts b/apps/admin/lib/queryKeys.ts index ef1038b37d..e81ac3b578 100644 --- a/apps/admin/lib/queryKeys.ts +++ b/apps/admin/lib/queryKeys.ts @@ -39,7 +39,8 @@ export const queryKeys = { osm: { all: () => ['osm'] as const, - search: (q?: string, sport?: string) => [...queryKeys.osm.all(), 'search', q, sport] as const, + search: ({ q, sport }: { q: string; sport?: string }) => + [...queryKeys.osm.all(), 'search', q, sport] as const, trail: (osmId: string) => [...queryKeys.osm.all(), 'trail', osmId] as const, conditions: (q?: string) => [...queryKeys.osm.all(), 'conditions', q] as const, }, @@ -56,7 +57,7 @@ export const queryKeys = { list: (limit?: number) => [...queryKeys.catalogAnalytics.etl.all(), limit] as const, failureSummary: (limit?: number) => [...queryKeys.catalogAnalytics.etl.all(), 'failureSummary', limit] as const, - jobFailures: (jobId: string, limit?: number) => + jobFailures: ({ jobId, limit }: { jobId: string; limit?: number }) => [...queryKeys.catalogAnalytics.etl.all(), 'jobFailures', jobId, limit] as const, }, }, diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index ef91c30016..49c7871acf 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -134,7 +134,7 @@ function Profile() { ); } -export default withAuthWall(Profile, ProfileAuthWall); +export default withAuthWall({ Component: Profile, AuthWall: ProfileAuthWall }); function renderItem(info: ListRenderItemInfo) { return ; @@ -194,7 +194,7 @@ function ListHeaderComponent() { } setIsUploading(true); - const remoteFileName = await uploadImage(image.fileName, image.uri); + const remoteFileName = await uploadImage({ fileName: image.fileName, uri: image.uri }); if (remoteFileName) { const success = await updateProfile({ avatarUrl: remoteFileName }); if (!success) { diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index 18e2607be5..c20d4050fb 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -250,7 +250,7 @@ export default function AIChat() { } const timeoutId = setTimeout(() => { - saveChatMessages(context, messages); + saveChatMessages({ context, messages }); }, 500); return () => clearTimeout(timeoutId); diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index f5fe095587..8bfaa24636 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -155,7 +155,7 @@ export default function CurrentPackScreen() { {t('packs.lastUpdated', { - time: getRelativeTime(pack.localUpdatedAt ?? pack.updatedAt, t), + time: getRelativeTime({ dateValue: pack.localUpdatedAt ?? pack.updatedAt, t }), })} diff --git a/apps/expo/app/(app)/recent-packs.tsx b/apps/expo/app/(app)/recent-packs.tsx index 20fa2db13a..46811d70eb 100644 --- a/apps/expo/app/(app)/recent-packs.tsx +++ b/apps/expo/app/(app)/recent-packs.tsx @@ -34,7 +34,7 @@ function RecentPackCard({ pack }: { pack: Pack }) { {pack.totalWeight ?? 0} g - {getRelativeTime(pack.localCreatedAt ?? pack.createdAt, t)} + {getRelativeTime({ dateValue: pack.localCreatedAt ?? pack.createdAt, t })} @@ -45,7 +45,7 @@ function RecentPackCard({ pack }: { pack: Pack }) { {t('packs.lastUpdated', { - time: getRelativeTime(pack.localUpdatedAt ?? pack.updatedAt, t), + time: getRelativeTime({ dateValue: pack.localUpdatedAt ?? pack.updatedAt, t }), })} diff --git a/apps/expo/app/(app)/season-suggestions.tsx b/apps/expo/app/(app)/season-suggestions.tsx index fda46f799e..8960d73c47 100644 --- a/apps/expo/app/(app)/season-suggestions.tsx +++ b/apps/expo/app/(app)/season-suggestions.tsx @@ -34,7 +34,13 @@ export default function SeasonSuggestionsScreen() { }); }; - const handleCreatePack = (suggestion: PackSuggestion, index: number) => { + const handleCreatePack = ({ + suggestion, + index, + }: { + suggestion: PackSuggestion; + index: number; + }) => { setCreatingPackIndex(index); // Add a short delay to show the loading state @@ -154,7 +160,7 @@ export default function SeasonSuggestionsScreen() {