Technical Documentation

Driftr

Architecture, Tech Stack & RevenueCat Implementation

1. Tech Stack Overview

Framework
React Native + Expo SDK 54
Cross-platform iOS & Android from single codebase
Routing
Expo Router 6
File-based navigation with type-safe routes
Backend
Supabase
PostgreSQL, Auth, Realtime, Storage — all-in-one
Monetization
RevenueCat SDK 9.7
In-app subscriptions with real-time entitlements
State Management
Zustand 5
Lightweight global state for auth & match data
Styling
NativeWind 4 (Tailwind)
Utility-first CSS for consistent, rapid UI
Animations
Reanimated 4 + Gesture Handler
60fps swipe gestures & match celebrations
Maps
React Native Maps + PostGIS
earthdistance extension for proximity queries

Key Dependencies

PackageVersionPurpose
react-native0.81.5Core framework
expo~54.0.33Managed workflow + native modules
@supabase/supabase-js^2.95.3Backend client (Auth, DB, Realtime, Storage)
react-native-purchases^9.7.6RevenueCat SDK for subscriptions
react-native-purchases-ui^9.7.6RevenueCat pre-built paywall components
zustand^5.0.11Global state management
nativewind^4.2.1Tailwind CSS for React Native
expo-camera~17.0.10Video intro recording
expo-location~19.0.8GPS positioning for map & proximity

2. System Architecture

Client React Native Expo Router Zustand NativeWind
Services Supabase Auth Supabase Realtime RevenueCat
Data PostgreSQL + PostGIS Supabase Storage Row Level Security

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

TablePurposeKey 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

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

Integration depth: RevenueCat is not bolted on — it's woven into the core UX. Users are identified by Supabase Auth ID, premium state is reactive via listener, and five strategic touchpoints create a cohesive monetization system.

Configuration

SettingValue
Entitlementpremium
Offeringdefault
Monthly Productdriftr_monthly — $7.99/mo
Annual Productdriftr_annual — $59.99/yr (7-day free trial)
User IdentitySupabase 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

1
4th Daily Wave — When a free user exhausts 3 daily waves, the next swipe triggers the paywall with "Unlimited waves" as the lead benefit.
2
Builder Contact — Tapping "Contact Builder" on a builder profile checks isPremium. Non-premium users see the paywall with builder-specific messaging.
3
See Who Waved — The "waves received" section shows blurred profiles for free users with a CTA to unlock premium.
4
Expanded Radius — Free users search within 50km. Tapping "Expand to 250km" routes to the paywall.
5
Premium Badge — Profile settings show a "Get Premium Badge" upsell for users who want to stand out on the map.
Design philosophy: Core functionality (browsing, swiping, matching, chatting) is free. Premium triggers at natural friction points where the value is immediately clear. This keeps free users engaged while making the upgrade feel like expanding the experience, not removing a wall.

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

1
useLocation hook requests GPS permission and gets device coordinates.
2
Coordinates are passed to the nearby_profiles() Supabase RPC function.
3
PostGIS calculates distances using ll_to_earth() and earth_distance() with a GiST index for performance.
4
Results filtered by connection mode (looking_for array), capped at 50 pins for map performance.
5
Map renders custom pins with user avatars and Parked/Rolling status indicators.
-- 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.

1
User A swipes right on User B → INSERT INTO waves (from_user, to_user, mode)
2
PostgreSQL trigger check_and_create_match() fires automatically.
3
Trigger queries: Does User B have an existing wave to User A in the same mode?
4
If reciprocal wave exists → INSERT INTO matches (user_a, user_b, mode) with ordered pair constraint.
5
Client detects new match → shows confetti celebration with 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;