diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 1df1af5636..4b0b4db8f5 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -1,28 +1,28 @@ import { treaty } from '@elysiajs/eden'; import type { App } from '@packrat/api'; -import type { - ActiveUsersSchema, - ActivityPointSchema, - AdminCatalogItemSchema, - AdminPackItemSchema, - AdminUserItemSchema, - BrandRowSchema, - BreakdownItemSchema, - CatalogOverviewSchema, - EmbeddingStatsSchema, - EtlFailureSummarySchema, - EtlJobFailuresSchema, - EtlJobSchema, - EtlResponseSchema, - GrowthPointSchema, - PriceBucketSchema, - TrailConditionReportSchema, - TrailGeometrySchema, - TrailSearchItemSchema, - TrailSearchResultSchema, -} from '@packrat/api/schemas/admin'; import { isObject } from '@packrat/guards'; -import type { Static } from '@sinclair/typebox'; +import type { + ActiveUsers, + ActivityPoint, + AdminCatalogItem, + AdminPackItem, + AdminStats, + AdminTrailConditionReport, + AdminUserItem, + BrandRow, + BreakdownItem, + CatalogOverview, + EmbeddingStats, + EtlFailureSummary, + EtlJob, + EtlJobFailures, + EtlResponse, + GrowthPoint, + PriceBucket, + TrailGeometry, + TrailSearchItem, + TrailSearchResult as TrailSearchResultList, +} from '@packrat/schemas/admin'; import { clearToken, getAuthHeader } from './auth'; import { adminEnv } from './env'; @@ -64,7 +64,7 @@ function unwrap(data: T | null | undefined, name: string): T { // ─── Stats ──────────────────────────────────────────────────────────────────── -export type AdminStats = { users: number; packs: number; items: number }; +export type { AdminStats }; export async function getStats(): Promise { const { data, error } = await adminClient.stats.get(); @@ -74,7 +74,7 @@ export async function getStats(): Promise { // ─── Users ──────────────────────────────────────────────────────────────────── -export type AdminUser = Static; +export type AdminUser = AdminUserItem; export interface PaginatedResponse { data: T[]; @@ -124,7 +124,7 @@ export async function restoreUser(id: string): Promise<{ success: boolean }> { // ─── Packs ──────────────────────────────────────────────────────────────────── -export type AdminPack = Static; +export type AdminPack = AdminPackItem; export async function getPacks({ limit = 100, @@ -152,7 +152,7 @@ export async function deletePack(id: string): Promise<{ success: boolean }> { // ─── Catalog Items ──────────────────────────────────────────────────────────── -export type AdminCatalogItem = Static; +export type { AdminCatalogItem }; export interface UpdateCatalogItemInput { name?: string; @@ -197,10 +197,7 @@ export async function updateCatalogItem( // ─── Analytics — Platform ───────────────────────────────────────────────────── -export type GrowthPoint = Static; -export type ActivityPoint = Static; -export type BreakdownItem = Static; -export type ActiveUsers = Static; +export type { GrowthPoint, ActivityPoint, BreakdownItem, ActiveUsers }; export type AnalyticsPeriod = 'day' | 'week' | 'month'; export async function getPlatformGrowth( @@ -233,12 +230,7 @@ export async function getPlatformBreakdown(): Promise { // ─── Analytics — Catalog ───────────────────────────────────────────────────── -export type CatalogOverview = Static; -export type BrandRow = Static; -export type PriceBucket = Static; -export type EtlJob = Static; -export type EtlResponse = Static; -export type EmbeddingStats = Static; +export type { CatalogOverview, BrandRow, PriceBucket, EtlJob, EtlResponse, EmbeddingStats }; export async function getCatalogOverview(): Promise { const { data, error } = await adminClient.analytics.catalog.overview.get(); @@ -276,10 +268,10 @@ export async function getCatalogEmbeddings(): Promise { // ─── Admin Trails ───────────────────────────────────────────────────────────── -export type TrailSearchResult = Static; -export type TrailGeometry = Static; -export type TrailSearchPage = Static; -export type TrailConditionReport = Static; +export type TrailSearchResult = TrailSearchItem; +export type TrailSearchPage = TrailSearchResultList; +export type { TrailGeometry }; +export type TrailConditionReport = AdminTrailConditionReport; export async function searchTrails({ q, @@ -345,8 +337,7 @@ export function resetStuckEtlJobs(): Promise<{ reset: number; ids: string[] }> { return adminFetch('/analytics/catalog/etl/reset-stuck', { method: 'POST' }); } -export type EtlFailureSummary = Static; -export type EtlJobFailures = Static; +export type { EtlFailureSummary, EtlJobFailures }; export function getEtlFailureSummary(limit = 20): Promise { return adminFetch(`/analytics/catalog/etl/failure-summary?limit=${limit}`); diff --git a/apps/admin/lib/queryKeys.ts b/apps/admin/lib/queryKeys.ts index b13ccdf3bb..ef1038b37d 100644 --- a/apps/admin/lib/queryKeys.ts +++ b/apps/admin/lib/queryKeys.ts @@ -39,9 +39,9 @@ export const queryKeys = { osm: { all: () => ['osm'] as const, - search: (q: string, sport?: string) => [...queryKeys.osm.all(), 'search', q, sport] as const, + search: (q?: string, sport?: string) => [...queryKeys.osm.all(), 'search', q, sport] as const, trail: (osmId: string) => [...queryKeys.osm.all(), 'trail', osmId] as const, - conditions: (search?: string) => [...queryKeys.osm.all(), 'conditions', search] as const, + conditions: (q?: string) => [...queryKeys.osm.all(), 'conditions', q] as const, }, catalogAnalytics: { diff --git a/apps/admin/package.json b/apps/admin/package.json index a0026c80e4..766d971d75 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,6 +15,7 @@ "@packrat/api-client": "workspace:*", "@packrat/app": "workspace:*", "@packrat/guards": "workspace:*", + "@packrat/schemas": "workspace:*", "@packrat/web-ui": "workspace:*", "@radix-ui/react-alert-dialog": "catalog:", "@radix-ui/react-avatar": "catalog:", diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index db114c3fe7..0e0b28f5e3 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -1,3 +1,4 @@ +import type { PackItem } from '@packrat/types'; import { Avatar, AvatarFallback, @@ -12,7 +13,6 @@ import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { getRelativeTime } from 'expo-app/lib/utils/getRelativeTime'; -import type { PackItem } from 'expo-app/types'; import { useLocalSearchParams } from 'expo-router'; import type React from 'react'; import { ScrollView, View } from 'react-native'; diff --git a/apps/expo/components/initial/UserAvatar.tsx b/apps/expo/components/initial/UserAvatar.tsx index 7ced670e65..3991d18e16 100644 --- a/apps/expo/components/initial/UserAvatar.tsx +++ b/apps/expo/components/initial/UserAvatar.tsx @@ -1,8 +1,8 @@ -import type { User } from 'expo-app/types'; +import type { MockUser } from 'expo-app/data/mockData'; import { Image, Text, View } from 'react-native'; type UserAvatarProps = { - user: User; + user: Pick; size?: 'sm' | 'md' | 'lg'; showName?: boolean; }; @@ -20,11 +20,13 @@ export function UserAvatar({ user, size = 'md', showName = false }: UserAvatarPr lg: 'text-base', }[size]; + const avatarUri = user.avatarUrl || null; + return ( - {user.avatar ? ( - + {avatarUri ? ( + ) : ( {user.name.substring(0, 2).toUpperCase()} diff --git a/apps/expo/components/initial/WeightBadge.tsx b/apps/expo/components/initial/WeightBadge.tsx index 83c5326a36..10b58ae0da 100644 --- a/apps/expo/components/initial/WeightBadge.tsx +++ b/apps/expo/components/initial/WeightBadge.tsx @@ -1,6 +1,6 @@ +import type { WeightUnit } from '@packrat/constants'; import { isString } from '@packrat/guards'; import { cn } from 'expo-app/lib/cn'; -import type { WeightUnit } from 'expo-app/types'; import { formatWeight } from 'expo-app/utils/weight'; import { Text, View } from 'react-native'; diff --git a/apps/expo/data/mockData.ts b/apps/expo/data/mockData.ts index f39d3619e8..1dcd6fff43 100644 --- a/apps/expo/data/mockData.ts +++ b/apps/expo/data/mockData.ts @@ -1,12 +1,20 @@ -import type { User } from 'expo-app/types'; +export type MockUser = { + id: string; + name: string; + email: string; + avatarUrl: string; + experience: string; + joinedAt: string; + bio: string; +}; // --- Users --- -export const mockUsers: [User, ...User[]] = [ +export const mockUsers: [MockUser, ...MockUser[]] = [ { id: '1', name: 'Alex Hiker', email: 'alex@example.com', - avatar: 'https://i.pravatar.cc/150?img=1', + avatarUrl: 'https://i.pravatar.cc/150?img=1', experience: 'expert', joinedAt: '2023-01-15T00:00:00.000Z', bio: 'Thru-hiker with 5,000+ miles under my feet. PCT, AT, and CDT completed.', @@ -15,7 +23,7 @@ export const mockUsers: [User, ...User[]] = [ id: '2', name: 'Sam Backpacker', email: 'sam@example.com', - avatar: 'https://i.pravatar.cc/150?img=2', + avatarUrl: 'https://i.pravatar.cc/150?img=2', experience: 'intermediate', joinedAt: '2023-03-22T00:00:00.000Z', bio: 'Weekend warrior trying to lighten my load.', @@ -24,7 +32,7 @@ export const mockUsers: [User, ...User[]] = [ id: '3', name: 'Jamie Newbie', email: 'jamie@example.com', - avatar: 'https://i.pravatar.cc/150?img=3', + avatarUrl: 'https://i.pravatar.cc/150?img=3', experience: 'beginner', joinedAt: '2023-06-10T00:00:00.000Z', bio: 'Just getting started with hiking and camping.', @@ -33,7 +41,7 @@ export const mockUsers: [User, ...User[]] = [ id: '4', name: 'Taylor Trailblazer', email: 'taylor@example.com', - avatar: 'https://i.pravatar.cc/150?img=4', + avatarUrl: 'https://i.pravatar.cc/150?img=4', experience: 'expert', joinedAt: '2022-11-05T00:00:00.000Z', bio: 'Explorer with a passion for the unbeaten path.', @@ -42,7 +50,7 @@ export const mockUsers: [User, ...User[]] = [ id: '5', name: 'Chris Camper', email: 'chris@example.com', - avatar: 'https://i.pravatar.cc/150?img=5', + avatarUrl: 'https://i.pravatar.cc/150?img=5', experience: 'intermediate', joinedAt: '2023-02-28T00:00:00.000Z', bio: 'Camping enthusiast and nature lover.', @@ -51,7 +59,7 @@ export const mockUsers: [User, ...User[]] = [ id: '6', name: 'Morgan Mountaineer', email: 'morgan@example.com', - avatar: 'https://i.pravatar.cc/150?img=6', + avatarUrl: 'https://i.pravatar.cc/150?img=6', experience: 'expert', joinedAt: '2023-01-20T00:00:00.000Z', bio: 'Scaling peaks and chasing horizons.', @@ -60,7 +68,7 @@ export const mockUsers: [User, ...User[]] = [ id: '7', name: 'Jordan Explorer', email: 'jordan@example.com', - avatar: 'https://i.pravatar.cc/150?img=7', + avatarUrl: 'https://i.pravatar.cc/150?img=7', experience: 'beginner', joinedAt: '2023-04-15T00:00:00.000Z', bio: 'New to outdoor adventures, learning every step.', @@ -69,7 +77,7 @@ export const mockUsers: [User, ...User[]] = [ id: '8', name: 'Riley Ranger', email: 'riley@example.com', - avatar: 'https://i.pravatar.cc/150?img=8', + avatarUrl: 'https://i.pravatar.cc/150?img=8', experience: 'intermediate', joinedAt: '2023-03-30T00:00:00.000Z', bio: 'Always ready for a spontaneous trip.', @@ -78,7 +86,7 @@ export const mockUsers: [User, ...User[]] = [ id: '9', name: 'Casey Climber', email: 'casey@example.com', - avatar: 'https://i.pravatar.cc/150?img=9', + avatarUrl: 'https://i.pravatar.cc/150?img=9', experience: 'expert', joinedAt: '2022-12-10T00:00:00.000Z', bio: 'Rock climbing is life.', @@ -87,7 +95,7 @@ export const mockUsers: [User, ...User[]] = [ id: '10', name: 'Peyton Paddler', email: 'peyton@example.com', - avatar: 'https://i.pravatar.cc/150?img=10', + avatarUrl: 'https://i.pravatar.cc/150?img=10', experience: 'intermediate', joinedAt: '2023-05-01T00:00:00.000Z', bio: 'Loves exploring rivers and lakes.', diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index c80857b729..95ed336fae 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,4 +1,4 @@ -import { clientEnvs } from '@packrat/env/expo-client'; +import { asBoolean, asString } from '@packrat/guards'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin, @@ -33,18 +33,18 @@ function redirect(route: string) { } function mapToUser(raw: Record): User { - const name = String(raw.name ?? ''); + const name = asString(raw.name) ?? ''; const spaceIdx = name.indexOf(' '); return { - id: String(raw.id ?? ''), - email: String(raw.email ?? ''), + id: asString(raw.id) ?? '', + email: asString(raw.email) ?? '', firstName: spaceIdx >= 0 ? name.slice(0, spaceIdx) : name, lastName: spaceIdx >= 0 ? name.slice(spaceIdx + 1) : '', - role: (raw.role as 'USER' | 'ADMIN') ?? 'USER', - emailVerified: (raw.emailVerified as boolean | null) ?? null, - avatarUrl: (raw.image as string | null) ?? null, - createdAt: (raw.createdAt as string | null) ?? null, - updatedAt: (raw.updatedAt as string | null) ?? null, + role: asString(raw.role) ?? 'USER', + emailVerified: asBoolean(raw.emailVerified) ?? null, + avatarUrl: asString(raw.image) ?? null, + createdAt: asString(raw.createdAt) ?? null, + updatedAt: asString(raw.updatedAt) ?? null, preferredWeightUnit: (raw.preferredWeightUnit as User['preferredWeightUnit']) ?? 'g', }; } @@ -188,27 +188,19 @@ export function useAuthActions() { }; const forgotPassword = async (email: string) => { - const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/request`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + const { error } = await authClient.requestPasswordReset({ + email, + redirectTo: 'packrat://reset-password', }); - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error ?? 'Forgot password failed'); - } + if (error) throw new Error(error.message ?? 'Forgot password failed'); }; - const resetPassword = async (email: string, opts: { token: string; newPassword: string }) => { - const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code: opts.token, newPassword: opts.newPassword }), + const resetPassword = async (_email: string, opts: { token: string; newPassword: string }) => { + const { error } = await authClient.resetPassword({ + token: opts.token, + newPassword: opts.newPassword, }); - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error ?? 'Reset password failed'); - } + if (error) throw new Error(error.message ?? 'Reset password failed'); }; const verifyEmail = async (_email: string, token: string) => { diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 9b9f284b41..b8e9714bc1 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -1,5 +1,6 @@ import { when } from '@legendapp/state'; import { clientEnvs } from '@packrat/env/expo-client'; +import { asBoolean, asString } from '@packrat/guards'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; import { userStore, userSyncState } from 'expo-app/features/auth/store'; @@ -23,20 +24,17 @@ async function runVersionGateMigration() { } function applySessionUser(sessionUser: Record) { + const name = asString(sessionUser.name) ?? ''; userStore.set({ - id: String(sessionUser.id ?? ''), - email: String(sessionUser.email ?? ''), - firstName: String(sessionUser.name ?? '').split(' ')[0] ?? '', - lastName: - String(sessionUser.name ?? '') - .split(' ') - .slice(1) - .join(' ') ?? '', - role: (sessionUser.role as 'USER' | 'ADMIN') ?? 'USER', // safe-cast: Better Auth client type omits additionalFields; role is present at runtime - emailVerified: (sessionUser.emailVerified as boolean | null) ?? null, - avatarUrl: (sessionUser.image as string | null) ?? null, - createdAt: (sessionUser.createdAt as string | null) ?? null, - updatedAt: (sessionUser.updatedAt as string | null) ?? null, + id: asString(sessionUser.id) ?? '', + email: asString(sessionUser.email) ?? '', + firstName: name.split(' ')[0] ?? '', + lastName: name.split(' ').slice(1).join(' ') ?? '', + role: asString(sessionUser.role) ?? 'USER', + emailVerified: asBoolean(sessionUser.emailVerified) ?? null, + avatarUrl: asString(sessionUser.image) ?? null, + createdAt: asString(sessionUser.createdAt) ?? null, + updatedAt: asString(sessionUser.updatedAt) ?? null, preferredWeightUnit: 'g', }); } diff --git a/apps/expo/features/catalog/components/ItemReviews.tsx b/apps/expo/features/catalog/components/ItemReviews.tsx index 326294d379..382f88ef49 100644 --- a/apps/expo/features/catalog/components/ItemReviews.tsx +++ b/apps/expo/features/catalog/components/ItemReviews.tsx @@ -44,10 +44,10 @@ export function ItemReviews({ reviews }: ItemReviewsProps) { - {reviews.map((review, i) => { - const reviewKey = review.title ?? String(i); + {reviews.map((review, idx) => { + const reviewKey = review.title ?? String(idx); const isExpanded = expandedReviews[reviewKey] || false; - const shouldTruncate = (review.text ?? '').length > 150; + const shouldTruncate = (review.text?.length ?? 0) > 150; return ( @@ -64,9 +64,9 @@ export function ItemReviews({ reviews }: ItemReviewsProps) { {review.user_name || t('catalog.anonymous')} - - {review.date ? formatDate(review.date) : ''} - + {review.date && ( + {formatDate(review.date)} + )} diff --git a/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts b/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts index f672865154..b141891f81 100644 --- a/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts +++ b/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts @@ -1,4 +1,4 @@ -import { CatalogItemSchema } from '@packrat/api/schemas/catalog'; +import { CatalogItemSchema } from '@packrat/schemas/catalog'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; diff --git a/apps/expo/features/catalog/hooks/useCatalogItems.ts b/apps/expo/features/catalog/hooks/useCatalogItems.ts index 50e9e0e7c9..528b58296b 100644 --- a/apps/expo/features/catalog/hooks/useCatalogItems.ts +++ b/apps/expo/features/catalog/hooks/useCatalogItems.ts @@ -1,4 +1,4 @@ -import { CatalogItemsResponseSchema } from '@packrat/api/schemas/catalog'; +import { CatalogItemsResponseSchema } from '@packrat/schemas/catalog'; import { useInfiniteQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; diff --git a/apps/expo/features/catalog/hooks/useVectorSearch.ts b/apps/expo/features/catalog/hooks/useVectorSearch.ts index 34baec182b..ac0d8a83cb 100644 --- a/apps/expo/features/catalog/hooks/useVectorSearch.ts +++ b/apps/expo/features/catalog/hooks/useVectorSearch.ts @@ -1,4 +1,4 @@ -import { VectorSearchResponseSchema } from '@packrat/api/schemas/catalog'; +import { VectorSearchResponseSchema } from '@packrat/schemas/catalog'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 80aec8997c..cedaffedec 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -1,5 +1,5 @@ -import { WeightUnitSchema } from '@packrat/api/types'; import { assertDefined, fromZod } from '@packrat/guards'; +import { WeightUnitSchema } from '@packrat/schemas/constants'; import { Button, Text } from '@packrat/ui/nativewindui'; import { useQueryClient } from '@tanstack/react-query'; import * as Burnt from 'burnt'; diff --git a/apps/expo/features/catalog/types.ts b/apps/expo/features/catalog/types.ts index a00dbdf8e8..8b2e07e5a8 100644 --- a/apps/expo/features/catalog/types.ts +++ b/apps/expo/features/catalog/types.ts @@ -1,4 +1,4 @@ -import type { CatalogItemSchema } from '@packrat/api/schemas/catalog'; +import type { CatalogItemSchema } from '@packrat/schemas/catalog'; import type { z } from 'zod'; import type { PackItemInput } from '../packs/input'; diff --git a/apps/expo/features/guides/types.ts b/apps/expo/features/guides/types.ts index 7bf619f216..f6ac2a5940 100644 --- a/apps/expo/features/guides/types.ts +++ b/apps/expo/features/guides/types.ts @@ -7,7 +7,7 @@ export interface Guide { description: string; content?: string; author?: string; - readingTime?: string; + readingTime?: number; difficulty?: string; createdAt: string; updatedAt: string; diff --git a/apps/expo/features/pack-templates/components/PackTemplateForm.tsx b/apps/expo/features/pack-templates/components/PackTemplateForm.tsx index a52eb54cb3..7d4a6a1168 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateForm.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateForm.tsx @@ -1,5 +1,5 @@ -import { PackCategorySchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { PackCategorySchema } from '@packrat/schemas/constants'; import { Button, createDropdownItem, diff --git a/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts b/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts index ffb1e49686..68b0c57afe 100644 --- a/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts +++ b/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts @@ -1,5 +1,5 @@ -import { WeightUnitSchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { WeightUnitSchema } from '@packrat/schemas/constants'; import { cacheCatalogItemImage } from 'expo-app/features/catalog/lib/cacheCatalogItemImage'; import type { CatalogItemWithPackItemFields } from 'expo-app/features/catalog/types'; import { useState } from 'react'; diff --git a/apps/expo/features/pack-templates/packTemplateListAtoms.ts b/apps/expo/features/pack-templates/packTemplateListAtoms.ts index 293d6d3ce0..beea35f837 100644 --- a/apps/expo/features/pack-templates/packTemplateListAtoms.ts +++ b/apps/expo/features/pack-templates/packTemplateListAtoms.ts @@ -1,4 +1,4 @@ -import type { PackCategory } from 'expo-app/types'; +import type { PackCategory } from '@packrat/constants'; import { atom } from 'jotai'; export const activeTemplateFilterAtom = atom('all'); diff --git a/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx b/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx index 615d0d2e98..8a9db11b7c 100644 --- a/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx +++ b/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx @@ -1,6 +1,7 @@ // CreatePackTemplateItemForm.tsx import { useActionSheet } from '@expo/react-native-action-sheet'; +import type { WeightUnit } from '@packrat/constants'; import { safeIndexOf } from '@packrat/guards'; import { Form, FormItem, FormSection, SegmentedControl, TextField } from '@packrat/ui/nativewindui'; import { useForm } from '@tanstack/react-form'; @@ -9,7 +10,6 @@ import { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import type { WeightUnit } from 'expo-app/types'; import { useRouter } from 'expo-router'; import { useMemo, useRef, useState } from 'react'; import { Alert, Image, Pressable, Switch, Text, TouchableOpacity, View } from 'react-native'; diff --git a/apps/expo/features/pack-templates/store/packTemplateItems.ts b/apps/expo/features/pack-templates/store/packTemplateItems.ts index f942a5e6f4..8ac2fbaef4 100644 --- a/apps/expo/features/pack-templates/store/packTemplateItems.ts +++ b/apps/expo/features/pack-templates/store/packTemplateItems.ts @@ -4,7 +4,7 @@ import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackTemplateItemSchema, PackTemplateWithItemsSchema, -} from '@packrat/api/schemas/packTemplates'; +} from '@packrat/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/pack-templates/store/packTemplates.ts b/apps/expo/features/pack-templates/store/packTemplates.ts index 8e6afbf641..78d023754b 100644 --- a/apps/expo/features/pack-templates/store/packTemplates.ts +++ b/apps/expo/features/pack-templates/store/packTemplates.ts @@ -1,10 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { - PackTemplateSchema, - PackTemplateWithItemsSchema, -} from '@packrat/api/schemas/packTemplates'; +import { PackTemplateSchema, PackTemplateWithItemsSchema } from '@packrat/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/components/PackForm.tsx b/apps/expo/features/packs/components/PackForm.tsx index 2b60376d96..f1f8c34966 100644 --- a/apps/expo/features/packs/components/PackForm.tsx +++ b/apps/expo/features/packs/components/PackForm.tsx @@ -1,5 +1,5 @@ -import { PackCategorySchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { PackCategorySchema } from '@packrat/schemas/constants'; import { Button, createDropdownItem, diff --git a/apps/expo/features/packs/components/TemplateItemsSection.tsx b/apps/expo/features/packs/components/TemplateItemsSection.tsx index 41823d6e60..e4086b0903 100644 --- a/apps/expo/features/packs/components/TemplateItemsSection.tsx +++ b/apps/expo/features/packs/components/TemplateItemsSection.tsx @@ -1,9 +1,9 @@ +import type { WeightUnit } from '@packrat/constants'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; -import type { WeightUnit } from 'expo-app/types'; import { Image, ScrollView, Text, View } from 'react-native'; export interface PackTemplateItem { diff --git a/apps/expo/features/packs/hooks/useAddCatalogItem.ts b/apps/expo/features/packs/hooks/useAddCatalogItem.ts index d5a27dfbf4..63a509d77b 100644 --- a/apps/expo/features/packs/hooks/useAddCatalogItem.ts +++ b/apps/expo/features/packs/hooks/useAddCatalogItem.ts @@ -1,5 +1,5 @@ -import { WeightUnitSchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { WeightUnitSchema } from '@packrat/schemas/constants'; import * as Burnt from 'burnt'; import { cacheCatalogItemImage } from 'expo-app/features/catalog/lib/cacheCatalogItemImage'; import type { CatalogItem } from 'expo-app/features/catalog/types'; diff --git a/apps/expo/features/packs/hooks/useBulkAddCatalogItems.ts b/apps/expo/features/packs/hooks/useBulkAddCatalogItems.ts index 24a36cd18d..463b4a4b42 100644 --- a/apps/expo/features/packs/hooks/useBulkAddCatalogItems.ts +++ b/apps/expo/features/packs/hooks/useBulkAddCatalogItems.ts @@ -1,5 +1,5 @@ -import { WeightUnitSchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { WeightUnitSchema } from '@packrat/schemas/constants'; import { useState } from 'react'; import { cacheCatalogItemImage } from '../../catalog/lib/cacheCatalogItemImage'; import type { CatalogItemWithPackItemFields } from '../../catalog/types'; diff --git a/apps/expo/features/packs/hooks/usePackDetailsFromApi.ts b/apps/expo/features/packs/hooks/usePackDetailsFromApi.ts index 67714cec3f..8bf90ec97a 100644 --- a/apps/expo/features/packs/hooks/usePackDetailsFromApi.ts +++ b/apps/expo/features/packs/hooks/usePackDetailsFromApi.ts @@ -1,4 +1,4 @@ -import { PackWithWeightsSchema } from '@packrat/api/schemas/packs'; +import { PackWithWeightsSchema } from '@packrat/schemas/packs'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; diff --git a/apps/expo/features/packs/hooks/usePackItemDetailsFromApi.ts b/apps/expo/features/packs/hooks/usePackItemDetailsFromApi.ts index 8229888c23..b12f8f829f 100644 --- a/apps/expo/features/packs/hooks/usePackItemDetailsFromApi.ts +++ b/apps/expo/features/packs/hooks/usePackItemDetailsFromApi.ts @@ -1,4 +1,4 @@ -import { PackItemSchema } from '@packrat/api/schemas/packs'; +import { PackItemSchema } from '@packrat/schemas/packs'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; diff --git a/apps/expo/features/packs/input.ts b/apps/expo/features/packs/input.ts index 806351be95..2ae602c9b2 100644 --- a/apps/expo/features/packs/input.ts +++ b/apps/expo/features/packs/input.ts @@ -1,4 +1,4 @@ -import type { WeightUnit } from 'expo-app/types'; +import type { WeightUnit } from '@packrat/constants'; export interface PackItemInput { name: string; diff --git a/apps/expo/features/packs/packListAtoms.ts b/apps/expo/features/packs/packListAtoms.ts index daf48bb44b..83d91ef285 100644 --- a/apps/expo/features/packs/packListAtoms.ts +++ b/apps/expo/features/packs/packListAtoms.ts @@ -1,4 +1,4 @@ -import type { PackCategory } from 'expo-app/types'; +import type { PackCategory } from '@packrat/constants'; import { atom } from 'jotai'; export const activeFilterAtom = atom('all'); diff --git a/apps/expo/features/packs/screens/CreatePackItemForm.tsx b/apps/expo/features/packs/screens/CreatePackItemForm.tsx index 9f0dff53d7..324de70dd7 100644 --- a/apps/expo/features/packs/screens/CreatePackItemForm.tsx +++ b/apps/expo/features/packs/screens/CreatePackItemForm.tsx @@ -1,4 +1,5 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import type { WeightUnit } from '@packrat/constants'; import { safeIndexOf } from '@packrat/guards'; import { Form, FormItem, FormSection, SegmentedControl, TextField } from '@packrat/ui/nativewindui'; import { useForm } from '@tanstack/react-form'; @@ -7,7 +8,6 @@ import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import type { WeightUnit } from 'expo-app/types'; import { useRouter } from 'expo-router'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Image, Pressable, Switch, Text, TouchableOpacity, View } from 'react-native'; diff --git a/apps/expo/features/packs/store/packItems.ts b/apps/expo/features/packs/store/packItems.ts index e8339a567f..e7d88f1744 100644 --- a/apps/expo/features/packs/store/packItems.ts +++ b/apps/expo/features/packs/store/packItems.ts @@ -1,8 +1,8 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { PackItemSchema, PackWithWeightsSchema } from '@packrat/api/schemas/packs'; import { isRemoteUrl } from '@packrat/guards'; +import { PackItemSchema, PackWithWeightsSchema } from '@packrat/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/store/packWeightHistory.ts b/apps/expo/features/packs/store/packWeightHistory.ts index 75dc2f32a1..8a5f979ce6 100644 --- a/apps/expo/features/packs/store/packWeightHistory.ts +++ b/apps/expo/features/packs/store/packWeightHistory.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { PackWeightHistoryResponseSchema } from '@packrat/api/schemas/packs'; +import { PackWeightHistoryResponseSchema } from '@packrat/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/store/packs.ts b/apps/expo/features/packs/store/packs.ts index 4cf21b0b8b..f73daf13e3 100644 --- a/apps/expo/features/packs/store/packs.ts +++ b/apps/expo/features/packs/store/packs.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { PackWithWeightsSchema } from '@packrat/api/schemas/packs'; +import { PackWithWeightsSchema } from '@packrat/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/types.ts b/apps/expo/features/packs/types.ts index 9b6f433895..512988ee48 100644 --- a/apps/expo/features/packs/types.ts +++ b/apps/expo/features/packs/types.ts @@ -1,6 +1,6 @@ +import type { PackCategory, WeightUnit } from '@packrat/constants'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import type { PackTemplateItem } from 'expo-app/features/pack-templates/types'; -import type { PackCategory, WeightUnit } from 'expo-app/types'; export type { PackCategory, WeightUnit }; diff --git a/apps/expo/features/profile/types.ts b/apps/expo/features/profile/types.ts index 433004df47..00836cd2eb 100644 --- a/apps/expo/features/profile/types.ts +++ b/apps/expo/features/profile/types.ts @@ -1,4 +1,4 @@ -import type { UserSchema } from '@packrat/api/schemas/users'; +import type { UserSchema } from '@packrat/schemas/users'; import type { z } from 'zod'; import type { WeightUnit } from '../packs/types'; diff --git a/apps/expo/features/trail-conditions/store/trailConditionReports.ts b/apps/expo/features/trail-conditions/store/trailConditionReports.ts index 0ddc8d5115..21a5989235 100644 --- a/apps/expo/features/trail-conditions/store/trailConditionReports.ts +++ b/apps/expo/features/trail-conditions/store/trailConditionReports.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { TrailConditionReportSchema } from '@packrat/api/schemas/trailConditions'; +import { TrailConditionReportSchema } from '@packrat/schemas/trailConditions'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx index bddef8c413..7fa5b63ef7 100644 --- a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx +++ b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx @@ -1,4 +1,4 @@ -import type { WeatherAPIForecastResponse } from '@packrat/api/schemas/weather'; +import type { WeatherAPIForecastResponse } from '@packrat/schemas/weather'; import { Icon } from 'expo-app/components/Icon'; import { WeatherForecast } from 'expo-app/features/weather/components/WeatherForecast'; import { diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index 6d428457d2..d14d5c0bc6 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { TripSchema } from '@packrat/api/schemas/trips'; +import { TripSchema } from '@packrat/schemas/trips'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/weather/lib/weatherService.ts b/apps/expo/features/weather/lib/weatherService.ts index 89f4078671..284cc2dd57 100644 --- a/apps/expo/features/weather/lib/weatherService.ts +++ b/apps/expo/features/weather/lib/weatherService.ts @@ -1,9 +1,9 @@ +import { assertDefined } from '@packrat/guards'; import { LocationSearchResponseSchema, type WeatherAPIForecastResponse, WeatherAPIForecastResponseSchema, -} from '@packrat/api/schemas/weather'; -import { assertDefined } from '@packrat/guards'; +} from '@packrat/schemas/weather'; import * as Sentry from '@sentry/react-native'; import { apiClient } from 'expo-app/lib/api/packrat'; import { getWeatherIconName as getIconNameFromCode } from './weatherIcons'; diff --git a/apps/expo/lib/utils/__tests__/compute-pack.test.ts b/apps/expo/lib/utils/__tests__/compute-pack.test.ts index 90825eac36..811605c56d 100644 --- a/apps/expo/lib/utils/__tests__/compute-pack.test.ts +++ b/apps/expo/lib/utils/__tests__/compute-pack.test.ts @@ -1,11 +1,10 @@ -import type { Pack, PackItem } from 'expo-app/types'; +import type { PackItem, PackWithItems } from '@packrat/types'; import { describe, expect, it } from 'vitest'; import { computePacksWeights, computePackWeights } from '../compute-pack'; // --------------------------------------------------------------------------- // Minimal factory helpers // --------------------------------------------------------------------------- -// Arbitrary fixed timestamp used only as a required field value, not asserted on const NOW = new Date().toISOString(); function makePackItem( @@ -14,28 +13,44 @@ function makePackItem( return { id: 'item-1', name: 'Test Item', + description: null, quantity: overrides.quantity ?? 1, + category: null, consumable: overrides.consumable ?? false, worn: overrides.worn ?? false, - category: 'tools', + image: null, + notes: null, packId: 'pack-1', + catalogItemId: null, userId: 'user-1', + deleted: false, + isAIGenerated: false, + templateItemId: null, + embedding: null, createdAt: NOW, updatedAt: NOW, ...overrides, - }; + } as PackItem; } -function makePack(items: PackItem[] = [], overrides: Partial = {}): Pack { +function makePack(items: PackItem[] = [], overrides: Partial = {}): PackWithItems { return { id: 'pack-1', name: 'Test Pack', + description: null, category: 'hiking', - items, userId: 'user-1', + templateId: null, + isPublic: false, + image: null, + tags: null, + deleted: false, + isAIGenerated: false, + localCreatedAt: NOW, + localUpdatedAt: NOW, createdAt: NOW, updatedAt: NOW, - isPublic: false, + items, ...overrides, }; } @@ -53,7 +68,7 @@ describe('computePackWeights', () => { it('throws when items property is null/undefined', () => { const pack = makePack(); // Force missing items - (pack as Pack & { items: undefined }).items = undefined; + (pack as unknown as { items: undefined }).items = undefined; expect(() => computePackWeights(pack)).toThrow('Pack with ID pack-1 has no items'); }); diff --git a/apps/expo/lib/utils/compute-pack.ts b/apps/expo/lib/utils/compute-pack.ts index e1e16c324c..28b81d97df 100644 --- a/apps/expo/lib/utils/compute-pack.ts +++ b/apps/expo/lib/utils/compute-pack.ts @@ -1,8 +1,16 @@ +import type { PackWithItems } from '@packrat/types'; import type { WeightUnit } from '@packrat/units'; import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; -import type { Pack } from 'expo-app/types'; -export const computePackWeights = (pack: Pack, preferredUnit: WeightUnit = 'g'): Pack => { +export type ComputedPack = PackWithItems & { + baseWeight: number; + totalWeight: number; +}; + +export const computePackWeights = ( + pack: PackWithItems, + preferredUnit: WeightUnit = 'g', +): ComputedPack => { if (!pack.items) { throw new Error(`Pack with ID ${pack.id} has no items`); } @@ -26,5 +34,7 @@ export const computePackWeights = (pack: Pack, preferredUnit: WeightUnit = 'g'): }; }; -export const computePacksWeights = (packs: Pack[], preferredUnit: WeightUnit = 'g'): Pack[] => - packs.map((pack) => computePackWeights(pack, preferredUnit)); +export const computePacksWeights = ( + packs: PackWithItems[], + preferredUnit: WeightUnit = 'g', +): ComputedPack[] => packs.map((pack) => computePackWeights(pack, preferredUnit)); diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index a9932c2b58..426e483c55 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -10,6 +10,9 @@ const config = getSentryExpoConfig(__dirname); config.resolver = { ...config.resolver, assetExts: [...(config.resolver?.assetExts ?? []), 'wasm'], + // Enable package.json "exports" field resolution so workspace packages with + // subpath exports (e.g. @packrat/schemas/constants) resolve correctly. + unstable_enablePackageExports: true, // Exclude the ESM "import" condition so packages like Jotai resolve to their // CJS builds instead of .mjs files that contain import.meta (invalid in // Metro's __d() CJS module wrapper). diff --git a/apps/expo/package.json b/apps/expo/package.json index efd594f165..301e1d0696 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -55,8 +55,12 @@ "@legendapp/state": "^3.0.0-beta.30", "@packrat/api-client": "workspace:*", "@packrat/config": "workspace:*", + "@packrat/constants": "workspace:*", + "@packrat/db": "workspace:*", "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", + "@packrat/schemas": "workspace:*", + "@packrat/types": "workspace:*", "@packrat/units": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", diff --git a/apps/expo/types/index.ts b/apps/expo/types/index.ts deleted file mode 100644 index 7e0084add1..0000000000 --- a/apps/expo/types/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ITEM_CATEGORIES, PACK_CATEGORIES, WEIGHT_UNITS } from '@packrat/api/types'; -import { z } from 'zod'; - -// --- User Schema --- -export const UserSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), - avatar: z.string().url(), - experience: z.enum(['beginner', 'intermediate', 'expert']), - joinedAt: z.string().datetime(), - bio: z.string().optional(), -}); - -export type User = z.infer; - -// --- Pack Category Enum --- -export const PackCategorySchema = z.enum(PACK_CATEGORIES); -export type PackCategory = z.infer; - -// --- Item Category Enum --- -export const ItemCategorySchema = z.enum(ITEM_CATEGORIES); -export type ItemCategory = z.infer; - -// --- Weight Unit Enum --- -export const WeightUnitSchema = z.enum(WEIGHT_UNITS); -export type WeightUnit = z.infer; - -// --- Pack Item Schema --- -export const PackItemSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().optional(), - weight: z.number().nonnegative(), - weightUnit: WeightUnitSchema, - quantity: z.number().int().positive(), - category: z.string(), - consumable: z.boolean(), - worn: z.boolean(), - image: z.string().url().optional(), - notes: z.string().optional(), - packId: z.string(), - catalogItemId: z.string().optional(), // Reference to original catalog item - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - userId: z.string(), -}); - -export type PackItem = z.infer; - -// --- Pack Schema --- -export const PackSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().optional(), - category: PackCategorySchema, - baseWeight: z.number().nonnegative().optional(), // Weight without consumables (computed) - totalWeight: z.number().nonnegative().optional(), // Total weight including consumables (computed) - items: z.array(PackItemSchema).optional(), - userId: z.string(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - isPublic: z.boolean(), - image: z.string().url().optional(), - tags: z.array(z.string()).optional(), -}); - -export type Pack = z.infer; - -// --- Arrays for Mock Data Validation --- -export const UsersArraySchema = z.array(UserSchema); -export const PacksArraySchema = z.array(PackSchema); -export const PackItemsArraySchema = z.array(PackItemSchema); diff --git a/apps/expo/utils/__tests__/weight.test.ts b/apps/expo/utils/__tests__/weight.test.ts index 9a93706335..2e4c05b762 100644 --- a/apps/expo/utils/__tests__/weight.test.ts +++ b/apps/expo/utils/__tests__/weight.test.ts @@ -1,4 +1,4 @@ -import type { PackItem } from 'expo-app/types'; +import type { PackItem } from '@packrat/types'; import { describe, expect, it } from 'vitest'; import { calculateBaseWeight, calculateTotalWeight, convertWeight, formatWeight } from '../weight'; @@ -11,16 +11,24 @@ function makeItem( return { id: 'item-1', name: 'Test Item', + description: null, quantity: overrides.quantity ?? 1, + category: null, consumable: overrides.consumable ?? false, worn: overrides.worn ?? false, + image: null, + notes: null, packId: 'pack-1', + catalogItemId: null, userId: 'user-1', - category: 'tools', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + deleted: false, + isAIGenerated: false, + templateItemId: null, + embedding: null, + createdAt: new Date(), + updatedAt: new Date(), ...overrides, - }; + } as PackItem; } // --------------------------------------------------------------------------- diff --git a/apps/expo/utils/weight.ts b/apps/expo/utils/weight.ts index a915c0f6b5..c790dce53d 100644 --- a/apps/expo/utils/weight.ts +++ b/apps/expo/utils/weight.ts @@ -1,6 +1,6 @@ +import type { PackItem } from '@packrat/types'; import type { WeightUnit } from '@packrat/units'; import { convert, displayWeight, normalize, parseWeightUnit } from '@packrat/units'; -import type { PackItem } from 'expo-app/types'; export { convert as convertWeight }; diff --git a/apps/trails/components/AuthGate.tsx b/apps/trails/components/AuthGate.tsx index 5fda173e03..a82791e74c 100644 --- a/apps/trails/components/AuthGate.tsx +++ b/apps/trails/components/AuthGate.tsx @@ -15,6 +15,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@packrat/web-ui/compon import { Loader2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { VerifyEmail } from 'trails-app/components/VerifyEmail'; +import { trailsAuthClient } from 'trails-app/lib/auth-client'; import { useAuth } from 'trails-app/lib/useAuth'; const TABS = ['register', 'login', 'forgot'] as const; @@ -22,7 +24,7 @@ type Tab = (typeof TABS)[number]; const isTab = makeEnumGuard(TABS); export function AuthGate() { - const { authGateOpen, closeAuthGate, register, login, forgotPassword } = useAuth(); + const { authGateOpen, closeAuthGate, register, login, pendingEmail } = useAuth(); const [tab, setTab] = useState('register'); const [loading, setLoading] = useState(false); @@ -75,7 +77,14 @@ export function AuthGate() { e.preventDefault(); setLoading(true); try { - await forgotPassword(forgotEmail); + const { error } = await trailsAuthClient.requestPasswordReset({ + email: forgotEmail, + redirectTo: + typeof window !== 'undefined' + ? `${window.location.origin}/reset-password` + : '/reset-password', + }); + if (error) throw new Error(error.message); setForgotSent(true); } catch { toast.error('Could not send reset email. Try again.'); @@ -88,174 +97,182 @@ export function AuthGate() { !open && closeAuthGate()}> - Search trails on PackRat + + {pendingEmail ? 'Verify your email' : 'Search trails on PackRat'} + - Create a free account to search trails by name or location. + {pendingEmail + ? 'Enter the 6-digit code we sent you to unlock search.' + : 'Create a free account to search trails by name or location.'} - { - if (isTab(v)) setTab(v); - }} - > - - Create account - Log in - + {pendingEmail ? ( + + ) : ( + { + if (isTab(v)) setTab(v); + }} + > + + Create account + Log in + - -
-
- - setRegFirstName(e.target.value)} - autoComplete="given-name" - /> -
-
- - setRegEmail(e.target.value)} - required - autoComplete="email" - /> -
-
- - setRegPassword(e.target.value)} - required - minLength={8} - autoComplete="new-password" - /> -
- -

