- NestJS backend: auth, restaurants, orders, drivers, payments, tracking, reviews, zones, admin, email - Next.js 14 frontend: landing, restaurants, checkout, tracking, dashboards, onboarding - Expo mobile app: driver orders and earnings screens - PostgreSQL + PostGIS schema with seed data - Docker Compose for local dev (Postgres, Redis, OSRM) - MapLibre GL + OpenStreetMap integration - Stripe subscription and payment processing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
8.8 KiB
TypeScript
212 lines
8.8 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import {
|
||
View, Text, ScrollView, TouchableOpacity,
|
||
StyleSheet, ActivityIndicator, RefreshControl,
|
||
} from 'react-native'
|
||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||
import { api } from '../../app/lib/api'
|
||
|
||
const COLORS = { bg: '#0F172A', card: '#1E293B', teal: '#0D9488', amber: '#F59E0B', green: '#10B981', text: '#F8FAFC', muted: '#94A3B8' }
|
||
|
||
interface DayEarning {
|
||
session_date: string
|
||
deliveries_count: number
|
||
delivery_revenue: number
|
||
tips_earned: number
|
||
net_earnings: number
|
||
daily_fee: number
|
||
fee_paid: boolean
|
||
}
|
||
|
||
function BreakEvenBar({ revenue, fee }: { revenue: number; fee: number }) {
|
||
const pct = Math.min(100, (revenue / fee) * 100)
|
||
const broke = revenue >= fee
|
||
return (
|
||
<View style={{ marginTop: 6 }}>
|
||
<View style={styles.barBg}>
|
||
<View style={[styles.barFill, { width: `${pct}%` as any, backgroundColor: broke ? COLORS.green : COLORS.amber }]} />
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
export default function EarningsScreen() {
|
||
const [days, setDays] = useState(7)
|
||
const [earnings, setEarnings] = useState<DayEarning[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
useEffect(() => { load() }, [days])
|
||
|
||
const load = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const token = await AsyncStorage.getItem('token')
|
||
if (!token) return
|
||
const data = await api.get(`/drivers/me/earnings?days=${days}`, token)
|
||
setEarnings(data)
|
||
} catch { /* silent */ } finally {
|
||
setLoading(false)
|
||
setRefreshing(false)
|
||
}
|
||
}
|
||
|
||
const totals = earnings.reduce(
|
||
(acc, d) => ({
|
||
deliveries: acc.deliveries + (d.deliveries_count || 0),
|
||
gross: acc.gross + Number(d.delivery_revenue || 0),
|
||
tips: acc.tips + Number(d.tips_earned || 0),
|
||
net: acc.net + Number(d.net_earnings || 0),
|
||
daysWorked: acc.daysWorked + 1,
|
||
}),
|
||
{ deliveries: 0, gross: 0, tips: 0, net: 0, daysWorked: 0 },
|
||
)
|
||
|
||
const avgPerDay = totals.daysWorked > 0 ? totals.net / totals.daysWorked : 0
|
||
const avgDeliveries = totals.daysWorked > 0 ? totals.deliveries / totals.daysWorked : 0
|
||
|
||
const PERIODS = [
|
||
{ label: '7d', value: 7 }, { label: '14d', value: 14 },
|
||
{ label: '30d', value: 30 }, { label: '90d', value: 90 },
|
||
]
|
||
|
||
return (
|
||
<ScrollView
|
||
style={styles.container}
|
||
contentContainerStyle={{ paddingBottom: 32 }}
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={COLORS.teal} />}
|
||
>
|
||
{/* Period selector */}
|
||
<View style={styles.periodRow}>
|
||
{PERIODS.map(p => (
|
||
<TouchableOpacity
|
||
key={p.value}
|
||
onPress={() => setDays(p.value)}
|
||
style={[styles.periodBtn, days === p.value && styles.periodBtnActive]}
|
||
>
|
||
<Text style={[styles.periodBtnText, days === p.value && { color: '#fff' }]}>{p.label}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
{/* Summary cards */}
|
||
<View style={styles.cardRow}>
|
||
<View style={[styles.card, { flex: 1 }]}>
|
||
<Text style={styles.cardLabel}>Net Earnings</Text>
|
||
<Text style={[styles.cardValue, { color: COLORS.green }]}>${totals.net.toFixed(2)}</Text>
|
||
<Text style={styles.cardSub}>after daily fees</Text>
|
||
</View>
|
||
<View style={[styles.card, { flex: 1, marginLeft: 10 }]}>
|
||
<Text style={styles.cardLabel}>Tips</Text>
|
||
<Text style={[styles.cardValue, { color: COLORS.amber }]}>${totals.tips.toFixed(2)}</Text>
|
||
<Text style={styles.cardSub}>100% yours</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.cardRow}>
|
||
<View style={[styles.card, { flex: 1 }]}>
|
||
<Text style={styles.cardLabel}>Deliveries</Text>
|
||
<Text style={styles.cardValue}>{totals.deliveries}</Text>
|
||
<Text style={styles.cardSub}>{totals.daysWorked} days worked</Text>
|
||
</View>
|
||
<View style={[styles.card, { flex: 1, marginLeft: 10 }]}>
|
||
<Text style={styles.cardLabel}>Avg / Day</Text>
|
||
<Text style={styles.cardValue}>${avgPerDay.toFixed(2)}</Text>
|
||
<Text style={styles.cardSub}>{avgDeliveries.toFixed(1)} deliveries avg</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Break-even insight */}
|
||
{totals.daysWorked > 0 && (
|
||
<View style={[styles.card, { marginHorizontal: 16, marginBottom: 16 }]}>
|
||
<Text style={{ color: COLORS.teal, fontWeight: '600', fontSize: 13 }}>
|
||
{avgDeliveries >= 4 ? '✅ Breaking even on average!' : '⚠️ Below break-even average'}
|
||
</Text>
|
||
<Text style={{ color: COLORS.muted, fontSize: 12, marginTop: 4 }}>
|
||
You average {avgDeliveries.toFixed(1)} deliveries/day. Break-even = 4 deliveries ($5 × 4 = $20 fee).
|
||
Every delivery past #4 is pure profit.
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Day-by-day */}
|
||
<View style={[styles.card, { marginHorizontal: 16 }]}>
|
||
<Text style={styles.sectionTitle}>Daily Breakdown</Text>
|
||
{loading ? (
|
||
<ActivityIndicator color={COLORS.teal} style={{ marginVertical: 24 }} />
|
||
) : earnings.length === 0 ? (
|
||
<Text style={{ color: COLORS.muted, textAlign: 'center', paddingVertical: 24 }}>
|
||
No earnings in this period
|
||
</Text>
|
||
) : (
|
||
earnings.map((day, i) => {
|
||
const net = Number(day.net_earnings || 0)
|
||
const revenue = Number(day.delivery_revenue || 0)
|
||
const fee = Number(day.daily_fee || 20)
|
||
const tips = Number(day.tips_earned || 0)
|
||
const date = new Date(day.session_date)
|
||
const isProfitable = net > 0
|
||
|
||
return (
|
||
<View key={day.session_date} style={[styles.dayRow, i > 0 && { borderTopColor: '#334155', borderTopWidth: 1 }]}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: COLORS.text, fontWeight: '600', fontSize: 14 }}>
|
||
{date.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||
</Text>
|
||
<Text style={{ color: COLORS.muted, fontSize: 12, marginTop: 2 }}>
|
||
{day.deliveries_count} deliveries{tips > 0 ? ` · $${tips.toFixed(2)} tips` : ''}
|
||
</Text>
|
||
<BreakEvenBar revenue={revenue} fee={fee} />
|
||
</View>
|
||
<View style={{ alignItems: 'flex-end', marginLeft: 12 }}>
|
||
<Text style={{ fontSize: 16, fontWeight: 'bold', color: isProfitable ? COLORS.green : '#EF4444' }}>
|
||
{isProfitable ? '+' : ''}${net.toFixed(2)}
|
||
</Text>
|
||
<Text style={{ color: COLORS.muted, fontSize: 11, marginTop: 2 }}>
|
||
${revenue.toFixed(2)} − $20
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
)
|
||
})
|
||
)}
|
||
</View>
|
||
|
||
{/* How it works */}
|
||
<View style={[styles.card, { marginHorizontal: 16, marginTop: 16 }]}>
|
||
<Text style={styles.sectionTitle}>How Your Pay Works</Text>
|
||
{[
|
||
['Daily access fee', '$20.00'],
|
||
['Per delivery', '$5.00 flat'],
|
||
['Tips', '100% yours'],
|
||
['Commission taken', '$0.00'],
|
||
['Break-even at', '4 deliveries'],
|
||
].map(([label, val]) => (
|
||
<View key={label} style={styles.infoRow}>
|
||
<Text style={{ color: COLORS.muted, fontSize: 13 }}>{label}</Text>
|
||
<Text style={{ color: COLORS.text, fontSize: 13, fontWeight: '600' }}>{val}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</ScrollView>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: COLORS.bg },
|
||
periodRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 },
|
||
periodBtn: { flex: 1, paddingVertical: 8, backgroundColor: COLORS.card, borderRadius: 8, alignItems: 'center' },
|
||
periodBtnActive: { backgroundColor: COLORS.teal },
|
||
periodBtnText: { color: COLORS.muted, fontWeight: '600', fontSize: 13 },
|
||
cardRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 10 },
|
||
card: { backgroundColor: COLORS.card, borderRadius: 12, padding: 14 },
|
||
cardLabel: { color: COLORS.muted, fontSize: 12, marginBottom: 4 },
|
||
cardValue: { color: COLORS.text, fontSize: 22, fontWeight: 'bold' },
|
||
cardSub: { color: COLORS.muted, fontSize: 11, marginTop: 2 },
|
||
sectionTitle: { color: COLORS.text, fontWeight: '700', fontSize: 15, marginBottom: 12 },
|
||
dayRow: { paddingVertical: 12, flexDirection: 'row', alignItems: 'center' },
|
||
barBg: { height: 4, backgroundColor: '#334155', borderRadius: 2, marginTop: 4 },
|
||
barFill: { height: 4, borderRadius: 2 },
|
||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6, borderBottomColor: '#334155', borderBottomWidth: 1 },
|
||
})
|