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

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