- By creating an account you agree to our{' '} - - Terms - {' '} - and{' '} - - Privacy Policy - - . -

-
-
- - -
-
- - setLoginEmail(e.target.value)} - required - autoComplete="email" - /> -
-
-
- - + + +
+ + setRegFirstName(e.target.value)} + autoComplete="given-name" + />
- setLoginPassword(e.target.value)} - required - autoComplete="current-password" - /> -
- - - - - - {forgotSent ? ( -
-

Check your inbox

-

- We sent a password reset link to{' '} - {forgotEmail}. -

- -
- ) : ( -
-

- Enter your email and we'll send you a link to reset your password. +

+ By creating an account you agree to our{' '} + + Terms + {' '} + and{' '} + + Privacy Policy + + .

+
+
+ + +
- + setForgotEmail(e.target.value)} + value={loginEmail} + onChange={(e) => setLoginEmail(e.target.value)} required autoComplete="email" />
+
+
+ + +
+ setLoginPassword(e.target.value)} + required + autoComplete="current-password" + /> +
-
- )} -
- + + + + {forgotSent ? ( +
+

Check your inbox

+

+ We sent a password reset link to{' '} + {forgotEmail}. +

+ +
+ ) : ( +
+

+ Enter your email and we'll send you a link to reset your password. +

+
+ + setForgotEmail(e.target.value)} + required + autoComplete="email" + /> +
+ + +
+ )} +
+ + )}
); diff --git a/apps/trails/lib/apiClient.ts b/apps/trails/lib/apiClient.ts index f21dfafcfc..fc30730757 100644 --- a/apps/trails/lib/apiClient.ts +++ b/apps/trails/lib/apiClient.ts @@ -1,7 +1,7 @@ 'use client'; import { createApiClient } from '@packrat/api-client'; -import { authClient } from 'trails-app/lib/auth-client'; +import { trailsAuthClient as authClient } from 'trails-app/lib/auth-client'; import { trailsEnv } from 'trails-app/lib/env'; export class AuthExpiredError extends Error { diff --git a/apps/trails/lib/auth-client.ts b/apps/trails/lib/auth-client.ts index b0ca02943c..a12b75ffae 100644 --- a/apps/trails/lib/auth-client.ts +++ b/apps/trails/lib/auth-client.ts @@ -1,10 +1,8 @@ 'use client'; -import { nextCookies } from 'better-auth/next-js'; import { createAuthClient } from 'better-auth/react'; import { trailsEnv } from 'trails-app/lib/env'; -export const authClient = createAuthClient({ +export const trailsAuthClient = createAuthClient({ baseURL: trailsEnv.NEXT_PUBLIC_API_URL, - plugins: [nextCookies()], }); diff --git a/apps/trails/lib/useAuth.tsx b/apps/trails/lib/useAuth.tsx index ecd3688f75..ef4c8b473f 100644 --- a/apps/trails/lib/useAuth.tsx +++ b/apps/trails/lib/useAuth.tsx @@ -1,18 +1,31 @@ 'use client'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; -import { authClient } from 'trails-app/lib/auth-client'; +import { fromZod } from '@packrat/guards'; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { + clearTokens, + clearUser, + getAccessToken, + getUser, + setTokens, + setUser, + type UserInfo, + UserInfoSchema, +} from 'trails-app/lib/auth'; +import { trailsAuthClient } from 'trails-app/lib/auth-client'; interface AuthState { isAuthed: boolean; - user: { id: string; email: string; name?: string | null } | null; + user: UserInfo | null; + pendingEmail: string | null; } interface AuthActions { register(email: string, opts: { password: string; firstName?: string }): Promise; + verifyEmail(token: string): Promise; + resendVerification(): Promise; login(email: string, password: string): Promise; logout(): Promise; - forgotPassword(email: string): Promise; openAuthGate(): void; closeAuthGate(): void; authGateOpen: boolean; @@ -20,43 +33,106 @@ interface AuthActions { const AuthContext = createContext<(AuthState & AuthActions) | null>(null); +function parseAuthUser(user: { + id: string; + email: string; + [key: string]: unknown; +}): UserInfo | null { + return ( + fromZod(UserInfoSchema)({ + id: user.id, + email: user.email, + firstName: (user.firstName as string | null | undefined) ?? null, + lastName: (user.lastName as string | null | undefined) ?? null, + }) ?? null + ); +} + export function AuthProvider({ children }: { children: React.ReactNode }) { - const session = authClient.useSession(); + const [state, setState] = useState({ + isAuthed: false, + user: null, + pendingEmail: null, + }); const [authGateOpen, setAuthGateOpen] = useState(false); - const isAuthed = !!session.data?.user; - const user = session.data?.user ?? null; + // Hydrate from localStorage on mount + useEffect(() => { + const token = getAccessToken(); + const user = getUser(); + if (token && user) { + setState({ isAuthed: true, user, pendingEmail: null }); + } + }, []); const register = useCallback( - async (email: string, { password, firstName }: { password: string; firstName?: string }) => { - const { error } = await authClient.signUp.email({ + async (email: string, opts: { password: string; firstName?: string }) => { + const name = opts.firstName ?? email; + const { data, error } = await trailsAuthClient.signUp.email({ email, - password, - name: firstName || email, + password: opts.password, + name, }); if (error) throw new Error(error.message ?? 'Registration failed'); - setAuthGateOpen(false); + if (data?.token) { + // autoSignIn: true succeeded — token is the Bearer session token + const parsedUser = parseAuthUser(data.user as Parameters[0]); + if (!parsedUser) throw new Error('Registration failed: unexpected user shape'); + setTokens(data.token, ''); + setUser(parsedUser); + setState({ isAuthed: true, user: parsedUser, pendingEmail: null }); + setAuthGateOpen(false); + } else { + setState((s) => ({ ...s, pendingEmail: email })); + } }, [], ); + const verifyEmail = useCallback( + async (token: string) => { + if (!state.pendingEmail) throw new Error('No pending email verification'); + const { error } = await trailsAuthClient.verifyEmail({ query: { token } }); + if (error) throw new Error(error.message ?? 'Verification failed'); + const sessionRes = await trailsAuthClient.getSession(); + if (!sessionRes.data?.session || !sessionRes.data.user) { + throw new Error('Verification failed: could not get session'); + } + const parsedUser = parseAuthUser(sessionRes.data.user as Parameters[0]); + if (!parsedUser) throw new Error('Verification failed: unexpected user shape'); + setTokens(sessionRes.data.session.token, ''); + setUser(parsedUser); + setState({ isAuthed: true, user: parsedUser, pendingEmail: null }); + setAuthGateOpen(false); + }, + [state.pendingEmail], + ); + + const resendVerification = useCallback(async () => { + if (!state.pendingEmail) throw new Error('No pending email'); + const { error } = await trailsAuthClient.sendVerificationEmail({ + email: state.pendingEmail, + callbackURL: typeof window !== 'undefined' ? window.location.origin : '', + }); + if (error) throw new Error(error.message ?? 'Resend failed'); + }, [state.pendingEmail]); + const login = useCallback(async (email: string, password: string) => { - const { error } = await authClient.signIn.email({ email, password }); - if (error) throw new Error(error.message ?? 'Login failed'); + const { data, error } = await trailsAuthClient.signIn.email({ email, password }); + if (error || !data) throw new Error(error?.message ?? 'Login failed'); + const parsedUser = parseAuthUser(data.user as Parameters[0]); + if (!parsedUser) throw new Error('Login failed: unexpected user shape'); + setTokens(data.token, ''); + setUser(parsedUser); + setState({ isAuthed: true, user: parsedUser, pendingEmail: null }); setAuthGateOpen(false); }, []); const logout = useCallback(async () => { - await authClient.signOut(); - }, []); - - const forgotPassword = useCallback(async (email: string) => { - const redirectTo = - typeof window !== 'undefined' - ? `${window.location.origin}/reset-password` - : '/reset-password'; - const { error } = await authClient.requestPasswordReset({ email, redirectTo }); - if (error) throw new Error(error.message ?? 'Failed to send reset email'); + await trailsAuthClient.signOut(); + clearTokens(); + clearUser(); + setState({ isAuthed: false, user: null, pendingEmail: null }); }, []); const openAuthGate = useCallback(() => setAuthGateOpen(true), []); @@ -64,24 +140,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const value = useMemo( () => ({ - isAuthed, - user, + ...state, authGateOpen, register, + verifyEmail, + resendVerification, login, logout, - forgotPassword, openAuthGate, closeAuthGate, }), [ - isAuthed, - user, + state, authGateOpen, register, + verifyEmail, + resendVerification, login, logout, - forgotPassword, openAuthGate, closeAuthGate, ], diff --git a/apps/trails/package.json b/apps/trails/package.json index 9cb6379932..7d6afa4681 100644 --- a/apps/trails/package.json +++ b/apps/trails/package.json @@ -21,7 +21,6 @@ "@radix-ui/react-separator": "catalog:", "@radix-ui/react-tabs": "catalog:", "@radix-ui/react-toast": "catalog:", - "better-auth": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", "input-otp": "catalog:", diff --git a/apps/web/app/auth/page.tsx b/apps/web/app/auth/page.tsx index 9e915f6416..d47266cf9d 100644 --- a/apps/web/app/auth/page.tsx +++ b/apps/web/app/auth/page.tsx @@ -1,9 +1,46 @@ 'use client'; +import { webEnv } from '@packrat/env/web'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import type React from 'react'; import { useState } from 'react'; -import { authClient } from 'web-app/lib/auth-client'; +import { setTokens } from 'web-app/lib/auth'; + +const API_BASE = webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787'; + +function useLoginMutation() { + return useMutation({ + mutationFn: async (body: { email: string; password: string }) => { + const res = await fetch(`${API_BASE}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Login failed'); + return res.json() as Promise<{ token?: string; user?: unknown }>; + }, + }); +} + +function useRegisterMutation() { + return useMutation({ + mutationFn: async (body: { + email: string; + password: string; + firstName?: string; + lastName?: string; + }) => { + const name = [body.firstName, body.lastName].filter(Boolean).join(' ') || body.email; + const res = await fetch(`${API_BASE}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: body.email, password: body.password, name }), + }); + if (!res.ok) throw new Error('Registration failed'); + return res.json(); + }, + }); +} export default function AuthPage() { const [tab, setTab] = useState<'login' | 'register'>('login'); @@ -13,35 +50,44 @@ export default function AuthPage() { const [info, setInfo] = useState(null); const router = useRouter(); - const loginMutation = useMutation({ - mutationFn: async (body: { email: string; password: string }) => { - const { error } = await authClient.signIn.email(body); - if (error) throw new Error(error.message ?? 'Login failed'); - }, - onSuccess: () => router.push('/'), - }); - - const registerMutation = useMutation({ - mutationFn: async (body: { email: string; password: string; name: string }) => { - const { error } = await authClient.signUp.email(body); - if (error) throw new Error(error.message ?? 'Registration failed'); - }, - onSuccess: () => { - setTab('login'); - setInfo('Account created! Please check your email to verify, then sign in.'); - }, - }); + const loginMutation = useLoginMutation(); + const registerMutation = useRegisterMutation(); function handleLogin(e: React.FormEvent) { e.preventDefault(); - loginMutation.mutate({ email, password }); + setInfo(null); + loginMutation.mutate( + { email, password }, + { + onSuccess: (data) => { + const token = (data as { token?: string }).token ?? ''; + if (!token) return; + setTokens(token, ''); + router.push('/'); + }, + }, + ); } function handleRegister(e: React.FormEvent) { e.preventDefault(); - registerMutation.mutate({ email, password, name: username || email }); + setInfo(null); + const [firstName, ...rest] = username.trim().split(' '); + const lastName = rest.join(' ') || undefined; + registerMutation.mutate( + { email, password, firstName: firstName ?? username, lastName }, + { + onSuccess: () => { + setTab('login'); + setInfo('Account created! Please check your email to verify, then sign in.'); + }, + }, + ); } + const loginError = loginMutation.error?.message ?? null; + const registerError = registerMutation.error?.message ?? null; + return (
@@ -83,9 +129,7 @@ export default function AuthPage() { required /> {info &&

{info}

} - {loginMutation.error && ( -

{loginMutation.error.message}

- )} + {loginError &&

{loginError}

}