metatroncubeswdev 89cf37f5b5 Initial commit — The Vibe fair-trade delivery platform
- 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>
2026-03-04 13:26:55 -05:00

212 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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