mirror of
https://github.com/EdiFarcas/Car-Fuel-Tracking-App.git
synced 2026-06-22 05:00:53 +03:00
Upgrades until 15.07.2025
This commit is contained in:
Generated
+43
@@ -12,6 +12,7 @@
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^6.11.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"framer-motion": "^12.23.3",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
@@ -3927,6 +3928,33 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.3",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.3.tgz",
|
||||
"integrity": "sha512-llmLkf44zuIZOPSrE4bl4J0UTg6bav+rlKEfMRKgvDMXqBrUtMg6cspoQeRVK3nqRLxTmAJhfGXk39UDdZD7Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.2",
|
||||
"motion-utils": "^12.23.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -5245,6 +5273,21 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.2.tgz",
|
||||
"integrity": "sha512-73j6xDHX/NvVh5L5oS1ouAVnshsvmApOq4F3VZo5MkYSD/YVsVLal4Qp9wvVgJM9uU2bLZyc0Sn8B9c/MMKk4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.2.tgz",
|
||||
"integrity": "sha512-cIEXlBlXAOUyiAtR0S+QPQUM9L3Diz23Bo+zM420NvSd/oPQJwg6U+rT+WRTpp0rizMsBGQOsAwhWIfglUcZfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^6.11.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"framer-motion": "^12.23.3",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -26,7 +26,7 @@ export async function POST(req: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { carId, mileage, liters, cost, currency, fuelType } = await req.json();
|
||||
const { carId, mileage, liters, cost, currency, fuelType, date } = await req.json();
|
||||
if (!carId || !mileage || !liters || !cost || !currency || !fuelType) {
|
||||
return NextResponse.json({ message: 'Missing fields' }, { status: 400 });
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export async function POST(req: Request) {
|
||||
currency,
|
||||
carId,
|
||||
fuelType,
|
||||
date: date ? new Date(date) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,21 @@ const currencies = ['EUR', 'USD', 'RON', 'GBP'];
|
||||
export default function FillUpsPage() {
|
||||
const { carId } = useParams();
|
||||
const [fillups, setFillups] = useState<FillUp[]>([]);
|
||||
// Toggle for showing/hiding fill-ups
|
||||
const [showFillUps, setShowFillUps] = useState(false);
|
||||
// Filter and sort state
|
||||
const [filterFuelType, setFilterFuelType] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState('date-desc');
|
||||
// Filter and sort logic for fill-ups
|
||||
const filteredFillUps = fillups
|
||||
.filter(f => !filterFuelType || f.fuelType === filterFuelType)
|
||||
.sort((a, b) => {
|
||||
if (sortOrder === 'date-desc') return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
if (sortOrder === 'date-asc') return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
if (sortOrder === 'mileage-desc') return b.mileage - a.mileage;
|
||||
if (sortOrder === 'mileage-asc') return a.mileage - b.mileage;
|
||||
return 0;
|
||||
});
|
||||
const [carFuelTypes, setCarFuelTypes] = useState<string[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
mileage: '',
|
||||
@@ -26,6 +41,7 @@ export default function FillUpsPage() {
|
||||
cost: '',
|
||||
currency: 'EUR',
|
||||
fuelType: '',
|
||||
date: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -58,7 +74,7 @@ export default function FillUpsPage() {
|
||||
if (res.ok) {
|
||||
const newFill = await res.json();
|
||||
setFillups((prev) => [newFill, ...prev]);
|
||||
setForm({ mileage: '', liters: '', cost: '', currency: 'EUR', fuelType: carFuelTypes[0] || '' });
|
||||
setForm({ mileage: '', liters: '', cost: '', currency: 'EUR', fuelType: carFuelTypes[0] || '', date: '' });
|
||||
setError('');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
@@ -77,10 +93,23 @@ export default function FillUpsPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow mb-8">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)] text-center">Fuel Fill-Ups</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-white p-6 rounded-lg border border-[var(--border)]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="date" className="font-medium text-[var(--primary)]">Date & Time</label>
|
||||
<input
|
||||
id="date"
|
||||
name="date"
|
||||
type="datetime-local"
|
||||
value={form.date}
|
||||
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">Select the date and hour of the fill-up</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="mileage" className="font-medium text-[var(--primary)]">Odometer</label>
|
||||
<input
|
||||
@@ -186,11 +215,69 @@ export default function FillUpsPage() {
|
||||
{error && <p className="col-span-full text-red-500 text-center mt-2">{error}</p>}
|
||||
</form>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{fillups.map((fill) => (
|
||||
<FillUpCard key={fill.id} fill={{ ...fill, car: { fuelType: fill.fuelType } }} />
|
||||
))}
|
||||
</ul>
|
||||
{/* Toggle and Filters for Fill-Ups */}
|
||||
{!showFillUps && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 rounded-lg font-semibold shadow border border-[var(--border)] transition-all bg-[var(--muted)] text-[var(--primary)]`}
|
||||
onClick={() => setShowFillUps(true)}
|
||||
>
|
||||
Show Fill-Ups
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showFillUps && (
|
||||
<div className="flex flex-col items-center gap-4 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg font-semibold shadow border border-[var(--border)] transition-all w-full max-w-xs bg-[var(--primary)] text-white mb-2"
|
||||
onClick={() => setShowFillUps(false)}
|
||||
>
|
||||
Hide Fill-Ups
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2 items-center justify-center w-full sm:flex-nowrap">
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<label className="text-sm font-medium text-[var(--primary)]">Filter by Fuel Type:</label>
|
||||
<select
|
||||
value={filterFuelType}
|
||||
onChange={e => setFilterFuelType(e.target.value)}
|
||||
className="border border-[var(--border)] px-2 py-1 rounded-lg bg-[var(--background)] text-[var(--foreground)] font-semibold"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{carFuelTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<label className="text-sm font-medium text-[var(--primary)] sm:ml-2">Sort by:</label>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={e => setSortOrder(e.target.value)}
|
||||
className="border border-[var(--border)] px-2 py-1 rounded-lg bg-[var(--background)] text-[var(--foreground)] font-semibold"
|
||||
>
|
||||
<option value="date-desc">Newest</option>
|
||||
<option value="date-asc">Oldest</option>
|
||||
<option value="mileage-desc">Highest Mileage</option>
|
||||
<option value="mileage-asc">Lowest Mileage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showFillUps && (
|
||||
<div className="mb-8">
|
||||
<ul
|
||||
className="space-y-3 overflow-y-auto pr-2"
|
||||
style={{ maxHeight: `${3 * 6.5}rem`, minHeight: filteredFillUps.length > 0 ? '6.5rem' : undefined }}
|
||||
>
|
||||
{filteredFillUps.map((fill) => (
|
||||
<FillUpCard key={fill.id} fill={{ ...fill, car: { fuelType: fill.fuelType } }} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { redirect } from 'next/navigation';
|
||||
@@ -12,14 +13,13 @@ 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: resolvedParams.carId,
|
||||
id: params.carId,
|
||||
user: { email: userEmail },
|
||||
},
|
||||
select: {
|
||||
@@ -44,74 +44,88 @@ export default async function CarDetailPage({ params }: CarDetailPageProps) {
|
||||
ELECTRIC: { color: 'bg-green-100 text-green-700', icon: <FaBolt className="inline mr-1 text-green-500" /> },
|
||||
};
|
||||
|
||||
// Normalize fuelTypes for compatibility
|
||||
const fuelTypes = Array.isArray(car.fuelTypes) ? car.fuelTypes : car.fuelTypes ? [car.fuelTypes] : [];
|
||||
|
||||
return (
|
||||
<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)]">
|
||||
<main className="max-w-full md:max-w-3xl mx-auto p-2 sm:p-4 md:p-8 mt-8">
|
||||
<div className="rounded-3xl shadow-2xl bg-[var(--muted)] p-2 sm:p-4 md:p-10 flex flex-col gap-4 sm:gap-6 md:gap-10 border border-[var(--border)] w-[96vw] max-w-[98vw] sm:w-[90vw] sm:max-w-[95vw] md:w-auto md:max-w-none">
|
||||
{/* 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>
|
||||
{Array.isArray(car.fuelTypes) && car.fuelTypes.length > 1 ? (
|
||||
<span className="ml-2 flex items-center">
|
||||
<span className={`px-2 py-1 rounded-l-full text-xs font-bold ${fuelMeta[car.fuelTypes[0]]?.color || 'bg-gray-100 text-gray-700'}`} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0, borderRight: '1px solid #ccc' }}>{fuelMeta[car.fuelTypes[0]]?.icon}{car.fuelTypes[0]}</span>
|
||||
<span className={`px-2 py-1 rounded-r-full text-xs font-bold ${fuelMeta[car.fuelTypes[1]]?.color || 'bg-gray-100 text-gray-700'}`} style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}>{fuelMeta[car.fuelTypes[1]]?.icon}{car.fuelTypes[1]}</span>
|
||||
</span>
|
||||
) : Array.isArray(car.fuelTypes) && car.fuelTypes.length === 1 ? (
|
||||
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-bold ${fuelMeta[car.fuelTypes[0]]?.color || 'bg-gray-100 text-gray-700'}`}>{fuelMeta[car.fuelTypes[0]]?.icon}{car.fuelTypes[0]}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<div className="flex flex-col items-center gap-2 sm:gap-3 p-2 sm:p-4 md:p-8 rounded-t-3xl bg-[var(--muted)] border-b border-[var(--border)] shadow-sm">
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-2 w-full">
|
||||
<span className="text-3xl sm:text-4xl md:text-6xl text-[var(--primary)] drop-shadow-lg"><FaCarSide /></span>
|
||||
<h1 className="text-xl sm:text-2xl md:text-4xl font-extrabold text-[var(--primary)] leading-tight tracking-tight flex flex-row flex-wrap items-center gap-1 sm:gap-2 md:gap-3">
|
||||
<span>{car.name}</span>
|
||||
<span className="inline-block px-2 md:px-3 py-1 rounded-full text-sm md:text-base font-bold bg-gray-200 text-gray-700 ml-2">{car.year}</span>
|
||||
</h1>
|
||||
</div>
|
||||
{/* Fuel Types Row */}
|
||||
{fuelTypes.length > 0 && (
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-2 w-full mt-1">
|
||||
{fuelTypes.length > 1 ? (
|
||||
<>
|
||||
<span className={`px-2 py-1 rounded-l-full text-xs font-bold ${fuelMeta[fuelTypes[0]]?.color || 'bg-gray-100 text-gray-700'}`} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0, borderRight: '1px solid #ccc' }}>{fuelMeta[fuelTypes[0]]?.icon}{fuelTypes[0]}</span>
|
||||
<span className={`px-2 py-1 rounded-r-full text-xs font-bold ${fuelMeta[fuelTypes[1]]?.color || 'bg-gray-100 text-gray-700'}`} style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}>{fuelMeta[fuelTypes[1]]?.icon}{fuelTypes[1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-bold ${fuelMeta[fuelTypes[0]]?.color || 'bg-gray-100 text-gray-700'}`}>{fuelMeta[fuelTypes[0]]?.icon}{fuelTypes[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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 className="flex flex-col gap-4 sm:gap-6 md:gap-8 px-1 sm:px-2 md:px-8 w-full">
|
||||
{/* Make & Model Row */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-2 md:gap-8 w-full">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<FaIndustry className="text-xl md:text-2xl text-gray-400" />
|
||||
<span className="font-semibold text-[var(--foreground)]">Make:</span>
|
||||
<span className="ml-1 text-gray-400 font-mono text-base md:text-lg">{car.make}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-3 md:ml-8">
|
||||
<FaCarSide className="text-xl md:text-2xl text-gray-400" />
|
||||
<span className="font-semibold text-[var(--foreground)]">Model:</span>
|
||||
<span className="ml-1 text-gray-400 font-mono text-base md:text-lg">{car.model}</span>
|
||||
</div>
|
||||
</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" />
|
||||
<div className="flex items-center gap-2 md:gap-3 justify-center w-full">
|
||||
<FaCalendarAlt className="text-xl md: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">
|
||||
{Array.isArray(car.fuelTypes) && car.fuelTypes.length > 0
|
||||
? car.fuelTypes.map((ft: string) => (
|
||||
<span key={ft} className={`ml-1 px-2 py-1 rounded-full text-xs font-bold flex items-center gap-1 ${fuelMeta[ft]?.color || 'bg-gray-100 text-gray-700'}`}>{fuelMeta[ft]?.icon}{ft}</span>
|
||||
))
|
||||
: null
|
||||
}
|
||||
<span className="text-gray-400 font-mono text-base md:text-lg">{car.year}</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>
|
||||
<DeleteCarButton carId={car.id} />
|
||||
<div className="flex flex-col md:flex-row flex-wrap gap-2 md:gap-4 justify-center items-center px-1 sm:px-2 md:px-8 pb-2 sm:pb-4 md:pb-8 w-full">
|
||||
<div className="w-full max-w-xs md:w-auto md:max-w-none flex justify-center">
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/mileage`}
|
||||
className="flex items-center justify-center gap-2 bg-blue-500 dark:bg-blue-600 text-white px-4 md:px-6 py-2 md:py-3 rounded-lg font-bold shadow-lg hover:bg-blue-700 dark:hover:bg-blue-800 hover:scale-[1.03] transition-all w-full"
|
||||
>
|
||||
➕ Add Mileage
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-full max-w-xs md:w-auto md:max-w-none flex justify-center">
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/fillups`}
|
||||
className="flex items-center justify-center gap-2 bg-green-500 dark:bg-green-600 text-white px-4 md:px-6 py-2 md:py-3 rounded-lg font-bold shadow-lg hover:bg-green-700 dark:hover:bg-green-800 hover:scale-[1.03] transition-all w-full"
|
||||
>
|
||||
➕ Add Fill-Up
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-full max-w-xs md:w-auto md:max-w-none flex justify-center">
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/stats`}
|
||||
className="flex items-center justify-center gap-2 bg-indigo-500 dark:bg-indigo-600 text-white px-4 md:px-6 py-2 md:py-3 rounded-lg font-bold shadow-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 hover:scale-[1.03] transition-all w-full"
|
||||
>
|
||||
📊 View Stats
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-full max-w-xs md:w-auto md:max-w-none flex justify-center">
|
||||
<DeleteCarButton carId={car.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,22 @@ export default function AddCarPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const { name, value } = e.target;
|
||||
if (name === 'fuelTypes') {
|
||||
// Only checkboxes will trigger this block
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setForm((prev) => {
|
||||
let updated = prev.fuelTypes;
|
||||
if (checked) {
|
||||
updated = [...prev.fuelTypes, value];
|
||||
// Only add if less than 2 selected
|
||||
if (prev.fuelTypes.length < 2) {
|
||||
updated = [...prev.fuelTypes, value];
|
||||
}
|
||||
} else {
|
||||
updated = prev.fuelTypes.filter((t: string) => t !== value);
|
||||
// Only remove if more than 1 selected
|
||||
if (prev.fuelTypes.length > 1) {
|
||||
updated = prev.fuelTypes.filter((t: string) => t !== value);
|
||||
}
|
||||
}
|
||||
return { ...prev, fuelTypes: updated };
|
||||
});
|
||||
@@ -116,19 +124,26 @@ export default function AddCarPage() {
|
||||
<div>
|
||||
<label className="block mb-1 font-semibold text-[var(--foreground)]">Fuel Types</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{fuelTypes.map((type) => (
|
||||
<label key={type} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="fuelTypes"
|
||||
value={type}
|
||||
checked={form.fuelTypes.includes(type)}
|
||||
onChange={handleChange}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
))}
|
||||
{fuelTypes.map((type) => {
|
||||
const checked = form.fuelTypes.includes(type);
|
||||
const disabled =
|
||||
(!checked && form.fuelTypes.length >= 2) ||
|
||||
(checked && form.fuelTypes.length === 1); // can't uncheck last
|
||||
return (
|
||||
<label key={type} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="fuelTypes"
|
||||
value={type}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
className="accent-[var(--primary)]"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
+6
-6
@@ -9,12 +9,12 @@
|
||||
--accent: #a21caf; /* purple-700 */
|
||||
--muted: #f3f4f6; /* gray-100 */
|
||||
--border: #e5e7eb; /* gray-200 */
|
||||
--statcard-primary-bg: #e0e7ff; /* indigo-100 */
|
||||
--statcard-primary-bg: #e5e7eb; /* gray-200 */
|
||||
--statcard-primary-border: #2563eb; /* blue-600 */
|
||||
--statcard-secondary-bg: #dcfce7; /* green-100 */
|
||||
--statcard-secondary-bg: #f1f5f9; /* gray-100/blue-50 */
|
||||
--statcard-secondary-border: #22c55e; /* green-500 */
|
||||
--statcard-accent-bg: #f3e8ff; /* purple-100 */
|
||||
--statcard-accent-border: #a21caf; /* purple-700 */
|
||||
--statcard-accent-bg: #e0f2fe; /* sky-100 */
|
||||
--statcard-accent-border: #0ea5e9; /* sky-500 */
|
||||
--statcard-default-bg: var(--background);
|
||||
--statcard-default-border: var(--border);
|
||||
}
|
||||
@@ -29,8 +29,8 @@
|
||||
--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-accent-bg: #0f172a; /* slate-900 */
|
||||
--statcard-accent-border: #0ea5e9; /* sky-500 */
|
||||
--statcard-default-bg: var(--background);
|
||||
--statcard-default-border: var(--border);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import ClientNavbar from "../components/ClientNavbar";
|
||||
import Providers from "./providers";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -52,6 +53,7 @@ export default async function RootLayout({
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="h-4" /> {/* Spacer between navbar and content */}
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
+3
-14
@@ -28,9 +28,9 @@ function UseCaseBlocks() {
|
||||
<h2 className="text-3xl font-bold text-center text-[var(--primary)] mb-8">Who Is This For?</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8">
|
||||
{blocks.map((b) => (
|
||||
<div key={b.title} className="group bg-white/40 dark:bg-[var(--muted)]/60 rounded-xl shadow p-6 flex flex-col items-center gap-3 border border-[var(--border)] transition-all hover:scale-105 hover:bg-white/60 dark:hover:bg-[var(--muted)]/80 animate-fade-in backdrop-blur">
|
||||
<div key={b.title} className="group bg-[var(--muted)] rounded-xl shadow p-6 flex flex-col items-center gap-3 border border-[var(--border)] transition-all hover:scale-105 hover:bg-[var(--primary)]/10 animate-fade-in">
|
||||
<span className="text-4xl group-hover:scale-125 transition-transform">{b.icon}</span>
|
||||
<h3 className="font-semibold text-lg text-[var(--primary)] group-hover:text-[var(--secondary)]">{b.title}</h3>
|
||||
<h3 className="font-semibold text-lg text-[var(--primary)] group-hover:text-[var(--secondary)] text-center w-full">{b.title}</h3>
|
||||
<p className="text-center text-[var(--foreground)]/80 group-hover:text-[var(--foreground)]">{b.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -66,7 +66,7 @@ function FeatureGrid() {
|
||||
{features.map((f, i) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className={`flex flex-col items-center text-center gap-2 bg-white/40 dark:bg-[var(--muted)]/60 rounded-xl shadow p-6 border border-[var(--border)] transition-all duration-300 cursor-pointer ${open === i ? "scale-105 bg-white/60 dark:bg-[var(--muted)]/80" : ""} backdrop-blur`}
|
||||
className={`flex flex-col items-center text-center gap-2 bg-[var(--muted)] rounded-xl shadow p-6 border border-[var(--border)] transition-all duration-300 cursor-pointer ${open === i ? "scale-105 bg-[var(--primary)]/10" : ""}`}
|
||||
onClick={() => setOpen(open === i ? null : i)}
|
||||
onMouseEnter={() => setOpen(i)}
|
||||
onMouseLeave={() => setOpen(null)}
|
||||
@@ -170,17 +170,6 @@ export default function Home() {
|
||||
|
||||
{/* ...existing reviews, etc... */}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full py-6 flex flex-col items-center justify-center border-t border-[var(--border)] bg-[var(--muted)] text-sm text-gray-500 gap-2 mt-auto">
|
||||
<nav className="flex gap-6 mb-1">
|
||||
<Link href="/about" className="hover:text-[var(--primary)] transition">About</Link>
|
||||
<Link href="/contact" className="hover:text-[var(--primary)] transition">Contact</Link>
|
||||
<Link href="/bugs" className="hover:text-[var(--primary)] transition">Bug Report</Link>
|
||||
<Link href="/improvment_ideas" className="hover:text-[var(--primary)] transition">Improvment Ideas</Link>
|
||||
<Link href="/privacy" className="hover:text-[var(--primary)] transition">Privacy Policy</Link>
|
||||
</nav>
|
||||
<span>© {new Date().getFullYear()} Car Fuel Tracker.</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-13
@@ -23,19 +23,23 @@ export default function CarGrid({ cars }: { cars: any[] }) {
|
||||
|
||||
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 className="flex flex-wrap gap-4 mb-4 items-center sm:flex-nowrap">
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<label htmlFor="sort-cars" className="font-medium text-sm 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>
|
||||
</div>
|
||||
{filtered.length ? (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 md:grid-cols-3" role="list" aria-label="Your cars">
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import Image from "./Image";
|
||||
|
||||
export default function ClientNavbar() {
|
||||
const { data: session } = useSession();
|
||||
@@ -12,19 +11,6 @@ export default function ClientNavbar() {
|
||||
const pathname = usePathname();
|
||||
const userInitial = session?.user?.name?.[0]?.toUpperCase() || session?.user?.email?.[0]?.toUpperCase() || "U";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for theme changes
|
||||
const updateTheme = () => {
|
||||
if (document.documentElement.classList.contains("dark")) setTheme("dark");
|
||||
else setTheme("light");
|
||||
};
|
||||
updateTheme();
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
const isActive = pathname === href;
|
||||
@@ -43,27 +29,7 @@ export default function ClientNavbar() {
|
||||
return (
|
||||
<nav className="flex w-full items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2 text-2xl font-bold tracking-tight text-[var(--primary)] hover:scale-105 transition">
|
||||
<span className="relative w-10 h-10 block">
|
||||
{theme === "dark" ? (
|
||||
<Image
|
||||
src="/Light_Logo.png"
|
||||
alt="FuelTrack logo light"
|
||||
className="w-10 h-10 object-contain transition-colors"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src="/Dark_Logo.png"
|
||||
alt="FuelTrack logo dark"
|
||||
className="w-10 h-10 object-contain transition-colors"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-3xl">🚗</span>
|
||||
<span>FuelTrack</span>
|
||||
</Link>
|
||||
{/* Desktop nav */}
|
||||
@@ -103,6 +69,7 @@ export default function ClientNavbar() {
|
||||
<div className="absolute right-0 mt-2 w-48 bg-[var(--muted)] rounded-xl shadow-lg border border-[var(--border)] z-50 flex flex-col p-2 gap-2 animate-fade-in">
|
||||
<NavLink href="/dashboard">Dashboard</NavLink>
|
||||
{isLoggedIn && <NavLink href="/dashboard/cars/new">Add Car</NavLink>}
|
||||
<ThemeToggle />
|
||||
{isLoggedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-[var(--primary)] text-white flex items-center justify-center font-bold text-base border-2 border-[var(--border)] shadow">{userInitial}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function FillUpCard({ fill }: { fill: any }) {
|
||||
<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'}
|
||||
<span className="ml-2 text-xs text-[var(--primary)] font-bold">{fill.fuelType}</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--foreground)]/60">
|
||||
{fill.cost} {fill.currency} — {new Date(fill.date).toLocaleString()}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full py-6 flex flex-col items-center justify-center border-t border-[var(--border)] bg-[var(--muted)] text-sm text-gray-500 gap-2 mt-auto">
|
||||
<nav className="flex flex-wrap justify-center gap-4 sm:gap-6 mb-1 w-full px-2">
|
||||
<Link href="/about" className="hover:text-[var(--primary)] transition">About</Link>
|
||||
<Link href="/contact" className="hover:text-[var(--primary)] transition">Contact</Link>
|
||||
<Link href="/bugs" className="hover:text-[var(--primary)] transition">Bug Report</Link>
|
||||
<Link href="/improvment_ideas" className="hover:text-[var(--primary)] transition">Improvment Ideas</Link>
|
||||
<Link href="/privacy" className="hover:text-[var(--primary)] transition">Privacy Policy</Link>
|
||||
</nav>
|
||||
<span>© {new Date().getFullYear()} FuelTrack</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default Image;
|
||||
|
||||
@@ -24,17 +24,17 @@ export default function LiveStatsPreview() {
|
||||
<section className="w-full max-w-4xl mx-auto py-8 px-4">
|
||||
<h2 className="text-2xl font-bold text-center text-[var(--primary)] mb-6">Live Community Stats</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div className="bg-white/40 dark:bg-[var(--muted)]/60 rounded-xl shadow p-6 flex flex-col items-center gap-2 border border-[var(--border)] animate-fade-in backdrop-blur">
|
||||
<div className="bg-[var(--muted)] rounded-xl shadow p-6 flex flex-col items-center gap-2 border border-[var(--border)] animate-fade-in">
|
||||
<span className="text-3xl">⛽</span>
|
||||
<span className="font-semibold text-lg">Avg. Fuel Price</span>
|
||||
<span className="text-2xl font-bold text-[var(--primary)]">{loading ? "..." : `${stats?.avgFuelPrice.toFixed(2)} RON/L`}</span>
|
||||
</div>
|
||||
<div className="bg-white/40 dark:bg-[var(--muted)]/60 rounded-xl shadow p-6 flex flex-col items-center gap-2 border border-[var(--border)] animate-fade-in backdrop-blur">
|
||||
<div className="bg-[var(--muted)] rounded-xl shadow p-6 flex flex-col items-center gap-2 border border-[var(--border)] animate-fade-in">
|
||||
<span className="text-3xl">📉</span>
|
||||
<span className="font-semibold text-lg">Avg. Efficiency</span>
|
||||
<span className="text-2xl font-bold text-[var(--primary)]">{loading ? "..." : `${stats?.avgEfficiency.toFixed(1)} L/100km`}</span>
|
||||
</div>
|
||||
<div className="bg-white/40 dark:bg-[var(--muted)]/60 rounded-xl shadow p-6 flex flex-col items-center gap-2 border border-[var(--border)] animate-fade-in backdrop-blur">
|
||||
<div className="bg-[var(--muted)] rounded-xl shadow p-6 flex flex-col items-center gap-2 border border-[var(--border)] animate-fade-in">
|
||||
<span className="text-3xl">🌱</span>
|
||||
<span className="font-semibold text-lg text-center w-full">CO₂ Saved by Hybrids/EVs</span>
|
||||
<span className="text-2xl font-bold text-green-700">{loading ? "..." : `${stats?.totalCO2Saved.toLocaleString()} kg`}</span>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeInOut" }}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -37,23 +37,30 @@ export default function RecentActivity() {
|
||||
);
|
||||
|
||||
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 className="mb-8">
|
||||
<ul
|
||||
className="space-y-3 overflow-y-auto pr-2"
|
||||
style={{ maxHeight: `${3 * 5.5}rem`, minHeight: activity.length > 0 ? '5.5rem' : undefined }}
|
||||
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} ${item.fuelType === 'ELECTRIC' ? 'kWh' : item.fuelType === 'LPG' ? 'L (LPG)' : item.fuelType === 'GASOLINE' ? 'L (Gasoline)' : item.fuelType === 'DIESEL' ? 'L (Diesel)' : 'L'}, ${item.mileage} km${item.cost ? `, ${item.cost} ${item.currency}` : ''}`
|
||||
: `${item.mileage} km`}
|
||||
{' '}on {new Date(item.date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--foreground)]/60">
|
||||
{item.type === 'fillup'
|
||||
? `${item.liters} ${item.fuelType === 'ELECTRIC' ? 'kWh' : item.fuelType === 'LPG' ? 'L (LPG)' : item.fuelType === 'GASOLINE' ? 'L (Gasoline)' : item.fuelType === 'DIESEL' ? 'L (Diesel)' : 'L'}, ${item.mileage} km${item.cost ? `, ${item.cost} ${item.currency}` : ''}`
|
||||
: `${item.mileage} km`}
|
||||
{' '}on {new Date(item.date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-13
@@ -1,4 +1,3 @@
|
||||
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 }) {
|
||||
@@ -25,20 +24,18 @@ export default function StatCard({ label, value, icon, color, info }: { label: s
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4 shadow-sm flex flex-col justify-center w-full"
|
||||
className="rounded-xl p-3 shadow-sm flex flex-row items-center gap-4 w-full h-full min-w-0 min-h-0"
|
||||
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>
|
||||
{icon && <span className={`text-2xl ${color ? `text-[var(--${color})]` : ''}`}>{icon}</span>}
|
||||
<div className="flex flex-row items-center w-full min-w-0 gap-2">
|
||||
<span className="text-sm text-[var(--foreground)]/60 flex items-center gap-1 text-left overflow-hidden text-ellipsis whitespace-nowrap max-w-[40%]">
|
||||
{label}
|
||||
{info && (
|
||||
<InfoTooltip label={`Info: ${label}`} description={info} />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-2xl font-semibold ${valueClass} text-left break-words flex-1 min-w-0`}>{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,9 +26,8 @@ export default function StatsPageClient({ fillUps, carId, error, loading = false
|
||||
const [units, setUnits] = useState<Units>('metric');
|
||||
// Hybrid: fuel type selection
|
||||
const allFuelTypes = Array.from(new Set(fillUps.map(f => f.fuelType).filter(Boolean)));
|
||||
const [selectedFuelType, setSelectedFuelType] = useState<string>('ALL');
|
||||
const [selectedFuelType, setSelectedFuelType] = useState<string>(allFuelTypes[0] || '');
|
||||
const filteredByFuel = useMemo(() => {
|
||||
if (selectedFuelType === 'ALL') return fillUps;
|
||||
return fillUps.filter(f => f.fuelType === selectedFuelType);
|
||||
}, [fillUps, selectedFuelType]);
|
||||
const now = useMemo(() => new Date(), []);
|
||||
@@ -70,6 +69,27 @@ export default function StatsPageClient({ fillUps, carId, error, loading = false
|
||||
);
|
||||
}
|
||||
|
||||
// Comparative insights logic
|
||||
// (removed duplicate block)
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MAIN RETURN BLOCK ---
|
||||
|
||||
// MAIN RETURN BLOCK (only one return statement)
|
||||
|
||||
// Comparative insights logic
|
||||
const last30 = filtered;
|
||||
const prev30 = fillUps.filter(f => {
|
||||
@@ -128,11 +148,11 @@ export default function StatsPageClient({ fillUps, carId, error, loading = false
|
||||
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 totalLiters = filtered.slice(0, -1).reduce((sum, f) => sum + (units === 'imperial' ? litersToGallons(f.liters) : f.liters), 0);
|
||||
const totalCost = filtered.slice(0).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);
|
||||
const lit = filtered.slice(0, -1).reduce((sum, f) => sum + f.liters, 0);
|
||||
if (units === 'imperial') {
|
||||
return lPer100kmToMpg((lit / dist) * 100);
|
||||
} else {
|
||||
@@ -186,61 +206,57 @@ export default function StatsPageClient({ fillUps, carId, error, loading = false
|
||||
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">
|
||||
<main className="flex flex-col items-center justify-center py-8 px-2 w-full">
|
||||
{/* Centered header and controls */}
|
||||
<div className="w-full max-w-2xl mx-auto p-4 sm:p-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-4 px-2 sm:px-8 lg:px-16 flex flex-col items-center">
|
||||
<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">
|
||||
<span className="text-xs font-semibold text-[var(--foreground)]/70 mb-1 sm:mb-0 sm:mr-2">Fuel:</span>
|
||||
{allFuelTypes.length > 1 && (
|
||||
<select
|
||||
value={selectedFuelType}
|
||||
onChange={e => setSelectedFuelType(e.target.value)}
|
||||
className="border border-[var(--border)] px-2 py-1 rounded-lg bg-[var(--background)] text-[var(--foreground)] font-semibold"
|
||||
>
|
||||
<option value="ALL">All Fuels</option>
|
||||
{allFuelTypes.map(ft => (
|
||||
<option key={ft} value={ft}>{ft}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<select
|
||||
value={selectedFuelType}
|
||||
onChange={e => setSelectedFuelType(e.target.value)}
|
||||
className="border border-[var(--border)] px-2 py-1 rounded-lg bg-[var(--background)] text-[var(--foreground)] font-semibold"
|
||||
>
|
||||
{allFuelTypes.map(ft => (
|
||||
<option key={ft} value={ft}>{ft}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!isElectric && (
|
||||
<UnitsToggle units={units} setUnits={setUnits} disabled={isElectric} />
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto items-center justify-center">
|
||||
<UnitsToggle units={units} setUnits={setUnits} disabled={isElectric} />
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<div className="flex flex-col sm:flex-row flex-wrap items-center gap-2 mb-2 w-full justify-center">
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto items-center">
|
||||
<StatsTimeRangeFilter value={range} onChange={setRange} />
|
||||
</div>
|
||||
<div className="flex items-end h-full">
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto items-center">
|
||||
<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">
|
||||
</div>
|
||||
{/* 2x2 Responsive CSS grid for stats, chart, achievements (achievements only under chart) */}
|
||||
<div
|
||||
className="w-full max-w-7xl mx-auto grid grid-cols-1 grid-rows-[auto_auto] gap-4 items-start mb-0
|
||||
lg:grid-cols-2 lg:grid-rows-1 lg:gap-6"
|
||||
>
|
||||
{/* Stats Cards: top left (1x1) */}
|
||||
<div
|
||||
className="col-span-1 row-span-1 lg:col-start-1 lg:row-start-1 flex justify-center"
|
||||
>
|
||||
<div className="bg-[var(--background)] border border-[var(--border)] rounded-3xl shadow-2xl p-4 sm:p-6 w-full max-w-full animate-fadein flex justify-center items-start mb-0">
|
||||
<div className="flex flex-row flex-wrap gap-4 sm:gap-6 w-full justify-center items-stretch">
|
||||
<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." />
|
||||
@@ -250,8 +266,35 @@ export default function StatsPageClient({ fillUps, carId, error, loading = false
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Achievements achievements={achievements} />
|
||||
|
||||
{/* Chart: top right (1x1) */}
|
||||
<div
|
||||
className="col-span-1 row-span-1 lg:col-start-2 lg:row-start-1 flex flex-col items-center"
|
||||
>
|
||||
<div className="w-full overflow-x-auto flex justify-center">
|
||||
<FuelStatsChart fillUps={chartFillUps} units={displayUnits} />
|
||||
</div>
|
||||
{/* Achievements: only under chart on desktop */}
|
||||
<div className="w-full mt-4 flex justify-center mb-0">
|
||||
<Achievements achievements={achievements} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Comparative insights and tips */}
|
||||
{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>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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>
|
||||
<span className="text-xs font-semibold text-[var(--foreground)]/70 mb-1 sm:mb-0 sm:mr-2">Metrics:</span>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user