diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index ba01a3aa8b..4e937730ea 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -41,7 +41,7 @@ export default (): ExpoConfig => scheme: 'packrat', web: { bundler: 'metro', - output: 'static', + output: 'single', favicon: './assets/favicon.png', }, plugins: [ diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index b3897ef208..c6b2be62fb 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -73,7 +73,6 @@ export default function AppLayout() { options={getCatalogAddToPackItemDetailsOptions(t)} /> - title: t('common.edit'), }) as const; -const getCatalogListOptions = (t: TranslationFunction) => - ({ - title: t('catalog.itemsCatalog'), - headerLargeTitle: true, - }) as const; - const getCatalogItemDetailOptions = (t: TranslationFunction) => ({ title: t('items.itemDetails'), diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index 0843b7374e..81dd6426d5 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -13,7 +13,7 @@ import { userStore } from 'expo-app/features/auth/store'; import { useColorScheme, useInitialAndroidBarSync } from 'expo-app/lib/hooks/useColorScheme'; import { Providers } from 'expo-app/providers'; import { NAV_THEME } from 'expo-app/theme'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; Sentry.init({ dsn: clientEnvs.EXPO_PUBLIC_SENTRY_DSN, @@ -41,6 +41,13 @@ function RootLayout() { const { colorScheme, isDarkColorScheme } = useColorScheme(); + // Sync NativeWind dark mode class to on web (darkMode: 'class' requires it) + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', isDarkColorScheme); + } + }, [isDarkColorScheme]); + return ( diff --git a/apps/expo/app/auth/(create-account)/index.tsx b/apps/expo/app/auth/(create-account)/index.tsx index c3afb3f70a..912b5e1654 100644 --- a/apps/expo/app/auth/(create-account)/index.tsx +++ b/apps/expo/app/auth/(create-account)/index.tsx @@ -65,7 +65,7 @@ export default function InfoScreen() { diff --git a/apps/expo/app/auth/(login)/forgot-password.tsx b/apps/expo/app/auth/(login)/forgot-password.tsx index 0a08340fde..b0360ec93b 100644 --- a/apps/expo/app/auth/(login)/forgot-password.tsx +++ b/apps/expo/app/auth/(login)/forgot-password.tsx @@ -85,7 +85,7 @@ export default function ForgotPasswordScreen() { diff --git a/apps/expo/app/auth/(login)/index.tsx b/apps/expo/app/auth/(login)/index.tsx index 18c0152b43..f025daf6c0 100644 --- a/apps/expo/app/auth/(login)/index.tsx +++ b/apps/expo/app/auth/(login)/index.tsx @@ -94,7 +94,7 @@ export default function LoginScreen() { diff --git a/apps/expo/app/auth/(login)/reset-password.tsx b/apps/expo/app/auth/(login)/reset-password.tsx index 5f0b6b236a..dc553a4d91 100644 --- a/apps/expo/app/auth/(login)/reset-password.tsx +++ b/apps/expo/app/auth/(login)/reset-password.tsx @@ -176,7 +176,7 @@ export default function ResetPasswordScreen() { diff --git a/apps/expo/app/auth/_layout.tsx b/apps/expo/app/auth/_layout.tsx index 783d96c3dd..dc4a2981df 100644 --- a/apps/expo/app/auth/_layout.tsx +++ b/apps/expo/app/auth/_layout.tsx @@ -3,6 +3,10 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { router, Stack } from 'expo-router'; import { Platform } from 'react-native'; +export const unstable_settings = { + anchor: 'index', +}; + export default function AuthLayout() { const { t } = useTranslation(); diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 7026ac86a5..1517058e72 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -61,7 +61,7 @@ export default function AuthIndexScreen() { diff --git a/apps/expo/features/auth/atoms/authAtoms.ts b/apps/expo/features/auth/atoms/authAtoms.ts index 2bb10c8d05..0eb302de7f 100644 --- a/apps/expo/features/auth/atoms/authAtoms.ts +++ b/apps/expo/features/auth/atoms/authAtoms.ts @@ -1,4 +1,4 @@ -import Storage from 'expo-sqlite/kv-store'; +import kvStorage from 'expo-app/lib/kvStorage'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; @@ -12,23 +12,9 @@ export type User = { }; // Token storage atom -export const tokenAtom = atomWithStorage('access_token', null, { - getItem: (key) => Storage.getItemSync(key), - setItem: (key, value) => { - if (value === null) return Storage.removeItemSync(key); - return Storage.setItemSync(key, value); - }, - removeItem: (key) => Storage.removeItemSync(key), -}); +export const tokenAtom = atomWithStorage('access_token', null, kvStorage); -export const refreshTokenAtom = atomWithStorage('refresh_token', null, { - getItem: (key) => Storage.getItemSync(key), - setItem: (key, value) => { - if (value === null) return Storage.removeItemSync(key); - return Storage.setItemSync(key, value); - }, - removeItem: (key) => Storage.removeItemSync(key), -}); +export const refreshTokenAtom = atomWithStorage('refresh_token', null, kvStorage); // Loading state atom export const isLoadingAtom = atom(false); diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 5ec2ba1473..aa790aec35 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -1,10 +1,12 @@ import { clientEnvs } from '@packrat/env/expo-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import { store } from 'expo-app/atoms/store'; import { router } from 'expo-router'; import Storage from 'expo-sqlite/kv-store'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; +import { tokenAtom } from '../atoms/authAtoms'; import { isAuthed } from '../store'; export function useAuthInit() { @@ -37,7 +39,12 @@ export function useAuthInit() { // If user has session or hasSkippedLogin before, continue to app if (accessToken || hasSkippedLogin === 'true') { - if (accessToken) isAuthed.set(true); + if (accessToken) { + isAuthed.set(true); + // Hydrate tokenAtom so components (e.g. AI chat) get the correct + // token without relying on the sync SQLite read (unavailable on web). + store.set(tokenAtom, accessToken); + } setIsLoading(false); return; } else { diff --git a/apps/expo/features/auth/store/user.ts b/apps/expo/features/auth/store/user.ts index 2fd01d5ec8..7991b3f0b3 100644 --- a/apps/expo/features/auth/store/user.ts +++ b/apps/expo/features/auth/store/user.ts @@ -1,9 +1,8 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import type { User } from 'expo-app/features/profile/types'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; export const userStore = observable(null); @@ -12,7 +11,7 @@ syncObservable( syncedCrud({ persist: { name: 'user', - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, }, }), ); diff --git a/apps/expo/features/catalog/components/CatalogItemCard.tsx b/apps/expo/features/catalog/components/CatalogItemCard.tsx index d1124d0b59..0f87ec374f 100644 --- a/apps/expo/features/catalog/components/CatalogItemCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemCard.tsx @@ -11,6 +11,7 @@ import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { TouchableWithoutFeedback, View } from 'react-native'; +import { normalizeDescription } from '../lib/normalizeDescription'; import type { CatalogItem } from '../types'; import { CatalogItemImage } from './CatalogItemImage'; @@ -52,7 +53,7 @@ export function CatalogItemCard({ item, onPress }: CatalogItemCardProps) { {item.brand && {item.brand}} - {item.description} + {normalizeDescription(item.description)} diff --git a/apps/expo/features/catalog/lib/normalizeDescription.ts b/apps/expo/features/catalog/lib/normalizeDescription.ts new file mode 100644 index 0000000000..bfeed22434 --- /dev/null +++ b/apps/expo/features/catalog/lib/normalizeDescription.ts @@ -0,0 +1,15 @@ +const DETAILS_ARRAY_RE = /^Details:\s*(\[[\s\S]*\])$/; + +export function normalizeDescription(description: string | null | undefined): string | null { + if (!description) return null; + const match = description.match(DETAILS_ARRAY_RE); + if (match && match[1]) { + try { + const items = JSON.parse(match[1]) as string[]; + return items.join('. '); + } catch { + // fall through + } + } + return description; +} diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index a67061fdab..aa0ee23943 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -18,6 +18,7 @@ import { Linking, Text as RNText, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CatalogItemImage } from '../components/CatalogItemImage'; import { useCatalogItemDetails } from '../hooks'; +import { normalizeDescription } from '../lib/normalizeDescription'; export function CatalogItemDetailScreen() { const router = useRouter(); @@ -109,7 +110,7 @@ export function CatalogItemDetailScreen() { )} - {item.description} + {normalizeDescription(item.description)} diff --git a/apps/expo/features/pack-templates/store/packTemplateItems.ts b/apps/expo/features/pack-templates/store/packTemplateItems.ts index 239c556c6c..f942a5e6f4 100644 --- a/apps/expo/features/pack-templates/store/packTemplateItems.ts +++ b/apps/expo/features/pack-templates/store/packTemplateItems.ts @@ -1,5 +1,4 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { @@ -8,7 +7,7 @@ import { } from '@packrat/api/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { PackTemplateItem } from '../types'; const listAllPackTemplateItems = async (): Promise => { @@ -84,7 +83,7 @@ syncObservable( updatePartial: true, mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packTemplateItems', }, diff --git a/apps/expo/features/pack-templates/store/packTemplates.ts b/apps/expo/features/pack-templates/store/packTemplates.ts index b2570c76ce..8e6afbf641 100644 --- a/apps/expo/features/pack-templates/store/packTemplates.ts +++ b/apps/expo/features/pack-templates/store/packTemplates.ts @@ -1,5 +1,4 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { @@ -8,7 +7,7 @@ import { } from '@packrat/api/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { PackTemplate, PackTemplateInStore } from '../types'; const listPackTemplates = async (): Promise => { @@ -73,7 +72,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packTemplates', }, diff --git a/apps/expo/features/packs/store/packItems.ts b/apps/expo/features/packs/store/packItems.ts index 1f4fe4ab96..e8339a567f 100644 --- a/apps/expo/features/packs/store/packItems.ts +++ b/apps/expo/features/packs/store/packItems.ts @@ -1,13 +1,12 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; 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 { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import Storage from 'expo-sqlite/kv-store'; import type { PackItem } from '../types'; import { uploadImage } from '../utils'; @@ -55,7 +54,7 @@ syncObservable( updatePartial: true, mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packItems', }, diff --git a/apps/expo/features/packs/store/packWeightHistory.ts b/apps/expo/features/packs/store/packWeightHistory.ts index cbf760e1f5..75dc2f32a1 100644 --- a/apps/expo/features/packs/store/packWeightHistory.ts +++ b/apps/expo/features/packs/store/packWeightHistory.ts @@ -1,12 +1,11 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackWeightHistoryResponseSchema } from '@packrat/api/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'; import { obs } from 'expo-app/lib/store'; -import Storage from 'expo-sqlite/kv-store'; import { nanoid } from 'nanoid'; import type { PackWeightHistoryEntry } from '../types'; import { computePackWeights } from '../utils'; @@ -40,7 +39,7 @@ syncObservable( fieldCreatedAt: 'createdAt', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packWeigthHistory', }, diff --git a/apps/expo/features/packs/store/packingMode.ts b/apps/expo/features/packs/store/packingMode.ts index d7e87a3d36..18ca1cb146 100644 --- a/apps/expo/features/packs/store/packingMode.ts +++ b/apps/expo/features/packs/store/packingMode.ts @@ -1,13 +1,12 @@ import { observable } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; export const packingModeStore = observable>>({}); syncObservable(packingModeStore, { persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packingMode', }, diff --git a/apps/expo/features/packs/store/packs.ts b/apps/expo/features/packs/store/packs.ts index 96d132c06b..4cf21b0b8b 100644 --- a/apps/expo/features/packs/store/packs.ts +++ b/apps/expo/features/packs/store/packs.ts @@ -1,11 +1,10 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackWithWeightsSchema } from '@packrat/api/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { PackInStore } from '../types'; const listPacks = async (): Promise => { @@ -58,7 +57,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packs', }, diff --git a/apps/expo/features/trail-conditions/store/trailConditionReports.ts b/apps/expo/features/trail-conditions/store/trailConditionReports.ts index 9152267dfb..0ddc8d5115 100644 --- a/apps/expo/features/trail-conditions/store/trailConditionReports.ts +++ b/apps/expo/features/trail-conditions/store/trailConditionReports.ts @@ -1,11 +1,10 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { TrailConditionReportSchema } from '@packrat/api/schemas/trailConditions'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { TrailConditionReportInStore } from '../types'; const listMyReports = async (_params: unknown, { lastSync }: { lastSync?: number } = {}) => { @@ -76,7 +75,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'trail_condition_reports', }, diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index c375a1497d..6d428457d2 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -1,11 +1,10 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { TripSchema } from '@packrat/api/schemas/trips'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { TripInStore } from '../types'; const listTrips = async () => { @@ -61,7 +60,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'trips', }, diff --git a/apps/expo/global.css b/apps/expo/global.css index cdb50e6e20..cfaca0ff4a 100644 --- a/apps/expo/global.css +++ b/apps/expo/global.css @@ -2,6 +2,59 @@ @tailwind components; @tailwind utilities; +/* Web: NativeWind emits platformSelect() for color classes which browsers can't parse. + These overrides use standard rgb(var()) syntax so web gets themed colors. */ +@media screen { + .text-foreground { + color: rgb(var(--foreground)); + } + .text-muted-foreground { + color: rgb(var(--muted-foreground)); + } + .text-card-foreground { + color: rgb(var(--card-foreground)); + } + .text-popover-foreground { + color: rgb(var(--popover-foreground)); + } + .text-primary-foreground { + color: rgb(var(--primary-foreground)); + } + .text-secondary-foreground { + color: rgb(var(--secondary-foreground)); + } + .text-accent-foreground { + color: rgb(var(--accent-foreground)); + } + .text-destructive-foreground { + color: rgb(var(--destructive-foreground)); + } + .bg-background { + background-color: rgb(var(--background)); + } + .bg-card { + background-color: rgb(var(--card)); + } + .bg-primary { + background-color: rgb(var(--primary)); + } + .bg-secondary { + background-color: rgb(var(--secondary)); + } + .bg-muted { + background-color: rgb(var(--muted)); + } + .bg-accent { + background-color: rgb(var(--accent)); + } + .bg-destructive { + background-color: rgb(var(--destructive)); + } + .border-border { + border-color: rgb(var(--border)); + } +} + @layer base { :root { --background: 242 242 247; diff --git a/apps/expo/lib/kvStorage.ts b/apps/expo/lib/kvStorage.ts new file mode 100644 index 0000000000..d21ac4e6e4 --- /dev/null +++ b/apps/expo/lib/kvStorage.ts @@ -0,0 +1,10 @@ +import Storage from 'expo-sqlite/kv-store'; + +export default { + getItem: (key: string): string | null => Storage.getItemSync(key), + setItem: (key: string, value: string | null) => { + if (value === null) Storage.removeItemSync(key); + else Storage.setItemSync(key, value); + }, + removeItem: (key: string) => Storage.removeItemSync(key), +}; diff --git a/apps/expo/lib/kvStorage.web.ts b/apps/expo/lib/kvStorage.web.ts new file mode 100644 index 0000000000..c9c9b9db28 --- /dev/null +++ b/apps/expo/lib/kvStorage.web.ts @@ -0,0 +1,10 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export default { + getItem: (key: string) => AsyncStorage.getItem(key), + setItem: (key: string, value: string | null) => { + if (value === null) return AsyncStorage.removeItem(key); + return AsyncStorage.setItem(key, value); + }, + removeItem: (key: string) => AsyncStorage.removeItem(key), +}; diff --git a/apps/expo/lib/persist-plugin.ts b/apps/expo/lib/persist-plugin.ts new file mode 100644 index 0000000000..322561d52f --- /dev/null +++ b/apps/expo/lib/persist-plugin.ts @@ -0,0 +1,4 @@ +import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; +import Storage from 'expo-sqlite/kv-store'; + +export const persistPlugin = observablePersistSqlite(Storage); diff --git a/apps/expo/lib/persist-plugin.web.ts b/apps/expo/lib/persist-plugin.web.ts new file mode 100644 index 0000000000..a92c6d1914 --- /dev/null +++ b/apps/expo/lib/persist-plugin.web.ts @@ -0,0 +1,7 @@ +import { observablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// On web, the expo-sqlite persist plugin requires SharedArrayBuffer (COEP/COOP +// headers). Use the AsyncStorage plugin instead, which falls through to our +// localStorage-backed mock via the metro web stub. +export const persistPlugin = observablePersistAsyncStorage({ AsyncStorage }); diff --git a/apps/expo/lib/utils/ImageCacheManager.ts b/apps/expo/lib/utils/ImageCacheManager.ts index b37dd0691a..210066b5b9 100644 --- a/apps/expo/lib/utils/ImageCacheManager.ts +++ b/apps/expo/lib/utils/ImageCacheManager.ts @@ -92,6 +92,7 @@ export class ImageCacheManager { * Clear all cached images */ public async clearCache(): Promise { + if ('document' in globalThis) return; const dirInfo = await FileSystem.getInfoAsync(this.cacheDirectory); if (dirInfo.exists) { await FileSystem.deleteAsync(this.cacheDirectory); diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index 5deea20121..2613f966d0 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -1,9 +1,50 @@ // Learn more https://docs.expo.io/guides/customizing-metro -const { getSentryExpoConfig } = require('@sentry/react-native/metro'); // ensures unique Debug IDs get assigned to the generated bundles and source maps uploaded to Sentry [read more](https://docs.sentry.io/platforms/react-native/manual-setup/expo/#add-sentry-metro-plugin) +const path = require('node:path'); +const { getSentryExpoConfig } = require('@sentry/react-native/metro'); // ensures unique Debug IDs get assigned to the generated bundles and source maps uploaded to Sentry [read more](https://docs.sentry.io/platforms/react-native/manual-setup/expo/#add-sentry-metro-native-setup) const { withNativeWind } = require('nativewind/metro'); /** @type {import('expo/metro-config').MetroConfig} */ // eslint-disable-next-line no-undef const config = getSentryExpoConfig(__dirname); +config.resolver = { + ...config.resolver, + assetExts: [...(config.resolver?.assetExts ?? []), 'wasm'], + // 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). + unstable_conditionNames: ['require', 'default', 'react-native', 'browser'], +}; + +// Native-only packages that need web shims. +// Add new entries here when a package crashes on web. +const WEB_STUBS = { + 'react-native-maps': 'mocks/react-native-maps.tsx', + 'react-native-blob-util': 'mocks/react-native-blob-util.ts', + '@react-native-ai/llama': 'mocks/react-native-ai-llama.ts', + 'llama.rn': 'mocks/react-native-ai-llama.ts', + '@react-native-ai/apple': 'mocks/react-native-ai-apple.ts', + 'expo-sqlite/kv-store': 'mocks/expo-sqlite-kv-store.ts', + // Keyboard utilities — on web the software keyboard doesn't overlay content + 'react-native-keyboard-controller': 'mocks/react-native-keyboard-controller.tsx', + // Google Sign-In and date picker are native-only; web uses password auth + '@react-native-google-signin/google-signin': 'mocks/google-signin.ts', + '@react-native-community/datetimepicker': 'mocks/datetimepicker.tsx', + // expo-file-system throws UnavailabilityError on web; stub all ops as no-ops + 'expo-file-system/legacy': 'mocks/expo-file-system-legacy.ts', +}; + +const originalResolveRequest = config.resolver?.resolveRequest; +config.resolver = { + ...config.resolver, + // biome-ignore lint/complexity/useMaxParams: Metro resolveRequest requires exactly 3 params + resolveRequest: (context, moduleName, platform) => { + if (platform === 'web' && WEB_STUBS[moduleName]) { + return { filePath: path.join(__dirname, WEB_STUBS[moduleName]), type: 'sourceFile' }; + } + if (originalResolveRequest) return originalResolveRequest(context, moduleName, platform); + return context.resolveRequest(context, moduleName, platform); + }, +}; + module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 }); diff --git a/apps/expo/mocks/async-storage.ts b/apps/expo/mocks/async-storage.ts new file mode 100644 index 0000000000..13e06f931f --- /dev/null +++ b/apps/expo/mocks/async-storage.ts @@ -0,0 +1,71 @@ +// SSR-safe async-storage shim for web — guards window.localStorage access +const isClient = typeof window !== 'undefined'; + +const storage: typeof import('@react-native-async-storage/async-storage').default = { + getItem: (key) => { + if (!isClient) return Promise.resolve(null); + return Promise.resolve(window.localStorage.getItem(key)); + }, + setItem: (key, value) => { + if (!isClient) return Promise.resolve(); + window.localStorage.setItem(key, value); + return Promise.resolve(); + }, + removeItem: (key) => { + if (!isClient) return Promise.resolve(); + window.localStorage.removeItem(key); + return Promise.resolve(); + }, + mergeItem: (key, value) => { + if (!isClient) return Promise.resolve(); + const existing = window.localStorage.getItem(key); + try { + const merged = existing + ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(value) }) + : value; + window.localStorage.setItem(key, merged); + } catch { + window.localStorage.setItem(key, value); + } + return Promise.resolve(); + }, + clear: () => { + if (!isClient) return Promise.resolve(); + window.localStorage.clear(); + return Promise.resolve(); + }, + getAllKeys: () => { + if (!isClient) return Promise.resolve([]); + return Promise.resolve(Object.keys(window.localStorage)); + }, + multiGet: (keys) => { + if (!isClient) return Promise.resolve(keys.map((k) => [k, null])); + return Promise.resolve(keys.map((k) => [k, window.localStorage.getItem(k)])); + }, + multiSet: (pairs) => { + if (!isClient) return Promise.resolve(); + for (const [k, v] of pairs) window.localStorage.setItem(k, v); + return Promise.resolve(); + }, + multiRemove: (keys) => { + if (!isClient) return Promise.resolve(); + for (const k of keys) window.localStorage.removeItem(k); + return Promise.resolve(); + }, + multiMerge: (pairs) => { + if (!isClient) return Promise.resolve(); + for (const [k, v] of pairs) { + const existing = window.localStorage.getItem(k); + try { + const merged = existing ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(v) }) : v; + window.localStorage.setItem(k, merged); + } catch { + window.localStorage.setItem(k, v); + } + } + return Promise.resolve(); + }, + flushGetRequests: () => {}, +}; + +export default storage; diff --git a/apps/expo/mocks/datetimepicker.tsx b/apps/expo/mocks/datetimepicker.tsx new file mode 100644 index 0000000000..82c77ac59c --- /dev/null +++ b/apps/expo/mocks/datetimepicker.tsx @@ -0,0 +1,41 @@ +// Web implementation for @react-native-community/datetimepicker. +// Uses a native element which the browser renders natively. +import type React from 'react'; + +type DateTimePickerEvent = { type: string; nativeEvent: { timestamp: number } }; + +type Props = { + value: Date; + mode?: 'date' | 'time' | 'datetime'; + display?: string; + onChange?: (event: DateTimePickerEvent, date?: Date) => void; + minimumDate?: Date; + maximumDate?: Date; +}; + +function toInputValue(date: Date, mode: string): string { + if (mode === 'time') { + return date.toTimeString().slice(0, 5); + } + return date.toISOString().split('T')[0] ?? ''; +} + +export default function DateTimePicker({ value, mode = 'date', onChange }: Props) { + const inputType = mode === 'time' ? 'time' : 'date'; + + function handleChange(e: React.ChangeEvent) { + if (!onChange) return; + const raw = e.target.value; + const date = mode === 'time' ? new Date(`1970-01-01T${raw}`) : new Date(raw); + onChange({ type: 'set', nativeEvent: { timestamp: date.getTime() } }, date); + } + + return ( + + ); +} diff --git a/apps/expo/mocks/expo-file-system-legacy.ts b/apps/expo/mocks/expo-file-system-legacy.ts new file mode 100644 index 0000000000..b4e0329b9d --- /dev/null +++ b/apps/expo/mocks/expo-file-system-legacy.ts @@ -0,0 +1,66 @@ +// Web stub for expo-file-system/legacy. +// File system APIs are native-only; all operations are no-ops on web. + +export const documentDirectory = ''; +export const cacheDirectory = ''; +export const bundleDirectory = ''; + +export const EncodingType = { + UTF8: 'utf8', + Base64: 'base64', +} as const; + +export const FileSystemUploadType = { + BINARY_CONTENT: 0, + MULTIPART: 1, +} as const; + +export const FileSystemSessionType = { + BACKGROUND: 0, + FOREGROUND: 1, +} as const; + +export async function getInfoAsync(_uri: string) { + return { exists: false, isDirectory: false, uri: _uri, size: 0, modificationTime: 0 }; +} + +export async function readAsStringAsync(_uri: string) { + return ''; +} + +export async function writeAsStringAsync(_uri: string, _contents: string) {} + +export async function deleteAsync(_uri: string) {} + +export async function moveAsync(_options: { from: string; to: string }) {} + +export async function copyAsync(_options: { from: string; to: string }) {} + +export async function makeDirectoryAsync(_uri: string, _options?: { intermediates?: boolean }) {} + +export async function readDirectoryAsync(_uri: string): Promise { + return []; +} + +export async function downloadAsync( + _uri: string, + _fileUri: string, +): Promise<{ status: number; uri: string; headers: Record; mimeType: string }> { + return { status: 200, uri: _fileUri, headers: {}, mimeType: '' }; +} + +export async function uploadAsync( + _url: string, + _fileUri: string, +): Promise<{ status: number; body: string; headers: Record }> { + return { status: 200, body: '', headers: {} }; +} + +export async function createDownloadResumable() { + return { + downloadAsync: async () => ({ status: 200, uri: '', headers: {}, mimeType: '' }), + pauseAsync: async () => {}, + resumeAsync: async () => {}, + savable: () => ({ url: '', fileUri: '', options: {}, resumeData: '' }), + }; +} diff --git a/apps/expo/mocks/expo-sqlite-kv-store.ts b/apps/expo/mocks/expo-sqlite-kv-store.ts new file mode 100644 index 0000000000..ea7305a655 --- /dev/null +++ b/apps/expo/mocks/expo-sqlite-kv-store.ts @@ -0,0 +1,176 @@ +import { isFunction } from '@packrat/guards'; + +type UpdateFn = (prevValue: string | null) => string; + +const PREFIX = '__kv__'; + +const isClient = typeof window !== 'undefined'; +const memFallback = new Map(); + +const rawGet = (key: string): string | null => + isClient ? window.localStorage.getItem(PREFIX + key) : (memFallback.get(key) ?? null); + +const rawSet = (key: string, value: string): void => { + if (isClient) window.localStorage.setItem(PREFIX + key, value); + else memFallback.set(key, value); +}; + +const rawRemove = (key: string): boolean => { + const had = rawGet(key) !== null; + if (isClient) window.localStorage.removeItem(PREFIX + key); + else memFallback.delete(key); + return had; +}; + +const rawKeys = (): string[] => { + if (!isClient) return Array.from(memFallback.keys()); + return Object.keys(window.localStorage) + .filter((k) => k.startsWith(PREFIX)) + .map((k) => k.slice(PREFIX.length)); +}; + +const deepMerge = ( + target: Record, + source: Record, +): Record => { + const out = { ...target }; + for (const key of Object.keys(source)) { + if ( + typeof source[key] === 'object' && + source[key] !== null && + typeof target[key] === 'object' && + target[key] !== null + ) { + out[key] = deepMerge( + target[key] as Record, + source[key] as Record, + ); + } else { + out[key] = source[key]; + } + } + return out; +}; + +class LocalStorageStorage { + getItemSync(key: string): string | null { + return rawGet(key); + } + + setItemSync(key: string, value: string | UpdateFn): void { + const v = isFunction(value) ? value(rawGet(key)) : value; + rawSet(key, v); + } + + removeItemSync(key: string): boolean { + return rawRemove(key); + } + + getAllKeysSync(): string[] { + return rawKeys(); + } + + clearSync(): boolean { + const keys = rawKeys(); + for (const k of keys) rawRemove(k); + return true; + } + + closeSync(): void {} + + getLengthSync(): number { + return rawKeys().length; + } + + getKeyByIndexSync(index: number): string | null { + return rawKeys()[index] ?? null; + } + + async getItemAsync(key: string): Promise { + return Promise.resolve(this.getItemSync(key)); + } + + async setItemAsync(key: string, value: string | UpdateFn): Promise { + this.setItemSync(key, value); + } + + async removeItemAsync(key: string): Promise { + return Promise.resolve(this.removeItemSync(key)); + } + + async getAllKeysAsync(): Promise { + return Promise.resolve(this.getAllKeysSync()); + } + + async clearAsync(): Promise { + return Promise.resolve(this.clearSync()); + } + + async closeAsync(): Promise {} + + async getLengthAsync(): Promise { + return Promise.resolve(this.getLengthSync()); + } + + async getKeyByIndexAsync(index: number): Promise { + return Promise.resolve(this.getKeyByIndexSync(index)); + } + + getItem(key: string): Promise { + return this.getItemAsync(key); + } + + setItem(key: string, value: string | UpdateFn): Promise { + return this.setItemAsync(key, value); + } + + removeItem(key: string): Promise { + return this.removeItemAsync(key).then(() => undefined); + } + + getAllKeys(): Promise { + return this.getAllKeysAsync(); + } + + clear(): Promise { + return this.clearAsync().then(() => undefined); + } + + close(): Promise { + return this.closeAsync(); + } + + async mergeItem(key: string, value: string): Promise { + const existing = this.getItemSync(key); + if (existing) { + try { + const merged = deepMerge(JSON.parse(existing), JSON.parse(value)); + rawSet(key, JSON.stringify(merged)); + } catch { + rawSet(key, value); + } + } else { + rawSet(key, value); + } + } + + async multiGet(keys: string[]): Promise<[string, string | null][]> { + return Promise.resolve(keys.map((k) => [k, this.getItemSync(k)])); + } + + async multiSet(pairs: [string, string][]): Promise { + for (const [k, v] of pairs) this.setItemSync(k, v); + } + + async multiRemove(keys: string[]): Promise { + for (const k of keys) this.removeItemSync(k); + } + + async multiMerge(pairs: [string, string][]): Promise { + for (const [k, v] of pairs) await this.mergeItem(k, v); + } +} + +export const AsyncStorage = new LocalStorageStorage(); +export const Storage = AsyncStorage; +export default AsyncStorage; diff --git a/apps/expo/mocks/google-signin.ts b/apps/expo/mocks/google-signin.ts new file mode 100644 index 0000000000..7401a3af81 --- /dev/null +++ b/apps/expo/mocks/google-signin.ts @@ -0,0 +1,20 @@ +// Web stub for @react-native-google-signin/google-signin. +// Google Sign-In is native-only; web users sign in with email/password. +export const GoogleSignin = { + configure: () => {}, + hasPlayServices: () => Promise.resolve(true), + signIn: () => Promise.reject(new Error('Google Sign-In is not supported on web')), + signOut: () => Promise.resolve(), + getTokens: () => Promise.reject(new Error('Google Sign-In is not supported on web')), + isSignedIn: () => false, + getCurrentUser: () => null, + revokeAccess: () => Promise.resolve(), +}; + +export const GoogleSigninButton = () => null; +export const statusCodes = { + SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', + IN_PROGRESS: 'IN_PROGRESS', + PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE', + SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED', +}; diff --git a/apps/expo/mocks/react-native-ai-apple.ts b/apps/expo/mocks/react-native-ai-apple.ts new file mode 100644 index 0000000000..7646bbd17d --- /dev/null +++ b/apps/expo/mocks/react-native-ai-apple.ts @@ -0,0 +1 @@ +export default null; diff --git a/apps/expo/mocks/react-native-ai-llama.ts b/apps/expo/mocks/react-native-ai-llama.ts new file mode 100644 index 0000000000..f7360ed2e7 --- /dev/null +++ b/apps/expo/mocks/react-native-ai-llama.ts @@ -0,0 +1,5 @@ +export type LlamaLanguageModel = never; + +export const llama = { + languageModel: () => null, +}; diff --git a/apps/expo/mocks/react-native-blob-util.ts b/apps/expo/mocks/react-native-blob-util.ts new file mode 100644 index 0000000000..b4977812f7 --- /dev/null +++ b/apps/expo/mocks/react-native-blob-util.ts @@ -0,0 +1,41 @@ +const noop = () => Promise.resolve(); + +const RNBlobUtil = { + fs: { + dirs: { + DocumentDir: '', + CacheDir: '', + MainBundleDir: '', + MovieDir: '', + MusicDir: '', + PictureDir: '', + LibraryDir: '', + DCIMDir: '', + DownloadDir: '', + SDCardDir: '', + SDCardApplicationDir: '', + }, + exists: () => Promise.resolve(false), + stat: () => Promise.resolve(null), + unlink: noop, + mkdir: noop, + writeFile: noop, + readFile: () => Promise.resolve(''), + ls: () => Promise.resolve([]), + }, + config: () => ({ + fetch: () => { + // Return a thenable with .progress()/.cancel() so callers like + // localModelManager.ts don't throw when chaining those methods. + const promise = Promise.resolve(null) as Promise & { + progress: (cb: unknown) => unknown; + cancel: () => void; + }; + promise.progress = () => promise; + promise.cancel = () => {}; + return promise; + }, + }), +}; + +export default RNBlobUtil; diff --git a/apps/expo/mocks/react-native-keyboard-controller.tsx b/apps/expo/mocks/react-native-keyboard-controller.tsx new file mode 100644 index 0000000000..e4c4814ccf --- /dev/null +++ b/apps/expo/mocks/react-native-keyboard-controller.tsx @@ -0,0 +1,51 @@ +// Web stub for react-native-keyboard-controller. +// On web the software keyboard does not overlay content, so these wrappers +// fall through to their React Native equivalents. +import type React from 'react'; +import { KeyboardAvoidingView, ScrollView, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import { useSharedValue } from 'react-native-reanimated'; + +export { KeyboardAvoidingView }; + +export function KeyboardProvider({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +export function KeyboardAwareScrollView({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} + +export function KeyboardStickyView({ + children, + ...props +}: { + children?: React.ReactNode; + offset?: { opened?: number; closed?: number }; + [key: string]: unknown; +}) { + return {children}; +} + +export function useReanimatedKeyboardAnimation(): { + height: SharedValue; + progress: SharedValue; +} { + const height = useSharedValue(0); + const progress = useSharedValue(0); + return { height, progress }; +} + +export const KeyboardController = { + dismiss: () => {}, + setFocusTo: () => {}, + addListener: () => ({ remove: () => {} }), +}; + +export const AndroidSoftInputModes = {}; +export const KeyboardEvents = { + addListener: () => ({ remove: () => {} }), +}; diff --git a/apps/expo/mocks/react-native-maps.tsx b/apps/expo/mocks/react-native-maps.tsx new file mode 100644 index 0000000000..07f295c9fe --- /dev/null +++ b/apps/expo/mocks/react-native-maps.tsx @@ -0,0 +1,99 @@ +// Web implementation for react-native-maps using react-leaflet. +// This file is only bundled on web via the metro WEB_STUBS resolver. +// Leaflet CSS is injected once via CDN to avoid needing a Metro CSS import. + +import type { LatLngExpression } from 'leaflet'; +import L, { type Icon } from 'leaflet'; +import type React from 'react'; +import { Marker as LeafletMarker, MapContainer, Popup, TileLayer } from 'react-leaflet'; + +if (typeof document !== 'undefined') { + const LEAFLET_CSS_ID = '__leaflet_css__'; + if (!document.getElementById(LEAFLET_CSS_ID)) { + const link = document.createElement('link'); + link.id = LEAFLET_CSS_ID; + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; + document.head.appendChild(link); + } + // Fix default marker icon paths broken by module bundlers. + delete (L.Icon.Default.prototype as Icon.Default & { _getIconUrl?: unknown })._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', + }); +} + +type Region = { + latitude: number; + longitude: number; + latitudeDelta: number; + longitudeDelta: number; +}; + +type Coordinate = { + latitude: number; + longitude: number; +}; + +type MapViewProps = { + style?: object; + initialRegion?: Region; + region?: Region; + children?: React.ReactNode; + onRegionChange?: (region: Region) => void; + onRegionChangeComplete?: (region: Region) => void; + onPress?: (event: { nativeEvent: { coordinate: Coordinate } }) => void; + [key: string]: unknown; +}; + +type MarkerProps = { + coordinate: Coordinate; + title?: string; + description?: string; + children?: React.ReactNode; + onPress?: () => void; + [key: string]: unknown; +}; + +function LeafletMap({ style, initialRegion, region, children, ...props }: MapViewProps) { + const r = region ?? initialRegion; + const center: LatLngExpression = r ? [r.latitude, r.longitude] : [20, 0]; + const zoom = r ? Math.round(10 - Math.log2(r.latitudeDelta + 0.001)) : 5; + + return ( + + + {children} + + ); +} + +export function Marker({ coordinate, title, children }: MarkerProps) { + return ( + + {title ? {title} : children} + + ); +} + +export default LeafletMap; +export { LeafletMap as MapView }; + +export const Callout = ({ children }: { children?: React.ReactNode }) => <>{children}; +export const Circle = () => null; +export const Polygon = () => null; +export const Polyline = () => null; +export const Overlay = () => null; +export const UrlTile = () => null; +export const PROVIDER_GOOGLE = 'google'; +export const PROVIDER_DEFAULT = undefined; diff --git a/apps/expo/package.json b/apps/expo/package.json index ab08e20268..cbf86a55d9 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -30,7 +30,7 @@ "format": "biome format --write", "ios": "APP_VARIANT=development expo run:ios", "lint": "biome check --write", - "start": "APP_VARIANT=development expo start", + "start": "APP_VARIANT=development EXPO_UNSTABLE_WEB_MODAL=1 expo start", "submit:android": "eas submit --platform android", "submit:ios": "eas submit --platform ios", "test": "vitest run", @@ -38,7 +38,7 @@ "update:development": "APP_VARIANT=development eas update --branch development --environment development", "update:preview": "APP_VARIANT=preview eas update --branch preview --environment preview", "update:production": "eas update --branch production --environment production", - "web": "expo start --web" + "web": "EXPO_UNSTABLE_WEB_MODAL=1 expo start --web" }, "eslintConfig": { "extends": "universe/native", @@ -117,6 +117,7 @@ "i": "^0.3.7", "i18next": "^25.8.18", "jotai": "^2.12.2", + "leaflet": "^1.9.4", "llama.rn": "0.10.1", "nanoid": "^5.1.9", "nativewind": "^4.2.3", @@ -124,6 +125,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-i18next": "^17.0.4", + "react-leaflet": "^5.0.0", "react-native": "0.83.6", "react-native-blob-util": "^0.24.5", "react-native-css-interop": "^0.2.3", @@ -149,6 +151,7 @@ "@babel/core": "^7.20.0", "@biomejs/biome": "2.4.6", "@types/he": "^1.2.3", + "@types/leaflet": "^1.9.21", "@types/react": "~19.2.10", "@types/ungap__structured-clone": "^1.2.0", "@typescript-eslint/eslint-plugin": "^7.7.0", diff --git a/bun.lock b/bun.lock index df820dcc85..e9e4f49cab 100644 --- a/bun.lock +++ b/bun.lock @@ -139,6 +139,7 @@ "i": "^0.3.7", "i18next": "^25.8.18", "jotai": "^2.12.2", + "leaflet": "^1.9.4", "llama.rn": "0.10.1", "nanoid": "^5.1.9", "nativewind": "^4.2.3", @@ -146,6 +147,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-i18next": "^17.0.4", + "react-leaflet": "^5.0.0", "react-native": "0.83.6", "react-native-blob-util": "^0.24.5", "react-native-css-interop": "^0.2.3", @@ -171,6 +173,7 @@ "@babel/core": "^7.20.0", "@biomejs/biome": "2.4.6", "@types/he": "^1.2.3", + "@types/leaflet": "^1.9.21", "@types/react": "~19.2.10", "@types/ungap__structured-clone": "^1.2.0", "@typescript-eslint/eslint-plugin": "^7.7.0", diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 9dc1a14230..bef70a70e3 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -127,7 +127,13 @@ export function createApiClient(config: ApiClientConfig) { // Treaty only uses the callable form of `fetch`; the globalThis.fetch type // includes a `preconnect` method our wrapper doesn't need. Cast through // unknown to bridge the two shapes without pulling preconnect into scope. - return treaty(config.baseUrl, { fetcher: authFetcher as unknown as typeof fetch }).api; + // parseDate:false disables Eden Treaty's JSON reviver that silently converts + // date-like strings (ISO 8601, "YYYY-MM-DD HH:MM") to Date objects. Without + // this, every Zod z.string().datetime() field in API response schemas fails. + return treaty(config.baseUrl, { + fetcher: authFetcher as unknown as typeof fetch, + parseDate: false, + }).api; } export type ApiClient = ReturnType; diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 5f57c35af9..76f3784ff7 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -56,9 +56,9 @@ export const CatalogItemSchema = z.object({ context: z.record(z.string(), z.string()).nullable().optional(), recommends: z.boolean().nullable().optional(), rating: z.number(), - title: z.string(), - text: z.string(), - date: z.date(), + title: z.string().nullable().optional(), + text: z.string().nullable().optional(), + date: z.string().nullable().optional(), images: z.array(z.string()).nullable().optional(), upvotes: z.number().nullable().optional(), downvotes: z.number().nullable().optional(),