Skip to main content

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:
ToolVersionInstallation
Node.js20.x LTSnodejs.org or nvm install 20
pnpm9.xnpm install -g pnpm
Xcode15+Mac App Store (iOS development)
Android StudioLatestdeveloper.android.com
Expo CLILatestInstalled 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

Getting Started

Repository Setup

1

Clone the Repository

git clone https://github.com/your-org/auction-excellence.git
cd auction-excellence/mobile
2

Install Dependencies

pnpm install
This installs all dependencies including React Native, Expo, and development tools.
3

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.
4

Start the Development Server

pnpm start
This starts the Expo development server. You’ll see a QR code and options to run on different platforms.
5

Run on Device or Simulator

iOS Simulator (macOS only):
pnpm ios
Android Emulator:
pnpm android
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:
PatternExampleDescription
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 CaseSolution
Server data (locations, submissions, messages)React Query
Auth session and user profileZustand (authStore)
Current auction contextZustand with persistence
Form stateReact Hook Form
Component-local UI stateuseState

Form Handling

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>;

React Hook Form Integration

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>
  );
};

The app uses React Navigation 7.x with native stack and bottom tabs.
// 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

1

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,
  },
});
2

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' }}
/>
3

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,
  });
}

Adding Form Validation

// 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

CommandDescription
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

Performance

  • 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