- 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>
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react'
|
|
import {
|
|
View, Text, StyleSheet, FlatList, TouchableOpacity,
|
|
ActivityIndicator, Alert,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { io, Socket } from 'socket.io-client'
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
import { api } from '../lib/api'
|
|
|
|
interface AvailableOrder {
|
|
id: string
|
|
order_number: number
|
|
restaurant: { name: string; address: string }
|
|
delivery_address: string
|
|
delivery_fee: number
|
|
tip_amount: number
|
|
distance_km: number
|
|
duration_minutes: number
|
|
item_count: number
|
|
}
|
|
|
|
export default function OrdersScreen() {
|
|
const [available, setAvailable] = useState<AvailableOrder[]>([])
|
|
const [activeOrder, setActiveOrder] = useState<any | null>(null)
|
|
const [connected, setConnected] = useState(false)
|
|
const [accepting, setAccepting] = useState<string | null>(null)
|
|
const socketRef = useRef<Socket | null>(null)
|
|
|
|
useEffect(() => {
|
|
connectSocket()
|
|
return () => { socketRef.current?.disconnect() }
|
|
}, [])
|
|
|
|
const connectSocket = async () => {
|
|
const token = await AsyncStorage.getItem('vibe_token')
|
|
const socket = io(`${process.env.EXPO_PUBLIC_WS_URL}/tracking`, {
|
|
auth: { token },
|
|
transports: ['websocket'],
|
|
})
|
|
socketRef.current = socket
|
|
|
|
socket.on('connect', () => {
|
|
setConnected(true)
|
|
socket.emit('join:zone', 'downtown-toronto')
|
|
})
|
|
|
|
socket.on('disconnect', () => setConnected(false))
|
|
|
|
socket.on('order:available', (order: AvailableOrder) => {
|
|
setAvailable((prev) => prev.some((o) => o.id === order.id) ? prev : [order, ...prev])
|
|
})
|
|
|
|
socket.on('order:taken', ({ orderId }: { orderId: string }) => {
|
|
setAvailable((prev) => prev.filter((o) => o.id !== orderId))
|
|
})
|
|
|
|
socket.on('delivery:assigned', (order: any) => {
|
|
setActiveOrder(order)
|
|
setAvailable([])
|
|
})
|
|
}
|
|
|
|
const acceptOrder = async (orderId: string) => {
|
|
setAccepting(orderId)
|
|
try {
|
|
const { data } = await api.patch(`/orders/${orderId}/assign-driver`)
|
|
setActiveOrder(data)
|
|
setAvailable([])
|
|
} catch (err: any) {
|
|
setAvailable((prev) => prev.filter((o) => o.id !== orderId))
|
|
Alert.alert('Order unavailable', err.response?.data?.message || 'This order was taken by another driver.')
|
|
} finally {
|
|
setAccepting(null)
|
|
}
|
|
}
|
|
|
|
const markPickedUp = () => {
|
|
Alert.alert('Confirm pickup', 'Have you picked up the order from the restaurant?', [
|
|
{ text: 'Not yet', style: 'cancel' },
|
|
{
|
|
text: 'Yes, picked up',
|
|
onPress: async () => {
|
|
await api.patch(`/orders/${activeOrder.id}/pickup`)
|
|
setActiveOrder((o: any) => ({ ...o, status: 'picked_up' }))
|
|
},
|
|
},
|
|
])
|
|
}
|
|
|
|
const markDelivered = () => {
|
|
Alert.alert('Confirm delivery', 'Has the customer received their order?', [
|
|
{ text: 'Not yet', style: 'cancel' },
|
|
{
|
|
text: 'Yes, delivered!',
|
|
onPress: async () => {
|
|
await api.patch(`/orders/${activeOrder.id}/delivered`)
|
|
setActiveOrder(null)
|
|
},
|
|
},
|
|
])
|
|
}
|
|
|
|
if (activeOrder) {
|
|
const earnings = Number(activeOrder.delivery_fee) + Number(activeOrder.tip_amount)
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
|
<View style={styles.activeOrder}>
|
|
<View style={[styles.statusBadge, activeOrder.status === 'picked_up' && styles.statusBadgeAmber]}>
|
|
<Text style={styles.statusText}>
|
|
{activeOrder.status === 'driver_assigned' ? '🏪 Go to Restaurant' : '🏠 Deliver to Customer'}
|
|
</Text>
|
|
</View>
|
|
|
|
<Text style={styles.orderNum}>Order #{activeOrder.order_number}</Text>
|
|
|
|
{activeOrder.status === 'driver_assigned' && (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionLabel}>Pickup from</Text>
|
|
<Text style={styles.sectionTitle}>{activeOrder.restaurant?.name}</Text>
|
|
<Text style={styles.sectionSub}>{activeOrder.restaurant?.address}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionLabel}>Deliver to</Text>
|
|
<Text style={styles.sectionTitle}>{activeOrder.delivery_address}</Text>
|
|
</View>
|
|
|
|
<View style={styles.earningsBox}>
|
|
<Text style={styles.earningsLabel}>This delivery earns you</Text>
|
|
<Text style={styles.earningsAmount}>${earnings.toFixed(2)}</Text>
|
|
{activeOrder.tip_amount > 0 && (
|
|
<Text style={styles.tipLine}>incl. ${Number(activeOrder.tip_amount).toFixed(2)} tip (100% yours)</Text>
|
|
)}
|
|
</View>
|
|
|
|
{activeOrder.status === 'driver_assigned' ? (
|
|
<TouchableOpacity style={styles.btnAmber} onPress={markPickedUp}>
|
|
<Text style={styles.btnText}>I've Picked It Up →</Text>
|
|
</TouchableOpacity>
|
|
) : (
|
|
<TouchableOpacity style={styles.btnGreen} onPress={markDelivered}>
|
|
<Text style={styles.btnText}>Mark as Delivered ✓</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
|
<View style={styles.header}>
|
|
<View style={[styles.dot, connected && styles.dotGreen]} />
|
|
<Text style={styles.headerText}>{connected ? 'Connected — watching for orders' : 'Connecting...'}</Text>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={available}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={styles.list}
|
|
ListEmptyComponent={
|
|
<View style={styles.empty}>
|
|
<Text style={styles.emptyIcon}>📡</Text>
|
|
<Text style={styles.emptyTitle}>Waiting for orders...</Text>
|
|
<Text style={styles.emptySub}>New orders in your zone appear here instantly</Text>
|
|
</View>
|
|
}
|
|
renderItem={({ item }) => (
|
|
<View style={styles.orderCard}>
|
|
<View style={styles.orderTop}>
|
|
<Text style={styles.orderEarnings}>
|
|
${(item.delivery_fee + item.tip_amount).toFixed(2)}
|
|
</Text>
|
|
{item.tip_amount > 0 && (
|
|
<Text style={styles.orderTip}>+${item.tip_amount.toFixed(2)} tip</Text>
|
|
)}
|
|
<View style={styles.orderMeta}>
|
|
<Text style={styles.orderMetaText}>{item.distance_km?.toFixed(1)} km</Text>
|
|
<Text style={styles.orderMetaText}>~{item.duration_minutes} min</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.route}>
|
|
<View style={styles.routeRow}>
|
|
<Text style={styles.routeIcon}>📍</Text>
|
|
<View>
|
|
<Text style={styles.routeLabel}>Pickup</Text>
|
|
<Text style={styles.routeMain}>{item.restaurant.name}</Text>
|
|
<Text style={styles.routeSub}>{item.restaurant.address}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={[styles.routeRow, { marginTop: 8 }]}>
|
|
<Text style={styles.routeIcon}>🏠</Text>
|
|
<View>
|
|
<Text style={styles.routeLabel}>Deliver to</Text>
|
|
<Text style={styles.routeSub}>{item.delivery_address}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={[styles.acceptBtn, accepting === item.id && styles.acceptBtnDisabled]}
|
|
onPress={() => acceptOrder(item.id)}
|
|
disabled={accepting === item.id}
|
|
>
|
|
{accepting === item.id ? (
|
|
<ActivityIndicator color="#fff" size="small" />
|
|
) : (
|
|
<Text style={styles.acceptBtnText}>Accept →</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
/>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: { flex: 1, backgroundColor: '#0F172A' },
|
|
|
|
header: {
|
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
|
paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#1E293B',
|
|
},
|
|
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#475569' },
|
|
dotGreen: { backgroundColor: '#22C55E' },
|
|
headerText: { color: '#94A3B8', fontSize: 13 },
|
|
|
|
list: { padding: 16, gap: 12 },
|
|
empty: { alignItems: 'center', paddingTop: 80 },
|
|
emptyIcon: { fontSize: 48, marginBottom: 12 },
|
|
emptyTitle: { color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 6 },
|
|
emptySub: { color: '#64748B', fontSize: 14, textAlign: 'center' },
|
|
|
|
orderCard: { backgroundColor: '#1E293B', borderRadius: 16, padding: 16 },
|
|
orderTop: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
|
|
orderEarnings: { color: '#22C55E', fontSize: 28, fontWeight: '800' },
|
|
orderTip: { color: '#22C55E', fontSize: 12, flex: 1 },
|
|
orderMeta: { alignItems: 'flex-end' },
|
|
orderMetaText: { color: '#64748B', fontSize: 12 },
|
|
|
|
route: { gap: 0, marginBottom: 14 },
|
|
routeRow: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
|
|
routeIcon: { fontSize: 16 },
|
|
routeLabel: { color: '#64748B', fontSize: 11 },
|
|
routeMain: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
|
routeSub: { color: '#94A3B8', fontSize: 12 },
|
|
|
|
acceptBtn: { backgroundColor: '#22C55E', borderRadius: 12, padding: 14, alignItems: 'center' },
|
|
acceptBtnDisabled: { opacity: 0.5 },
|
|
acceptBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
|
|
// Active order
|
|
activeOrder: { flex: 1, padding: 16, gap: 16 },
|
|
statusBadge: { backgroundColor: '#1E3A5F', borderRadius: 10, padding: 10, alignItems: 'center' },
|
|
statusBadgeAmber: { backgroundColor: '#451A03' },
|
|
statusText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
orderNum: { color: '#64748B', fontSize: 13 },
|
|
section: { backgroundColor: '#1E293B', borderRadius: 12, padding: 14 },
|
|
sectionLabel: { color: '#64748B', fontSize: 11, marginBottom: 4 },
|
|
sectionTitle: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
|
sectionSub: { color: '#94A3B8', fontSize: 13, marginTop: 2 },
|
|
earningsBox: { backgroundColor: '#14532D', borderRadius: 12, padding: 16, alignItems: 'center' },
|
|
earningsLabel: { color: '#86EFAC', fontSize: 13 },
|
|
earningsAmount: { color: '#22C55E', fontSize: 40, fontWeight: '800', marginTop: 4 },
|
|
tipLine: { color: '#86EFAC', fontSize: 12, marginTop: 4 },
|
|
btnAmber: { backgroundColor: '#D97706', borderRadius: 14, padding: 16, alignItems: 'center' },
|
|
btnGreen: { backgroundColor: '#15803D', borderRadius: 14, padding: 16, alignItems: 'center' },
|
|
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
|
})
|