diff --git a/next-env.d.ts b/next-env.d.ts
index fd36f94..0c7fad7 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,7 @@
///
///
///
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.js b/next.config.js
index dc96abb..c5ebd50 100644
--- a/next.config.js
+++ b/next.config.js
@@ -4,11 +4,13 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
+ turbopack: {},
images: { unoptimized: true },
webpack: (config) => {
config.ignoreWarnings = [
{
module: /@supabase\/realtime-js/,
+
},
];
return config;
diff --git a/package-lock.json b/package-lock.json
index 055222c..220c3b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,7 +59,7 @@
"eslint-config-next": "13.5.1",
"input-otp": "^1.2.4",
"lucide-react": "^0.446.0",
- "next": "13.5.1",
+ "next": "^16.1.6",
"next-themes": "^0.3.0",
"postcss": "^8.4.30",
"react": "18.2.0",
@@ -273,9 +273,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
- "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1065,6 +1065,472 @@
"deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause"
},
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1364,9 +1830,9 @@
}
},
"node_modules/@next/env": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.1.tgz",
- "integrity": "sha512-CIMWiOTyflFn/GFx33iYXkgLSQsMQZV4jB91qaj/TfxGaGOXxn8C1j72TaUSPIyN7ziS/AYG46kGmnvuk1oOpg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1379,9 +1845,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.1.tgz",
- "integrity": "sha512-Bcd0VFrLHZnMmJy6LqV1CydZ7lYaBao8YBEdQUVzV8Ypn/l5s//j5ffjfvMzpEQ4mzlAj3fIY+Bmd9NxpWhACw==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+ "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"cpu": [
"arm64"
],
@@ -1395,9 +1861,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.1.tgz",
- "integrity": "sha512-uvTZrZa4D0bdWa1jJ7X1tBGIxzpqSnw/ATxWvoRO9CVBvXSx87JyuISY+BWsfLFF59IRodESdeZwkWM2l6+Kjg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+ "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"cpu": [
"x64"
],
@@ -1411,9 +1877,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.1.tgz",
- "integrity": "sha512-/52ThlqdORPQt3+AlMoO+omicdYyUEDeRDGPAj86ULpV4dg+/GCFCKAmFWT0Q4zChFwsAoZUECLcKbRdcc0SNg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+ "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"cpu": [
"arm64"
],
@@ -1427,9 +1893,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.1.tgz",
- "integrity": "sha512-L4qNXSOHeu1hEAeeNsBgIYVnvm0gg9fj2O2Yx/qawgQEGuFBfcKqlmIE/Vp8z6gwlppxz5d7v6pmHs1NB6R37w==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+ "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"cpu": [
"arm64"
],
@@ -1443,9 +1909,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.1.tgz",
- "integrity": "sha512-QVvMrlrFFYvLtABk092kcZ5Mzlmsk2+SV3xYuAu8sbTuIoh0U2+HGNhVklmuYCuM3DAAxdiMQTNlRQmNH11udw==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+ "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"cpu": [
"x64"
],
@@ -1459,9 +1925,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.1.tgz",
- "integrity": "sha512-bBnr+XuWc28r9e8gQ35XBtyi5KLHLhTbEvrSgcWna8atI48sNggjIK8IyiEBO3KIrcUVXYkldAzGXPEYMnKt1g==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+ "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"cpu": [
"x64"
],
@@ -1480,9 +1946,9 @@
"integrity": "sha512-aDH8VVNfzv2UvwMMw8LOdzlWu514TOprKWZt+5CPiCeGhN0N5uqVpj5oysQKY/WUkeVzOM+Mk9fg8GxRTSjBcw=="
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.1.tgz",
- "integrity": "sha512-EQGeE4S5c9v06jje9gr4UlxqUEA+zrsgPi6kg9VwR+dQHirzbnVJISF69UfKVkmLntknZJJI9XpWPB6q0Z7mTg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+ "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"cpu": [
"arm64"
],
@@ -1495,26 +1961,10 @@
"node": ">= 10"
}
},
- "node_modules/@next/swc-win32-ia32-msvc": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.1.tgz",
- "integrity": "sha512-1y31Q6awzofVjmbTLtRl92OX3s+W0ZfO8AP8fTnITcIo9a6ATDc/eqa08fd6tSpFu6IFpxOBbdevOjwYTGx/AQ==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.1.tgz",
- "integrity": "sha512-+9XBQizy7X/GuwNegq+5QkkxAPV7SBsIwapVRQd9WSvvU20YO23B3bZUpevdabi4fsd25y9RJDDncljy/V54ww==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+ "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"cpu": [
"x64"
],
@@ -4304,6 +4754,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+ "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -4370,17 +4829,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "dependencies": {
- "streamsearch": "^1.1.0"
- },
- "engines": {
- "node": ">=10.16.0"
- }
- },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -4927,6 +5375,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -4940,9 +5398,9 @@
"license": "Apache-2.0"
},
"node_modules/diff": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
- "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
+ "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
@@ -6071,12 +6529,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/glob-to-regexp": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
- "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
- "license": "BSD-2-Clause"
- },
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@@ -6149,12 +6601,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "license": "ISC"
- },
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -6818,9 +7264,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -6966,9 +7412,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -7129,47 +7575,53 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "13.5.1",
- "resolved": "https://registry.npmjs.org/next/-/next-13.5.1.tgz",
- "integrity": "sha512-GIudNR7ggGUZoIL79mSZcxbXK9f5pwAIPZxEM8+j2yLqv5RODg4TkmUlaKSYVqE1bPQueamXSqdC3j7axiTSEg==",
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"license": "MIT",
"dependencies": {
- "@next/env": "13.5.1",
- "@swc/helpers": "0.5.2",
- "busboy": "1.6.0",
- "caniuse-lite": "^1.0.30001406",
- "postcss": "8.4.14",
- "styled-jsx": "5.1.1",
- "watchpack": "2.4.0",
- "zod": "3.21.4"
+ "@next/env": "16.1.6",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
- "node": ">=16.14.0"
+ "node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "13.5.1",
- "@next/swc-darwin-x64": "13.5.1",
- "@next/swc-linux-arm64-gnu": "13.5.1",
- "@next/swc-linux-arm64-musl": "13.5.1",
- "@next/swc-linux-x64-gnu": "13.5.1",
- "@next/swc-linux-x64-musl": "13.5.1",
- "@next/swc-win32-arm64-msvc": "13.5.1",
- "@next/swc-win32-ia32-msvc": "13.5.1",
- "@next/swc-win32-x64-msvc": "13.5.1"
+ "@next/swc-darwin-arm64": "16.1.6",
+ "@next/swc-darwin-x64": "16.1.6",
+ "@next/swc-linux-arm64-gnu": "16.1.6",
+ "@next/swc-linux-arm64-musl": "16.1.6",
+ "@next/swc-linux-x64-gnu": "16.1.6",
+ "@next/swc-linux-x64-musl": "16.1.6",
+ "@next/swc-win32-arm64-msvc": "16.1.6",
+ "@next/swc-win32-x64-msvc": "16.1.6",
+ "sharp": "^0.34.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
"sass": {
"optional": true
}
@@ -7186,18 +7638,18 @@
}
},
"node_modules/next/node_modules/@swc/helpers": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz",
- "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==",
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
- "tslib": "^2.4.0"
+ "tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
- "version": "8.4.14",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
- "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==",
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
@@ -7206,11 +7658,15 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.4",
+ "nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -7218,15 +7674,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/next/node_modules/zod": {
- "version": "3.21.4",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
- "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -8327,9 +8774,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -8384,6 +8831,51 @@
"node": ">= 0.4"
}
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -8545,14 +9037,6 @@
"node": ">= 0.4"
}
},
- "node_modules/streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -8772,9 +9256,9 @@
}
},
"node_modules/styled-jsx": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
- "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
@@ -8783,7 +9267,7 @@
"node": ">= 12.0.0"
},
"peerDependencies": {
- "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
@@ -8832,9 +9316,10 @@
}
},
"node_modules/sucrase/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -9481,19 +9966,6 @@
"d3-timer": "^3.0.1"
}
},
- "node_modules/watchpack": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
- "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
- "license": "MIT",
- "dependencies": {
- "glob-to-regexp": "^0.4.1",
- "graceful-fs": "^4.1.2"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
diff --git a/package.json b/package.json
index 7d03d34..d7b1dde 100644
--- a/package.json
+++ b/package.json
@@ -48,8 +48,8 @@
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",
"@types/node": "20.6.2",
- "@types/react": "18.2.22",
- "@types/react-dom": "18.2.7",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.15",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -61,12 +61,12 @@
"eslint-config-next": "13.5.1",
"input-otp": "^1.2.4",
"lucide-react": "^0.446.0",
- "next": "13.5.1",
+ "next": "^16.1.6",
"next-themes": "^0.3.0",
"postcss": "^8.4.30",
- "react": "18.2.0",
+ "react": "^19.0.0",
"react-day-picker": "^8.10.1",
- "react-dom": "18.2.0",
+ "react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-hot-toast": "^2.5.2",
"react-resizable-panels": "^2.1.3",
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
index 8d97ae5..e84d087 100644
--- a/src/app/auth/signin/page.tsx
+++ b/src/app/auth/signin/page.tsx
@@ -62,7 +62,7 @@ export default function SignIn() {
{/* Magic link form (optional) */}
- {/*
+
or
@@ -107,7 +107,7 @@ export default function SignIn() {
{message.text}
)}
- */}
+
diff --git a/src/app/courses/[courseId]/page.tsx b/src/app/courses/[courseId]/page.tsx
index 0842b1e..d27876e 100644
--- a/src/app/courses/[courseId]/page.tsx
+++ b/src/app/courses/[courseId]/page.tsx
@@ -8,14 +8,18 @@ import CoursePageStats from "@/components/courses/course_page/CoursePageStats";
import CoursePageReviews from "@/components/courses/course_page/CoursePageReviews";
import RateThisCourse from "@/components/courses/course_page/RateThisCourse";
import Example from "@/components/courses/course_page/CoursePageLoader";
+import { use } from 'react';
+//
-export default function CoursePage({ params }: { params: { courseId: string } }) {
+
+export default function CoursePage({ params }: { params: Promise<{ courseId: string }> }) {
+ const { courseId } = use(params);
const { courses, isLoading } = useCourses();
const [averageRating, setAverageRating] = useState(0);
const [reviewCount, setReviewCount] = useState(0);
const [courseUUID, setCourseUUID] = useState
(null);
- const course = courses.find((course) => course.id === params.courseId);
+ const course = courses.find((course) => course.id === courseId);
/* ---------- Fetch Course UUID from Supabase ---------- */
useEffect(() => {
diff --git a/src/components/common/Pagination.tsx b/src/components/common/Pagination.tsx
new file mode 100644
index 0000000..b43c01b
--- /dev/null
+++ b/src/components/common/Pagination.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import React from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+
+interface PaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+ isLoading?: boolean;
+}
+
+export function Pagination({
+ currentPage,
+ totalPages,
+ onPageChange,
+ hasNextPage,
+ hasPreviousPage,
+ isLoading = false,
+}: PaginationProps) {
+ // Generate page numbers to show
+ const getPageNumbers = () => {
+ const pages: (number | string)[] = [];
+ const showEllipsis = totalPages > 7;
+
+ if (!showEllipsis) {
+ // Show all pages if 7 or fewer
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ // Always show first page
+ pages.push(1);
+
+ if (currentPage <= 3) {
+ // Near the start
+ pages.push(2, 3, 4, '...', totalPages);
+ } else if (currentPage >= totalPages - 2) {
+ // Near the end
+ pages.push('...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages);
+ } else {
+ // In the middle
+ pages.push('...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages);
+ }
+ }
+
+ return pages;
+ };
+
+ if (totalPages <= 1) {
+ return null; // Don't show pagination if only 1 page
+ }
+
+ return (
+
+ {/* Previous Button */}
+
+
+ {/* Page Numbers */}
+
+ {getPageNumbers().map((page, index) => {
+ if (page === '...') {
+ return (
+
+ ...
+
+ );
+ }
+
+ const pageNum = page as number;
+ const isActive = pageNum === currentPage;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Next Button */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx
new file mode 100644
index 0000000..e6b3dcf
--- /dev/null
+++ b/src/components/common/VoteButton.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { ChevronUp, ChevronDown } from 'lucide-react';
+import toast from 'react-hot-toast';
+import { supabase } from '@/lib/supabase';
+
+export type VoteType = 'helpful' | 'unhelpful' | null;
+
+interface VoteButtonProps {
+ reviewId: string;
+ initialVoteType?: VoteType;
+ initialVoteCount?: number;
+ onVote?: (reviewId: string, voteType: VoteType) => void;
+ size?: 'sm' | 'md' | 'lg';
+}
+
+export function VoteButton({
+ reviewId,
+ initialVoteType = null,
+ initialVoteCount = 0,
+ onVote,
+ size = 'md',
+}: VoteButtonProps) {
+ const [currentVote, setCurrentVote] = useState(initialVoteType);
+ const [voteCount, setVoteCount] = useState(initialVoteCount);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Update state when props change (important for late-loading vote data)
+ useEffect(() => {
+ setCurrentVote(initialVoteType);
+ }, [initialVoteType]);
+
+ useEffect(() => {
+ setVoteCount(initialVoteCount);
+ }, [initialVoteCount]);
+
+ // Size variants (Reddit-style vertical layout)
+ const sizes = {
+ sm: {
+ container: 'gap-0',
+ icon: 'w-3.5 h-3.5',
+ text: 'text-[10px]',
+ padding: 'p-0.5',
+ },
+ md: {
+ container: 'gap-1',
+ icon: 'w-5 h-5',
+ text: 'text-sm',
+ padding: 'p-1',
+ },
+ lg: {
+ container: 'gap-1.5',
+ icon: 'w-6 h-6',
+ text: 'text-base',
+ padding: 'p-1.5',
+ },
+ };
+
+ const handleVote = async (voteType: 'helpful' | 'unhelpful') => {
+ if (isLoading) return;
+
+ if (data.success) {
+ // Update with server response
+ setCurrentVote(data.vote_type);
+
+ // Callback
+ if (onVote) {
+ onVote(reviewId, data.vote_type);
+ }
+ }
+ };
+
+ return (
+
+ {/* Upvote button */}
+
+
+ {/* Vote count display */}
+ 0
+ ? 'text-gray-700 dark:text-gray-300'
+ : voteCount < 0
+ ? 'text-gray-500 dark:text-gray-400'
+ : 'text-gray-500 dark:text-gray-400'
+ }
+ `}
+ >
+ {voteCount > 0 && '+'}{voteCount}
+
+
+ {/* Downvote button */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/courses/course_page/CoursePageReviews.tsx b/src/components/courses/course_page/CoursePageReviews.tsx
index 26d3b0f..31197fe 100644
--- a/src/components/courses/course_page/CoursePageReviews.tsx
+++ b/src/components/courses/course_page/CoursePageReviews.tsx
@@ -2,8 +2,9 @@
import React, { useEffect, useState } from "react";
import AddReviewButton from "../AddReviewButton";
-import { ChevronRight, ChevronDown } from "lucide-react";
-import { supabase } from "@/lib/supabase";
+import { VoteButton, VoteType } from "@/components/common/VoteButton";
+import { Pagination } from "@/components/common/Pagination";
+import { usePaginatedReviews } from "@/hooks/usePaginatedReviews";
interface CoursePageReviewsProps {
id: string; // Course ID
@@ -11,7 +12,7 @@ interface CoursePageReviewsProps {
}
/* Single Review Card (vertical format) */
-const CourseReviewItem = ({ review }: { review: any }) => {
+const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: VoteType }) => {
const formattedDate = new Date(review.created_at).toLocaleString("en-IN", {
dateStyle: "medium",
timeStyle: "short",
@@ -27,104 +28,136 @@ const CourseReviewItem = ({ review }: { review: any }) => {
return (
-
-
- {getAnonymousName(review.anonymous_id)}
-
-
- {formattedDate}
-
+
+ {/* Left side - Review content */}
+
+
+
+ {getAnonymousName(review.anonymous_id)}
+
+
+ {formattedDate}
+
+
+
+
+ {review.comment || "No comment provided."}
+
+
+
+ {/* Right side - Vote Button */}
+
+
+
-
- {review.comment || "No comment provided."}
-
);
};
/* Main Reviews Component */
const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => {
- const [reviews, setReviews] = useState
([]);
- const [loading, setLoading] = useState(true);
- const [showAll, setShowAll] = useState(false); // Toggle for View All
+ const [userVotes, setUserVotes] = useState>({});
+
+ // Use pagination hook (always call hooks at top level)
+ const {
+ reviews,
+ currentPage,
+ totalPages,
+ totalItems,
+ isLoading,
+ error,
+ hasNextPage,
+ hasPreviousPage,
+ goToPage,
+ } = usePaginatedReviews({
+ targetId: id,
+ targetType: 'course',
+ initialPage: 1,
+ limit: 10,
+ sortBy: 'created_at',
+ sortOrder: 'desc',
+ });
+ // Fetch user votes whenever reviews change
useEffect(() => {
- const fetchReviews = async () => {
- setLoading(true);
-
- // Fetch all reviews
- const { data, error } = await supabase
- .from("reviews")
- .select(`
- id,
- anonymous_id,
- comment,
- created_at
- `)
- .eq("target_id", id)
- .eq("target_type", "course")
- .order("created_at", { ascending: false });
-
- if (error) {
- console.error("Error fetching reviews:", error.message);
- } else {
- setReviews(data || []);
+ const fetchUserVotes = async () => {
+ if (reviews.length === 0) return;
+
+ const reviewIds = reviews.map(r => r.id).join(',');
+
+ try {
+ const response = await fetch(`/api/ratings/vote?review_ids=${reviewIds}`);
+ const votesData = await response.json();
+
+ if (votesData.success) {
+ setUserVotes(votesData.votes || {});
+ }
+ } catch (error) {
+ console.error("Error fetching user votes:", error);
}
-
- setLoading(false);
};
- fetchReviews();
- }, [id]);
-
- // Show only 3 unless expanded
- const displayedReviews = showAll ? reviews : reviews.slice(0, 3);
+ fetchUserVotes();
+ }, [reviews]);
return (
{/* Header */}
- Student Reviews
+ Student Reviews {totalItems > 0 && `(${totalItems})`}
{/* Reviews list */}
- {loading ? (
+ {isLoading && currentPage === 1 ? (
Loading reviews...
+ ) : error ? (
+
+ Error: {error}
+
) : reviews.length === 0 ? (
No reviews yet for this course.
) : (
- displayedReviews.map((review) => (
-
- ))
+ <>
+ {reviews.map((review) => (
+
+ ))}
+ >
)}
- {/* View All Toggle */}
- {reviews.length > 3 && (
-
setShowAll(!showAll)}
- className="p-2 border-t border-muted dark:border-gray-700 text-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
- >
-
+ {/* Pagination - only show if more than 1 page */}
+ {totalPages > 1 && (
+
)}
);
};
-export default CoursePageReviews;
+export default CoursePageReviews;
\ No newline at end of file
diff --git a/src/hooks/usePaginatedReviews.ts b/src/hooks/usePaginatedReviews.ts
new file mode 100644
index 0000000..05d214a
--- /dev/null
+++ b/src/hooks/usePaginatedReviews.ts
@@ -0,0 +1,141 @@
+import { useState, useEffect, useCallback } from 'react';
+import { PaginatedResponse } from '@/types/pagination';
+
+interface Review {
+ id: string;
+ anonymous_id: string;
+ rating_value: number;
+ comment: string;
+ votes: number;
+ created_at: string;
+ // ... other review fields
+}
+
+interface UsePaginatedReviewsOptions {
+ targetId: string;
+ targetType: 'course' | 'professor';
+ initialPage?: number;
+ limit?: number;
+ sortBy?: string;
+ sortOrder?: 'asc' | 'desc';
+}
+
+export function usePaginatedReviews({
+ targetId,
+ targetType,
+ initialPage = 1,
+ limit = 10,
+ sortBy = 'created_at',
+ sortOrder = 'desc',
+}: UsePaginatedReviewsOptions) {
+ const [data, setData] = useState
([]);
+ const [currentPage, setCurrentPage] = useState(initialPage);
+ const [totalPages, setTotalPages] = useState(0);
+ const [totalItems, setTotalItems] = useState(0);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasNextPage, setHasNextPage] = useState(false);
+ const [hasPreviousPage, setHasPreviousPage] = useState(false);
+
+ const fetchReviews = useCallback(async (page: number) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const params = new URLSearchParams({
+ page: page.toString(),
+ limit: limit.toString(),
+ target_id: targetId,
+ target_type: targetType,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ });
+
+ const url = `/api/reviews?${params}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('API error response:', errorText);
+ throw new Error(`Failed to fetch reviews: ${response.status}`);
+ }
+
+ const result: PaginatedResponse = await response.json();
+
+ // Check if the response has the expected structure
+ if (!result || typeof result !== 'object') {
+ throw new Error('Invalid response format');
+ }
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch reviews');
+ }
+
+ setData(result.data || []);
+ setCurrentPage(result.pagination.currentPage);
+ setTotalPages(result.pagination.totalPages);
+ setTotalItems(result.pagination.totalItems);
+ setHasNextPage(result.pagination.hasNextPage);
+ setHasPreviousPage(result.pagination.hasPreviousPage);
+
+ } catch (err) {
+ console.error('Error fetching reviews:', err);
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ // Set empty state on error
+ setData([]);
+ setTotalPages(0);
+ setTotalItems(0);
+ setHasNextPage(false);
+ setHasPreviousPage(false);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [targetId, targetType, limit, sortBy, sortOrder]);
+
+ // Initial fetch - only run when we have a valid targetId
+ useEffect(() => {
+ if (!targetId) {
+ console.warn('usePaginatedReviews: targetId is missing');
+ return;
+ }
+ fetchReviews(currentPage);
+ }, [fetchReviews, currentPage, targetId]);
+
+ // Navigation functions
+ const goToPage = useCallback((page: number) => {
+ if (page >= 1 && page <= totalPages) {
+ setCurrentPage(page);
+ }
+ }, [totalPages]);
+
+ const nextPage = useCallback(() => {
+ if (hasNextPage) {
+ setCurrentPage(prev => prev + 1);
+ }
+ }, [hasNextPage]);
+
+ const previousPage = useCallback(() => {
+ if (hasPreviousPage) {
+ setCurrentPage(prev => prev - 1);
+ }
+ }, [hasPreviousPage]);
+
+ const refresh = useCallback(() => {
+ fetchReviews(currentPage);
+ }, [fetchReviews, currentPage]);
+
+ return {
+ reviews: data,
+ currentPage,
+ totalPages,
+ totalItems,
+ isLoading,
+ error,
+ hasNextPage,
+ hasPreviousPage,
+ goToPage,
+ nextPage,
+ previousPage,
+ refresh,
+ };
+}
\ No newline at end of file
diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts
new file mode 100644
index 0000000..8271a48
--- /dev/null
+++ b/src/lib/pagination.ts
@@ -0,0 +1,69 @@
+import { PaginationParams, PaginationMeta, PaginationOptions } from '@/types/pagination';
+
+/**
+ * Calculate pagination metadata
+ */
+export function calculatePagination(
+ totalItems: number,
+ page: number,
+ limit: number
+): PaginationMeta {
+ const safeTotal = Math.max(0, totalItems);
+ const safeLimit = Math.max(1, limit);
+ const totalPages = Math.ceil(safeTotal / safeLimit);
+ const currentPage = Math.max(1, Math.min(page, totalPages));
+
+ return {
+ currentPage,
+ totalPages,
+ totalItems: safeTotal,
+ itemsPerPage: safeLimit,
+ hasNextPage: currentPage < totalPages,
+ hasPreviousPage: currentPage > 1,
+ };
+}
+
+/**
+ * Validate and normalize pagination parameters
+ */
+export function validatePaginationParams(
+ page?: number | string,
+ limit?: number | string,
+ options: PaginationOptions = {}
+): PaginationParams {
+ const defaultLimit = options.defaultLimit || 10;
+ const maxLimit = options.maxLimit || 100;
+
+ const normalizedPage = Math.max(1, parseInt(String(page || 1), 10) || 1);
+ const normalizedLimit = Math.min(
+ maxLimit,
+ Math.max(1, parseInt(String(limit || defaultLimit), 10) || defaultLimit)
+ );
+
+ return {
+ page: normalizedPage,
+ limit: normalizedLimit,
+ };
+}
+
+/**
+ * Calculate offset for SQL queries
+ */
+export function getOffset(page: number, limit: number): number {
+ return (page - 1) * limit;
+}
+
+/**
+ * Build pagination response
+ */
+export function buildPaginationResponse(
+ data: T[],
+ totalItems: number,
+ params: PaginationParams
+) {
+ return {
+ data,
+ pagination: calculatePagination(totalItems, params.page, params.limit),
+ success: true,
+ };
+}
\ No newline at end of file
diff --git a/src/lib/withPagination.ts b/src/lib/withPagination.ts
new file mode 100644
index 0000000..5ffc99c
--- /dev/null
+++ b/src/lib/withPagination.ts
@@ -0,0 +1,94 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import { SupabaseClient } from '@supabase/supabase-js';
+import { validatePaginationParams, getOffset, buildPaginationResponse } from '@/lib/pagination';
+
+// Helper to safely extract single string from query param
+export const getParam = (param: string | string[] | undefined): string | undefined =>
+ Array.isArray(param) ? param[0] : param;
+
+interface PaginatedHandlerOptions {
+ // Build and return the Supabase query (without range applied)
+ buildQuery: (
+ supabase: SupabaseClient,
+ req: NextApiRequest
+ ) => Promise<{
+ query: any;
+ error?: string; // Return an error string to short-circuit with 400
+ }>;
+
+ // Optional: transform each item before returning
+ transform?: (item: any) => T;
+
+ // Pagination config
+ defaultLimit?: number;
+ maxLimit?: number;
+}
+
+/**
+ * Generic paginated API handler.
+ *
+ * Usage:
+ * export default withPagination({ buildQuery, transform })
+ */
+export function withPagination({
+ buildQuery,
+ transform,
+ defaultLimit = 10,
+ maxLimit = 50,
+}: PaginatedHandlerOptions) {
+ return async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed', success: false });
+ }
+
+ try {
+ // Dynamically import supabase client
+ const { createClient } = await import('@/utils/supabase/server-pages');
+ const supabase = createClient(req, res);
+
+ // Parse pagination params
+ const page = getParam(req.query.page);
+ const limit = getParam(req.query.limit);
+
+ const paginationParams = validatePaginationParams(page, limit, {
+ defaultLimit,
+ maxLimit,
+ });
+
+ // Let the caller build the query
+ const { query, error: queryError } = await buildQuery(supabase, req);
+
+ // Short-circuit if the caller returned a validation error
+ if (queryError) {
+ return res.status(400).json({ error: queryError, success: false });
+ }
+
+ // Apply pagination
+ const offset = getOffset(paginationParams.page, paginationParams.limit);
+ const { data, error, count } = await query
+ .range(offset, offset + paginationParams.limit - 1);
+
+ if (error) {
+ console.error('Supabase query error:', error);
+ return res.status(500).json({ error: 'Failed to fetch data', success: false });
+ }
+
+ if (count === null) {
+ console.warn('withPagination: count is null — did buildQuery include { count: "exact" } in .select()?');
+ }
+
+ // Optionally transform results
+ const results: T[] = transform
+ ? (data || []).map(transform)
+ : (data || []);
+
+ return res.status(200).json(
+ buildPaginationResponse(results, count || 0, paginationParams)
+ );
+
+ } catch (error) {
+ console.error('Unexpected error:', error);
+ return res.status(500).json({ error: 'Internal server error', success: false });
+ }
+ };
+}
\ No newline at end of file
diff --git a/src/migrations/migration.sql b/src/migrations/migration.sql
index e33b914..f0d4df8 100644
--- a/src/migrations/migration.sql
+++ b/src/migrations/migration.sql
@@ -92,15 +92,19 @@ CREATE TABLE reviews (
);
-- Create the votes table
+-- UPDATED: Now tracks auth_id to prevent duplicate votes from same user
CREATE TABLE votes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
anonymous_id UUID NOT NULL,
+ auth_id UUID NOT NULL, -- Track real user to prevent duplicates
vote_type TEXT NOT NULL CHECK (vote_type IN ('helpful', 'unhelpful')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- -- Ensure a user can only vote once per review
- UNIQUE (review_id, anonymous_id)
+ -- Ensure a user can only vote once per review (using anonymous_id for backward compatibility)
+ UNIQUE (review_id, anonymous_id),
+ -- NEW: Prevent same real user from voting twice (even with different anonymous_ids)
+ UNIQUE (review_id, auth_id)
);
-- Create the flags table
@@ -120,7 +124,8 @@ CREATE TABLE flags (
CREATE INDEX idx_reviews_target ON reviews(target_id, target_type);
CREATE INDEX idx_reviews_anonymous_id ON reviews(anonymous_id);
CREATE INDEX idx_votes_review_id ON votes(review_id);
-CREATE INDEX idx_flagsMathematics-I_review_id ON flags(review_id);
+CREATE INDEX idx_votes_auth_id ON votes(auth_id); -- NEW: Index for auth_id lookups
+CREATE INDEX idx_flags_review_id ON flags(review_id);
CREATE INDEX idx_flags_status ON flags(status);
-- Create function to update course ratings
@@ -252,8 +257,13 @@ END;
$$ LANGUAGE plpgsql;
-- Create function to update review votes
+-- IMPORTANT: SECURITY DEFINER allows this function to bypass RLS policies
+-- This is necessary so the trigger can update the reviews table
CREATE OR REPLACE FUNCTION update_review_votes()
-RETURNS TRIGGER AS $$
+RETURNS TRIGGER
+SECURITY DEFINER
+SET search_path = public
+AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
UPDATE reviews
@@ -444,25 +454,26 @@ CREATE POLICY review_delete ON reviews
);
-- Vote policies
+-- UPDATED: Now checks auth_id to prevent duplicate votes
CREATE POLICY vote_select ON votes
FOR SELECT USING (true);
CREATE POLICY vote_insert ON votes
FOR INSERT WITH CHECK (
auth.uid() IS NOT NULL AND
- anonymous_id = get_anonymous_id()
+ auth_id = auth.uid()
);
CREATE POLICY vote_update ON votes
FOR UPDATE USING (
- anonymous_id = get_anonymous_id()
+ auth_id = auth.uid()
) WITH CHECK (
- anonymous_id = get_anonymous_id()
+ auth_id = auth.uid()
);
CREATE POLICY vote_delete ON votes
FOR DELETE USING (
- anonymous_id = get_anonymous_id() OR is_admin()
+ auth_id = auth.uid() OR is_admin()
);
-- Flag policies
@@ -481,40 +492,33 @@ CREATE POLICY flag_update ON flags
CREATE POLICY flag_delete ON flags
FOR DELETE USING (is_admin());
---
---
---
---
--- THIS IS THE CORRECTED FUNCTION
---
---
---
---
-- Create function to create an anonymous user on signup
+-- UPDATED: Added check to prevent duplicate users
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
new_salt TEXT;
new_hash TEXT;
+ existing_user_count INT;
BEGIN
- -- Generate a salt
- -- We explicitly call the function inside the 'extensions' schema
- new_salt := encode(extensions.gen_random_bytes(16), 'hex');
-
- -- Create verification hash (placeholder)
- -- We explicitly call the function inside the 'extensions' schema
- new_hash := encode(extensions.digest(NEW.email || new_salt, 'sha256'), 'hex');
+ -- Check if user already exists in our users table
+ SELECT COUNT(*) INTO existing_user_count
+ FROM public.users
+ WHERE auth_id = NEW.id;
- -- Insert new user
- INSERT INTO public.users (auth_id, verification_hash, salt)
- VALUES (NEW.id, new_hash, new_salt);
+ -- Only create if doesn't exist
+ IF existing_user_count = 0 THEN
+ -- Generate a salt
+ new_salt := encode(extensions.gen_random_bytes(16), 'hex');
+
+ -- Create verification hash
+ new_hash := encode(extensions.digest(NEW.email || new_salt, 'sha256'), 'hex');
+
+ -- Insert new user
+ INSERT INTO public.users (auth_id, verification_hash, salt)
+ VALUES (NEW.id, new_hash, new_salt);
+ END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-
--- Trigger to create user profile after auth user is created
-CREATE TRIGGER on_auth_user_created
- AFTER INSERT ON auth.users
- FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-
\ No newline at end of file
diff --git a/src/pages/api/ratings/route.ts b/src/pages/api/ratings/route.ts
deleted file mode 100644
index d86d4b1..0000000
--- a/src/pages/api/ratings/route.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import { NextResponse } from 'next/server';
-import { supabase } from '@/lib/supabase';
-import { supabaseAdmin } from '@/lib/supabase-admin';
-import { createFuzzyTimestamp, sanitizeContent } from '@/lib/anonymization';
-import { RatingInsert } from '@/types/supabase';
-
-// POST /api/ratings - Create a new rating
-export async function POST(request: Request) {
- try {
- // Get session to verify authentication
- const { data: { session } } = await supabase.auth.getSession();
-
- if (!session?.user?.id) {
- return NextResponse.json(
- { error: 'Authentication required' },
- { status: 401 }
- );
- }
-
- // Get JSON data from request
- const json = await request.json();
- const {
- targetId,
- targetType,
- ratingMetrics,
- comment,
- semester,
- year
- } = json;
-
- // Validate required fields
- if (!targetId || !targetType || !ratingMetrics || !semester || !year) {
- return NextResponse.json(
- { error: 'Missing required fields' },
- { status: 400 }
- );
- }
-
- // Validate target type
- if (targetType !== 'course' && targetType !== 'professor') {
- return NextResponse.json(
- { error: 'Invalid target type' },
- { status: 400 }
- );
- }
-
- // Get the anonymous ID for the authenticated user
- const { data: anonymousData, error: anonymousError } = await supabase
- .from('users')
- .select('anonymous_id')
- .eq('auth_id', session.user.id)
- .single();
-
- if (anonymousError || !anonymousData?.anonymous_id) {
- console.error('Error fetching anonymous ID:', anonymousError);
- return NextResponse.json(
- { error: 'Failed to verify anonymous identity' },
- { status: 500 }
- );
- }
-
- // Process the rating data
- const sanitizedComment = comment ? sanitizeContent(comment) : null;
- const displayDate = createFuzzyTimestamp();
-
- // Check if user has already rated this target
- const { data: existingRating, error: checkError } = await supabase
- .from('ratings')
- .select('id')
- .eq('anonymous_id', anonymousData.anonymous_id)
- .eq('target_id', targetId)
- .eq('target_type', targetType)
- .single();
-
- // If user has already rated, return error
- if (existingRating) {
- return NextResponse.json(
- { error: 'You have already rated this item. Please edit your existing rating instead.' },
- { status: 409 }
- );
- }
-
- // Create the rating
- const ratingData: RatingInsert = {
- anonymous_id: anonymousData.anonymous_id,
- target_id: targetId,
- target_type: targetType,
- rating_metrics: ratingMetrics,
- comment: sanitizedComment,
- semester,
- year,
- display_date: displayDate,
- helpfulness_score: 0,
- is_flagged: false
- };
-
- // Use admin client to create rating (to bypass RLS if needed)
- const { data, error } = await supabaseAdmin
- .from('ratings')
- .insert(ratingData)
- .select('id')
- .single();
-
- if (error) {
- console.error('Error creating rating:', error);
- return NextResponse.json(
- { error: 'Failed to submit rating' },
- { status: 500 }
- );
- }
-
- // Update the rating statistics view or trigger
- // This would typically be handled by a database trigger
-
- return NextResponse.json({
- success: true,
- data: {
- id: data.id,
- displayDate
- }
- });
-
- } catch (error) {
- console.error('Unexpected error in ratings API:', error);
- return NextResponse.json(
- { error: 'An unexpected error occurred' },
- { status: 500 }
- );
- }
-}
-
-// GET /api/ratings - Get ratings with filters
-export async function GET(request: Request) {
- try {
- const { searchParams } = new URL(request.url);
- const targetId = searchParams.get('targetId');
- const targetType = searchParams.get('targetType');
- const page = parseInt(searchParams.get('page') || '1');
- const pageSize = parseInt(searchParams.get('pageSize') || '10');
- const sortBy = searchParams.get('sortBy') || 'created_at';
- const sortOrder = searchParams.get('sortOrder') || 'desc';
-
- // Validate required params
- if (!targetId || !targetType) {
- return NextResponse.json(
- { error: 'Missing required parameters: targetId and targetType' },
- { status: 400 }
- );
- }
-
- // Validate target type
- if (targetType !== 'course' && targetType !== 'professor') {
- return NextResponse.json(
- { error: 'Invalid target type' },
- { status: 400 }
- );
- }
-
- // Calculate pagination
- const from = (page - 1) * pageSize;
- const to = from + pageSize - 1;
-
- // Query ratings
- let query = supabase
- .from('ratings')
- .select('id, rating_metrics, comment, semester, year, display_date, created_at, helpfulness_score')
- .eq('target_id', targetId)
- .eq('target_type', targetType)
- .eq('is_flagged', false)
- .range(from, to);
-
- // Apply sorting
- if (sortBy === 'helpfulness') {
- query = query.order('helpfulness_score', { ascending: sortOrder === 'asc' });
- } else if (sortBy === 'date') {
- query = query.order('created_at', { ascending: sortOrder === 'asc' });
- } else if (sortBy === 'rating') {
- // For rating, we need to sort by the overall metric within the rating_metrics JSONB field
- // This might need a different approach depending on your database
- query = query.order('rating_metrics->overall', { ascending: sortOrder === 'asc' });
- }
-
- const { data: ratings, error, count } = await query;
-
- if (error) {
- console.error('Error fetching ratings:', error);
- return NextResponse.json(
- { error: 'Failed to fetch ratings' },
- { status: 500 }
- );
- }
-
- // Get total count for pagination
- const { count: totalCount, error: countError } = await supabase
- .from('ratings')
- .select('*', { count: 'exact', head: true })
- .eq('target_id', targetId)
- .eq('target_type', targetType)
- .eq('is_flagged', false);
-
- if (countError) {
- console.error('Error counting ratings:', countError);
- }
-
- return NextResponse.json({
- data: ratings,
- meta: {
- page,
- pageSize,
- totalCount: totalCount || 0,
- totalPages: Math.ceil((totalCount || 0) / pageSize)
- }
- });
-
- } catch (error) {
- console.error('Unexpected error in ratings API:', error);
- return NextResponse.json(
- { error: 'An unexpected error occurred' },
- { status: 500 }
- );
- }
-}
\ No newline at end of file
diff --git a/src/pages/api/ratings/vote/index.ts b/src/pages/api/ratings/vote/index.ts
new file mode 100644
index 0000000..d80b44d
--- /dev/null
+++ b/src/pages/api/ratings/vote/index.ts
@@ -0,0 +1,224 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import { createClient } from '@/utils/supabase/server-pages';
+
+/**
+ * Vote API Route - UPDATED to prevent duplicate votes using auth_id
+ * Handles helpful/unhelpful functionality for course and professor reviews
+ */
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const supabase = createClient(req, res);
+
+ // Handle POST - Cast or update vote
+ if (req.method === 'POST') {
+ try {
+ const { review_id, vote_type } = req.body;
+
+ // Validate input
+ if (!review_id || !vote_type) {
+ return res.status(400).json({ error: 'review_id and vote_type are required' });
+ }
+
+ if (!['helpful', 'unhelpful'].includes(vote_type)) {
+ return res.status(400).json({ error: 'vote_type must be "helpful" or "unhelpful"' });
+ }
+
+ // Get user session
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+
+ if (authError || !user) {
+ console.error('Auth error:', authError);
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+
+ // Get anonymous ID
+ const { data: anonData, error: anonError } = await supabase.rpc('get_anonymous_id');
+
+ if (anonError || !anonData) {
+ console.error('Error getting anonymous ID:', anonError);
+ return res.status(500).json({ error: 'Failed to get user identifier' });
+ }
+
+ const anonymous_id = anonData;
+ const auth_id = user.id; // Track real user to prevent duplicates
+
+ // Check if user already voted (by auth_id to prevent duplicate anonymous_ids)
+ const { data: existingVote, error: checkError } = await supabase
+ .from('votes')
+ .select('id, vote_type')
+ .eq('review_id', review_id)
+ .eq('auth_id', auth_id) // ← Check by real user, not anonymous_id
+ .single();
+
+ if (checkError && checkError.code !== 'PGRST116') {
+ console.error('Error checking existing vote:', checkError);
+ return res.status(500).json({ error: 'Failed to check existing vote' });
+ }
+
+ // Case 1: Same vote type - remove vote (toggle off)
+ if (existingVote && existingVote.vote_type === vote_type) {
+ const { error: deleteError } = await supabase
+ .from('votes')
+ .delete()
+ .eq('id', existingVote.id);
+
+ if (deleteError) {
+ console.error('Error deleting vote:', deleteError);
+ return res.status(500).json({ error: 'Failed to remove vote' });
+ }
+
+ return res.status(200).json({
+ success: true,
+ action: 'removed',
+ vote_type: null,
+ });
+ }
+
+ // Case 2: Different vote type - update vote
+ if (existingVote && existingVote.vote_type !== vote_type) {
+ const { error: updateError } = await supabase
+ .from('votes')
+ .update({
+ vote_type,
+ anonymous_id, // Update anonymous_id in case it changed
+ created_at: new Date().toISOString()
+ })
+ .eq('id', existingVote.id);
+
+ if (updateError) {
+ console.error('Error updating vote:', updateError);
+ return res.status(500).json({ error: 'Failed to update vote' });
+ }
+
+ return res.status(200).json({
+ success: true,
+ action: 'updated',
+ vote_type,
+ });
+ }
+
+ // Case 3: New vote - insert
+ const { error: insertError } = await supabase
+ .from('votes')
+ .insert({
+ review_id,
+ anonymous_id,
+ auth_id, // ← Add auth_id to track real user
+ vote_type,
+ });
+
+ if (insertError) {
+ console.error('Error inserting vote:', insertError);
+ return res.status(500).json({ error: 'Failed to cast vote' });
+ }
+
+ return res.status(200).json({
+ success: true,
+ action: 'created',
+ vote_type,
+ });
+
+ } catch (error) {
+ console.error('Unexpected error in vote route:', error);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+
+ // Handle GET - Fetch user votes
+ else if (req.method === 'GET') {
+ try {
+ const { review_ids } = req.query;
+
+ if (!review_ids || typeof review_ids !== 'string') {
+ return res.status(400).json({ error: 'review_ids parameter is required' });
+ }
+
+ // Get user session
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) {
+ // If not logged in, return empty votes
+ return res.status(200).json({
+ success: true,
+ votes: {},
+ });
+ }
+
+ const auth_id = user.id;
+ const reviewIdArray = review_ids.split(',').map(id => id.trim());
+
+ // Batch fetch votes by auth_id
+ const { data: votes, error: fetchError } = await supabase
+ .from('votes')
+ .select('review_id, vote_type')
+ .eq('auth_id', auth_id) // ← Fetch by real user
+ .in('review_id', reviewIdArray);
+
+ if (fetchError) {
+ console.error('Error fetching votes:', fetchError);
+ return res.status(500).json({ error: 'Failed to fetch votes' });
+ }
+
+ // Transform to object map
+ const votesMap = (votes || []).reduce((acc, vote) => {
+ acc[vote.review_id] = vote.vote_type;
+ return acc;
+ }, {} as Record);
+
+ return res.status(200).json({
+ success: true,
+ votes: votesMap,
+ });
+
+ } catch (error) {
+ console.error('Unexpected error in vote GET route:', error);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+
+ // Handle DELETE - Remove vote
+ else if (req.method === 'DELETE') {
+ try {
+ const { review_id } = req.body;
+
+ if (!review_id) {
+ return res.status(400).json({ error: 'review_id is required' });
+ }
+
+ // Get user session
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+
+ if (authError || !user) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+
+ const auth_id = user.id;
+
+ // Delete the vote by auth_id
+ const { error: deleteError } = await supabase
+ .from('votes')
+ .delete()
+ .eq('review_id', review_id)
+ .eq('auth_id', auth_id); // ← Delete by real user
+
+ if (deleteError) {
+ console.error('Error deleting vote:', deleteError);
+ return res.status(500).json({ error: 'Failed to delete vote' });
+ }
+
+ return res.status(200).json({
+ success: true,
+ action: 'deleted',
+ });
+
+ } catch (error) {
+ console.error('Unexpected error in vote DELETE route:', error);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+
+ // Method not allowed
+ else {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+}
\ No newline at end of file
diff --git a/src/pages/api/ratings/vote/route.ts b/src/pages/api/ratings/vote/route.ts
deleted file mode 100644
index a211f6b..0000000
--- a/src/pages/api/ratings/vote/route.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { NextResponse } from 'next/server';
-import { supabase } from '@/lib/supabase';
-import { supabaseAdmin } from '@/lib/supabase-admin';
-import { VoteInsert } from '@/types/supabase';
-
-// POST /api/ratings/vote - Vote on a rating (helpful/unhelpful)
-export async function POST(request: Request) {
- try {
- // Get session to verify authentication
- const { data: { session } } = await supabase.auth.getSession();
-
- if (!session?.user?.id) {
- return NextResponse.json(
- { error: 'Authentication required' },
- { status: 401 }
- );
- }
-
- // Get JSON data from request
- const json = await request.json();
- const { ratingId, voteType } = json;
-
- // Validate required fields
- if (!ratingId) {
- return NextResponse.json(
- { error: 'Missing rating ID' },
- { status: 400 }
- );
- }
-
- // Validate vote type
- if (voteType !== 'helpful' && voteType !== 'unhelpful') {
- return NextResponse.json(
- { error: 'Invalid vote type' },
- { status: 400 }
- );
- }
-
- // Get the anonymous ID for the authenticated user
- const { data: anonymousData, error: anonymousError } = await supabase
- .from('users')
- .select('anonymous_id')
- .eq('auth_id', session.user.id)
- .single();
-
- if (anonymousError || !anonymousData?.anonymous_id) {
- console.error('Error fetching anonymous ID:', anonymousError);
- return NextResponse.json(
- { error: 'Failed to verify anonymous identity' },
- { status: 500 }
- );
- }
-
- // Check if user has already voted on this rating
- const { data: existingVote, error: checkError } = await supabase
- .from('rating_votes')
- .select('id, vote_type')
- .eq('rating_id', ratingId)
- .eq('anonymous_id', anonymousData.anonymous_id)
- .single();
-
- // Transaction to handle vote logic
- const { data, error } = await supabaseAdmin.rpc('handle_rating_vote', {
- p_rating_id: ratingId,
- p_anonymous_id: anonymousData.anonymous_id,
- p_vote_type: voteType,
- p_existing_vote_type: existingVote?.vote_type || null
- });
-
- if (error) {
- console.error('Error processing vote:', error);
- return NextResponse.json(
- { error: 'Failed to process vote' },
- { status: 500 }
- );
- }
-
- return NextResponse.json({
- success: true,
- data: {
- voteType,
- helpfulnessScore: data.new_helpfulness_score
- }
- });
-
- } catch (error) {
- console.error('Unexpected error in rating vote API:', error);
- return NextResponse.json(
- { error: 'An unexpected error occurred' },
- { status: 500 }
- );
- }
-}
-
-// DELETE /api/ratings/vote - Remove a vote from a rating
-export async function DELETE(request: Request) {
- try {
- // Get session to verify authentication
- const { data: { session } } = await supabase.auth.getSession();
-
- if (!session?.user?.id) {
- return NextResponse.json(
- { error: 'Authentication required' },
- { status: 401 }
- );
- }
-
- const { searchParams } = new URL(request.url);
- const ratingId = searchParams.get('ratingId');
-
- // Validate required fields
- if (!ratingId) {
- return NextResponse.json(
- { error: 'Missing rating ID' },
- { status: 400 }
- );
- }
-
- // Get the anonymous ID for the authenticated user
- const { data: anonymousData, error: anonymousError } = await supabase
- .from('users')
- .select('anonymous_id')
- .eq('auth_id', session.user.id)
- .single();
-
- if (anonymousError || !anonymousData?.anonymous_id) {
- console.error('Error fetching anonymous ID:', anonymousError);
- return NextResponse.json(
- { error: 'Failed to verify anonymous identity' },
- { status: 500 }
- );
- }
-
- // Check if user has a vote on this rating
- const { data: existingVote, error: checkError } = await supabase
- .from('rating_votes')
- .select('id, vote_type')
- .eq('rating_id', ratingId)
- .eq('anonymous_id', anonymousData.anonymous_id)
- .single();
-
- if (!existingVote) {
- return NextResponse.json(
- { error: 'No vote found to remove' },
- { status: 404 }
- );
- }
-
- // Transaction to remove vote and update helpfulness score
- const { data, error } = await supabaseAdmin.rpc('remove_rating_vote', {
- p_rating_id: ratingId,
- p_anonymous_id: anonymousData.anonymous_id,
- p_vote_type: existingVote.vote_type
- });
-
- if (error) {
- console.error('Error removing vote:', error);
- return NextResponse.json(
- { error: 'Failed to remove vote' },
- { status: 500 }
- );
- }
-
- return NextResponse.json({
- success: true,
- data: {
- helpfulnessScore: data.new_helpfulness_score
- }
- });
-
- } catch (error) {
- console.error('Unexpected error in rating vote API:', error);
- return NextResponse.json(
- { error: 'An unexpected error occurred' },
- { status: 500 }
- );
- }
-}
\ No newline at end of file
diff --git a/src/pages/api/reviews/index.ts b/src/pages/api/reviews/index.ts
new file mode 100644
index 0000000..2d8decd
--- /dev/null
+++ b/src/pages/api/reviews/index.ts
@@ -0,0 +1,90 @@
+import { withPagination, getParam } from '@/lib/withPagination';
+
+export default withPagination({
+ defaultLimit: 10,
+ maxLimit: 50,
+ buildQuery: async (supabase, req) => {
+ const target_id = getParam(req.query.target_id);
+ const target_type = getParam(req.query.target_type);
+ const sort_by = getParam(req.query.sort_by) ?? 'created_at';
+ const sort_order = getParam(req.query.sort_order) ?? 'desc';
+
+ // Validate required params
+ if (!target_id || !target_type) {
+ return { query: null, error: 'target_id and target_type are required' };
+ }
+ if (!['course', 'professor'].includes(target_type)) {
+ return { query: null, error: 'target_type must be "course" or "professor"' };
+ }
+
+
+ // USE THIS WHEN YOU PASS UUIDS AS TARGET IDS
+ // In your page component, fetch the course/professor first:
+ //
+ // const { data: course } = await supabase
+ // .from('courses')
+ // .select('id, code, title, ...')
+ // .eq('code', params.code)
+ // .single();
+ //
+ // Then pass the UUID:
+ //
+
+ let actualTargetId = target_id;
+
+ // Check if target_id is a code (not a UUID)
+ // UUIDs have format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (contains dashes)
+ // Codes are simpler: mal100, cs101, etc. (no dashes)
+ const isUUID = target_id.includes('-');
+
+ if (!isUUID) {
+ // It's a code, need to look up the UUID
+ if (target_type === 'course') {
+ const { data: course, error: lookupError } = await supabase
+ .from('courses')
+ .select('id')
+ .eq('code', target_id.toUpperCase()) // Case-insensitive lookup
+ .single();
+
+ if (lookupError || !course) {
+ return { query: null, error: `Course with code "${target_id}" not found` };
+ }
+
+ actualTargetId = course.id;
+ } else if (target_type === 'professor') {
+ // For professors, you might use email or name as the code
+ // Adjust this based on how you identify professors
+ const { data: professor, error: lookupError } = await supabase
+ .from('professors')
+ .select('id')
+ .eq('email', target_id.toLowerCase())
+ .single();
+
+ if (lookupError || !professor) {
+ return { query: null, error: `Professor with identifier "${target_id}" not found` };
+ }
+
+ actualTargetId = professor.id;
+ }
+ }
+
+ // Validate sort column
+ const validSortColumns = ['created_at', 'votes', 'rating_value'];
+ const sortColumn = validSortColumns.includes(sort_by) ? sort_by : 'created_at';
+
+ // Build query with the actual UUID
+ const query = supabase
+ .from('reviews')
+ .select(`
+ id, anonymous_id, rating_value, comment, votes,
+ is_flagged, difficulty_rating, workload_rating,
+ knowledge_rating, teaching_rating, approachability_rating,
+ created_at, updated_at
+ `, { count: 'exact' })
+ .eq('target_id', actualTargetId) // ← Use resolved UUID
+ .eq('target_type', target_type)
+ .order(sortColumn, { ascending: sort_order === 'asc' });
+
+ return { query };
+ },
+});
\ No newline at end of file
diff --git a/src/types/pagination.ts b/src/types/pagination.ts
new file mode 100644
index 0000000..f5bd0ba
--- /dev/null
+++ b/src/types/pagination.ts
@@ -0,0 +1,27 @@
+export interface PaginationParams {
+ page: number;
+ limit: number;
+ sortBy?: string;
+ sortOrder?: 'asc' | 'desc';
+}
+
+export interface PaginationMeta {
+ currentPage: number;
+ totalPages: number;
+ totalItems: number;
+ itemsPerPage: number;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ pagination: PaginationMeta;
+ success: boolean;
+ error?: string;
+}
+
+export interface PaginationOptions {
+ defaultLimit?: number;
+ maxLimit?: number;
+}
\ No newline at end of file
diff --git a/src/types/reviews.tsx b/src/types/reviews.tsx
index e502b91..1f15dd9 100644
--- a/src/types/reviews.tsx
+++ b/src/types/reviews.tsx
@@ -1,19 +1,90 @@
+/**
+ * Review from database - matches the reviews table schema
+ */
export interface Review {
id: string;
- courseId: string;
- courseName: string;
- courseCode: string;
- semester: string;
- overallRating: number;
- workloadRating: number;
- contentRating: number;
- teachingRating: number;
- supportRating: number;
- comment: string;
- date: string;
- user: {
- id: string;
- name: string;
- avatar: string;
- };
+ anonymous_id: string;
+ target_id: string;
+ target_type: 'course' | 'professor';
+ rating_value: number; // Overall rating 1-5
+ comment: string | null;
+ votes: number; // Net helpful votes (helpful - unhelpful)
+ is_flagged: boolean;
+ created_at: string;
+ updated_at: string;
+
+ // Course-specific ratings (null for professor reviews)
+ difficulty_rating: number | null;
+ workload_rating: number | null;
+
+ // Professor-specific ratings (null for course reviews)
+ knowledge_rating: number | null;
+ teaching_rating: number | null;
+ approachability_rating: number | null;
+}
+
+/**
+ * Vote from database - matches the votes table schema
+ */
+export interface Vote {
+ id: string;
+ review_id: string;
+ anonymous_id: string;
+ vote_type: 'helpful' | 'unhelpful';
+ created_at: string;
+}
+
+/**
+ * User vote status for a review
+ */
+export type VoteType = 'helpful' | 'unhelpful' | null;
+
+/**
+ * Review with additional computed/joined data for display
+ */
+export interface ReviewWithUserVote extends Review {
+ userVote?: VoteType; // Current user's vote on this review
+ anonymousName?: string; // Generated name like "Student 1234"
+}
+
+/**
+ * Input for creating a new course review
+ */
+export interface CreateCourseReviewInput {
+ target_id: string; // Course UUID
+ rating_value: number; // Overall rating 1-5
+ difficulty_rating: number; // 1-5
+ workload_rating: number; // 1-5
+ comment?: string | null;
+}
+
+/**
+ * Input for creating a new professor review
+ */
+export interface CreateProfessorReviewInput {
+ target_id: string; // Professor UUID
+ rating_value: number; // Overall rating 1-5
+ knowledge_rating: number; // 1-5
+ teaching_rating: number; // 1-5
+ approachability_rating: number; // 1-5
+ comment?: string | null;
+}
+
+/**
+ * API response for voting
+ */
+export interface VoteResponse {
+ success: boolean;
+ action: 'created' | 'updated' | 'removed' | 'deleted';
+ vote_type: VoteType;
+ error?: string;
+}
+
+/**
+ * API response for fetching user votes
+ */
+export interface UserVotesResponse {
+ success: boolean;
+ votes: Record; // Map of review_id -> vote_type
+ error?: string;
}
\ No newline at end of file
diff --git a/src/utils/supabase/server-pages.ts b/src/utils/supabase/server-pages.ts
new file mode 100644
index 0000000..d692d26
--- /dev/null
+++ b/src/utils/supabase/server-pages.ts
@@ -0,0 +1,31 @@
+import { createServerClient, serializeCookieHeader } from '@supabase/ssr'
+import { SerializeOptions } from 'cookie'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { serialize } from 'cookie'
+
+export function createClient(req: NextApiRequest, res: NextApiResponse) {
+ return createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return Object.keys(req.cookies).map((name) => ({
+ name,
+ value: req.cookies[name] || ''
+ }))
+ },
+ setAll(cookiesToSet) {
+ res.setHeader(
+ 'Set-Cookie',
+ cookiesToSet.map(({ name, value, options }) =>
+ serializeCookieHeader(name, value, options)
+ )
+ )
+ }
+ }
+ }
+ )
+}
+
+
diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts
index ac3942f..87a6ba7 100644
--- a/src/utils/supabase/server.ts
+++ b/src/utils/supabase/server.ts
@@ -2,7 +2,7 @@ import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
- const cookieStore = cookies()
+ const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
diff --git a/tsconfig.json b/tsconfig.json
index 2639f89..50d9095 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,13 +23,23 @@
}
],
"paths": {
- "@/*": ["./src/*"]
+ "@/*": [
+ "./src/*"
+ ]
}
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}