Overview
The Auction Excellence mobile app is built with React Native 0.81.5 and Expo 54, using TypeScript for type safety. This guide covers everything you need to set up your development environment, understand the codebase architecture, and contribute to the mobile app.
React Native + Expo Cross-platform iOS and Android development with managed workflow
TypeScript Full type safety with generated database types
React Query Server state management with caching and optimistic updates
Zustand Lightweight client state management for auth and preferences
Prerequisites
Before you begin, ensure you have the following installed:
Tool Version Installation Node.js 20.x LTS nodejs.org or nvm install 20pnpm 9.x npm install -g pnpmXcode 15+ Mac App Store (iOS development) Android Studio Latest developer.android.com Expo CLI Latest Installed via pnpm
# Install Homebrew if not already installed
/bin/bash -c "$( curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install Node.js via nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
nvm use 20
# Install pnpm
npm install -g pnpm
# Install Xcode Command Line Tools
xcode-select --install
# Install watchman for better file watching
brew install watchman
# Install Node.js from nodejs.org or via scoop
scoop install nodejs-lts
# Install pnpm
npm install -g pnpm
# Install Android Studio from developer.android.com
# Enable "Android SDK" and "Android Virtual Device" during setup
# Install Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
nvm use 20
# Install pnpm
npm install -g pnpm
# Install Android Studio
# Download from developer.android.com/studio
# Add to PATH: export ANDROID_HOME=$HOME/Android/Sdk
Getting Started
Repository Setup
Clone the Repository
git clone https://github.com/your-org/auction-excellence.git
cd auction-excellence/mobile
Install Dependencies
This installs all dependencies including React Native, Expo, and development tools.
Configure Environment Variables
Create a .env file in the mobile directory: EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Never commit .env files to version control. The .gitignore file excludes these automatically.
Start the Development Server
This starts the Expo development server. You’ll see a QR code and options to run on different platforms.
Run on Device or Simulator
iOS Simulator (macOS only): Android Emulator: Physical Device:
Install Expo Go on your device
Scan the QR code from the terminal
Use pnpm start --clear to clear the Metro bundler cache if you encounter stale code issues.
Project Structure
The mobile app follows a feature-based structure with clear separation of concerns:
mobile/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base components (Button, Input, Card)
│ │ ├── forms/ # Form components with validation
│ │ ├── camera/ # Camera capture functionality
│ │ ├── scanner/ # VIN barcode scanner
│ │ ├── chat/ # Chat UI components
│ │ ├── submissions/ # Lot submission components
│ │ ├── quality/ # Quality inspection components
│ │ ├── problems/ # A3 problem report components
│ │ └── profile/ # User profile components
│ ├── screens/ # Full-screen components by feature
│ │ ├── auth/ # Login, accept invite
│ │ ├── home/ # Home dashboard
│ │ ├── submissions/ # Lot submission screens
│ │ ├── quality/ # Inspection screens
│ │ ├── problems/ # Problem report screens
│ │ ├── chat/ # Chat screens
│ │ └── profile/ # Profile screens
│ ├── hooks/ # React Query custom hooks (40+)
│ ├── stores/ # Zustand state stores
│ ├── navigation/ # React Navigation setup
│ ├── lib/
│ │ ├── supabase/ # Supabase client and storage
│ │ ├── schemas/ # Zod validation schemas
│ │ └── utils/ # Helper functions
│ ├── types/ # TypeScript interfaces
│ ├── providers/ # App context providers
│ └── __tests__/ # Test files and mocks
├── app.config.js # Expo configuration
├── package.json
├── tsconfig.json
├── jest.config.js
└── babel.config.js
Key Directories
Reusable UI components organized by domain:
ui/ - Base primitives: Button, Input, Card, Modal, Text
forms/ - Form components: LocationSelector, MultiTypeSubmissionForm
camera/ - CameraCapture for photo capture with compression
chat/ - MessageItem, MessageInput, ChannelList, ReactionBar
Full-screen components that represent navigable views:
Each feature has its own subdirectory (auth, submissions, chat, etc.)
Screens compose components and manage screen-level state
Navigation headers are configured here
React Query hooks for all data operations:
40+ custom hooks for queries and mutations
Naming convention: useXxx (e.g., useLocations, useSubmitLotCount)
Test files in hooks/__tests__/
Zustand stores for client-side state:
authStore.ts - Authentication state and session management
auctionStore.ts - Current auction context with persistence
Shared utilities and configurations:
supabase/client.ts - Supabase client setup
supabase/storage.ts - Image upload with progress tracking
schemas/ - Zod validation schemas for forms
Key Technologies
React Native 0.81.5 + Expo 54
The app uses Expo’s managed workflow for simplified development:
// app.config.js - Expo configuration
export default {
name: "Auction Excellence" ,
slug: "mobile" ,
version: "1.0.0" ,
scheme: "auctionexcellence" ,
ios: {
bundleIdentifier: "com.auctionexcellence.mobile" ,
infoPlist: {
NSCameraUsageDescription: "Take photos for lot submissions" ,
NSPhotoLibraryUsageDescription: "Select photos from your library" ,
},
} ,
android: {
package: "com.auctionexcellence.mobile" ,
permissions: [ "CAMERA" , "READ_MEDIA_IMAGES" ],
} ,
plugins: [
"expo-camera" ,
"expo-image-picker" ,
"expo-notifications" ,
] ,
} ;
TypeScript Configuration
Strict TypeScript is enabled for maximum type safety:
{
"compilerOptions" : {
"strict" : true ,
"noUncheckedIndexedAccess" : true ,
"paths" : {
"@/*" : [ "./src/*" ]
}
}
}
Database types are auto-generated from Supabase and located in src/types/. Run pnpm supabase:gen-types in the root directory to update them.
React Query Patterns
All data fetching uses TanStack Query (React Query) 5.x for server state management.
Query Pattern
Queries fetch and cache data from the server:
// hooks/useLocations.ts
import { useQuery } from '@tanstack/react-query' ;
import { supabase } from '@/lib/supabase/client' ;
import { useAuthStore } from '@/stores/authStore' ;
import { useCurrentAuction } from '@/hooks/useCurrentAuction' ;
export function useLocations () {
const { user } = useAuthStore ();
const auction = useCurrentAuction ();
return useQuery ({
// Hierarchical query key for cache management
queryKey: [ 'locations' , auction . id , user ?. id ],
queryFn : async () => {
const { data , error } = await supabase
. from ( 'locations' )
. select ( '*' )
. eq ( 'auction_id' , auction . id )
. order ( 'name' );
if ( error ) throw error ;
return data ;
},
// Only run when we have required data
enabled: !! user ?. id && !! auction . id ,
// Cache configuration
staleTime: 30 * 1000 , // Consider fresh for 30 seconds
gcTime: 5 * 60 * 1000 , // Keep in cache for 5 minutes
});
}
Usage in components:
const { data : locations , isLoading , error , refetch } = useLocations ();
if ( isLoading ) return < ActivityIndicator />;
if ( error ) return < ErrorMessage message ={ error . message } onRetry ={ refetch } />;
return locations . map ( location => (
< LocationCard key = {location. id } location = { location } />
));
Mutation Pattern
Mutations modify server data with optimistic updates:
// hooks/useSubmitLotCount.ts
import { useMutation , useQueryClient } from '@tanstack/react-query' ;
import { supabase } from '@/lib/supabase/client' ;
import { uploadLotImage } from '@/lib/supabase/storage' ;
interface SubmitLotCountParams {
location_id : string ;
entries : Array <{
submission_type_id : string ;
car_count : number ;
image_uri ?: string ;
}>;
image_uri ?: string ;
onUploadProgress ?: ( progress : number ) => void ;
}
export const useSubmitLotCount = () => {
const queryClient = useQueryClient ();
const { user } = useAuthStore ();
return useMutation ({
mutationFn : async ({
location_id ,
entries ,
image_uri ,
onUploadProgress ,
} : SubmitLotCountParams ) => {
if ( ! user ?. id ) {
throw new Error ( 'User not authenticated' );
}
// Upload images with progress tracking
let submissionImageUrl : string | null = null ;
if ( image_uri ) {
submissionImageUrl = await uploadLotImage (
image_uri ,
user . id ,
onUploadProgress
);
}
// Call RPC function
const { data , error } = await supabase . rpc (
'create_lot_submission_with_entries' ,
{
p_location_id: location_id ,
p_image_url: submissionImageUrl ,
p_entries: entries ,
}
);
if ( error ) throw error ;
return { submission_id: data };
},
onSuccess : () => {
// Invalidate related queries to refresh data
queryClient . invalidateQueries ({ queryKey: [ 'recent-submissions' ] });
queryClient . invalidateQueries ({ queryKey: [ 'lot-submissions' ] });
},
});
};
Optimistic Updates
For real-time feel, update the UI before the server responds:
// hooks/useSendMessage.ts
export const useSendMessage = () => {
const queryClient = useQueryClient ();
const { user } = useAuthStore ();
return useMutation ({
mutationFn : async ({ channelId , content }) => {
// ... actual API call
},
onMutate : async ({ channelId , content }) => {
// Cancel outgoing refetches
await queryClient . cancelQueries ({ queryKey: [ 'messages' , channelId ] });
// Snapshot previous value for rollback
const previousMessages = queryClient . getQueryData ([ 'messages' , channelId ]);
// Create optimistic message
const optimisticMessage = {
id: `temp- ${ Date . now () } ` ,
channel_id: channelId ,
user_id: user . id ,
content: content . trim (),
created_at: new Date (). toISOString (),
user: {
id: user . id ,
full_name: user . full_name ,
},
};
// Optimistically add to cache
queryClient . setQueryData ([ 'messages' , channelId ], ( old ) => ({
... old ,
pages: [
{
... old . pages [ 0 ],
data: [ optimisticMessage , ... old . pages [ 0 ]. data ],
},
... old . pages . slice ( 1 ),
],
}));
return { previousMessages };
},
onError : ( _error , { channelId }, context ) => {
// Rollback on error
if ( context ?. previousMessages ) {
queryClient . setQueryData (
[ 'messages' , channelId ],
context . previousMessages
);
}
},
onSettled : ( _data , _error , { channelId }) => {
// Always refetch after mutation settles
queryClient . invalidateQueries ({ queryKey: [ 'messages' , channelId ] });
},
});
};
Query Key Conventions
Follow these conventions for cache management:
Pattern Example Description Entity list ['locations', auctionId]All locations for an auction Single entity ['location', locationId]One location by ID Filtered list ['messages', channelId]Messages for a channel User-scoped ['locations', auctionId, userId]User’s assigned locations Nested resource ['channel', channelId, 'members']Members of a channel
Zustand State Management
Zustand handles client-side state that doesn’t come from the server.
Auth Store
The auth store manages authentication state:
// stores/authStore.ts
import { create } from 'zustand' ;
import { supabase } from '@/lib/supabase/client' ;
import type { Session , User } from '@supabase/supabase-js' ;
interface AuthState {
session : Session | null ;
user : User | null ;
loading : boolean ;
initialized : boolean ;
error : string | null ;
// Actions
setSession : ( session : Session | null ) => void ;
signIn : ( email : string , password : string ) => Promise < void >;
signOut : () => Promise < void >;
initialize : () => Promise < void >;
}
export const useAuthStore = create < AuthState >(( set , get ) => ({
session: null ,
user: null ,
loading: false ,
initialized: false ,
error: null ,
signIn : async ( email : string , password : string ) => {
try {
set ({ loading: true , error: null });
const { data , error } = await supabase . auth . signInWithPassword ({
email ,
password ,
});
if ( error ) {
// User-friendly error messages
let errorMessage = error . message ;
if ( error . message . includes ( 'Invalid login credentials' )) {
errorMessage = 'Invalid email or password. Please try again.' ;
}
set ({ error: errorMessage });
throw new Error ( errorMessage );
}
if ( data . session ) {
set ({ session: data . session });
// Fetch user profile
const { data : userData , error : userError } = await supabase
. from ( 'users' )
. select ( '*' )
. eq ( 'id' , data . user . id )
. single ();
if ( userError ) throw userError ;
set ({ user: userData , error: null });
}
} finally {
set ({ loading: false });
}
},
initialize : async () => {
try {
const { data : { session } } = await supabase . auth . getSession ();
if ( session ) {
set ({ session });
// Fetch user profile...
}
// Listen for auth changes
supabase . auth . onAuthStateChange (( _event , session ) => {
set ({ session });
if ( ! session ) {
set ({ user: null });
}
});
set ({ initialized: true });
} catch {
set ({ initialized: true , session: null , user: null });
}
},
}));
Auction Store with Persistence
The auction store persists the current auction selection:
// stores/auctionStore.ts
import { create } from 'zustand' ;
import { persist , createJSONStorage } from 'zustand/middleware' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
interface AuctionState {
currentAuction : Auction | null ;
userAuctions : Auction [];
setCurrentAuction : ( auction : Auction | null ) => void ;
setUserAuctions : ( auctions : Auction []) => void ;
switchAuction : ( auctionId : string ) => void ;
}
export const useAuctionStore = create < AuctionState >()(
persist (
( set , get ) => ({
currentAuction: null ,
userAuctions: [],
setUserAuctions : ( auctions ) => {
const current = get (). currentAuction ;
const currentStillValid = current &&
auctions . some (( a ) => a . id === current . id );
if ( currentStillValid ) {
// Keep current if still valid
const freshCurrent = auctions . find (( a ) => a . id === current . id );
set ({ userAuctions: auctions , currentAuction: freshCurrent });
} else if ( auctions . length === 1 ) {
// Auto-select if only one auction
set ({ userAuctions: auctions , currentAuction: auctions [ 0 ] });
} else {
// Clear selection if multiple auctions
set ({ userAuctions: auctions , currentAuction: null });
}
},
switchAuction : ( auctionId ) => {
const auction = get (). userAuctions . find (( a ) => a . id === auctionId );
if ( auction ) {
set ({ currentAuction: auction });
}
},
}),
{
name: 'auction-storage' ,
storage: createJSONStorage (() => AsyncStorage ),
partialize : ( state ) => ({
currentAuction: state . currentAuction ,
}),
}
)
);
When to Use Each
Use Case Solution Server data (locations, submissions, messages) React Query Auth session and user profile Zustand (authStore) Current auction context Zustand with persistence Form state React Hook Form Component-local UI state useState
Forms use React Hook Form 7.x with Zod 3.x for validation.
Zod Schemas
Define validation schemas in lib/schemas/:
// lib/schemas/submissions.ts
import { z } from 'zod' ;
export const submissionEntrySchema = z . object ({
submission_type_id: z . string (). min ( 1 ),
submission_type_name: z . string (). min ( 1 ),
car_count: z . number (). int (). positive (),
});
export type SubmissionEntryData = z . infer < typeof submissionEntrySchema >;
export const lotSubmissionSchema = z . object ({
location_id: z . string (). min ( 1 , 'Please select a location' ),
entries: z
. array ( submissionEntrySchema )
. min ( 1 , 'At least one entry with a car count is required' ),
image_uri: z . string (). nullable (),
});
export type LotSubmissionFormData = z . infer < typeof lotSubmissionSchema >;
Use the @hookform/resolvers/zod package:
import { useForm } from 'react-hook-form' ;
import { zodResolver } from '@hookform/resolvers/zod' ;
import { lotSubmissionSchema , type LotSubmissionFormData } from '@/lib/schemas' ;
export const SubmitLotScreen : React . FC = () => {
const { mutate : submitLotCount , isPending } = useSubmitLotCount ();
const {
control ,
handleSubmit ,
reset ,
formState : { errors , isValid },
} = useForm < LotSubmissionFormData >({
resolver: zodResolver ( lotSubmissionSchema ),
defaultValues: {
location_id: '' ,
entries: [],
image_uri: null ,
},
mode: 'onChange' , // Validate on change for real-time feedback
});
const onSubmit = ( data : LotSubmissionFormData ) => {
submitLotCount ( data , {
onSuccess : () => {
reset ();
Alert . alert ( 'Success' , 'Lot count submitted successfully!' );
},
onError : ( error ) => {
Alert . alert ( 'Error' , error . message );
},
});
};
return (
< View >
< Controller
control = { control }
name = "location_id"
render = {({ field : { onChange , value } }) => (
< LocationSelector
value = { value }
onChange = { onChange }
error = {errors.location_id?. message }
/>
)}
/>
< Button
title = "Submit"
onPress = { handleSubmit ( onSubmit )}
loading = { isPending }
disabled = {!isValid || isPending }
/>
</ View >
);
};
Error Display Pattern
Display validation errors consistently:
// components/ui/Input.tsx
interface InputProps extends TextInputProps {
label ?: string ;
error ?: string ;
}
export const Input : React . FC < InputProps > = ({ label , error , ... props }) => {
return (
< View style = {styles. container } >
{ label && < Text style = {styles. label } > { label } </ Text > }
< TextInput
style = { [styles.input, error && styles.inputError]}
{...props}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
Component Patterns
UI Components
Base UI components in components/ui/ follow consistent patterns:
// components/ui/Button.tsx
import React from 'react' ;
import {
TouchableOpacity ,
Text ,
ActivityIndicator ,
StyleSheet ,
type TouchableOpacityProps ,
} from 'react-native' ;
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' ;
export interface ButtonProps extends Omit < TouchableOpacityProps , 'style' > {
title ?: string ;
children ?: React . ReactNode ;
variant ?: ButtonVariant ;
loading ?: boolean ;
fullWidth ?: boolean ;
}
export const Button : React . FC < ButtonProps > = ({
title ,
children ,
variant = 'primary' ,
loading = false ,
disabled = false ,
fullWidth = false ,
onPress ,
... rest
}) => {
const isDisabled = disabled || loading ;
const content = children || title ;
return (
< TouchableOpacity
style = { [
styles.button,
styles[variant],
fullWidth && styles.fullWidth,
isDisabled && styles.disabled,
]}
onPress={onPress}
disabled={isDisabled}
accessibilityRole="button"
accessibilityLabel={typeof content === 'string' ? content : title }
accessibilityState = {{ disabled : isDisabled }}
{ ... rest }
>
{ loading ? (
< ActivityIndicator
color = { variant === 'outline' ? '#007AFF' : '#FFFFFF' }
size = "small"
/>
) : typeof content === ' string ' ? (
< Text style = { [styles.text, styles[ ` ${ variant } Text` ]]}>{content}</Text>
) : (
content
)}
</ TouchableOpacity >
);
};
const styles = StyleSheet . create ({
button: {
paddingHorizontal: 24 ,
paddingVertical: 12 ,
borderRadius: 8 ,
alignItems: 'center' ,
justifyContent: 'center' ,
minHeight: 48 ,
},
primary: {
backgroundColor: '#007AFF' ,
},
// ... other variants
});
Screen Pattern
Screens compose components and manage navigation:
// screens/submissions/SubmitLotScreen.tsx
import React , { useState , useCallback , useMemo , useEffect } from 'react' ;
import {
View ,
ScrollView ,
KeyboardAvoidingView ,
Platform ,
Alert ,
} from 'react-native' ;
import { LocationSelector } from '@/components/forms/LocationSelector' ;
import { MultiTypeSubmissionForm } from '@/components/forms/MultiTypeSubmissionForm' ;
import { CameraCapture } from '@/components/camera/CameraCapture' ;
import { Button } from '@/components/ui/Button' ;
import { useSubmitLotCount } from '@/hooks/useSubmitLotCount' ;
import { useLocationSubmissionTypes } from '@/hooks/useLocationSubmissionTypes' ;
export const SubmitLotScreen : React . FC = () => {
// Local UI state
const [ showCamera , setShowCamera ] = useState ( false );
const [ uploadProgress , setUploadProgress ] = useState ( 0 );
const [ locationId , setLocationId ] = useState < string >( '' );
const [ imageUri , setImageUri ] = useState < string | null >( null );
const [ entries , setEntries ] = useState < SubmissionEntry []>([]);
// Data hooks
const { mutate : submitLotCount , isPending } = useSubmitLotCount ();
const { data : submissionTypes } = useLocationSubmissionTypes (
locationId || undefined
);
// Initialize entries when types load
useEffect (() => {
if ( submissionTypes ?. length ) {
const newEntries = submissionTypes . map (( st ) => ({
submission_type_id: st . submission_type . id ,
submission_type_name: st . submission_type . name ,
car_count: null ,
image_uri: null ,
}));
setEntries ( newEntries );
}
}, [ submissionTypes , locationId ]);
// Computed values
const entriesWithValues = useMemo (
() => entries . filter (( e ) => e . car_count !== null && e . car_count > 0 ),
[ entries ]
);
const isValid = useMemo (
() => locationId . length > 0 && entriesWithValues . length > 0 ,
[ locationId , entriesWithValues ]
);
// Handlers
const handleSubmit = useCallback (() => {
submitLotCount (
{
location_id: locationId ,
entries: entriesWithValues ,
image_uri: imageUri || undefined ,
onUploadProgress: setUploadProgress ,
},
{
onSuccess : () => {
Alert . alert ( 'Success' , 'Lot count submitted!' );
// Reset form...
},
onError : ( error ) => {
Alert . alert ( 'Error' , error . message );
},
}
);
}, [ locationId , entriesWithValues , imageUri , submitLotCount ]);
// Show camera if active
if ( showCamera ) {
return (
< CameraCapture
onImageCaptured = { setImageUri }
onCancel = {() => setShowCamera ( false )}
/>
);
}
return (
< KeyboardAvoidingView
style = {styles. container }
behavior = {Platform. OS === 'ios' ? 'padding' : 'height' }
>
< ScrollView >
< LocationSelector value = { locationId } onChange = { setLocationId } />
< MultiTypeSubmissionForm
locationId = {locationId || undefined }
entries = { entries }
onEntriesChange = { setEntries }
isLoading = { isPending }
/>
< Button
title = { `Submit ( ${ entriesWithValues . length } entries)` }
onPress = { handleSubmit }
loading = { isPending }
disabled = {!isValid || isPending }
fullWidth
/>
</ ScrollView >
</ KeyboardAvoidingView >
);
};
Navigation
The app uses React Navigation 7.x with native stack and bottom tabs.
Navigation Structure
// navigation/RootNavigator.tsx
import { NavigationContainer } from '@react-navigation/native' ;
import { useAuthStore } from '@/stores/authStore' ;
import { AuthNavigator } from './AuthNavigator' ;
import { MainNavigator } from './MainNavigator' ;
import { linking } from './linking' ;
export const RootNavigator : React . FC = () => {
const { session , initialized , initialize } = useAuthStore ();
useEffect (() => {
initialize ();
}, [ initialize ]);
if ( ! initialized ) {
return < LoadingScreen />;
}
return (
< NavigationContainer linking = { linking } >
{ session ? < MainNavigator /> : < AuthNavigator />}
</ NavigationContainer >
);
};
Tab Navigator
// navigation/MainNavigator.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' ;
import { Feather } from '@expo/vector-icons' ;
const Tab = createBottomTabNavigator ();
export const MainNavigator : React . FC = () => {
const unreadCount = useUnreadMessageCount ();
return (
< Tab . Navigator
screenOptions = {{
tabBarActiveTintColor : '#007AFF' ,
tabBarInactiveTintColor : '#8E8E93' ,
}}
>
< Tab . Screen
name = "Home"
component = { HomeScreen }
options = {{
tabBarIcon : ({ color , size }) => (
< Feather name = "home" size = { size } color = { color } />
),
}}
/>
< Tab . Screen
name = "Overwatch"
component = { SubmissionsNavigator }
options = {{
tabBarIcon : ({ color , size }) => (
< Feather name = "truck" size = { size } color = { color } />
),
}}
/>
< Tab . Screen
name = "Chat"
component = { ChatNavigator }
options = {{
tabBarIcon : ({ color , size }) => (
< Feather name = "message-circle" size = { size } color = { color } />
),
tabBarBadge : unreadCount > 0 ? unreadCount : undefined ,
}}
/>
< Tab . Screen
name = "Profile"
component = { ProfileNavigator }
options = {{
tabBarIcon : ({ color , size }) => (
< Feather name = "user" size = { size } color = { color } />
),
}}
/>
</ Tab . Navigator >
);
};
Deep Linking
Configure deep linking for push notifications:
// navigation/linking.ts
import type { LinkingOptions } from '@react-navigation/native' ;
export const linking : LinkingOptions < RootStackParamList > = {
prefixes: [ 'auctionexcellence://' , 'https://app.auctionexcellence.com' ],
config: {
screens: {
Main: {
screens: {
Chat: {
screens: {
Channel: 'chat/:channelId' ,
Message: 'chat/:channelId/message/:messageId' ,
},
},
Submissions: 'submissions' ,
},
},
AcceptInvite: 'invite/:token' ,
},
},
};
Supabase Integration
Client Setup
// lib/supabase/client.ts
import 'react-native-url-polyfill/auto' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
import { createClient } from '@supabase/supabase-js' ;
import type { Database } from '@/types/database' ;
const supabaseUrl = process . env . EXPO_PUBLIC_SUPABASE_URL || '' ;
const supabaseAnonKey = process . env . EXPO_PUBLIC_SUPABASE_ANON_KEY || '' ;
if ( ! supabaseUrl || ! supabaseAnonKey ) {
throw new Error ( 'Missing Supabase environment variables' );
}
export const supabase = createClient < Database >( supabaseUrl , supabaseAnonKey , {
auth: {
storage: AsyncStorage ,
autoRefreshToken: true ,
persistSession: true ,
detectSessionInUrl: false ,
},
});
Image Upload with Progress
// lib/supabase/storage.ts
export const uploadLotImage = async (
imageUri : string ,
userId : string ,
onProgress ?: ( progress : number ) => void
) : Promise < string > => {
try {
if ( onProgress ) onProgress ( 10 );
// Compress the image
const compressedUri = await compressImage ( imageUri );
if ( onProgress ) onProgress ( 30 );
// Convert to ArrayBuffer
const arrayBuffer = await uriToArrayBuffer ( compressedUri );
if ( onProgress ) onProgress ( 50 );
// Check file size
const fileSizeMB = arrayBuffer . byteLength / ( 1024 * 1024 );
if ( fileSizeMB > 5 ) {
throw new Error ( `Image too large: ${ fileSizeMB . toFixed ( 1 ) } MB` );
}
// Generate unique path
const imagePath = ` ${ userId } / ${ Date . now () } .jpg` ;
if ( onProgress ) onProgress ( 60 );
// Upload to Supabase Storage
const { data , error } = await supabase . storage
. from ( 'lot-images' )
. upload ( imagePath , arrayBuffer , {
contentType: 'image/jpeg' ,
cacheControl: '3600' ,
upsert: false ,
});
if ( error ) throw new Error ( `Upload failed: ${ error . message } ` );
if ( onProgress ) onProgress ( 90 );
// Get public URL
const { data : { publicUrl } } = supabase . storage
. from ( 'lot-images' )
. getPublicUrl ( data . path );
if ( onProgress ) onProgress ( 100 );
return publicUrl ;
} catch ( error ) {
console . error ( 'Error uploading image:' , error );
throw error ;
}
};
Real-time Subscriptions
// hooks/useChannelMessages.ts
export function useChannelMessages ( channelId : string ) {
const queryClient = useQueryClient ();
useEffect (() => {
const channel = supabase
. channel ( `messages: ${ channelId } ` )
. on (
'postgres_changes' ,
{
event: 'INSERT' ,
schema: 'public' ,
table: 'messages' ,
filter: `channel_id=eq. ${ channelId } ` ,
},
( payload ) => {
// Add new message to cache
queryClient . setQueryData ([ 'messages' , channelId ], ( old ) => {
// ... update logic
});
}
)
. subscribe ();
return () => {
supabase . removeChannel ( channel );
};
}, [ channelId , queryClient ]);
// ... query logic
}
Testing
Jest Configuration
// jest.config.js
const jestExpoPreset = require ( 'jest-expo/jest-preset' );
module . exports = {
... jestExpoPreset ,
setupFiles: [ '<rootDir>/jest.pre-setup.js' ],
setupFilesAfterEnv: [ '<rootDir>/jest.setup.js' ],
testMatch: [ '**/__tests__/**/*.test.[jt]s?(x)' ],
moduleNameMapper: {
'^@/(.*)$' : '<rootDir>/src/$1' ,
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}' ,
'!src/**/*.d.ts' ,
'!src/**/__tests__/**' ,
'!src/types/**' ,
],
};
Testing Hooks
// hooks/__tests__/useLocations.test.ts
import { renderHook , waitFor } from '@testing-library/react-native' ;
import { useLocations } from '../useLocations' ;
import { createWrapper , setMockQueryResult } from './hookTestUtils' ;
describe ( 'useLocations' , () => {
it ( 'should fetch locations successfully' , async () => {
const mockLocations = [
{ id: '1' , name: 'North Lot' , auction_id: 'auction-1' },
{ id: '2' , name: 'South Lot' , auction_id: 'auction-1' },
];
setMockQueryResult ({ data: mockLocations , error: null });
const { result } = renderHook (() => useLocations (), {
wrapper: createWrapper (),
});
await waitFor (() => {
expect ( result . current . isSuccess ). toBe ( true );
});
expect ( result . current . data ). toEqual ( mockLocations );
});
it ( 'should handle errors' , async () => {
setMockQueryResult ({ data: null , error: new Error ( 'Network error' ) });
const { result } = renderHook (() => useLocations (), {
wrapper: createWrapper (),
});
await waitFor (() => {
expect ( result . current . isError ). toBe ( true );
});
expect ( result . current . error ?. message ). toBe ( 'Network error' );
});
});
Test Utilities
// hooks/__tests__/hookTestUtils.tsx
import { QueryClient , QueryClientProvider } from '@tanstack/react-query' ;
import type { ReactNode } from 'react' ;
export const createWrapper = () => {
const queryClient = new QueryClient ({
defaultOptions: {
queries: { retry: false , gcTime: 0 },
mutations: { retry: false },
},
});
return ({ children } : { children : ReactNode }) => (
< QueryClientProvider client = { queryClient } >
{ children }
</ QueryClientProvider >
);
};
// Mock state management
export let mockQueryResult : { data : unknown ; error : unknown } = {
data: [],
error: null ,
};
export const setMockQueryResult = ( result : typeof mockQueryResult ) => {
mockQueryResult = result ;
};
Common Tasks
Adding a New Screen
Create the Screen Component
Create a new file in the appropriate screens/ subdirectory: // screens/example/NewScreen.tsx
import React from 'react' ;
import { View , Text , StyleSheet } from 'react-native' ;
export const NewScreen : React . FC = () => {
return (
< View style = {styles. container } >
< Text > New Screen </ Text >
</ View >
);
};
const styles = StyleSheet . create ({
container: {
flex: 1 ,
padding: 16 ,
},
});
Add to Navigator
Add the screen to the appropriate navigator: // navigation/ExampleNavigator.tsx
import { NewScreen } from '@/screens/example/NewScreen' ;
< Stack . Screen
name = "NewScreen"
component = { NewScreen }
options = {{ title : 'New Screen' }}
/>
Update Type Definitions
Add the screen to the navigation param list: // navigation/types.ts
export type ExampleStackParamList = {
NewScreen : undefined ;
// or with params:
NewScreen : { id : string };
};
Creating a New Hook
// hooks/useExample.ts
import { useQuery } from '@tanstack/react-query' ;
import { supabase } from '@/lib/supabase/client' ;
import { useAuthStore } from '@/stores/authStore' ;
interface Example {
id : string ;
name : string ;
}
export function useExample ( id : string | undefined ) {
const { user } = useAuthStore ();
return useQuery ({
queryKey: [ 'example' , id ],
queryFn : async () : Promise < Example > => {
const { data , error } = await supabase
. from ( 'examples' )
. select ( '*' )
. eq ( 'id' , id ! )
. single ();
if ( error ) throw error ;
return data ;
},
enabled: !! user ?. id && !! id ,
});
}
// lib/schemas/example.ts
import { z } from 'zod' ;
export const exampleSchema = z . object ({
name: z . string (). min ( 1 , 'Name is required' ). max ( 100 ),
email: z . string (). email ( 'Invalid email address' ),
count: z . number (). int (). positive ( 'Must be a positive number' ),
});
export type ExampleFormData = z . infer < typeof exampleSchema >;
Key Commands
Command Description pnpm startStart Expo dev server pnpm iosRun on iOS Simulator pnpm androidRun on Android Emulator pnpm testRun Jest tests pnpm test:watchRun tests in watch mode pnpm test:coverageGenerate coverage report pnpm type-checkTypeScript validation pnpm checkBiome lint + format pnpm lintBiome linting only pnpm formatBiome formatting only
Best Practices
Type Everything Use TypeScript strictly. Avoid any and leverage generated database types.
Use React Query All server data should flow through React Query hooks for caching and sync.
Validate Forms Every form needs a Zod schema. Validate on change for real-time feedback.
Handle Errors Every hook and component should handle loading, error, and empty states.
Code Style
Biome for linting and formatting (not ESLint/Prettier)
Run pnpm check before committing
Use pnpm type-check to verify TypeScript
Use useMemo and useCallback for expensive computations and stable references
Implement proper list virtualization for long lists
Compress images before upload
Use enabled option in queries to prevent unnecessary fetches
Next Steps