diff --git a/mobile/.env b/mobile/.env new file mode 100644 index 0000000..ce6ef6c --- /dev/null +++ b/mobile/.env @@ -0,0 +1 @@ +EXPO_PUBLIC_SOLANA_RPC_URL=https://mainnet.helius-rpc.com/?api-key=dd39f964-79fe-4373-a22b-7cac000f163b diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..ad99ee2 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,25 @@ +# Dependencies +node_modules + +# Lockfiles +bun.lock + +# Contracts (Anchor / Rust) +contracts/node_modules +contracts/target/debug +contracts/target/release +contracts/target/sbpf-solana-solana +contracts/target/.rustc_info.json + +# Expo +.expo +expo-env.d.ts + +# Metro bundler +.metro-cache + +# Build output +dist + +# OS files +.DS_Store diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..2ffc6b8 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,38 @@ +{ + "expo": { + "name": "expo-starter", + "slug": "expo-starter", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "myapp", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.trynoah" + }, + "android": { + "package": "com.trynoah", + "versionCode": 1, + "adaptiveIcon": { + "foregroundImage": "./assets/images/icon.png", + "backgroundColor": "#ffffff" + } + }, + "web": { + "bundler": "metro", + "output": "single", + "favicon": "./assets/images/favicon.png", + "baseUrl": "/mobile" + }, + "plugins": [ + "expo-router", + "expo-font", + "expo-web-browser" + ], + "experiments": { + "typedRoutes": true + } + } +} \ No newline at end of file diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..aa3f431 --- /dev/null +++ b/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,40 @@ +import { Tabs } from 'expo-router'; +import { Home } from 'lucide-react-native'; +import { StyleSheet } from 'react-native'; + +export default function TabLayout() { + return ( + + , + }} + /> + + ); +} + +const styles = StyleSheet.create({ + tabBar: { + backgroundColor: '#FFFFFF', + borderTopWidth: 0.5, + borderTopColor: '#E5E5EA', + paddingBottom: 8, + paddingTop: 8, + height: 84, + }, + tabBarLabel: { + fontSize: 12, + fontWeight: '500', + }, +}); diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..3538d55 --- /dev/null +++ b/mobile/app/(tabs)/index.tsx @@ -0,0 +1,612 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + FlatList, + ActivityIndicator, + StyleSheet, + RefreshControl, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { toast } from 'sonner-native'; +import { + CheckCircle2, + Circle, + Trash2, + Plus, + RefreshCw, + Edit2, + X, + Check, + Activity, + Database, + HardDrive, +} from 'lucide-react-native'; +import { getApiUrl } from '../../utils/api'; + +type Todo = { + id: number; + title: string; + description: string | null; + completed: boolean; + createdAt: string; + updatedAt: string; + attachment: unknown | null; +}; + +type HealthResponse = { + status: string; + projectId: string; + ready: boolean; + config: { + apiPrefix: string; + databaseConfigured: boolean; + storageConfigured: boolean; + missingEnv: string[]; + }; +}; + +export default function Index() { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Form State + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Edit State + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); + + // Health State + const [health, setHealth] = useState(null); + const [healthLoading, setHealthLoading] = useState(true); + + // Fetch Health Check Status + const fetchHealth = useCallback(async () => { + try { + setHealthLoading(true); + const res = await fetch(getApiUrl('/health')); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setHealth(data); + } catch (err) { + console.warn('Health check failed', err); + setHealth(null); + } finally { + setHealthLoading(false); + } + }, []); + + // Fetch Todos List + const fetchTodos = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await fetch(getApiUrl('/todos')); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setTodos(data.todos ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch todos'); + toast.error('Fetch Failed', { + description: 'Could not retrieve todo items from backend.', + }); + } finally { + setLoading(false); + } + }, []); + + // Initial Load + useEffect(() => { + fetchHealth(); + fetchTodos(); + }, [fetchHealth, fetchTodos]); + + const handleRefresh = async () => { + await Promise.all([fetchHealth(), fetchTodos()]); + toast.success('Refreshed', { + description: 'Dashboard updated successfully.', + }); + }; + + // Create Todo + const createTodo = async () => { + if (!title.trim()) return; + try { + setSubmitting(true); + const res = await fetch(getApiUrl('/todos'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: title.trim(), + description: description.trim() || null, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setTitle(''); + setDescription(''); + toast.success('Success', { + description: 'Todo item created.', + }); + await fetchTodos(); + } catch (err) { + toast.error('Error', { + description: 'Failed to create todo item.', + }); + } finally { + setSubmitting(false); + } + }; + + // Toggle Completed + const toggleCompleted = async (todo: Todo) => { + try { + const res = await fetch(getApiUrl(`/todos/${todo.id}`), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ completed: !todo.completed }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + toast.success(todo.completed ? 'Reopened' : 'Completed', { + description: `"${todo.title}" updated.`, + }); + await fetchTodos(); + } catch (err) { + toast.error('Error', { + description: 'Failed to update todo status.', + }); + } + }; + + // Update Title/Description + const saveEdit = async (id: number) => { + if (!editTitle.trim()) return; + try { + const res = await fetch(getApiUrl(`/todos/${id}`), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: editTitle.trim(), + description: editDescription.trim() || null, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setEditingId(null); + toast.success('Updated', { + description: 'Todo changes saved.', + }); + await fetchTodos(); + } catch (err) { + toast.error('Error', { + description: 'Failed to update todo details.', + }); + } + }; + + // Delete Todo + const deleteTodo = async (id: number) => { + try { + const res = await fetch(getApiUrl(`/todos/${id}`), { + method: 'DELETE', + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + toast.success('Deleted', { + description: 'Todo item removed.', + }); + await fetchTodos(); + } catch (err) { + toast.error('Error', { + description: 'Failed to delete todo.', + }); + } + }; + + return ( + + {/* Header Banner */} + + + Todo Workspace + + + + + + {/* Health Check Panel */} + {healthLoading ? ( + + + Verifying system health... + + ) : health ? ( + + + + + Status: {health.status.toUpperCase()} + + + + + + DB + + + + Storage + + + + ) : ( + + + Backend Offline + + )} + + + {/* Main Content List */} + item.id.toString()} + refreshControl={ + + } + contentContainerStyle={styles.listContent} + ListHeaderComponent={ + /* Add Todo Panel */ + + Add New Task + + + + {submitting ? ( + + ) : ( + <> + + Create Todo + + )} + + + } + ListEmptyComponent={ + !loading ? ( + + No tasks yet. Create one above! + + ) : null + } + renderItem={({ item }) => ( + + {editingId === item.id ? ( + /* Editing State */ + + + + + saveEdit(item.id)} + > + + + setEditingId(null)} + > + + + + + ) : ( + /* Display State */ + + toggleCompleted(item)} style={styles.checkboxContainer}> + {item.completed ? ( + + ) : ( + + )} + + + + + {item.title} + + {item.description ? ( + + {item.description} + + ) : null} + + + + { + setEditingId(item.id); + setEditTitle(item.title); + setEditDescription(item.description ?? ''); + }} + > + + + deleteTodo(item.id)}> + + + + + )} + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + header: { + backgroundColor: '#FFFFFF', + paddingTop: 50, + paddingHorizontal: 20, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + headerTitle: { + fontSize: 22, + fontWeight: 'bold', + color: '#111827', + }, + refreshBtn: { + padding: 8, + borderRadius: 8, + backgroundColor: '#EEF2F6', + }, + healthContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 8, + backgroundColor: '#F3F4F6', + }, + healthOk: { + backgroundColor: '#ECFDF5', + }, + healthErr: { + backgroundColor: '#FEF2F2', + }, + healthRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + healthStatusText: { + fontSize: 12, + fontWeight: '600', + color: '#374151', + }, + healthIcons: { + flexDirection: 'row', + gap: 12, + }, + healthIconItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + healthIconLabel: { + fontSize: 10, + fontWeight: '500', + color: '#6B7280', + }, + healthText: { + fontSize: 12, + color: '#6B7280', + marginLeft: 8, + }, + listContent: { + padding: 20, + paddingBottom: 40, + }, + formContainer: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 16, + marginBottom: 20, + borderWidth: 1, + borderColor: '#E5E7EB', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#374151', + marginBottom: 12, + }, + input: { + backgroundColor: '#F9FAFB', + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 14, + color: '#111827', + marginBottom: 10, + }, + textArea: { + height: 60, + textAlignVertical: 'top', + }, + createBtn: { + backgroundColor: '#4F46E5', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 10, + borderRadius: 8, + marginTop: 4, + }, + createBtnDisabled: { + backgroundColor: '#93C5FD', + }, + btnIcon: { + marginRight: 6, + }, + createBtnText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 14, + }, + todoCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 16, + marginBottom: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.03, + shadowRadius: 2, + elevation: 1, + }, + todoCardCompleted: { + backgroundColor: '#FAFAFA', + borderColor: '#E5E7EB', + }, + todoRow: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + checkboxContainer: { + marginRight: 12, + marginTop: 2, + }, + todoTexts: { + flex: 1, + marginRight: 8, + }, + todoTitle: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + marginBottom: 4, + }, + todoDesc: { + fontSize: 13, + color: '#6B7280', + }, + lineThrough: { + textDecorationLine: 'line-through', + color: '#9CA3AF', + }, + todoActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + actionIconBtn: { + padding: 6, + borderRadius: 6, + backgroundColor: '#F3F4F6', + }, + editForm: { + gap: 8, + }, + editInput: { + backgroundColor: '#F9FAFB', + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 6, + fontSize: 14, + color: '#111827', + }, + editActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 8, + marginTop: 4, + }, + iconBtn: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 6, + justifyContent: 'center', + alignItems: 'center', + }, + saveBtn: { + backgroundColor: '#10B981', + }, + cancelBtn: { + backgroundColor: '#EF4444', + }, + emptyContainer: { + alignItems: 'center', + paddingVertical: 40, + }, + emptyText: { + fontSize: 14, + color: '#9CA3AF', + }, +}); diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx new file mode 100644 index 0000000..329a09f --- /dev/null +++ b/mobile/app/+not-found.tsx @@ -0,0 +1,33 @@ +import { Link, Stack } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function NotFoundScreen() { + return ( + <> + + + This screen doesn't exist. + + Go to home screen! + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + text: { + fontSize: 20, + fontWeight: 600, + }, + link: { + marginTop: 15, + paddingVertical: 15, + }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 0000000..ef4861e --- /dev/null +++ b/mobile/app/_layout.tsx @@ -0,0 +1,20 @@ +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { Toaster } from 'sonner-native'; +import { useFrameworkReady } from '@/hooks/useFrameworkReady'; + +export default function RootLayout() { + useFrameworkReady(); // Assuming this handles side effects + + return ( + + + + + + + + + ); +} diff --git a/mobile/assets/images/favicon.png b/mobile/assets/images/favicon.png new file mode 100644 index 0000000..e75f697 Binary files /dev/null and b/mobile/assets/images/favicon.png differ diff --git a/mobile/assets/images/icon.png b/mobile/assets/images/icon.png new file mode 100644 index 0000000..a0b1526 Binary files /dev/null and b/mobile/assets/images/icon.png differ diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js new file mode 100644 index 0000000..76effc0 --- /dev/null +++ b/mobile/eslint.config.js @@ -0,0 +1,9 @@ +const { defineConfig } = require('eslint/config'); +const expoConfig = require('eslint-config-expo/flat'); + +module.exports = defineConfig([ + ...expoConfig, + { + ignores: ['dist/**', 'node_modules/**', '.expo/**'], + }, +]); diff --git a/mobile/hooks/useFrameworkReady.ts b/mobile/hooks/useFrameworkReady.ts new file mode 100644 index 0000000..1e292cb --- /dev/null +++ b/mobile/hooks/useFrameworkReady.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +declare global { + interface Window { + frameworkReady?: () => void; + } +} + +export function useFrameworkReady() { + useEffect(() => { + window.frameworkReady?.(); + }); +} diff --git a/mobile/index.ts b/mobile/index.ts new file mode 100644 index 0000000..5d3db20 --- /dev/null +++ b/mobile/index.ts @@ -0,0 +1,13 @@ +import 'react-native-reanimated'; +// Import crypto polyfills first +import './utils/crypto-setup'; +import './scripts/network-interceptor'; + +if (typeof document !== 'undefined') { + const utilityScript = document.createElement('script'); + utilityScript.src = 'https://content.trynoah.ai/expo-utils.js'; + document.head.appendChild(utilityScript); +} + +// Then import the main expo-router entry +import 'expo-router/entry'; diff --git a/mobile/metro.config.ts b/mobile/metro.config.ts new file mode 100644 index 0000000..0fd9738 --- /dev/null +++ b/mobile/metro.config.ts @@ -0,0 +1,30 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { FileStore } = require('metro-cache'); +const path = require('path'); + +const config = getDefaultConfig(__dirname); + +// Add resolver configuration for Node.js polyfills +config.resolver.alias = { + ...config.resolver.alias, + crypto: 'react-native-get-random-values', + stream: 'readable-stream', + buffer: 'buffer', +}; + + +config.resolver.unstable_conditionNames = ['require', 'react-native']; + +// Add node_modules to resolver platforms +config.resolver.platforms = ['native', 'android', 'ios', 'web']; + +// Configure metro to handle the polyfills +config.resolver.resolverMainFields = ['react-native', 'browser', 'main']; + +config.cacheStores = [ + new FileStore({ + root: path.join(__dirname, '.metro-cache'), + }), +]; + +module.exports = config; diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..df1231c --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,63 @@ +{ + "name": "expo-starter", + "main": "index.ts", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "expo start", + "build:web": "expo export --platform web", + "lint": "expo lint", + "android": "expo run:android", + "ios": "expo run:ios" + }, + "dependencies": { + "@expo/vector-icons": "15.1.1", + "@lucide/lab": "0.1.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/native": "^7.1.8", + "@solana/spl-token": "0.4.13", + "@solana/web3.js": "1.98.2", + "@supabase/supabase-js": "2.57.4", + "bs58": "6.0.0", + "buffer": "6.0.3", + "expo": "~54.0.35", + "expo-blur": "~15.0.8", + "expo-camera": "~17.0.10", + "expo-clipboard": "~8.0.8", + "expo-constants": "~18.0.11", + "expo-font": "~14.0.10", + "expo-haptics": "~15.0.8", + "expo-linear-gradient": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-router": "~6.0.17", + "expo-splash-screen": "~31.0.12", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", + "lucide-react-native": "0.555.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-get-random-values": "1.11.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-url-polyfill": "2.0.0", + "react-native-web": "0.21.2", + "react-native-webview": "13.15.0", + "react-native-worklets": "0.5.1", + "sonner-native": "^0.24.0", + "tweetnacl": "1.0.3" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@types/react": "~19.1.10", + "eslint": "^9.0.0", + "eslint-config-expo": "~10.0.0", + "typescript": "~5.9.2" + } +} diff --git a/mobile/scripts/network-interceptor.ts b/mobile/scripts/network-interceptor.ts new file mode 100644 index 0000000..99e3314 --- /dev/null +++ b/mobile/scripts/network-interceptor.ts @@ -0,0 +1,51 @@ +if (typeof window !== 'undefined') { + const _origFetch = window.fetch; + if (typeof _origFetch === 'function' && !(window as any).__noahNetworkPatched) { + (window as any).__noahNetworkPatched = true; + (window as any).__noahOrigFetch = _origFetch; + window.fetch = function (input: any, init?: any) { + let url = 'unknown'; + try { url = input instanceof Request ? input.url : String(input); } catch (_) {} + const method = ((input instanceof Request ? input.method : init?.method) || 'GET').toUpperCase(); + const start = Date.now(); + const post = (status: number, statusText: string, error: string | null) => { + try { + window.parent?.postMessage( + {type: 'iframe-network', data: {method, url, status, statusText, duration: Date.now() - start, requestBody: null, responseBody: null, error, timestamp: Date.now()}}, + '*' + ); + } catch (_) {} + }; + let promise: Promise; + try { promise = _origFetch.apply(this, arguments as any); } + catch (e: any) { post(0, '', e?.message || String(e)); throw e; } + return promise.then( + (resp) => { post(resp.status, resp.statusText, null); return resp; }, + (err: any) => { post(0, '', err?.message || String(err)); throw err; } + ); + }; + } + + if (typeof XMLHttpRequest !== 'undefined' && !(window as any).__noahXHRPatched) { + (window as any).__noahXHRPatched = true; + const _open = XMLHttpRequest.prototype.open; + const _send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method: string, url: string) { + (this as any)._noahMethod = method; + (this as any)._noahUrl = url; + (this as any)._noahStart = Date.now(); + return _open.apply(this, arguments as any); + }; + XMLHttpRequest.prototype.send = function () { + this.addEventListener('loadend', () => { + try { + window.parent?.postMessage( + {type: 'iframe-network', data: {method: (this as any)._noahMethod || 'GET', url: (this as any)._noahUrl || '', status: this.status, statusText: this.statusText, duration: Date.now() - ((this as any)._noahStart || Date.now()), requestBody: null, responseBody: null, error: this.status === 0 ? 'Network error' : null, timestamp: Date.now()}}, + '*' + ); + } catch (_) {} + }); + return _send.apply(this, arguments as any); + }; + } +} diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..ce27fee --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} diff --git a/mobile/types/wallet.ts b/mobile/types/wallet.ts new file mode 100644 index 0000000..d99da46 --- /dev/null +++ b/mobile/types/wallet.ts @@ -0,0 +1,5 @@ + +export interface WalletData { + publicKey: string; + privateKey: string; +} diff --git a/mobile/utils/api.ts b/mobile/utils/api.ts new file mode 100644 index 0000000..e58fe3a --- /dev/null +++ b/mobile/utils/api.ts @@ -0,0 +1,23 @@ +export function getApiUrl(path: string): string { + const cleanPath = path.startsWith('/') ? path : `/${path}`; + + if (typeof window !== 'undefined' && window.location) { + const origin = window.location.origin; + // Local Expo dev server direct port + if (origin.includes(':8081')) { + return `http://localhost:8080/api${cleanPath}`; + } + // Deployed Modal Sandbox direct port + if (origin.includes('-8081.modal.run')) { + const backendOrigin = origin.replace('-8081.modal.run', '-8080.modal.run'); + return `${backendOrigin}/api${cleanPath}`; + } + + // Relative path works when accessed via port 5173 /mobile proxy + return `/api${cleanPath}`; + } + + // Fallback for native devices + const baseUrl = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8080/api'; + return `${baseUrl}${cleanPath}`; +} diff --git a/mobile/utils/crypto-setup.ts b/mobile/utils/crypto-setup.ts new file mode 100644 index 0000000..9ed4ce9 --- /dev/null +++ b/mobile/utils/crypto-setup.ts @@ -0,0 +1,31 @@ +import 'react-native-get-random-values'; +import 'react-native-url-polyfill/auto'; +import { Buffer } from 'buffer'; + +if (typeof global !== 'undefined') { + if (typeof global.Buffer === 'undefined') { + global.Buffer = Buffer; + } +} else if (typeof window !== 'undefined') { + if (typeof (window as any).Buffer === 'undefined') { + (window as any).Buffer = Buffer; + } +} + +const { getRandomValues } = require('react-native-get-random-values'); + +if (typeof global !== 'undefined') { + if (typeof global.crypto === 'undefined') { + (global as any).crypto = { + getRandomValues: getRandomValues, + }; + } +} else if (typeof window !== 'undefined') { + if (typeof window.crypto === 'undefined') { + (window as any).crypto = { + getRandomValues: getRandomValues, + }; + } +} + +export {}; diff --git a/website/vite.config.ts b/website/vite.config.ts index 64f147e..49936fd 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -22,6 +22,10 @@ export default defineConfig(({ mode }) => ({ target: "http://localhost:8080", changeOrigin: true, }, + "/mobile": { + target: "http://localhost:8081", + changeOrigin: true, + }, }, }, plugins: [react(), mode === "development" && componentTagger()],