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

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