Update app improvment 2. Dashboard, stat, UI. All

This commit is contained in:
EdiFarcas
2025-07-09 01:48:50 +03:00
parent 1ca2ae8798
commit 27f7ede84a
34 changed files with 1635 additions and 200 deletions
+53
View File
@@ -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));
}
+29
View File
@@ -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);
}
+3 -3
View File
@@ -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"}
>
+4 -4
View File
@@ -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>
+66 -24
View File
@@ -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>
);
+21 -53
View File
@@ -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} />;
}
+80 -49
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+16
View File
@@ -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)]`}
>
+30
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+38 -7
View File
@@ -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>
);
+56
View File
@@ -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>
);
}
+7
View File
@@ -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>
);
}
+8 -5
View File
@@ -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>
+8 -6
View File
@@ -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>
+80
View File
@@ -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>
);
}
+50
View File
@@ -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 -1
View File
@@ -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>
);
+58
View File
@@ -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>
);
}
+35 -7
View File
@@ -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>
);
+50
View File
@@ -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>
);
}
+236
View File
@@ -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 cars 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>
);
}
+17
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}