Contents
1. Tech Stack Overview
Key Dependencies
| Package | Version | Purpose |
|---|---|---|
react-native | 0.81.5 | Core framework |
expo | ~54.0.33 | Managed workflow + native modules |
@supabase/supabase-js | ^2.95.3 | Backend client (Auth, DB, Realtime, Storage) |
react-native-purchases | ^9.7.6 | RevenueCat SDK for subscriptions |
react-native-purchases-ui | ^9.7.6 | RevenueCat pre-built paywall components |
zustand | ^5.0.11 | Global state management |
nativewind | ^4.2.1 | Tailwind CSS for React Native |
expo-camera | ~17.0.10 | Video intro recording |
expo-location | ~19.0.8 | GPS positioning for map & proximity |
2. System Architecture
File Structure
driftr/ ├── app/ │ ├── _layout.tsx # Root layout — RevenueCat & auth init │ ├── (auth)/ # Welcome, login, signup screens │ ├── (onboarding)/ # 5-step onboarding (basics → location) │ ├── (tabs)/ # Main app: discover, dating, friends, builders, profile │ └── (screens)/ # Modals: paywall, chat, matches, settings ├── hooks/ │ ├── usePremium.ts # RevenueCat entitlement hook │ ├── useWaveLimit.ts # Daily wave counter (free/premium) │ └── useNearbyProfiles.ts # Geo-proximity queries ├── stores/ │ ├── auth-store.ts # Authentication state (Zustand) │ └── match-store.ts # Matches & messaging state ├── lib/ │ ├── supabase.ts # Supabase client configuration │ └── constants.ts # App constants (wave limits, etc.) ├── components/ # SwipeCard, MapPin, MatchCelebration, etc. └── supabase/ ├── schema.sql # Full database schema + RLS policies └── seed.sql # Test data
3. Database Schema
Core Tables
| Table | Purpose | Key Columns |
|---|---|---|
profiles |
User profiles (extends auth.users) | name, avatar_url, video_intro_url, van_type, travel_style, latitude, longitude, status, looking_for[], is_builder, is_verified |
waves |
Directional likes | from_user, to_user, mode (dating/friends). Unique constraint prevents duplicates. |
matches |
Bilateral connections | user_a, user_b, mode. Auto-created by DB trigger. Ordered pair constraint (a < b). |
messages |
Chat messages | match_id, sender_id, content. Realtime-enabled via Supabase publication. |
builder_reviews |
Builder ratings | builder_id, reviewer_id, rating (1-5), comment. One review per pair. |
daily_wave_count |
Rate limiting | user_id, date (UTC), count. Primary key (user_id, date). |
RPC Functions
-- Proximity search using PostGIS earthdistance extension CREATE FUNCTION nearby_profiles( lat DOUBLE PRECISION, lng DOUBLE PRECISION, radius_km INTEGER, filter_mode TEXT ) RETURNS TABLE (...) -- Uses ll_to_earth() + earth_distance() with GiST index -- Filters by looking_for[], excludes null coords -- Returns distance_km sorted ASC, capped at 50 results -- Atomic wave counter (UPSERT) CREATE FUNCTION increment_wave_count( p_user_id UUID, p_date DATE ) RETURNS INTEGER -- INSERT ... ON CONFLICT (user_id, date) DO UPDATE SET count = count + 1
Database Triggers
handle_new_user()— Auto-creates a profile row when a user signs up via Supabase Auth.check_and_create_match()— Fires on every wave INSERT. Checks if a reciprocal wave exists in the same mode. If yes, auto-creates a match. No polling, no cron jobs.
Row Level Security
Every table has RLS enabled. Profiles are publicly readable; users can only update their own. Waves, matches, and messages are scoped to participating users. Reviews are public-read with owner-only write.
4. RevenueCat Implementation
Configuration
| Setting | Value |
|---|---|
| Entitlement | premium |
| Offering | default |
| Monthly Product | driftr_monthly — $7.99/mo |
| Annual Product | driftr_annual — $59.99/yr (7-day free trial) |
| User Identity | Supabase Auth user.id |
Initialization Flow
RevenueCat is initialized in the root layout (app/_layout.tsx) immediately after Supabase authentication:
// app/_layout.tsx — Root layout import Purchases from 'react-native-purchases'; // On auth state change: Purchases.configure({ apiKey: Platform.OS === 'ios' ? process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY : process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY }); Purchases.logIn(user.id); // Sync with Supabase Auth ID
usePremium Hook
The usePremium hook (hooks/usePremium.ts) provides reactive premium state to the entire app:
export function usePremium() { const [isPremium, setIsPremium] = useState(false); useEffect(() => { // Check initial state const info = await Purchases.getCustomerInfo(); setIsPremium(!!info.entitlements.active['premium']); // Listen for real-time changes (purchase, restore, expiry) const listener = Purchases.addCustomerInfoUpdateListener( (info) => setIsPremium(!!info.entitlements.active['premium']) ); return () => listener.remove(); }, []); return { isPremium }; }
useWaveLimit Hook
The useWaveLimit hook integrates with both RevenueCat (premium check) and Supabase (wave counting):
export function useWaveLimit() { const { isPremium } = usePremium(); const limit = isPremium ? Infinity : FREE_WAVE_LIMIT; // 3 // Query daily_wave_count from Supabase // Increment via increment_wave_count() RPC return { wavesRemaining, canWave, incrementWave }; }
Paywall Screen
The paywall (app/(screens)/paywall.tsx) fetches offerings from RevenueCat and handles purchases:
// Fetch current offering const offerings = await Purchases.getOfferings(); const packages = offerings.current?.availablePackages; // Purchase flow const { customerInfo } = await Purchases.purchasePackage(pkg); if (customerInfo.entitlements.active['premium']) { // Auto-dismiss paywall, user is now premium router.back(); } // Restore purchases const info = await Purchases.restorePurchases();
5 Strategic Paywall Touchpoints
isPremium. Non-premium users see the paywall with builder-specific messaging.5. Geo-Proximity System
The map is the home screen. Every pin represents a real person. The system uses PostgreSQL's earthdistance extension with cube for efficient spherical distance calculations.
How It Works
useLocation hook requests GPS permission and gets device coordinates.nearby_profiles() Supabase RPC function.ll_to_earth() and earth_distance() with a GiST index for performance.looking_for array), capped at 50 pins for map performance.-- GiST index for fast proximity queries CREATE INDEX idx_profiles_location ON profiles USING gist ( ll_to_earth(latitude, longitude) ); -- Query: users within 50km, sorted by distance SELECT *, earth_distance( ll_to_earth(lat, lng), ll_to_earth(p.latitude, p.longitude) ) / 1000 AS distance_km FROM profiles p WHERE earth_box(ll_to_earth(lat, lng), radius_km * 1000) @> ll_to_earth(p.latitude, p.longitude) ORDER BY distance_km LIMIT 50;
6. Wave & Match Engine
Matching is handled entirely at the database level — no polling, no cron jobs, no background workers.
INSERT INTO waves (from_user, to_user, mode)check_and_create_match() fires automatically.INSERT INTO matches (user_a, user_b, mode) with ordered pair constraint.MatchCelebration component.-- Trigger function: auto-create match on bilateral wave CREATE FUNCTION check_and_create_match() RETURNS trigger AS $$ BEGIN IF EXISTS ( SELECT 1 FROM waves WHERE from_user = NEW.to_user AND to_user = NEW.from_user AND mode = NEW.mode ) THEN INSERT INTO matches (user_a, user_b, mode) VALUES ( LEAST(NEW.from_user, NEW.to_user), GREATEST(NEW.from_user, NEW.to_user), NEW.mode ) ON CONFLICT DO NOTHING; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql;