- 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>
231 lines
9.3 KiB
TypeScript
231 lines
9.3 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import axios from 'axios'
|
||
import { format, parseISO } from 'date-fns'
|
||
|
||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'
|
||
|
||
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, Math.round((revenue / fee) * 100))
|
||
const broke = revenue >= fee
|
||
return (
|
||
<div className="w-full">
|
||
<div className="flex justify-between text-xs mb-1">
|
||
<span className={broke ? 'text-green-600 font-medium' : 'text-gray-500'}>
|
||
{broke ? 'Profitable!' : `${Math.ceil((fee - revenue) / 5)} deliveries to break even`}
|
||
</span>
|
||
<span className="text-gray-500">{pct}%</span>
|
||
</div>
|
||
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full transition-all ${broke ? 'bg-green-500' : 'bg-amber-400'}`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function DriverEarningsPage() {
|
||
const router = useRouter()
|
||
const [days, setDays] = useState(30)
|
||
const [earnings, setEarnings] = useState<DayEarning[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
const token = localStorage.getItem('token')
|
||
if (!token) { router.push('/login'); return }
|
||
fetchEarnings(token)
|
||
}, [days])
|
||
|
||
const fetchEarnings = async (token: string) => {
|
||
setLoading(true)
|
||
try {
|
||
const { data } = await axios.get(`${API}/drivers/me/earnings?days=${days}`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
setEarnings(data)
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.message || 'Failed to load earnings')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Aggregate stats
|
||
const totals = earnings.reduce(
|
||
(acc, d) => ({
|
||
deliveries: acc.deliveries + (d.deliveries_count || 0),
|
||
grossRevenue: acc.grossRevenue + Number(d.delivery_revenue || 0),
|
||
tips: acc.tips + Number(d.tips_earned || 0),
|
||
netEarnings: acc.netEarnings + Number(d.net_earnings || 0),
|
||
fees: acc.fees + Number(d.daily_fee || 0),
|
||
daysWorked: acc.daysWorked + 1,
|
||
}),
|
||
{ deliveries: 0, grossRevenue: 0, tips: 0, netEarnings: 0, fees: 0, daysWorked: 0 },
|
||
)
|
||
|
||
const avgPerDay = totals.daysWorked > 0 ? totals.netEarnings / totals.daysWorked : 0
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
{/* Header */}
|
||
<div className="bg-gray-900 text-white">
|
||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||
<button onClick={() => router.back()} className="text-gray-400 hover:text-white mb-4 flex items-center gap-1 text-sm">
|
||
← Back
|
||
</button>
|
||
<h1 className="text-2xl font-bold">Earnings History</h1>
|
||
<p className="text-gray-400 text-sm mt-1">Your delivery earnings breakdown</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-4xl mx-auto px-4 py-6 space-y-6">
|
||
{/* Period selector */}
|
||
<div className="flex gap-2">
|
||
{[7, 14, 30, 90].map(d => (
|
||
<button
|
||
key={d}
|
||
onClick={() => setDays(d)}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||
days === d
|
||
? 'bg-teal-600 text-white'
|
||
: 'bg-white text-gray-700 border hover:border-teal-400'
|
||
}`}
|
||
>
|
||
{d === 7 ? '1 week' : d === 14 ? '2 weeks' : d === 30 ? '30 days' : '3 months'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Summary cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-white rounded-xl p-4 shadow-sm border">
|
||
<p className="text-gray-500 text-sm">Net Earnings</p>
|
||
<p className="text-2xl font-bold text-green-600 mt-1">${totals.netEarnings.toFixed(2)}</p>
|
||
<p className="text-gray-400 text-xs mt-1">after $20/day fees</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl p-4 shadow-sm border">
|
||
<p className="text-gray-500 text-sm">Total Deliveries</p>
|
||
<p className="text-2xl font-bold text-gray-900 mt-1">{totals.deliveries}</p>
|
||
<p className="text-gray-400 text-xs mt-1">{totals.daysWorked} days worked</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl p-4 shadow-sm border">
|
||
<p className="text-gray-500 text-sm">Tips Earned</p>
|
||
<p className="text-2xl font-bold text-amber-600 mt-1">${totals.tips.toFixed(2)}</p>
|
||
<p className="text-gray-400 text-xs mt-1">100% yours</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl p-4 shadow-sm border">
|
||
<p className="text-gray-500 text-sm">Avg / Day</p>
|
||
<p className="text-2xl font-bold text-gray-900 mt-1">${avgPerDay.toFixed(2)}</p>
|
||
<p className="text-gray-400 text-xs mt-1">on days worked</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Break-even insight */}
|
||
{totals.daysWorked > 0 && (
|
||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4">
|
||
<p className="text-teal-800 font-medium text-sm">
|
||
Your average: {(totals.deliveries / totals.daysWorked).toFixed(1)} deliveries/day
|
||
— break-even at 4/day ($20 ÷ $5 = 4 deliveries)
|
||
</p>
|
||
<p className="text-teal-700 text-xs mt-1">
|
||
You're earning {Math.round(totals.deliveries / totals.daysWorked) > 4 ? 'above' : 'near'} break-even on average.
|
||
Every delivery after #4 is pure profit.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Day-by-day table */}
|
||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||
<div className="px-4 py-3 border-b">
|
||
<h2 className="font-semibold text-gray-900">Day-by-Day Breakdown</h2>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="p-8 text-center text-gray-400">Loading earnings...</div>
|
||
) : error ? (
|
||
<div className="p-8 text-center text-red-500">{error}</div>
|
||
) : earnings.length === 0 ? (
|
||
<div className="p-8 text-center">
|
||
<p className="text-gray-500">No earnings data for this period</p>
|
||
<p className="text-gray-400 text-sm mt-1">Start delivering to see your earnings here</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y">
|
||
{earnings.map((day) => {
|
||
const net = Number(day.net_earnings || 0)
|
||
const fee = Number(day.daily_fee || 20)
|
||
const revenue = Number(day.delivery_revenue || 0)
|
||
const tips = Number(day.tips_earned || 0)
|
||
const isProfitable = net > 0
|
||
|
||
return (
|
||
<div key={day.session_date} className="px-4 py-4">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div>
|
||
<p className="font-medium text-gray-900">
|
||
{format(parseISO(day.session_date), 'EEE, MMM d')}
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
{day.deliveries_count} deliveries
|
||
{tips > 0 && ` · $${tips.toFixed(2)} tips`}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className={`font-bold text-lg ${isProfitable ? 'text-green-600' : 'text-red-500'}`}>
|
||
{isProfitable ? '+' : ''}${net.toFixed(2)}
|
||
</p>
|
||
<p className="text-xs text-gray-400">
|
||
$${revenue.toFixed(2)} earned − $${fee.toFixed(2)} fee
|
||
{!day.fee_paid && ' (unpaid)'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<BreakEvenBar revenue={revenue} fee={fee} />
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* How earnings work */}
|
||
<div className="bg-gray-900 text-white rounded-xl p-5">
|
||
<h3 className="font-semibold mb-3">How Your Earnings Work</h3>
|
||
<div className="space-y-2 text-sm text-gray-300">
|
||
<div className="flex justify-between">
|
||
<span>Daily access fee</span><span className="font-medium text-white">$20.00</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Per delivery</span><span className="font-medium text-white">$5.00 flat</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Tips</span><span className="font-medium text-white">100% yours</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Commission taken</span><span className="font-medium text-green-400">$0.00</span>
|
||
</div>
|
||
<div className="border-t border-gray-700 pt-2 flex justify-between font-semibold text-white">
|
||
<span>Break-even at</span><span>4 deliveries</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|