First protorype

This commit is contained in:
Alexandru Eduard Farcas
2025-07-04 12:24:00 +03:00
parent 325b0bdc30
commit 024e5ca656
26 changed files with 1278 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
import NextAuth, { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// Check for missing credentials
if (!credentials?.email || !credentials?.password) {
return null;
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
// Validate password
if (user && await bcrypt.compare(credentials.password, user.hashedPassword)) {
return user;
}
// Return null if authentication fails
return null;
},
}),
],
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return NextResponse.json({ message: 'Email and password are required' }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
return NextResponse.json({ message: 'Email already exists' }, { status: 400 });
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
hashedPassword,
},
});
return NextResponse.json({ message: 'User registered successfully' }, { status: 200 });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const { name, make, model, year, fuelType } = body;
if (!name || !make || !model || !year || !fuelType) {
return NextResponse.json({ message: 'Missing fields' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email: session.user?.email! },
});
if (!user) {
return NextResponse.json({ message: 'User not found' }, { status: 404 });
}
await prisma.car.create({
data: {
name,
make,
model,
year: parseInt(year),
fuelType,
userId: user.id,
},
});
return NextResponse.json({ message: 'Car added successfully' }, { status: 200 });
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const carId = searchParams.get('carId');
const fillUps = await prisma.fillUp.findMany({
where: {
car: {
id: carId || '',
user: { email: session.user?.email! },
},
},
orderBy: { date: 'desc' },
});
return NextResponse.json(fillUps);
}
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 } = await req.json();
if (!carId || !mileage || !liters || !cost || !currency) {
return NextResponse.json({ message: 'Missing fields' }, { status: 400 });
}
const car = await prisma.car.findFirst({
where: {
id: carId,
user: { email: session.user?.email! },
},
});
if (!car) return NextResponse.json({ message: 'Car not found' }, { status: 404 });
const fillUp = await prisma.fillUp.create({
data: {
mileage: parseInt(mileage),
liters: parseFloat(liters),
cost: parseFloat(cost),
currency,
carId,
},
});
return NextResponse.json(fillUp);
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET: Return mileage entries for a car
export async function GET(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const carId = searchParams.get('carId');
const entries = await prisma.mileageEntry.findMany({
where: {
car: {
id: carId || '',
user: {
email: session.user?.email!,
},
},
},
orderBy: { date: 'desc' },
});
return NextResponse.json(entries);
}
// POST: Add mileage entry
export async function POST(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const { carId, mileage } = await req.json();
if (!carId || !mileage) {
return NextResponse.json({ message: 'Missing fields' }, { status: 400 });
}
const car = await prisma.car.findFirst({
where: {
id: carId,
user: { email: session.user?.email! },
},
});
if (!car) return NextResponse.json({ message: 'Car not found' }, { status: 404 });
const entry = await prisma.mileageEntry.create({
data: {
mileage: parseInt(mileage),
carId,
},
});
return NextResponse.json(entry);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await signIn('credentials', {
redirect: false,
email,
password,
});
if (res?.ok) {
router.push('/dashboard');
} else {
setError('Invalid credentials');
}
};
return (
<main className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Login</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Email"
value={email}
required
onChange={(e) => setEmail(e.target.value)}
className="w-full border px-3 py-2 rounded"
/>
<input
type="password"
placeholder="Password"
value={password}
required
onChange={(e) => setPassword(e.target.value)}
className="w-full border px-3 py-2 rounded"
/>
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded">
Sign In
</button>
{error && <p className="text-red-500">{error}</p>}
</form>
</main>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
router.push('/auth/login');
} else {
const data = await res.json();
setError(data.message || 'Registration failed');
}
};
return (
<main className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Register</h1>
<form onSubmit={handleRegister} className="space-y-4">
<input
type="email"
placeholder="Email"
value={email}
required
onChange={(e) => setEmail(e.target.value)}
className="w-full border px-3 py-2 rounded"
/>
<input
type="password"
placeholder="Password"
value={password}
required
onChange={(e) => setPassword(e.target.value)}
className="w-full border px-3 py-2 rounded"
/>
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded">
Register
</button>
{error && <p className="text-red-500">{error}</p>}
</form>
</main>
);
}
@@ -0,0 +1,116 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import FillUpCard from '@/components/FillUpCard';
interface FillUp {
id: string;
mileage: number;
liters: number;
cost: number;
currency: string;
date: string;
}
const currencies = ['EUR', 'USD', 'RON', 'GBP'];
export default function FillUpsPage() {
const { carId } = useParams();
const [fillups, setFillups] = useState<FillUp[]>([]);
const [form, setForm] = useState({
mileage: '',
liters: '',
cost: '',
currency: 'EUR',
});
const [error, setError] = useState('');
// Fetch fill-up entries
useEffect(() => {
fetch(`/api/fillups?carId=${carId}`)
.then((res) => res.json())
.then(setFillups);
}, [carId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/fillups', {
method: 'POST',
body: JSON.stringify({ ...form, carId }),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
const newFill = await res.json();
setFillups((prev) => [newFill, ...prev]);
setForm({ mileage: '', liters: '', cost: '', currency: 'EUR' });
setError('');
} else {
const data = await res.json();
setError(data.message || 'Error adding fill-up');
}
};
return (
<main className="max-w-xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Fuel Fill-Ups</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
name="mileage"
type="number"
placeholder="Odometer"
value={form.mileage}
onChange={handleChange}
required
className="w-full border px-3 py-2 rounded"
/>
<input
name="liters"
type="number"
step="0.01"
placeholder="Liters"
value={form.liters}
onChange={handleChange}
required
className="w-full border px-3 py-2 rounded"
/>
<input
name="cost"
type="number"
step="0.01"
placeholder="Total Cost"
value={form.cost}
onChange={handleChange}
required
className="w-full border px-3 py-2 rounded"
/>
<select
name="currency"
value={form.currency}
onChange={handleChange}
className="w-full border px-3 py-2 rounded"
>
{currencies.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded">
Add Fill-Up
</button>
{error && <p className="text-red-500">{error}</p>}
</form>
<ul className="space-y-2">
{fillups.map((fill) => (
<FillUpCard key={fill.id} fill={fill} /> // ✅
))}
</ul>
</main>
);
}
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
interface MileageEntry {
id: string;
mileage: number;
date: string;
}
export default function MileagePage() {
const { carId } = useParams();
const router = useRouter();
const [entries, setEntries] = useState<MileageEntry[]>([]);
const [mileage, setMileage] = useState('');
const [error, setError] = useState('');
// Fetch existing entries
useEffect(() => {
fetch(`/api/mileage?carId=${carId}`)
.then((res) => res.json())
.then((data) => setEntries(data));
}, [carId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/mileage', {
method: 'POST',
body: JSON.stringify({ carId, mileage }),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
const newEntry = await res.json();
setEntries((prev) => [newEntry, ...prev]);
setMileage('');
} else {
const data = await res.json();
setError(data.message || 'Error adding mileage');
}
};
return (
<main className="max-w-xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Mileage Log</h1>
<form onSubmit={handleSubmit} className="flex items-center gap-4">
<input
type="number"
value={mileage}
onChange={(e) => setMileage(e.target.value)}
placeholder="Odometer (e.g. 102300)"
className="flex-1 border px-3 py-2 rounded"
required
/>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Add
</button>
</form>
{error && <p className="text-red-500">{error}</p>}
<ul className="space-y-2">
{entries.map((entry) => (
<li key={entry.id} className="border rounded p-3">
<p className="font-medium">{entry.mileage} km</p>
<p className="text-sm text-gray-500">{new Date(entry.date).toLocaleString()}</p>
</li>
))}
</ul>
</main>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { getSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import Link from 'next/link';
interface CarDetailPageProps {
params: {
carId: string;
};
}
export default async function CarDetailPage({ params }: CarDetailPageProps) {
const session = await getSession();
if (!session) redirect('/auth/login');
const userEmail = session.user?.email!;
const car = await prisma.car.findFirst({
where: {
id: params.carId,
user: { email: userEmail },
},
});
if (!car) {
return <div className="p-6">Car not found or unauthorized access.</div>;
}
return (
<main className="max-w-3xl mx-auto p-6 space-y-4">
<h1 className="text-3xl font-bold">{car.name}</h1>
<p className="text-gray-700">{car.make} {car.model} ({car.year})</p>
<p className="text-gray-600">Fuel Type: {car.fuelType}</p>
<div className="flex gap-4 mt-6">
<Link
href={`/dashboard/cars/${car.id}/mileage`}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Add Mileage
</Link>
<Link
href={`/dashboard/cars/${car.id}/fillups`}
className="bg-green-600 text-white px-4 py-2 rounded"
>
Add Fill-Up
</Link>
<Link
href={`/dashboard/cars/${car.id}/stats`}
className="bg-purple-600 text-white px-4 py-2 rounded"
>
📊 View Stats
</Link>
</div>
</main>
);
}
@@ -0,0 +1,64 @@
import { getSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import StatCard from '@/components/StatCard';
interface StatsProps {
params: {
carId: string;
};
}
export default async function StatsPage({ params }: StatsProps) {
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' },
},
},
});
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-6">
<h1 className="text-2xl font-bold mb-2">Not Enough Data</h1>
<p className="text-gray-600">Add at least 2 fill-ups to see statistics.</p>
</main>
);
}
// 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-xl mx-auto p-6 space-y-4">
<h1 className="text-2xl font-bold">Fuel Stats</h1>
<div className="space-y-2">
<StatCard label="Total Distance" value={`${totalDistance.toFixed(0)} km`} />
<StatCard label="Total Fuel Used" value={`${totalLiters.toFixed(2)} L`} />
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${fillUps[0].currency}`} />
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} L / 100km`} />
<StatCard label="Average Cost / km" value={`${costPerKm.toFixed(2)} ${fillUps[0].currency}`} />
</div>
</main>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
const fuelTypes = ['GASOLINE', 'DIESEL', 'LPG'];
export default function AddCarPage() {
const router = useRouter();
const [form, setForm] = useState({
name: '',
make: '',
model: '',
year: '',
fuelType: 'GASOLINE',
});
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/cars', {
method: 'POST',
body: JSON.stringify(form),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
router.push('/dashboard');
} else {
const data = await res.json();
setError(data.message || 'Failed to add car');
}
};
return (
<main className="max-w-md mx-auto p-6 space-y-4">
<h1 className="text-2xl font-bold">Add New Car</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
name="name"
value={form.name}
onChange={handleChange}
required
placeholder="Car Name"
className="w-full border px-3 py-2 rounded"
/>
<input
name="make"
value={form.make}
onChange={handleChange}
required
placeholder="Make (e.g. BMW)"
className="w-full border px-3 py-2 rounded"
/>
<input
name="model"
value={form.model}
onChange={handleChange}
required
placeholder="Model (e.g. 320i)"
className="w-full border px-3 py-2 rounded"
/>
<input
name="year"
type="number"
value={form.year}
onChange={handleChange}
required
placeholder="Year"
className="w-full border px-3 py-2 rounded"
/>
<select
name="fuelType"
value={form.fuelType}
onChange={handleChange}
className="w-full border px-3 py-2 rounded"
>
{fuelTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded">
Save Car
</button>
{error && <p className="text-red-500">{error}</p>}
</form>
</main>
);
}
View File
+37
View File
@@ -0,0 +1,37 @@
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';
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 },
});
return (
<main className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Your Cars</h1>
<Link href="/dashboard/cars/new" className="bg-blue-600 text-white px-4 py-2 rounded">
+ Add Car
</Link>
</div>
{user?.cars.length ? (
<ul className="grid gap-4">
{user.cars.map((car) => (
<CarCard key={car.id} car={car} />
))}
</ul>
) : (
<p>No cars added yet.</p>
)}
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
import Link from 'next/link';
import { Car } from '@prisma/client';
export default function CarCard({ car }: { car: Car }) {
return (
<Link href={`/dashboard/cars/${car.id}`}>
<div className="border rounded-xl p-4 shadow-sm hover:shadow-md transition bg-gray-50">
<h2 className="text-xl font-bold text-black">{car.name}</h2>
<p className="text-gray-600">{car.make} {car.model} ({car.year})</p>
<p className="text-sm text-gray-500 mt-1">Fuel Type: {car.fuelType}</p>
</div>
</Link>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { FillUp } from '@prisma/client';
export default function FillUpCard({ fill }: { fill: FillUp }) {
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">
{fill.cost} {fill.currency} {new Date(fill.date).toLocaleString()}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<main className="max-w-4xl mx-auto p-6">{children}</main>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-xl p-4 shadow-sm bg-white">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
export function getSession() {
return getServerSession(authOptions);
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;