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
+363
View File
@@ -8,8 +8,12 @@
"name": "car_app",
"version": "0.1.0",
"dependencies": {
"@auth/prisma-adapter": "^2.10.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.11.0",
"bcryptjs": "^3.0.2",
"next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -53,6 +57,174 @@
"node": ">=6.0.0"
}
},
"node_modules/@auth/core": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz",
"integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
"cookie": "0.6.0",
"jose": "^5.1.3",
"oauth4webapi": "^2.10.4",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/core/node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@auth/core/node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@auth/core/node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@auth/core/node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/@auth/prisma-adapter": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.10.0.tgz",
"integrity": "sha512-EliOQoTjGK87jWWqnJvlQjbR4PjQZQqtwRwPAe108WwT9ubuuJJIrL68aNnQr4hFESz6P7SEX2bZy+y2yL37Gw==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.40.0"
},
"peerDependencies": {
"@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6"
}
},
"node_modules/@auth/prisma-adapter/node_modules/@auth/core": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz",
"integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/prisma-adapter/node_modules/jose": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@auth/prisma-adapter/node_modules/oauth4webapi": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.5.tgz",
"integrity": "sha512-1K88D2GiAydGblHo39NBro5TebGXa+7tYoyIbxvqv3+haDDry7CBE1eSYuNbOSsYCCU6y0gdynVZAkm4YPw4hg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@auth/prisma-adapter/node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@auth/prisma-adapter/node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -767,6 +939,16 @@
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@next-auth/prisma-adapter": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz",
"integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==",
"license": "ISC",
"peerDependencies": {
"@prisma/client": ">=2.26.0 || >=3",
"next-auth": "^4"
}
},
"node_modules/@next/env": {
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.4.tgz",
@@ -959,6 +1141,15 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@prisma/client": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.0.tgz",
@@ -1357,6 +1548,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2257,6 +2456,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2457,6 +2665,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4194,6 +4411,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4583,6 +4809,24 @@
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-cache/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -4791,6 +5035,38 @@
}
}
},
"node_modules/next-auth": {
"version": "4.24.11",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.2",
"next": "^12.2.5 || ^13 || ^14 || ^15",
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -4819,6 +5095,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4829,6 +5122,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4942,6 +5244,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5108,6 +5434,28 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.26.9",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz",
"integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5118,6 +5466,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prisma": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.0.tgz",
@@ -6133,6 +6487,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+4
View File
@@ -9,8 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"@auth/prisma-adapter": "^2.10.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.11.0",
"bcryptjs": "^3.0.2",
"next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "FuelType" AS ENUM ('GASOLINE', 'DIESEL', 'LPG');
-- CreateEnum
CREATE TYPE "Currency" AS ENUM ('EUR', 'USD', 'RON', 'GBP');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Car" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"fuelType" "FuelType" NOT NULL,
CONSTRAINT "Car_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FillUp" (
"id" TEXT NOT NULL,
"carId" TEXT NOT NULL,
"mileage" INTEGER NOT NULL,
"liters" DOUBLE PRECISION NOT NULL,
"cost" DOUBLE PRECISION NOT NULL,
"currency" "Currency" NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FillUp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MileageEntry" (
"id" TEXT NOT NULL,
"carId" TEXT NOT NULL,
"mileage" INTEGER NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MileageEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Car" ADD CONSTRAINT "Car_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FillUp" ADD CONSTRAINT "FillUp_carId_fkey" FOREIGN KEY ("carId") REFERENCES "Car"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MileageEntry" ADD CONSTRAINT "MileageEntry_carId_fkey" FOREIGN KEY ("carId") REFERENCES "Car"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `make` to the `Car` table without a default value. This is not possible if the table is not empty.
- Added the required column `model` to the `Car` table without a default value. This is not possible if the table is not empty.
- Added the required column `year` to the `Car` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Car" ADD COLUMN "make" TEXT NOT NULL,
ADD COLUMN "model" TEXT NOT NULL,
ADD COLUMN "year" INTEGER NOT NULL;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+8 -1
View File
@@ -10,6 +10,7 @@ datasource db {
model User {
id String @id @default(cuid())
email String @unique
hashedPassword String
cars Car[]
}
@@ -17,12 +18,18 @@ model Car {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String
name String
name String // User-defined name (e.g. "Red BMW")
make String // Manufacturer (e.g. "BMW")
model String // Model (e.g. "320i")
year Int // Year (e.g. 2019)
fuelType FuelType
fillUps FillUp[]
mileage MileageEntry[]
}
model FillUp {
id String @id @default(cuid())
car Car @relation(fields: [carId], references: [id])
+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;