- 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>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
import {
|
|
View, Text, StyleSheet, ScrollView, TouchableOpacity,
|
|
ActivityIndicator, Alert, Switch,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { api } from '../lib/api'
|
|
|
|
interface BreakEvenStatus {
|
|
dailyFee: number
|
|
deliveriesCompleted: number
|
|
deliveryRevenue: number
|
|
tipsEarned: number
|
|
totalEarned: number
|
|
netEarnings: number
|
|
remainingCost: number
|
|
deliveriesToBreakEven: number
|
|
hasBreakEven: boolean
|
|
progressPercent: number
|
|
nextDeliveryIsProfit: boolean
|
|
message: string
|
|
profitAmount: number
|
|
}
|
|
|
|
export default function DriverDashboardScreen() {
|
|
const [session, setSession] = useState<any>(null)
|
|
const [breakEven, setBreakEven] = useState<BreakEvenStatus | null>(null)
|
|
const [earnings, setEarnings] = useState<any[]>([])
|
|
const [isOnline, setIsOnline] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => { loadSession() }, [])
|
|
|
|
const loadSession = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const { data } = await api.get('/drivers/me/session')
|
|
setSession(data.session)
|
|
setBreakEven(data.breakEven)
|
|
setEarnings(data.earnings || [])
|
|
setIsOnline(data.session?.status === 'active')
|
|
} catch {}
|
|
setLoading(false)
|
|
}
|
|
|
|
const toggleOnline = async (value: boolean) => {
|
|
try {
|
|
if (value) {
|
|
await api.post('/payments/driver/daily-fee')
|
|
const { data } = await api.post('/drivers/me/session/start')
|
|
setSession(data.session)
|
|
setBreakEven(data.breakEven)
|
|
setIsOnline(true)
|
|
} else {
|
|
Alert.alert('Go offline?', 'You will stop receiving new orders.', [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Go Offline',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
await api.post('/drivers/me/session/end')
|
|
setIsOnline(false)
|
|
loadSession()
|
|
},
|
|
},
|
|
])
|
|
}
|
|
} catch (err: any) {
|
|
Alert.alert('Error', err.response?.data?.message || 'Something went wrong')
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator color="#0D9488" size="large" />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
|
|
|
{/* Online toggle */}
|
|
<View style={styles.onlineCard}>
|
|
<View>
|
|
<Text style={styles.onlineLabel}>{isOnline ? 'You are online' : 'You are offline'}</Text>
|
|
<Text style={styles.onlineSub}>{isOnline ? 'Receiving delivery requests' : 'Pay $20 to start your day'}</Text>
|
|
</View>
|
|
<Switch
|
|
value={isOnline}
|
|
onValueChange={toggleOnline}
|
|
trackColor={{ false: '#334155', true: '#0D9488' }}
|
|
thumbColor="#fff"
|
|
/>
|
|
</View>
|
|
|
|
{/* Break-even card */}
|
|
{breakEven ? (
|
|
<View style={[styles.card, breakEven.hasBreakEven && styles.cardGreen]}>
|
|
<View style={styles.cardHeader}>
|
|
<Text style={[styles.cardTitle, breakEven.hasBreakEven && styles.textWhite]}>
|
|
Break-Even Progress
|
|
</Text>
|
|
<Text style={[styles.percent, breakEven.hasBreakEven && styles.textWhite]}>
|
|
{breakEven.progressPercent}%
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Progress bar */}
|
|
<View style={styles.progressBg}>
|
|
<View
|
|
style={[
|
|
styles.progressFill,
|
|
{ width: `${breakEven.progressPercent}%` as any },
|
|
breakEven.hasBreakEven && { backgroundColor: '#fff' },
|
|
breakEven.nextDeliveryIsProfit && { backgroundColor: '#F59E0B' },
|
|
]}
|
|
/>
|
|
</View>
|
|
|
|
{/* Stats row */}
|
|
<View style={styles.statsRow}>
|
|
<StatBox label="Deliveries" value={String(breakEven.deliveriesCompleted)} inverted={breakEven.hasBreakEven} />
|
|
<StatBox label="Earned" value={`$${breakEven.totalEarned.toFixed(2)}`} inverted={breakEven.hasBreakEven} />
|
|
<StatBox
|
|
label="Net"
|
|
value={`${breakEven.netEarnings >= 0 ? '+' : ''}$${breakEven.netEarnings.toFixed(2)}`}
|
|
inverted={breakEven.hasBreakEven}
|
|
highlight={breakEven.netEarnings >= 0}
|
|
/>
|
|
</View>
|
|
|
|
{/* Message */}
|
|
<View style={[styles.messageBadge, breakEven.hasBreakEven && styles.messageBadgeGreen]}>
|
|
<Text style={[styles.messageText, breakEven.hasBreakEven && styles.textWhite]}>
|
|
{breakEven.message}
|
|
</Text>
|
|
</View>
|
|
|
|
{breakEven.tipsEarned > 0 && (
|
|
<Text style={[styles.tipsText, breakEven.hasBreakEven && styles.textWhiteAlpha]}>
|
|
Tips: ${breakEven.tipsEarned.toFixed(2)} — always 100% yours
|
|
</Text>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Ready to start?</Text>
|
|
<Text style={styles.subtleText}>Toggle online above to begin your day.</Text>
|
|
<View style={styles.explainer}>
|
|
<Row label="Daily fee" value="$20.00" />
|
|
<Row label="Per delivery" value="+$5.00" valueColor="#22C55E" />
|
|
<Row label="Break-even" value="4 deliveries" />
|
|
<Row label="Tips" value="100% yours" valueColor="#22C55E" />
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Today's deliveries */}
|
|
{earnings.length > 0 && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Today's Deliveries</Text>
|
|
{earnings.map((e, i) => (
|
|
<View key={e.id} style={styles.earningRow}>
|
|
<View>
|
|
<Text style={styles.earningLabel}>Delivery #{i + 1}</Text>
|
|
{e.is_profit && (
|
|
<View style={styles.profitBadge}>
|
|
<Text style={styles.profitText}>Profit</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View style={styles.earningRight}>
|
|
<Text style={styles.earningAmount}>${Number(e.total).toFixed(2)}</Text>
|
|
{e.tip_amount > 0 && (
|
|
<Text style={styles.tipLine}>+${Number(e.tip_amount).toFixed(2)} tip</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
function StatBox({ label, value, inverted, highlight }: any) {
|
|
return (
|
|
<View style={[styles.statBox, inverted && styles.statBoxInverted]}>
|
|
<Text style={[styles.statValue, inverted && styles.textWhite, highlight && !inverted && { color: '#22C55E' }]}>
|
|
{value}
|
|
</Text>
|
|
<Text style={[styles.statLabel, inverted && styles.textWhiteAlpha]}>{label}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function Row({ label, value, valueColor }: any) {
|
|
return (
|
|
<View style={styles.row}>
|
|
<Text style={styles.rowLabel}>{label}</Text>
|
|
<Text style={[styles.rowValue, valueColor && { color: valueColor }]}>{value}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: { flex: 1, backgroundColor: '#0F172A' },
|
|
scroll: { flex: 1 },
|
|
scrollContent: { padding: 16, gap: 12 },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0F172A' },
|
|
|
|
onlineCard: {
|
|
backgroundColor: '#1E293B', borderRadius: 16, padding: 16,
|
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
|
},
|
|
onlineLabel: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
|
onlineSub: { color: '#94A3B8', fontSize: 12, marginTop: 2 },
|
|
|
|
card: { backgroundColor: '#1E293B', borderRadius: 16, padding: 16 },
|
|
cardGreen: { backgroundColor: '#15803D' },
|
|
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
|
cardTitle: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
|
percent: { color: '#0D9488', fontWeight: '800', fontSize: 18 },
|
|
|
|
progressBg: { height: 8, backgroundColor: '#334155', borderRadius: 4, marginBottom: 12, overflow: 'hidden' },
|
|
progressFill: { height: '100%', backgroundColor: '#0D9488', borderRadius: 4 },
|
|
|
|
statsRow: { flexDirection: 'row', gap: 8, marginBottom: 12 },
|
|
statBox: { flex: 1, backgroundColor: '#0F172A', borderRadius: 12, padding: 10, alignItems: 'center' },
|
|
statBoxInverted: { backgroundColor: 'rgba(255,255,255,0.15)' },
|
|
statValue: { color: '#fff', fontWeight: '800', fontSize: 18 },
|
|
statLabel: { color: '#94A3B8', fontSize: 11, marginTop: 2 },
|
|
|
|
messageBadge: { backgroundColor: '#0F172A', borderRadius: 10, padding: 10, marginBottom: 8 },
|
|
messageBadgeGreen: { backgroundColor: 'rgba(255,255,255,0.15)' },
|
|
messageText: { color: '#94A3B8', fontSize: 13, textAlign: 'center', fontWeight: '600' },
|
|
|
|
tipsText: { color: '#94A3B8', fontSize: 12, textAlign: 'center' },
|
|
textWhite: { color: '#fff' },
|
|
textWhiteAlpha: { color: 'rgba(255,255,255,0.7)' },
|
|
|
|
subtleText: { color: '#64748B', fontSize: 13, marginTop: 4, marginBottom: 12 },
|
|
explainer: { gap: 8 },
|
|
row: { flexDirection: 'row', justifyContent: 'space-between' },
|
|
rowLabel: { color: '#94A3B8', fontSize: 13 },
|
|
rowValue: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
|
|
|
earningRow: {
|
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
|
paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#0F172A',
|
|
},
|
|
earningLabel: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
|
earningRight: { alignItems: 'flex-end' },
|
|
earningAmount: { color: '#0D9488', fontSize: 16, fontWeight: '700' },
|
|
tipLine: { color: '#22C55E', fontSize: 11, marginTop: 2 },
|
|
profitBadge: { backgroundColor: '#14532D', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2, marginTop: 3, alignSelf: 'flex-start' },
|
|
profitText: { color: '#22C55E', fontSize: 10, fontWeight: '700' },
|
|
})
|