mirror of
https://github.com/EdiFarcas/Car-Fuel-Tracking-App.git
synced 2026-06-28 15:00:42 +03:00
Update app improvment 2. Dashboard, stat, UI. All
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const userEmail = session.user.email!;
|
||||
// Get all cars for the user
|
||||
const cars = await prisma.car.findMany({
|
||||
where: { user: { email: userEmail } },
|
||||
select: { id: true, name: true, make: true, model: true },
|
||||
});
|
||||
const carIds = cars.map((c) => c.id);
|
||||
|
||||
// Get recent fill-ups and mileage entries for all cars
|
||||
const fillUps = await prisma.fillUp.findMany({
|
||||
where: { carId: { in: carIds } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 10,
|
||||
include: { car: { select: { name: true, make: true, model: true } } },
|
||||
});
|
||||
const mileageEntries = await prisma.mileageEntry.findMany({
|
||||
where: { carId: { in: carIds } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 10,
|
||||
include: { car: { select: { name: true, make: true, model: true } } },
|
||||
});
|
||||
|
||||
// Merge and sort by date
|
||||
const activity = [
|
||||
...fillUps.map(f => ({
|
||||
type: 'fillup',
|
||||
id: f.id,
|
||||
car: f.car,
|
||||
mileage: f.mileage,
|
||||
liters: f.liters,
|
||||
cost: f.cost,
|
||||
currency: f.currency,
|
||||
date: f.date,
|
||||
})),
|
||||
...mileageEntries.map(m => ({
|
||||
type: 'mileage',
|
||||
id: m.id,
|
||||
car: m.car,
|
||||
mileage: m.mileage,
|
||||
date: m.date,
|
||||
})),
|
||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
return NextResponse.json(activity.slice(0, 10));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { carId: string } }) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const carId = params.carId;
|
||||
if (!carId) return NextResponse.json({ message: 'Missing carId' }, { status: 400 });
|
||||
|
||||
const car = await prisma.car.findFirst({
|
||||
where: {
|
||||
id: carId,
|
||||
user: { email: session.user?.email! },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
make: true,
|
||||
model: true,
|
||||
year: true,
|
||||
fuelType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!car) return NextResponse.json({ message: 'Car not found' }, { status: 404 });
|
||||
return NextResponse.json(car);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full max-w-2xl text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-gray-900 placeholder:text-gray-500"
|
||||
className="w-full max-w-2xl text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-[var(--background)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50"
|
||||
/>
|
||||
<div className="relative w-full max-w-2xl">
|
||||
<input
|
||||
@@ -47,12 +47,12 @@ export default function LoginPage() {
|
||||
value={password}
|
||||
required
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-gray-900 placeholder:text-gray-500 pr-16"
|
||||
className="w-full text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-[var(--background)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 pr-16"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-gray-500 hover:text-[var(--primary)] focus:outline-none"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-[var(--foreground)]/60 hover:text-[var(--primary)] focus:outline-none"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function RegisterPage() {
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full max-w-2xl text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--secondary)] text-gray-900 placeholder:text-gray-500"
|
||||
className="w-full max-w-2xl text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-[var(--background)] focus:outline-none focus:ring-2 focus:ring-[var(--secondary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50"
|
||||
/>
|
||||
<div className="relative w-full max-w-2xl">
|
||||
<input
|
||||
@@ -45,19 +45,19 @@ export default function RegisterPage() {
|
||||
value={password}
|
||||
required
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--secondary)] text-gray-900 placeholder:text-gray-500 pr-16"
|
||||
className="w-full text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-[var(--background)] focus:outline-none focus:ring-2 focus:ring-[var(--secondary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 pr-16"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-gray-500 hover:text-[var(--secondary)] focus:outline-none"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-[var(--foreground)]/60 hover:text-[var(--secondary)] focus:outline-none"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" className="w-full max-w-2xl bg-[var(--secondary)] text-white py-4 text-lg rounded-lg font-semibold shadow hover:bg-green-700 hover:scale-105 hover:shadow-lg transition-all duration-200">
|
||||
<button type="submit" className="w-full max-w-2xl bg-[var(--secondary)] text-white py-4 text-lg rounded-lg font-semibold shadow hover:bg-[var(--secondary)]/80 hover:scale-105 hover:shadow-lg transition-all duration-200">
|
||||
Register
|
||||
</button>
|
||||
{error && <p className="text-red-500 text-center w-full">{error}</p>}
|
||||
|
||||
@@ -18,6 +18,7 @@ const currencies = ['EUR', 'USD', 'RON', 'GBP'];
|
||||
export default function FillUpsPage() {
|
||||
const { carId } = useParams();
|
||||
const [fillups, setFillups] = useState<FillUp[]>([]);
|
||||
const [carFuelType, setCarFuelType] = useState<string | undefined>(undefined);
|
||||
const [form, setForm] = useState({
|
||||
mileage: '',
|
||||
liters: '',
|
||||
@@ -33,6 +34,13 @@ export default function FillUpsPage() {
|
||||
.then(setFillups);
|
||||
}, [carId]);
|
||||
|
||||
// Fetch car fuel type
|
||||
useEffect(() => {
|
||||
fetch(`/api/cars/${carId}`)
|
||||
.then((res) => res.json())
|
||||
.then((car) => setCarFuelType(car?.fuelType));
|
||||
}, [carId]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
};
|
||||
@@ -56,6 +64,16 @@ export default function FillUpsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Only render fill-ups when carFuelType is loaded
|
||||
if (carFuelType === undefined) {
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Fuel Fill-Ups</h1>
|
||||
<div className="text-center text-[var(--foreground)]/60 py-12">Loading car details...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Fuel Fill-Ups</h1>
|
||||
@@ -76,19 +94,23 @@ export default function FillUpsPage() {
|
||||
<span className="text-xs text-gray-500">Enter the odometer reading at fill-up</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="liters" className="font-medium text-[var(--primary)]">Liters</label>
|
||||
<label htmlFor="liters" className="font-medium text-[var(--primary)]">
|
||||
{carFuelType === 'ELECTRIC' ? 'Kilowatt-hours' : 'Liters'}
|
||||
</label>
|
||||
<input
|
||||
id="liters"
|
||||
name="liters"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g. 45.5"
|
||||
placeholder={carFuelType === 'ELECTRIC' ? 'e.g. 45.5' : 'e.g. 45.5'}
|
||||
value={form.liters}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)]"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">How many liters did you fill?</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{carFuelType === 'ELECTRIC' ? 'How many kilowatt-hours did you charge?' : 'How many liters did you fill?'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="cost" className="font-medium text-[var(--primary)]">Total Cost</label>
|
||||
@@ -127,7 +149,7 @@ export default function FillUpsPage() {
|
||||
|
||||
<ul className="space-y-3">
|
||||
{fillups.map((fill) => (
|
||||
<FillUpCard key={fill.id} fill={fill} /> // ✅
|
||||
<FillUpCard key={fill.id} fill={{ ...fill, car: { fuelType: carFuelType } }} />
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { FaCarSide, FaGasPump, FaCalendarAlt, FaIndustry, FaBolt } from 'react-icons/fa';
|
||||
|
||||
interface CarDetailPageProps {
|
||||
params: {
|
||||
@@ -10,13 +11,14 @@ interface CarDetailPageProps {
|
||||
}
|
||||
|
||||
export default async function CarDetailPage({ params }: CarDetailPageProps) {
|
||||
const resolvedParams = await params;
|
||||
const session = await getSession();
|
||||
if (!session) redirect('/auth/login');
|
||||
|
||||
const userEmail = session.user?.email!;
|
||||
const car = await prisma.car.findFirst({
|
||||
where: {
|
||||
id: params.carId,
|
||||
id: resolvedParams.carId,
|
||||
user: { email: userEmail },
|
||||
},
|
||||
});
|
||||
@@ -25,30 +27,70 @@ export default async function CarDetailPage({ params }: CarDetailPageProps) {
|
||||
return <div className="p-8 text-center text-red-500 text-lg font-semibold">Car not found or unauthorized access.</div>;
|
||||
}
|
||||
|
||||
// Fuel type badge color and icon
|
||||
const fuelMeta: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||
GASOLINE: { color: 'bg-blue-100 text-blue-700', icon: <FaGasPump className="inline mr-1" /> },
|
||||
DIESEL: { color: 'bg-yellow-100 text-yellow-800', icon: <FaGasPump className="inline mr-1" /> },
|
||||
LPG: { color: 'bg-pink-100 text-pink-700', icon: <FaGasPump className="inline mr-1" /> },
|
||||
ELECTRIC: { color: 'bg-green-100 text-green-700', icon: <FaBolt className="inline mr-1 text-green-500" /> },
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto p-8 space-y-6 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-3xl font-bold text-[var(--primary)]">{car.name}</h1>
|
||||
<p className="text-lg text-gray-700">{car.make} {car.model} ({car.year})</p>
|
||||
<p className="text-gray-600">Fuel Type: <span className="font-semibold text-[var(--secondary)]">{car.fuelType}</span></p>
|
||||
<div className="flex flex-wrap gap-4 mt-8">
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/mileage`}
|
||||
className="bg-[var(--primary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-blue-700 transition"
|
||||
>
|
||||
➕ Add Mileage
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/fillups`}
|
||||
className="bg-[var(--secondary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition"
|
||||
>
|
||||
➕ Add Fill-Up
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/stats`}
|
||||
className="bg-[var(--accent)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-purple-800 transition"
|
||||
>
|
||||
📊 View Stats
|
||||
</Link>
|
||||
<main className="max-w-3xl mx-auto p-0 md:p-8 mt-8">
|
||||
<div className="rounded-3xl shadow-2xl bg-[var(--muted)] p-0 md:p-10 flex flex-col gap-10 border border-[var(--border)]">
|
||||
{/* Car Icon & Header */}
|
||||
<div className="flex flex-col items-center gap-3 p-8 rounded-t-3xl bg-[var(--muted)] border-b border-[var(--border)] shadow-sm">
|
||||
<span className="text-6xl text-[var(--primary)] drop-shadow-lg mb-2"><FaCarSide /></span>
|
||||
<h1 className="text-4xl font-extrabold text-[var(--primary)] leading-tight tracking-tight flex items-center gap-3">
|
||||
{car.name}
|
||||
<span className="inline-block px-3 py-1 rounded-full text-base font-bold bg-gray-200 text-gray-700 ml-2">{car.year}</span>
|
||||
</h1>
|
||||
<span className={`mt-2 px-4 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${fuelMeta[car.fuelType]?.color || 'bg-gray-100 text-gray-700'}`}>{fuelMeta[car.fuelType]?.icon}{car.fuelType}</span>
|
||||
</div>
|
||||
{/* Car Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<FaIndustry className="text-2xl text-gray-400" />
|
||||
<span className="font-semibold text-[var(--foreground)]">Make:</span>
|
||||
<span className="ml-1 text-gray-400 font-mono text-lg">{car.make}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaCarSide className="text-2xl text-gray-400" />
|
||||
<span className="font-semibold text-[var(--foreground)]">Model:</span>
|
||||
<span className="ml-1 text-gray-400 font-mono text-lg">{car.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaCalendarAlt className="text-2xl text-gray-400" />
|
||||
<span className="font-semibold text-[var(--foreground)]">Year:</span>
|
||||
<span className="ml-1 text-gray-400 font-mono text-lg">{car.year}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{fuelMeta[car.fuelType]?.icon}
|
||||
<span className="font-semibold text-[var(--foreground)]">Fuel:</span>
|
||||
<span className={`ml-1 px-2 py-1 rounded-full text-xs font-bold ${fuelMeta[car.fuelType]?.color || 'bg-gray-100 text-gray-700'}`}>{car.fuelType}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-4 justify-center px-8 pb-8">
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/mileage`}
|
||||
className="flex items-center gap-2 bg-[var(--primary)] text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:bg-blue-700 hover:scale-[1.03] transition-all"
|
||||
>
|
||||
➕ Add Mileage
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/fillups`}
|
||||
className="flex items-center gap-2 bg-[var(--secondary)] text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:bg-green-700 hover:scale-[1.03] transition-all"
|
||||
>
|
||||
➕ Add Fill-Up
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/stats`}
|
||||
className="flex items-center gap-2 bg-[var(--accent)] text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:bg-purple-800 hover:scale-[1.03] transition-all"
|
||||
>
|
||||
📊 View Stats
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { redirect } from 'next/navigation';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatsPageClient from '@/components/StatsPageClient';
|
||||
|
||||
interface StatsProps {
|
||||
params: {
|
||||
@@ -10,60 +10,28 @@ interface StatsProps {
|
||||
}
|
||||
|
||||
export default async function StatsPage({ params }: StatsProps) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect('/auth/login');
|
||||
let fillUpsWithCarName = [];
|
||||
let error = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) redirect('/auth/login');
|
||||
|
||||
const car = await prisma.car.findFirst({
|
||||
where: {
|
||||
id: params.carId,
|
||||
user: { email: session.user?.email || '' },
|
||||
},
|
||||
include: {
|
||||
fillUps: {
|
||||
orderBy: { mileage: 'asc' },
|
||||
const car = await prisma.car.findFirst({
|
||||
where: {
|
||||
id: params.carId,
|
||||
user: { email: session.user?.email || '' },
|
||||
},
|
||||
},
|
||||
});
|
||||
include: {
|
||||
fillUps: {
|
||||
orderBy: { mileage: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!car) return <div className="p-6">Car not found or unauthorized.</div>;
|
||||
const fillUps = car.fillUps;
|
||||
|
||||
if (fillUps.length < 2) {
|
||||
return (
|
||||
<main className="max-w-xl mx-auto p-8 flex flex-col items-center justify-center bg-[var(--muted)] rounded-xl shadow space-y-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-5xl text-[var(--primary)]">⛽</span>
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Not Enough Data</h1>
|
||||
<p className="text-gray-700 text-center">Add at least 2 fill-ups to see your fuel statistics and insights.</p>
|
||||
<a href={`/dashboard/cars/${params.carId}/fillups`} className="mt-4 inline-block bg-[var(--secondary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition">➕ Add Fill-Up</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
if (!car) throw new Error('Car not found or unauthorized.');
|
||||
fillUpsWithCarName = car.fillUps.map(f => ({ ...f, car: { name: car.name, fuelType: car.fuelType } }));
|
||||
} catch (e: any) {
|
||||
error = e?.message || 'An unexpected error occurred.';
|
||||
}
|
||||
|
||||
// Compute stats
|
||||
const first = fillUps[0];
|
||||
const last = fillUps[fillUps.length - 1];
|
||||
|
||||
const totalDistance = last.mileage - first.mileage;
|
||||
const totalLiters = fillUps.slice(1).reduce((sum, f) => sum + f.liters, 0); // ignore first fill
|
||||
const totalCost = fillUps.slice(1).reduce((sum, f) => sum + f.cost, 0);
|
||||
|
||||
const avgConsumption = (totalLiters / totalDistance) * 100; // L/100km
|
||||
const costPerKm = totalCost / totalDistance;
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-3xl font-bold text-[var(--primary)] flex items-center gap-2">
|
||||
<span role="img" aria-label="stats">📊</span> Fuel Stats
|
||||
</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<StatCard label="Total Distance" value={`${totalDistance.toFixed(0)} km`} icon="🛣️" color="primary" />
|
||||
<StatCard label="Total Fuel Used" value={`${totalLiters.toFixed(2)} L`} icon="⛽" color="secondary" />
|
||||
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${fillUps[0].currency}`} icon="💸" color="accent" />
|
||||
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} L / 100km`} icon="📏" color="primary" />
|
||||
<StatCard label="Average Cost / km" value={`${costPerKm.toFixed(2)} ${fillUps[0].currency}`} icon="💰" color="secondary" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <StatsPageClient fillUps={fillUpsWithCarName} carId={params.carId} error={error} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const fuelTypes = ['GASOLINE', 'DIESEL', 'LPG'];
|
||||
const fuelTypes = ['GASOLINE', 'DIESEL', 'LPG', 'ELECTRIC'];
|
||||
|
||||
export default function AddCarPage() {
|
||||
const router = useRouter();
|
||||
@@ -30,62 +30,93 @@ export default function AddCarPage() {
|
||||
if (res.ok) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
let data = { message: 'Failed to add car' };
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// If response is empty or not JSON, keep default error
|
||||
}
|
||||
setError(data.message || 'Failed to add car');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-lg mx-auto p-8 bg-[var(--muted)] rounded-xl shadow space-y-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Add New Car</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Car Name"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
name="make"
|
||||
value={form.make}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Make (e.g. BMW)"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
name="model"
|
||||
value={form.model}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Model (e.g. 320i)"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
name="year"
|
||||
type="number"
|
||||
value={form.year}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Year"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<select
|
||||
name="fuelType"
|
||||
value={form.fuelType}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
<main className="max-w-lg mx-auto mt-12 p-8 bg-[var(--muted)] border border-[var(--border)] rounded-2xl shadow-xl space-y-8 flex flex-col items-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-4xl text-[var(--primary)]">🚗</span>
|
||||
<h1 className="text-3xl font-extrabold text-[var(--primary)] tracking-tight">Add New Car</h1>
|
||||
<p className="text-[var(--foreground)]/70 text-center text-base">Fill in your car details to start tracking fuel and mileage.</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="w-full space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="block mb-1 font-semibold text-[var(--foreground)]">Car Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="e.g. My BMW"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="make" className="block mb-1 font-semibold text-[var(--foreground)]">Make</label>
|
||||
<input
|
||||
id="make"
|
||||
name="make"
|
||||
value={form.make}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="e.g. BMW"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="model" className="block mb-1 font-semibold text-[var(--foreground)]">Model</label>
|
||||
<input
|
||||
id="model"
|
||||
name="model"
|
||||
value={form.model}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="e.g. 320i"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="year" className="block mb-1 font-semibold text-[var(--foreground)]">Year</label>
|
||||
<input
|
||||
id="year"
|
||||
name="year"
|
||||
value={form.year}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="e.g. 2020"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fuelType" className="block mb-1 font-semibold text-[var(--foreground)]">Fuel Type</label>
|
||||
<select
|
||||
id="fuelType"
|
||||
name="fuelType"
|
||||
value={form.fuelType}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
|
||||
>
|
||||
{fuelTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[var(--primary)] text-white py-3 rounded-lg font-bold shadow-lg hover:bg-[var(--primary)]/80 hover:scale-[1.03] hover:shadow-xl transition-all focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
>
|
||||
{fuelTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="w-full bg-[var(--primary)] text-white py-3 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
|
||||
Save Car
|
||||
</button>
|
||||
{error && <p className="text-red-500 text-center">{error}</p>}
|
||||
{error && <p className="text-red-500 text-center font-semibold">{error}</p>}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
+41
-18
@@ -1,39 +1,62 @@
|
||||
import Link from 'next/link';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import CarCard from '@/components/CarCard';
|
||||
import CarGrid from '@/components/CarGrid';
|
||||
import RecentActivity from '@/components/RecentActivity';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect('/auth/login');
|
||||
|
||||
const userEmail = session.user.email!;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
include: { cars: true },
|
||||
// Fetch cars with last fill-up and mileage entries
|
||||
const cars = await prisma.car.findMany({
|
||||
where: { user: { email: userEmail } },
|
||||
include: {
|
||||
fillUps: { orderBy: { date: 'desc' }, take: 1 },
|
||||
mileage: { orderBy: { date: 'desc' }, take: 5 }, // fixed: use 'mileage' not 'mileageEntries'
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-8 max-w-5xl mx-auto p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<h1 className="text-3xl font-bold text-[var(--primary)]">Your Cars</h1>
|
||||
<Link href="/dashboard/cars/new" className="bg-[var(--primary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
|
||||
<main className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
{/* Personalized Greeting */}
|
||||
<div className="flex items-center gap-4 bg-[var(--muted)] rounded-xl p-6 mb-4 border border-[var(--border)] shadow">
|
||||
<span className="w-12 h-12 flex items-center justify-center rounded-full bg-[var(--primary)] text-white font-bold text-2xl border-2 border-[var(--border)]">
|
||||
{session.user?.name?.[0]?.toUpperCase() || session.user?.email?.[0]?.toUpperCase() || 'U'}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--primary)]">Welcome back{session.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}!</h2>
|
||||
<p className="text-[var(--foreground)]/60 text-sm">Ready to track your next fill-up?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Your Cars</h1>
|
||||
<Link href="/dashboard/cars/new" className="bg-[var(--primary)] text-white px-4 py-2 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]">
|
||||
+ Add Car
|
||||
</Link>
|
||||
</div>
|
||||
{user?.cars.length ? (
|
||||
<ul className="grid gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{user.cars.map((car) => (
|
||||
<CarCard key={car.id} car={car} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{cars.length ? (
|
||||
<CarGrid cars={cars} />
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="mb-4">No cars added yet.</p>
|
||||
<Link href="/dashboard/cars/new" className="text-[var(--primary)] underline hover:text-blue-700">Add your first car</Link>
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-[var(--foreground)]/40" role="status" aria-live="polite">
|
||||
<span className="text-5xl" aria-hidden="true">🚗</span>
|
||||
<div className="font-semibold">No cars added yet.</div>
|
||||
<div className="text-sm">Add your first car to get started!</div>
|
||||
<Link href="/dashboard/cars/new" className="mt-4 bg-[var(--primary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-[var(--primary)]/80 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]">
|
||||
+ Add Car
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-bold text-[var(--primary)] mb-4">Recent Activity</h2>
|
||||
<RecentActivity />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+21
-14
@@ -8,22 +8,29 @@
|
||||
--accent: #a21caf; /* purple-700 */
|
||||
--muted: #f3f4f6; /* gray-100 */
|
||||
--border: #e5e7eb; /* gray-200 */
|
||||
--statcard-primary-bg: #e0e7ff; /* indigo-100 */
|
||||
--statcard-primary-border: #2563eb; /* blue-600 */
|
||||
--statcard-secondary-bg: #dcfce7; /* green-100 */
|
||||
--statcard-secondary-border: #22c55e; /* green-500 */
|
||||
--statcard-accent-bg: #f3e8ff; /* purple-100 */
|
||||
--statcard-accent-border: #a21caf; /* purple-700 */
|
||||
--statcard-default-bg: var(--background);
|
||||
--statcard-default-border: var(--border);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--muted: #171717;
|
||||
--border: #232323;
|
||||
}
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--muted: #171717;
|
||||
--border: #232323;
|
||||
--statcard-primary-bg: #1e293b; /* slate-800 */
|
||||
--statcard-primary-border: #2563eb; /* blue-600 */
|
||||
--statcard-secondary-bg: #052e16; /* green-950 */
|
||||
--statcard-secondary-border: #22c55e; /* green-500 */
|
||||
--statcard-accent-bg: #3b0764; /* purple-950 */
|
||||
--statcard-accent-border: #a21caf; /* purple-700 */
|
||||
--statcard-default-bg: var(--background);
|
||||
--statcard-default-border: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -24,8 +24,24 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// This script will set the correct theme class on <html> before hydration
|
||||
const setThemeScript = `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: setThemeScript }} />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--background)] text-[var(--foreground)]`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
export type Achievement = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
achieved: boolean;
|
||||
};
|
||||
|
||||
export default function Achievements({ achievements }: { achievements: Achievement[] }) {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-6 w-full max-w-3xl mx-auto"
|
||||
aria-label="Achievements"
|
||||
>
|
||||
{achievements.map(a => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`flex flex-col items-center p-2 rounded-lg shadow border transition-all duration-200 w-full min-w-0 min-h-20 ${a.achieved ? 'bg-[var(--background)] border-[var(--primary)]' : 'bg-[var(--muted)] border-[var(--border)] opacity-60'}`}
|
||||
aria-label={a.achieved ? `${a.label} unlocked` : `${a.label} locked`}
|
||||
>
|
||||
<span className={`text-xl mb-0.5 ${a.achieved ? 'text-[var(--primary)]' : 'text-[var(--foreground)]/40'}`}>{a.icon}</span>
|
||||
<span className="font-bold text-xs text-center mb-0.5 break-words leading-tight">{a.label}</span>
|
||||
<span className="text-[10px] text-center text-[var(--foreground)]/70 break-words leading-tight">{a.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function AchievementsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 mb-6 animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center p-2 rounded-lg shadow border w-32 h-20 bg-[var(--muted)] border-[var(--border)] opacity-60">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--border)] mb-1" />
|
||||
<div className="h-3 w-3/4 bg-[var(--border)] rounded mb-0.5" />
|
||||
<div className="h-2 w-2/3 bg-[var(--border)] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,44 @@
|
||||
import { Car } from '@prisma/client';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function CarCard({ car }: { car: Car }) {
|
||||
// Removed unused Car import and suppressed 'any' type warning for pragmatic dashboard use
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function CarCard({ car }: { car: any }) {
|
||||
const lastFill = car.fillUps?.[0];
|
||||
const mileageEntries = car.mileage || [];
|
||||
const avgMileage = mileageEntries.length > 1
|
||||
? Math.round((mileageEntries[0].mileage - mileageEntries[mileageEntries.length - 1].mileage) / (mileageEntries.length - 1))
|
||||
: null;
|
||||
|
||||
function getFuelBadgeColor(fuelType: string) {
|
||||
switch (fuelType) {
|
||||
case 'GASOLINE': return 'bg-blue-200 text-blue-800 dark:bg-blue-400 dark:text-blue-900';
|
||||
case 'DIESEL': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-300 dark:text-yellow-900';
|
||||
case 'LPG': return 'bg-purple-100 text-purple-800 dark:bg-purple-300 dark:text-purple-900';
|
||||
case 'ELECTRIC': return 'bg-green-100 text-green-800 dark:bg-green-300 dark:text-green-900';
|
||||
default: return 'bg-gray-200 text-gray-700 dark:bg-gray-400 dark:text-gray-900';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/cars/${car.id}`} className="block group">
|
||||
<div className="border border-[var(--border)] rounded-xl p-6 shadow-sm bg-white group-hover:shadow-lg transition flex flex-col gap-2 h-full">
|
||||
<h2 className="text-xl font-bold text-[var(--primary)] group-hover:underline">{car.name}</h2>
|
||||
<p className="text-gray-700">{car.make} {car.model} <span className="text-gray-400">({car.year})</span></p>
|
||||
<p className="text-sm text-[var(--secondary)] mt-1 font-semibold">Fuel Type: {car.fuelType}</p>
|
||||
<Link href={`/dashboard/cars/${car.id}`}>
|
||||
<div className="border border-[var(--border)] rounded-xl p-4 shadow-sm hover:shadow-md transition bg-[var(--muted)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-[var(--primary)]">{car.name}</h2>
|
||||
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-bold ${getFuelBadgeColor(car.fuelType)}`}>{car.fuelType}</span>
|
||||
</div>
|
||||
<p className="text-[var(--foreground)]/80">{car.make} {car.model} ({car.year})</p>
|
||||
<div className="mt-3 text-sm text-[var(--foreground)]/80">
|
||||
{lastFill ? (
|
||||
<div>Last Fill-Up: <span className="font-semibold">{new Date(lastFill.date).toLocaleDateString()}</span> <span className="ml-2">({lastFill.mileage} km)</span></div>
|
||||
) : (
|
||||
<div className="opacity-60">Last Fill-Up: Not enough data</div>
|
||||
)}
|
||||
{avgMileage !== null ? (
|
||||
<div>Avg. Mileage: <span className="font-semibold">{avgMileage} km</span></div>
|
||||
) : (
|
||||
<div className="opacity-60">Avg. Mileage: Not enough data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useState, useMemo } from 'react';
|
||||
import CarCard from '@/components/CarCard';
|
||||
|
||||
const fuelTypes = ['ALL', 'GASOLINE', 'DIESEL', 'LPG', 'ELECTRIC'];
|
||||
const sortOptions = [
|
||||
{ value: 'recent', label: 'Most Recent' },
|
||||
{ value: 'name', label: 'Name (A-Z)' },
|
||||
{ value: 'year', label: 'Year (Newest)' },
|
||||
];
|
||||
|
||||
export default function CarGrid({ cars }: { cars: any[] }) {
|
||||
const [fuel, setFuel] = useState('ALL');
|
||||
const [sort, setSort] = useState('recent');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let filtered = fuel === 'ALL' ? cars : cars.filter((c) => c.fuelType === fuel);
|
||||
if (sort === 'name') filtered = [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
else if (sort === 'year') filtered = [...filtered].sort((a, b) => b.year - a.year);
|
||||
else filtered = [...filtered].sort((a, b) => b.id.localeCompare(a.id)); // fallback: recent
|
||||
return filtered;
|
||||
}, [cars, fuel, sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-4 mb-4 items-center">
|
||||
<label htmlFor="fuel-filter" className="font-medium text-sm">Fuel:</label>
|
||||
<select id="fuel-filter" value={fuel} onChange={e => setFuel(e.target.value)} className="border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">
|
||||
{fuelTypes.map((type) => (
|
||||
<option key={type} value={type}>{type.charAt(0) + type.slice(1).toLowerCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor="sort-cars" className="font-medium text-sm ml-4">Sort by:</label>
|
||||
<select id="sort-cars" value={sort} onChange={e => setSort(e.target.value)} className="border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{filtered.length ? (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 md:grid-cols-3" role="list" aria-label="Your cars">
|
||||
{filtered.map((car) => (
|
||||
<li key={car.id} className="focus-within:ring-2 focus-within:ring-[var(--primary)]" tabIndex={-1}>
|
||||
<CarCard car={car} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-gray-400" role="status" aria-live="polite">
|
||||
<span className="text-5xl" aria-hidden="true">🚗</span>
|
||||
<div className="font-semibold">No cars match your filter.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function ChartSkeleton() {
|
||||
return (
|
||||
<div className="w-full h-72 bg-[var(--muted)] rounded-xl p-4 shadow border border-[var(--border)] flex items-center justify-center animate-pulse mb-6">
|
||||
<div className="w-3/4 h-2/3 bg-[var(--border)] rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
export default function ClientNavbar() {
|
||||
const { data: session } = useSession();
|
||||
@@ -16,7 +17,8 @@ export default function ClientNavbar() {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition hover:bg-[var(--primary)] hover:text-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] ${isActive ? "underline text-[var(--primary)]" : "text-[var(--foreground)]"}`}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition border border-[var(--primary)] shadow-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]
|
||||
${isActive ? "bg-[var(--primary)] text-white" : "bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{children}
|
||||
@@ -34,13 +36,14 @@ export default function ClientNavbar() {
|
||||
<div className="hidden md:flex gap-4 items-center">
|
||||
<NavLink href="/dashboard">Dashboard</NavLink>
|
||||
{isLoggedIn && <NavLink href="/dashboard/cars/new">Add Car</NavLink>}
|
||||
<ThemeToggle />
|
||||
{isLoggedIn ? (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="w-9 h-9 rounded-full bg-[var(--primary)] text-white flex items-center justify-center font-bold text-lg border-2 border-[var(--border)] shadow" title={session.user?.name || session.user?.email || undefined}>{userInitial}</span>
|
||||
<form action="/api/auth/signout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-purple-800 transition focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-[var(--accent)]/80 transition focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
@@ -49,7 +52,7 @@ export default function ClientNavbar() {
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/login" className="px-4 py-2 rounded-lg border border-[var(--primary)] text-[var(--primary)] font-semibold hover:bg-[var(--primary)] hover:text-white transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Login</Link>
|
||||
<Link href="/auth/register" className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-blue-700 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
|
||||
<Link href="/auth/register" className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-[var(--primary)]/80 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -72,7 +75,7 @@ export default function ClientNavbar() {
|
||||
<form action="/api/auth/signout" method="post" className="w-full">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-purple-800 transition mt-2 focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
className="w-full px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-[var(--accent)]/80 transition mt-2 focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
@@ -81,7 +84,7 @@ export default function ClientNavbar() {
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/login" className="w-full px-4 py-2 rounded-lg border border-[var(--primary)] text-[var(--primary)] font-semibold hover:bg-[var(--primary)] hover:text-white transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Login</Link>
|
||||
<Link href="/auth/register" className="w-full px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-blue-700 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
|
||||
<Link href="/auth/register" className="w-full px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-[var(--primary)]/80 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { FillUp } from '@prisma/client';
|
||||
|
||||
export default function FillUpCard({ fill }: { fill: FillUp }) {
|
||||
export default function FillUpCard({ fill }: { fill: any }) {
|
||||
// If car.fuelType is ELECTRIC, show kWh instead of L
|
||||
const isElectric = fill.car?.fuelType === 'ELECTRIC';
|
||||
return (
|
||||
<div className="border rounded p-3 bg-white shadow-sm">
|
||||
<div className="font-semibold text-black">{fill.mileage} km - {fill.liters} L</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="border border-[var(--border)] rounded p-3 bg-[var(--background)] shadow-sm">
|
||||
<div className="font-semibold text-[var(--foreground)]">
|
||||
{fill.mileage} km - {fill.liters} {isElectric ? 'kWh' : 'L'}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--foreground)]/60">
|
||||
{fill.cost} {fill.currency} — {new Date(fill.date).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||
import InfoTooltip from "./InfoTooltip";
|
||||
import { Units } from './UnitsToggle';
|
||||
|
||||
interface FillUp {
|
||||
date: string;
|
||||
mileage: number;
|
||||
liters: number;
|
||||
cost: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function kmToMiles(km: number) { return km * 0.621371; }
|
||||
function litersToGallons(l: number) { return l * 0.264172; }
|
||||
function lPer100kmToMpg(l100: number) { return l100 > 0 ? 235.215 / l100 : 0; }
|
||||
|
||||
export default function FuelStatsChart({ fillUps, units = 'metric' }: { fillUps: (FillUp & { car?: { fuelType?: string } })[]; units?: Units }) {
|
||||
if (!fillUps || fillUps.length < 2) return null;
|
||||
|
||||
// Get fuel type from first fill-up
|
||||
const fuelType = fillUps[0]?.car?.fuelType;
|
||||
const isElectric = fuelType === 'ELECTRIC';
|
||||
// Always use metric for electric cars
|
||||
const displayUnits = isElectric ? 'metric' : units;
|
||||
|
||||
// Prepare data: skip first fill (no consumption calc)
|
||||
const chartData = fillUps.slice(1).map((f, i) => {
|
||||
const prev = fillUps[i];
|
||||
const distance = displayUnits === 'imperial' ? kmToMiles(f.mileage - prev.mileage) : f.mileage - prev.mileage;
|
||||
const liters = displayUnits === 'imperial' ? litersToGallons(f.liters) : f.liters;
|
||||
const consumption = isElectric
|
||||
? (liters / distance) * 100
|
||||
: (displayUnits === 'imperial'
|
||||
? lPer100kmToMpg((f.liters / (f.mileage - prev.mileage)) * 100)
|
||||
: (liters / distance) * 100);
|
||||
return {
|
||||
date: new Date(f.date).toLocaleDateString(),
|
||||
consumption: Number.isFinite(consumption) ? Number(consumption.toFixed(2)) : 0,
|
||||
cost: f.cost,
|
||||
mileage: displayUnits === 'imperial' ? kmToMiles(f.mileage) : f.mileage,
|
||||
};
|
||||
});
|
||||
|
||||
// Chart title with info icon
|
||||
const chartTitle = (
|
||||
<span className="flex items-center gap-1">
|
||||
Fuel Consumption Over Time
|
||||
<InfoTooltip
|
||||
label="Chart info"
|
||||
description={`This chart shows your car's fuel consumption trend over time, calculated from your fill-up records. Units: ${isElectric ? 'kWh/100km (lower is better)' : (displayUnits === 'imperial' ? 'MPG (higher is better)' : 'L/100km (lower is better)')}.`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-72 bg-[var(--muted)] rounded-xl p-4 shadow border border-[var(--border)] flex flex-col">
|
||||
<h2 className="text-lg font-bold mb-2 text-[var(--primary)] flex items-center justify-center">{chartTitle}</h2>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 30, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="date" stroke="var(--foreground)" fontSize={12} />
|
||||
<YAxis
|
||||
stroke="var(--foreground)"
|
||||
fontSize={12}
|
||||
domain={[0, 'auto']}
|
||||
tickFormatter={v => v}
|
||||
label={{ value: isElectric ? 'kWh/100km' : (displayUnits === 'imperial' ? 'MPG' : 'L/100km'), angle: -90, position: 'insideLeft', offset: 10 }}
|
||||
/>
|
||||
<RechartsTooltip contentStyle={{ background: 'var(--background)', color: 'var(--foreground)', border: '1px solid var(--border)' }} />
|
||||
<Line type="monotone" dataKey="consumption" stroke="#2563eb" strokeWidth={2} dot={{ r: 4, fill: '#2563eb' }} activeDot={{ r: 6 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Y-axis info icon for screen readers, visually above chart */}
|
||||
<div className="sr-only" aria-live="polite">Fuel consumption is measured in {isElectric ? 'kilowatt-hours per 100 kilometers (kWh/100km)' : (displayUnits === 'imperial' ? 'miles per gallon (MPG)' : 'liters per 100 kilometers (L/100km)')}.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface InfoTooltipProps {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function InfoTooltip({ label, description }: InfoTooltipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const iconRef = useRef<HTMLButtonElement>(null);
|
||||
const tooltipId = `tooltip-${label.replace(/\s+/g, '-')}`;
|
||||
|
||||
// Close tooltip on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-block align-middle">
|
||||
<button
|
||||
ref={iconRef}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-describedby={open ? tooltipId : undefined}
|
||||
tabIndex={0}
|
||||
className="ml-1 text-[var(--primary)] hover:text-[var(--accent)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] rounded-full"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none"/><text x="10" y="15" textAnchor="middle" fontSize="12" fill="currentColor" fontFamily="Arial" fontWeight="bold">i</text></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className="absolute z-50 left-1/2 -translate-x-1/2 mt-2 w-56 bg-[var(--background)] text-[var(--foreground)] border border-[var(--border)] rounded-lg shadow-lg p-3 text-sm"
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<main className="max-w-4xl mx-auto p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ActivityItem {
|
||||
type: 'fillup' | 'mileage';
|
||||
id: string;
|
||||
car: { name: string; make: string; model: string };
|
||||
mileage: number;
|
||||
liters?: number;
|
||||
cost?: number;
|
||||
currency?: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export default function RecentActivity() {
|
||||
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/activity')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setActivity(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-[var(--foreground)]/40 flex flex-col items-center gap-2 py-8" role="status" aria-live="polite"><span className="text-4xl" aria-hidden="true">⏳</span>Loading activity...</div>;
|
||||
if (!activity.length) return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-[var(--foreground)]/40" role="status" aria-live="polite">
|
||||
<span className="text-4xl" aria-hidden="true">📋</span>
|
||||
<div className="font-semibold">No recent activity yet.</div>
|
||||
<div className="text-sm">Start by logging a fill-up or mileage entry!</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className="space-y-3" role="list" aria-label="Recent activity">
|
||||
{activity.map((item) => (
|
||||
<li key={item.type + item.id} className="flex items-center gap-3 bg-[var(--muted)] rounded-lg p-4 border border-[var(--border)] shadow-sm focus-within:ring-2 focus-within:ring-[var(--primary)]" tabIndex={0} aria-label={`${item.type === 'fillup' ? 'Fill-Up' : 'Mileage'} for ${item.car.name}`}>
|
||||
<span className={`text-2xl ${item.type === 'fillup' ? 'text-[var(--secondary)]' : 'text-[var(--primary)]'}`} aria-hidden="true">{item.type === 'fillup' ? '⛽' : '🛣️'}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-[var(--foreground)]">
|
||||
{item.type === 'fillup' ? 'Fill-Up' : 'Mileage'} for <span className="text-[var(--primary)]">{item.car.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--foreground)]/60">
|
||||
{item.type === 'fillup'
|
||||
? `${item.liters} L, ${item.mileage} km${item.cost ? `, ${item.cost} ${item.currency}` : ''}`
|
||||
: `${item.mileage} km`}
|
||||
{' '}on {new Date(item.date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,44 @@
|
||||
export default function StatCard({ label, value, icon, color }: { label: string; value: string; icon?: string; color?: 'primary' | 'secondary' | 'accent' }) {
|
||||
import { useState } from "react";
|
||||
import InfoTooltip from "./InfoTooltip";
|
||||
|
||||
export default function StatCard({ label, value, icon, color, info }: { label: string; value: string; icon?: string; color?: 'primary' | 'secondary' | 'accent'; info?: string }) {
|
||||
const valueClass =
|
||||
label === 'Total Fuel Cost'
|
||||
? 'text-gray-900'
|
||||
? 'text-[var(--foreground)]'
|
||||
: color
|
||||
? `text-[var(--${color})]`
|
||||
: 'text-[var(--foreground)]';
|
||||
|
||||
// Determine card color scheme
|
||||
let cardBg = 'var(--statcard-default-bg)';
|
||||
let cardBorder = 'var(--statcard-default-border)';
|
||||
if (color === 'primary') {
|
||||
cardBg = 'var(--statcard-primary-bg)';
|
||||
cardBorder = 'var(--statcard-primary-border)';
|
||||
} else if (color === 'secondary') {
|
||||
cardBg = 'var(--statcard-secondary-bg)';
|
||||
cardBorder = 'var(--statcard-secondary-border)';
|
||||
} else if (color === 'accent') {
|
||||
cardBg = 'var(--statcard-accent-bg)';
|
||||
cardBorder = 'var(--statcard-accent-border)';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 shadow-sm bg-white flex items-center gap-4">
|
||||
{icon && <span className={`text-2xl ${color ? `text-[var(--${color})]` : ''}`}>{icon}</span>}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className={`text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||
<div
|
||||
className="rounded-xl p-4 shadow-sm flex flex-col justify-center w-full"
|
||||
style={{ background: cardBg, border: `1.5px solid ${cardBorder}` }}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center w-full gap-4">
|
||||
{icon && <span className={`text-2xl ${color ? `text-[var(--${color})]` : ''}`}>{icon}</span>}
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<p className="text-sm text-[var(--foreground)]/60 flex items-center gap-1 text-center w-full justify-center">
|
||||
{label}
|
||||
{info && (
|
||||
<InfoTooltip label={`Info: ${label}`} description={info} />
|
||||
)}
|
||||
</p>
|
||||
<p className={`text-2xl font-semibold ${valueClass} text-center w-full`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Units } from './UnitsToggle';
|
||||
|
||||
export default function StatsExportButton({ fillUps, units, isElectric: isElectricProp }: { fillUps: { car?: { fuelType?: string; name?: string }; mileage: number; liters: number; cost: number; currency: string; date: string | Date }[]; units: Units; isElectric?: boolean }) {
|
||||
function kmToMiles(km: number) { return km * 0.621371; }
|
||||
function litersToGallons(l: number) { return l * 0.264172; }
|
||||
|
||||
// Determine if electric from prop or fillUps
|
||||
const isElectric = isElectricProp ?? (fillUps[0]?.car?.fuelType === 'ELECTRIC');
|
||||
|
||||
function handleExport() {
|
||||
if (!fillUps?.length) return;
|
||||
// Only export selected fields: car name, mileage, liters/gallons/kWh, cost, currency, date
|
||||
const headers = [
|
||||
"Car Name",
|
||||
isElectric ? "Mileage (km)" : (units === 'imperial' ? "Mileage (mi)" : "Mileage (km)"),
|
||||
isElectric ? "kWh" : (units === 'imperial' ? "Gallons" : "Liters"),
|
||||
"Cost",
|
||||
"Currency",
|
||||
"Date"
|
||||
];
|
||||
const rows = fillUps.map(f => [
|
||||
f.car?.name || '',
|
||||
isElectric ? f.mileage : (units === 'imperial' ? kmToMiles(f.mileage).toFixed(1) : f.mileage),
|
||||
isElectric ? f.liters : (units === 'imperial' ? litersToGallons(f.liters).toFixed(2) : f.liters),
|
||||
f.cost,
|
||||
f.currency,
|
||||
f.date instanceof Date ? f.date.toISOString() : f.date
|
||||
]);
|
||||
const csv = [headers.join(","), ...rows.map(r => r.join(","))].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `car-stats-${isElectric ? 'electric' : units}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="px-4 py-2 rounded-lg border border-[var(--primary)] font-semibold shadow-sm transition bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] mb-4 ml-2"
|
||||
onClick={handleExport}
|
||||
type="button"
|
||||
aria-label="Export stats as CSV"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
import FuelStatsChart from '@/components/FuelStatsChart';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatsTimeRangeFilter from '@/components/StatsTimeRangeFilter';
|
||||
import StatsExportButton from '@/components/StatsExportButton';
|
||||
import UnitsToggle, { Units } from '@/components/UnitsToggle';
|
||||
import Achievements, { Achievement } from '@/components/Achievements';
|
||||
import StatsSkeleton from '@/components/StatsSkeleton';
|
||||
import AchievementsSkeleton from '@/components/AchievementsSkeleton';
|
||||
import ChartSkeleton from '@/components/ChartSkeleton';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface FillUp {
|
||||
id: string;
|
||||
mileage: number;
|
||||
liters: number;
|
||||
cost: number;
|
||||
currency: string;
|
||||
date: string | Date;
|
||||
car?: { fuelType?: string };
|
||||
}
|
||||
|
||||
export default function StatsPageClient({ fillUps, carId, error, loading = false }: { fillUps: FillUp[]; carId: string; error?: string; loading?: boolean }) {
|
||||
const [range, setRange] = useState<number | null>(30);
|
||||
const [units, setUnits] = useState<Units>('metric');
|
||||
const now = useMemo(() => new Date(), []);
|
||||
const filtered = useMemo(() => {
|
||||
if (!range) return fillUps;
|
||||
return fillUps.filter(f => {
|
||||
const d = typeof f.date === 'string' ? new Date(f.date) : f.date;
|
||||
return d >= new Date(now.getTime() - (range ?? 30) * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
}, [fillUps, range, now]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto p-4 sm:p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-8 px-2 sm:px-8 lg:px-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-7xl mb-2 opacity-80" role="img" aria-label="Error">❌</span>
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Oops, Something Went Wrong</h1>
|
||||
<p className="text-[var(--foreground)]/80 text-center max-w-xs">{error}</p>
|
||||
<a href={`/dashboard/cars/${carId}/fillups`} className="mt-4 inline-block bg-[var(--primary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-[var(--secondary)] transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">➕ Add Your First Fill-Up</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto p-4 sm:p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-8 px-2 sm:px-8 lg:px-16">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
||||
<div className="h-10 w-40 bg-[var(--border)] rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<div className="h-10 w-32 bg-[var(--border)] rounded-full animate-pulse" />
|
||||
<div className="h-10 w-32 bg-[var(--border)] rounded-full animate-pulse" />
|
||||
</div>
|
||||
<ChartSkeleton />
|
||||
<StatsSkeleton />
|
||||
<AchievementsSkeleton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Comparative insights logic
|
||||
const last30 = filtered;
|
||||
const prev30 = fillUps.filter(f => {
|
||||
const d = typeof f.date === 'string' ? new Date(f.date) : f.date;
|
||||
return d < new Date(now.getTime() - (range ?? 30) * 24 * 60 * 60 * 1000) && d >= new Date(now.getTime() - 2 * (range ?? 30) * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
function avgCons(list: FillUp[]) {
|
||||
if (list.length < 2) return null;
|
||||
let totalDist = 0, totalLit = 0;
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
totalDist += list[i].mileage - list[i - 1].mileage;
|
||||
totalLit += list[i].liters;
|
||||
}
|
||||
return totalDist > 0 ? (totalLit / totalDist) * 100 : null;
|
||||
}
|
||||
const avgLast30 = avgCons(last30);
|
||||
const avgPrev30 = avgCons(prev30);
|
||||
let comparison = null;
|
||||
if (avgLast30 && avgPrev30) {
|
||||
const diff = avgLast30 - avgPrev30;
|
||||
const percent = (diff / avgPrev30) * 100;
|
||||
comparison = {
|
||||
improved: diff < 0,
|
||||
percent: Math.abs(percent).toFixed(1),
|
||||
diff: diff.toFixed(2),
|
||||
};
|
||||
}
|
||||
let insight = '';
|
||||
if (comparison) {
|
||||
if (comparison.improved) {
|
||||
insight = `Great job! Your average fuel consumption improved by ${comparison.percent}% over the selected period.`;
|
||||
} else if (Number(comparison.percent) > 0) {
|
||||
insight = `Heads up! Your average fuel consumption increased by ${comparison.percent}% over the selected period.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats calculations
|
||||
if (filtered.length < 2) {
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center py-16 px-2 min-h-[60vh]">
|
||||
<div className="w-full max-w-md mx-auto bg-[var(--muted)] rounded-xl shadow p-8 flex flex-col items-center">
|
||||
<span className="text-7xl mb-2 opacity-80" role="img" aria-label="No stats">📉</span>
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)] text-center">No Stats Yet</h1>
|
||||
<p className="text-[var(--foreground)]/80 text-center max-w-xs mb-4">Add at least 2 fill-ups to unlock your personalized fuel stats, insights, and achievements. Start tracking your car’s journey today!</p>
|
||||
<a href={`/dashboard/cars/${carId}/fillups`} className="mt-4 inline-block bg-[var(--primary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-[var(--secondary)] transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">➕ Add Your First Fill-Up</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Conversion helpers
|
||||
function kmToMiles(km: number) { return km * 0.621371; }
|
||||
function litersToGallons(l: number) { return l * 0.264172; }
|
||||
function lPer100kmToMpg(l100: number) { return l100 > 0 ? 235.215 / l100 : 0; }
|
||||
|
||||
const first = filtered[0];
|
||||
const last = filtered[filtered.length - 1];
|
||||
const totalDistance = units === 'imperial' ? kmToMiles(last.mileage - first.mileage) : last.mileage - first.mileage;
|
||||
const totalLiters = filtered.slice(1).reduce((sum, f) => sum + (units === 'imperial' ? litersToGallons(f.liters) : f.liters), 0);
|
||||
const totalCost = filtered.slice(1).reduce((sum, f) => sum + f.cost, 0);
|
||||
const avgConsumption = (() => {
|
||||
const dist = last.mileage - first.mileage;
|
||||
const lit = filtered.slice(1).reduce((sum, f) => sum + f.liters, 0);
|
||||
if (units === 'imperial') {
|
||||
return lPer100kmToMpg((lit / dist) * 100);
|
||||
} else {
|
||||
return (lit / dist) * 100;
|
||||
}
|
||||
})();
|
||||
const costPerDist = totalCost / (units === 'imperial' ? kmToMiles(last.mileage - first.mileage) : last.mileage - first.mileage);
|
||||
const chartFillUps = filtered.map(f => ({ ...f, date: typeof f.date === 'string' ? f.date : f.date.toISOString() }));
|
||||
|
||||
// Achievement logic
|
||||
const numFillUps = filtered.length;
|
||||
const totalKm = last.mileage - first.mileage;
|
||||
const bestEfficiency = Math.min(...filtered.slice(1).map((f, i) => {
|
||||
if (i === 0) return Infinity;
|
||||
const dist = filtered[i].mileage - filtered[i - 1].mileage;
|
||||
return dist > 0 ? (filtered[i].liters / dist) * 100 : Infinity;
|
||||
}));
|
||||
const achievements: Achievement[] = [
|
||||
{
|
||||
id: 'first-fill',
|
||||
label: 'First Fill-Up',
|
||||
description: 'Logged your first fill-up!',
|
||||
icon: '⛽',
|
||||
achieved: numFillUps >= 1,
|
||||
},
|
||||
{
|
||||
id: 'ten-fills',
|
||||
label: '10 Fill-Ups',
|
||||
description: 'Logged 10 fill-ups!',
|
||||
icon: '🔟',
|
||||
achieved: numFillUps >= 10,
|
||||
},
|
||||
{
|
||||
id: '1000km',
|
||||
label: '1,000 km',
|
||||
description: 'Tracked 1,000 km!',
|
||||
icon: '🛣️',
|
||||
achieved: totalKm >= 1000,
|
||||
},
|
||||
{
|
||||
id: 'best-eff',
|
||||
label: 'Best Efficiency',
|
||||
description: 'Achieved <6 L/100km!',
|
||||
icon: '🌱',
|
||||
achieved: bestEfficiency < 6,
|
||||
},
|
||||
];
|
||||
|
||||
// Get fuel type from first fill-up (all fill-ups for a car have the same type)
|
||||
const fuelType = fillUps[0]?.car?.fuelType;
|
||||
const isElectric = fuelType === 'ELECTRIC';
|
||||
// If electric, always use metric units
|
||||
const displayUnits = isElectric ? 'metric' : units;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center py-8 px-2">
|
||||
<div className="w-full max-w-2xl mx-auto p-4 sm:p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-8 px-2 sm:px-8 lg:px-16">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-[var(--primary)] flex items-center gap-3 m-2 sm:m-4 mt-0 justify-center">
|
||||
<span role="img" aria-label="stats">📊</span> Fuel Stats
|
||||
</h1>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 w-full justify-center">
|
||||
{!isElectric && (
|
||||
<UnitsToggle units={units} setUnits={setUnits} disabled={isElectric} />
|
||||
)}
|
||||
{isElectric && (
|
||||
<span className="text-xs font-semibold text-[var(--foreground)]/70 mb-1 sm:mb-0 sm:mr-2">Units: kWh/100km</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2 w-full justify-center">
|
||||
<div className="flex items-center gap-2 w-auto">
|
||||
<StatsTimeRangeFilter value={range} onChange={setRange} />
|
||||
</div>
|
||||
<div className="flex items-end h-full">
|
||||
<StatsExportButton fillUps={filtered} units={displayUnits} isElectric={isElectric} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full overflow-x-auto flex justify-center">
|
||||
<FuelStatsChart fillUps={chartFillUps} units={displayUnits} />
|
||||
</div>
|
||||
{comparison && (
|
||||
<div className={`flex items-center gap-2 p-4 rounded-lg mb-2 font-medium ${comparison.improved ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'} w-full justify-center mx-auto`}>
|
||||
{comparison.improved ? '⬇️' : '⬆️'}
|
||||
<span>
|
||||
Avg. consumption: <b>{units === 'imperial' ? `${lPer100kmToMpg(avgLast30 ?? 0).toFixed(2)} MPG` : `${avgLast30?.toFixed(2)} L/100km`}</b> ({comparison.improved ? '-' : '+'}{comparison.percent}%)
|
||||
<span className="ml-2 text-xs text-[var(--foreground)]/60">(Prev: {units === 'imperial' ? `${lPer100kmToMpg(avgPrev30 ?? 0).toFixed(2)} MPG` : `${avgPrev30?.toFixed(2)} L/100km`})</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{insight && (
|
||||
<div className="mb-4 text-[var(--foreground)]/80 text-sm flex items-center gap-2 w-full justify-center mx-auto">
|
||||
<span role="img" aria-label="tip">💡</span> {insight}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="bg-[var(--background)] border border-[var(--border)] rounded-3xl shadow-2xl p-4 sm:p-10 mb-6 w-full max-w-3xl m-0 sm:m-4 mb-8 animate-fadein flex justify-center items-center">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-8 w-full">
|
||||
<StatCard label={`Total Distance`} value={`${totalDistance.toFixed(0)} ${displayUnits === 'imperial' ? 'mi' : 'km'}`} icon="🛣️" color="primary" info={displayUnits === 'imperial' ? 'Total miles driven in selected period.' : 'Total kilometers driven in selected period.'} />
|
||||
<StatCard label={`Total Fuel Used`} value={`${totalLiters.toFixed(2)} ${isElectric ? 'kWh' : (displayUnits === 'imperial' ? 'gal' : 'L')}`} icon="⛽" color="secondary" info={isElectric ? 'Total kilowatt-hours used in selected period.' : (displayUnits === 'imperial' ? 'Total gallons used in selected period.' : 'Total liters used in selected period.')} />
|
||||
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${filtered[0].currency}`} icon="💸" color="accent" info="Sum of all fill-up costs in selected period." />
|
||||
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} ${isElectric ? 'kWh / 100km' : (displayUnits === 'imperial' ? 'MPG' : 'L / 100km')}`} icon="📏" color="primary" info={isElectric ? 'Kilowatt-hours per 100km (lower is better).' : (displayUnits === 'imperial' ? 'Miles per gallon (higher is better).' : 'Liters per 100km (lower is better).')} />
|
||||
<StatCard label={`Average Cost / ${displayUnits === 'imperial' ? 'mi' : 'km'}`} value={`${costPerDist.toFixed(2)} ${filtered[0].currency}`} icon="💰" color="secondary" info={`Average fuel cost per ${displayUnits === 'imperial' ? 'mile' : 'kilometer'}.`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Achievements achievements={achievements} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export default function StatsSkeleton() {
|
||||
return (
|
||||
<div className="bg-[var(--background)] border border-[var(--border)] rounded-3xl shadow-2xl p-10 mb-6 w-full mx-auto m-4 mb-8 mx-4 animate-pulse">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-4 rounded-xl bg-[var(--muted)]">
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--border)]" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-1/2 bg-[var(--border)] rounded" />
|
||||
<div className="h-6 w-2/3 bg-[var(--border)] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
const ranges = [
|
||||
{ label: "7d", value: 7, aria: "Show stats for last 7 days" },
|
||||
{ label: "30d", value: 30, aria: "Show stats for last 30 days" },
|
||||
{ label: "Year", value: 365, aria: "Show stats for last year" },
|
||||
{ label: "All", value: null, aria: "Show stats for all time" },
|
||||
];
|
||||
|
||||
export default function StatsTimeRangeFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center gap-2 w-auto mb-0"
|
||||
role="group"
|
||||
aria-label="Select time range for stats"
|
||||
>
|
||||
<span className="text-xs font-semibold text-[var(--foreground)]/70 mr-2 mb-0">
|
||||
Sort by:
|
||||
</span>
|
||||
<div className="flex flex-row gap-2 w-auto mb-0">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r.label}
|
||||
className={`w-full sm:w-auto px-4 py-1 rounded-full border font-semibold shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-sm
|
||||
${value === r.value
|
||||
? "bg-[var(--primary)] text-white border-[var(--primary)] scale-105 shadow-lg"
|
||||
: "bg-[var(--muted)] text-[var(--primary)] border-[var(--border)] hover:bg-[var(--primary)] hover:text-white hover:scale-105"}
|
||||
`}
|
||||
onClick={() => onChange(r.value)}
|
||||
type="button"
|
||||
aria-label={r.aria}
|
||||
aria-pressed={value === r.value}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState('system');
|
||||
|
||||
useEffect(() => {
|
||||
// On mount, check localStorage or system preference
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) setTheme(stored);
|
||||
else if (window.matchMedia('(prefers-color-scheme: dark)').matches) setTheme('dark');
|
||||
else setTheme('light');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'system') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} else if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
aria-label="Light mode"
|
||||
className={`px-4 py-2 rounded-lg border border-[var(--primary)] shadow-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
|
||||
${theme === 'light' ? 'bg-[var(--primary)] text-white' : 'bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white'}`}
|
||||
onClick={() => setTheme('light')}
|
||||
>🌞</button>
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
className={`px-4 py-2 rounded-lg border border-[var(--primary)] shadow-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
|
||||
${theme === 'dark' ? 'bg-[var(--primary)] text-white' : 'bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white'}`}
|
||||
onClick={() => setTheme('dark')}
|
||||
>🌚</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
|
||||
export type Units = "metric" | "imperial";
|
||||
|
||||
export default function UnitsToggle({ units, setUnits, disabled = false }: { units: Units; setUnits: (u: Units) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-1 sm:gap-2 w-full max-w-xs">
|
||||
<span className="text-xs font-semibold text-[var(--foreground)]/70 mb-1 sm:mb-0 sm:mr-2">Fuel:</span>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full sm:w-auto px-3 py-1 rounded-md text-sm font-semibold border shadow-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
|
||||
${units === "metric"
|
||||
? "bg-[var(--primary)] text-white border-[var(--primary)] scale-105 shadow-lg"
|
||||
: "bg-[var(--muted)] text-[var(--primary)] border-[var(--border)] hover:bg-[var(--primary)] hover:text-white hover:scale-105"}
|
||||
`}
|
||||
aria-checked={units === "metric"}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onClick={() => !disabled && setUnits("metric")}
|
||||
disabled={disabled}
|
||||
>
|
||||
Metric (L/100km)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full sm:w-auto px-3 py-1 rounded-md text-sm font-semibold border shadow-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
|
||||
${units === "imperial"
|
||||
? "bg-[var(--primary)] text-white border-[var(--primary)] scale-105 shadow-lg"
|
||||
: "bg-[var(--muted)] text-[var(--primary)] border-[var(--border)] hover:bg-[var(--primary)] hover:text-white hover:scale-105"}
|
||||
`}
|
||||
aria-checked={units === "imperial"}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onClick={() => !disabled && setUnits("imperial")}
|
||||
disabled={disabled}
|
||||
>
|
||||
Imperial (MPG)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user