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

231 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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