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(),