From 8d13a9c47836808a6bbabc937329fbfe77050c6e Mon Sep 17 00:00:00 2001 From: alSN0W Date: Mon, 9 Feb 2026 23:30:23 +0530 Subject: [PATCH 01/17] Added api endpoints for voting on reviews. --- next-env.d.ts | 3 +- next.config.js | 2 + package-lock.json | 772 ++++++++++++++++++++++------ package.json | 2 +- src/app/auth/signin/page.tsx | 4 +- src/app/courses/[courseId]/page.tsx | 6 +- src/pages/api/ratings/vote/route.ts | 229 +++------ tsconfig.json | 24 +- 8 files changed, 735 insertions(+), 307 deletions(-) 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..9902e63 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,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", 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..236ed65 100644 --- a/src/app/courses/[courseId]/page.tsx +++ b/src/app/courses/[courseId]/page.tsx @@ -8,14 +8,16 @@ 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/pages/api/ratings/vote/route.ts b/src/pages/api/ratings/vote/route.ts index a211f6b..f1f1375 100644 --- a/src/pages/api/ratings/vote/route.ts +++ b/src/pages/api/ratings/vote/route.ts @@ -1,177 +1,114 @@ -import { NextResponse } from 'next/server'; -import { supabase } from '@/lib/supabase'; -import { supabaseAdmin } from '@/lib/supabase-admin'; -import { VoteInsert } from '@/types/supabase'; +import { NextRequest, NextResponse } from 'next/server'; +import { get_anonymous_id } from '@/lib/utils'; -// POST /api/ratings/vote - Vote on a rating (helpful/unhelpful) -export async function POST(request: Request) { +// POST - Create or toggle a vote +export async function POST(request: NextRequest) { 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) { + const body = await request.json(); + const { ratingId, voteType } = body; // 'upvote' or 'downvote' + const anonymousId = get_anonymous_id(); + + if (!ratingId || !voteType) { return NextResponse.json( - { error: 'Missing rating ID' }, + { error: 'Missing ratingId or voteType' }, { status: 400 } ); } - - // Validate vote type - if (voteType !== 'helpful' && voteType !== 'unhelpful') { + + // TODO: Check if vote exists and toggle or create + // TODO: Update database + + return NextResponse.json( + { success: true, message: 'Vote created' }, + { status: 201 } + ); + } catch (error) { + return NextResponse.json( + { error: 'Failed to create vote' }, + { status: 500 } + ); + } +} + +// PUT - Update a vote +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { voteId, voteType } = body; + const anonymousId = get_anonymous_id(); + + if (!voteId || !voteType) { return NextResponse.json( - { error: 'Invalid vote type' }, + { error: 'Missing voteId or voteType' }, { 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 - } - }); - + + // TODO: Verify ownership and update database + + return NextResponse.json( + { success: true, message: 'Vote updated' }, + { status: 200 } + ); } catch (error) { - console.error('Unexpected error in rating vote API:', error); return NextResponse.json( - { error: 'An unexpected error occurred' }, + { error: 'Failed to update vote' }, { status: 500 } ); } } -// DELETE /api/ratings/vote - Remove a vote from a rating -export async function DELETE(request: Request) { +// DELETE - Remove a vote +export async function DELETE(request: NextRequest) { 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) { + const voteId = searchParams.get('voteId'); + const anonymousId = get_anonymous_id(); + + if (!voteId) { return NextResponse.json( - { error: 'Missing rating ID' }, + { error: 'Missing voteId' }, { 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); + + // TODO: Verify ownership and delete from database + + return NextResponse.json( + { success: true, message: 'Vote deleted' }, + { status: 200 } + ); + } catch (error) { + return NextResponse.json( + { error: 'Failed to delete vote' }, + { status: 500 } + ); + } +} + +// GET - Batch fetch votes +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const ratingIds = searchParams.getAll('ratingIds'); + const anonymousId = get_anonymous_id(); + + if (!ratingIds.length) { return NextResponse.json( - { error: 'Failed to remove vote' }, - { status: 500 } + { error: 'Missing ratingIds' }, + { status: 400 } ); } - - return NextResponse.json({ - success: true, - data: { - helpfulnessScore: data.new_helpfulness_score - } - }); - + + // TODO: Fetch votes for the given rating IDs + + return NextResponse.json( + { votes: [] }, + { status: 200 } + ); } catch (error) { - console.error('Unexpected error in rating vote API:', error); return NextResponse.json( - { error: 'An unexpected error occurred' }, + { error: 'Failed to fetch votes' }, { status: 500 } ); } 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" + ] } From 268dca875d9381088570a2edf6a80e56ee4f88ed Mon Sep 17 00:00:00 2001 From: alSN0W Date: Tue, 10 Feb 2026 23:08:19 +0530 Subject: [PATCH 02/17] fixed the faulty api endpoints, verified with postman --- src/app/courses/[courseId]/page.tsx | 4 + src/components/QuickVoteTest.tsx | 63 ++++++ src/hooks/useVote.ts | 335 ++++++++++++++++++++++++++++ src/pages/api/ratings/vote/index.ts | 215 ++++++++++++++++++ src/pages/api/ratings/vote/route.ts | 115 ---------- src/utils/supabase/server-pages.ts | 24 ++ src/utils/supabase/server.ts | 2 +- 7 files changed, 642 insertions(+), 116 deletions(-) create mode 100644 src/components/QuickVoteTest.tsx create mode 100644 src/hooks/useVote.ts create mode 100644 src/pages/api/ratings/vote/index.ts delete mode 100644 src/pages/api/ratings/vote/route.ts create mode 100644 src/utils/supabase/server-pages.ts diff --git a/src/app/courses/[courseId]/page.tsx b/src/app/courses/[courseId]/page.tsx index 236ed65..494a2da 100644 --- a/src/app/courses/[courseId]/page.tsx +++ b/src/app/courses/[courseId]/page.tsx @@ -9,6 +9,9 @@ import CoursePageReviews from "@/components/courses/course_page/CoursePageReview import RateThisCourse from "@/components/courses/course_page/RateThisCourse"; import Example from "@/components/courses/course_page/CoursePageLoader"; import { use } from 'react'; +import { QuickVoteTest } from '@/components/QuickVoteTest'; +// + export default function CoursePage({ params }: { params: Promise<{ courseId: string }> }) { const { courseId } = use(params); @@ -101,6 +104,7 @@ export default function CoursePage({ params }: { params: Promise<{ courseId: str
+
{/* Right Section - Sticky Sidebar */} diff --git a/src/components/QuickVoteTest.tsx b/src/components/QuickVoteTest.tsx new file mode 100644 index 0000000..8242ae0 --- /dev/null +++ b/src/components/QuickVoteTest.tsx @@ -0,0 +1,63 @@ +// QUICK INLINE TEST - Copy this anywhere in your app + +'use client'; + +import { useVotes } from '@/hooks/useVote'; + +export function QuickVoteTest() { + const testReviewId = 'test-123'; + + const { votes, voteCounts, castVote, toggleVote, removeVote } = useVotes({ + reviewIds: [testReviewId], + initialCounts: { + [testReviewId]: { upvotes: 10, downvotes: 3 } + } + }); + + const currentVote = votes[testReviewId]; + const counts = voteCounts[testReviewId] || { upvotes: 0, downvotes: 0 }; + + return ( +
+

Vote Test

+ +

+ Current: {currentVote || 'none'} | + 👍 {counts.upvotes} | + 👎 {counts.downvotes} +

+ +
+ + + + + + + +
+
+ ); +} + +// Usage: Add to any page diff --git a/src/hooks/useVote.ts b/src/hooks/useVote.ts new file mode 100644 index 0000000..57a7b62 --- /dev/null +++ b/src/hooks/useVote.ts @@ -0,0 +1,335 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { toast } from 'sonner'; + +export type VoteType = 'helpful' | 'unhelpful' | null; + +interface VoteState { + [reviewId: string]: VoteType; +} + +interface VoteCounts { + [reviewId: string]: { + helpful: number; + unhelpful: number; + }; +} + +interface UseVotesOptions { + reviewIds?: string[]; + initialCounts?: VoteCounts; + onVoteSuccess?: (reviewId: string, voteType: VoteType) => void; + onVoteError?: (error: Error) => void; +} + +interface UseVotesReturn { + votes: VoteState; + voteCounts: VoteCounts; + isLoading: boolean; + castVote: (reviewId: string, voteType: 'helpful' | 'unhelpful') => Promise; + removeVote: (reviewId: string) => Promise; + toggleVote: (reviewId: string, voteType: 'helpful' | 'unhelpful') => Promise; + getUserVote: (reviewId: string) => VoteType; + refreshVotes: (reviewIds?: string[]) => Promise; +} + +export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { + const { reviewIds = [], initialCounts = {}, onVoteSuccess, onVoteError } = options; + + const [votes, setVotes] = useState({}); + const [voteCounts, setVoteCounts] = useState(initialCounts); + const [isLoading, setIsLoading] = useState(false); + + // Track pending operations to prevent race conditions + const pendingOperations = useRef>(new Set()); + + // Cache to store previous states for rollback + const rollbackCache = useRef>(new Map()); + + /** + * Fetch user votes for specified review IDs + */ + const fetchUserVotes = useCallback(async (ids: string[]) => { + if (ids.length === 0) return; + + try { + setIsLoading(true); + const response = await fetch(`/api/ratings/vote?review_ids=${ids.join(',')}`); + + if (!response.ok) { + throw new Error('Failed to fetch votes'); + } + + const data = await response.json(); + + if (data.success) { + setVotes(prev => ({ + ...prev, + ...data.votes, + })); + } + } catch (error) { + console.error('Error fetching votes:', error); + if (onVoteError) { + onVoteError(error instanceof Error ? error : new Error('Failed to fetch votes')); + } + } finally { + setIsLoading(false); + } + }, [onVoteError]); + + /** + * Initial fetch on mount or when reviewIds change + */ + useEffect(() => { + if (reviewIds.length > 0) { + fetchUserVotes(reviewIds); + } + }, [reviewIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + /** + * Update vote counts optimistically + */ + const updateVoteCountsOptimistically = useCallback( + (reviewId: string, oldVote: VoteType, newVote: VoteType) => { + setVoteCounts(prev => { + const current = prev[reviewId] || { helpful: 0, unhelpful: 0 }; + let helpful = current.helpful; + let unhelpful = current.unhelpful; + + // Remove old vote + if (oldVote === 'helpful') helpful--; + if (oldVote === 'unhelpful') unhelpful--; + + // Add new vote + if (newVote === 'helpful') helpful++; + if (newVote === 'unhelpful') unhelpful++; + + return { + ...prev, + [reviewId]: { helpful, unhelpful }, + }; + }); + }, + [] + ); + + /** + * Rollback optimistic updates on error + */ + const rollbackVote = useCallback((reviewId: string) => { + const cached = rollbackCache.current.get(reviewId); + if (cached) { + setVotes(prev => ({ + ...prev, + [reviewId]: cached.vote, + })); + setVoteCounts(prev => ({ + ...prev, + [reviewId]: cached.counts, + })); + rollbackCache.current.delete(reviewId); + } + }, []); + + /** + * Cast or update a vote + */ + const castVote = useCallback( + async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { + // Prevent concurrent operations on the same review + if (pendingOperations.current.has(reviewId)) { + return; + } + + pendingOperations.current.add(reviewId); + + try { + const oldVote = votes[reviewId] || null; + const oldCounts = voteCounts[reviewId] || { helpful: 0, unhelpful: 0 }; + + // Save state for potential rollback + rollbackCache.current.set(reviewId, { + vote: oldVote, + counts: oldCounts, + }); + + // Optimistic update + setVotes(prev => ({ + ...prev, + [reviewId]: voteType, + })); + updateVoteCountsOptimistically(reviewId, oldVote, voteType); + + // API call + const response = await fetch('/api/ratings/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), + }); + + if (!response.ok) { + throw new Error('Failed to cast vote'); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to cast vote'); + } + + // Update state based on server response + setVotes(prev => ({ + ...prev, + [reviewId]: data.vote_type, + })); + + // Clear rollback cache on success + rollbackCache.current.delete(reviewId); + + if (onVoteSuccess) { + onVoteSuccess(reviewId, data.vote_type); + } + + } catch (error) { + console.error('Error casting vote:', error); + + // Rollback on error + rollbackVote(reviewId); + + toast.error('Failed to cast vote. Please try again.'); + + if (onVoteError) { + onVoteError(error instanceof Error ? error : new Error('Failed to cast vote')); + } + } finally { + pendingOperations.current.delete(reviewId); + } + }, + [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] + ); + + /** + * Remove a vote + */ + const removeVote = useCallback( + async (reviewId: string) => { + if (pendingOperations.current.has(reviewId)) { + return; + } + + pendingOperations.current.add(reviewId); + + try { + const oldVote = votes[reviewId] || null; + const oldCounts = voteCounts[reviewId] || { helpful: 0, unhelpful: 0 }; + + // Save state for rollback + rollbackCache.current.set(reviewId, { + vote: oldVote, + counts: oldCounts, + }); + + // Optimistic update + setVotes(prev => ({ + ...prev, + [reviewId]: null, + })); + updateVoteCountsOptimistically(reviewId, oldVote, null); + + // API call + const response = await fetch('/api/ratings/vote', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ review_id: reviewId }), + }); + + if (!response.ok) { + throw new Error('Failed to remove vote'); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to remove vote'); + } + + // Clear rollback cache + rollbackCache.current.delete(reviewId); + + if (onVoteSuccess) { + onVoteSuccess(reviewId, null); + } + + } catch (error) { + console.error('Error removing vote:', error); + + // Rollback + rollbackVote(reviewId); + + toast.error('Failed to remove vote. Please try again.'); + + if (onVoteError) { + onVoteError(error instanceof Error ? error : new Error('Failed to remove vote')); + } + } finally { + pendingOperations.current.delete(reviewId); + } + }, + [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] + ); + + /** + * Toggle vote - if same type, remove; otherwise update + */ + const toggleVote = useCallback( + async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { + const currentVote = votes[reviewId]; + + if (currentVote === voteType) { + // Same vote - remove it + await removeVote(reviewId); + } else { + // Different vote or no vote - cast it + await castVote(reviewId, voteType); + } + }, + [votes, castVote, removeVote] + ); + + /** + * Get user's vote for a specific review + */ + const getUserVote = useCallback( + (reviewId: string): VoteType => { + return votes[reviewId] || null; + }, + [votes] + ); + + /** + * Manually refresh votes + */ + const refreshVotes = useCallback( + async (ids?: string[]) => { + const idsToFetch = ids || reviewIds; + if (idsToFetch.length > 0) { + await fetchUserVotes(idsToFetch); + } + }, + [reviewIds, fetchUserVotes] + ); + + return { + votes, + voteCounts, + isLoading, + castVote, + removeVote, + toggleVote, + getUserVote, + refreshVotes, + }; +} \ 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..8477b61 --- /dev/null +++ b/src/pages/api/ratings/vote/index.ts @@ -0,0 +1,215 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { createClient } from '@/utils/supabase/server-pages'; + +/** + * Vote API Route + * 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 && authError.message !== 'Auth session missing!') { + console.error('Auth error:', authError); + return res.status(401).json({ error: 'Authentication error' }); + } + + // 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; + + // Check if user already voted + const { data: existingVote, error: checkError } = await supabase + .from('votes') + .select('id, vote_type') + .eq('review_id', review_id) + .eq('anonymous_id', 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, 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, + 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 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 reviewIdArray = review_ids.split(',').map(id => id.trim()); + + // Batch fetch votes + const { data: votes, error: fetchError } = await supabase + .from('votes') + .select('review_id, vote_type') + .eq('anonymous_id', anonymous_id) + .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 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; + + // Delete the vote + const { error: deleteError } = await supabase + .from('votes') + .delete() + .eq('review_id', review_id) + .eq('anonymous_id', anonymous_id); + + 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 f1f1375..0000000 --- a/src/pages/api/ratings/vote/route.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { get_anonymous_id } from '@/lib/utils'; - -// POST - Create or toggle a vote -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { ratingId, voteType } = body; // 'upvote' or 'downvote' - const anonymousId = get_anonymous_id(); - - if (!ratingId || !voteType) { - return NextResponse.json( - { error: 'Missing ratingId or voteType' }, - { status: 400 } - ); - } - - // TODO: Check if vote exists and toggle or create - // TODO: Update database - - return NextResponse.json( - { success: true, message: 'Vote created' }, - { status: 201 } - ); - } catch (error) { - return NextResponse.json( - { error: 'Failed to create vote' }, - { status: 500 } - ); - } -} - -// PUT - Update a vote -export async function PUT(request: NextRequest) { - try { - const body = await request.json(); - const { voteId, voteType } = body; - const anonymousId = get_anonymous_id(); - - if (!voteId || !voteType) { - return NextResponse.json( - { error: 'Missing voteId or voteType' }, - { status: 400 } - ); - } - - // TODO: Verify ownership and update database - - return NextResponse.json( - { success: true, message: 'Vote updated' }, - { status: 200 } - ); - } catch (error) { - return NextResponse.json( - { error: 'Failed to update vote' }, - { status: 500 } - ); - } -} - -// DELETE - Remove a vote -export async function DELETE(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const voteId = searchParams.get('voteId'); - const anonymousId = get_anonymous_id(); - - if (!voteId) { - return NextResponse.json( - { error: 'Missing voteId' }, - { status: 400 } - ); - } - - // TODO: Verify ownership and delete from database - - return NextResponse.json( - { success: true, message: 'Vote deleted' }, - { status: 200 } - ); - } catch (error) { - return NextResponse.json( - { error: 'Failed to delete vote' }, - { status: 500 } - ); - } -} - -// GET - Batch fetch votes -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const ratingIds = searchParams.getAll('ratingIds'); - const anonymousId = get_anonymous_id(); - - if (!ratingIds.length) { - return NextResponse.json( - { error: 'Missing ratingIds' }, - { status: 400 } - ); - } - - // TODO: Fetch votes for the given rating IDs - - return NextResponse.json( - { votes: [] }, - { status: 200 } - ); - } catch (error) { - return NextResponse.json( - { error: 'Failed to fetch votes' }, - { status: 500 } - ); - } -} \ 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..71baf8f --- /dev/null +++ b/src/utils/supabase/server-pages.ts @@ -0,0 +1,24 @@ +import { createServerClient } from '@supabase/ssr' +import { NextApiRequest, NextApiResponse } from 'next' + +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) { + cookiesToSet.forEach(({ name, value, options }) => { + res.setHeader('Set-Cookie', `${name}=${value}; Path=/; ${options ? Object.entries(options).map(([k, v]) => `${k}=${v}`).join('; ') : ''}`) + }) + }, + }, + } + ) +} \ No newline at end of file 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!, From e66517dda16760467a6853dfa4bebe92f2ed6f08 Mon Sep 17 00:00:00 2001 From: alSN0W Date: Thu, 12 Feb 2026 13:41:34 +0530 Subject: [PATCH 03/17] added vote button --- src/app/courses/[courseId]/page.tsx | 2 - src/components/QuickVoteTest.tsx | 63 ------ src/components/common/VoteButton.tsx | 193 ++++++++++++++++++ .../courses/course_page/CoursePageReviews.tsx | 16 +- 4 files changed, 207 insertions(+), 67 deletions(-) delete mode 100644 src/components/QuickVoteTest.tsx create mode 100644 src/components/common/VoteButton.tsx diff --git a/src/app/courses/[courseId]/page.tsx b/src/app/courses/[courseId]/page.tsx index 494a2da..d27876e 100644 --- a/src/app/courses/[courseId]/page.tsx +++ b/src/app/courses/[courseId]/page.tsx @@ -9,7 +9,6 @@ import CoursePageReviews from "@/components/courses/course_page/CoursePageReview import RateThisCourse from "@/components/courses/course_page/RateThisCourse"; import Example from "@/components/courses/course_page/CoursePageLoader"; import { use } from 'react'; -import { QuickVoteTest } from '@/components/QuickVoteTest'; // @@ -104,7 +103,6 @@ export default function CoursePage({ params }: { params: Promise<{ courseId: str
- {/* Right Section - Sticky Sidebar */} diff --git a/src/components/QuickVoteTest.tsx b/src/components/QuickVoteTest.tsx deleted file mode 100644 index 8242ae0..0000000 --- a/src/components/QuickVoteTest.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// QUICK INLINE TEST - Copy this anywhere in your app - -'use client'; - -import { useVotes } from '@/hooks/useVote'; - -export function QuickVoteTest() { - const testReviewId = 'test-123'; - - const { votes, voteCounts, castVote, toggleVote, removeVote } = useVotes({ - reviewIds: [testReviewId], - initialCounts: { - [testReviewId]: { upvotes: 10, downvotes: 3 } - } - }); - - const currentVote = votes[testReviewId]; - const counts = voteCounts[testReviewId] || { upvotes: 0, downvotes: 0 }; - - return ( -
-

Vote Test

- -

- Current: {currentVote || 'none'} | - 👍 {counts.upvotes} | - 👎 {counts.downvotes} -

- -
- - - - - - - -
-
- ); -} - -// Usage: Add to any page diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx new file mode 100644 index 0000000..bc5d6c4 --- /dev/null +++ b/src/components/common/VoteButton.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronUp, ChevronDown } from 'lucide-react'; +import { VoteType } from '@/hooks/useVote'; + +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); + + // Size variants (Reddit-style vertical layout) + const sizes = { + sm: { + container: 'gap-0.5', + icon: 'w-4 h-4', + text: 'text-xs', + 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; + + setIsLoading(true); + + try { + const oldVote = currentVote; + const oldCount = voteCount; + + // Optimistic update + let newVote: VoteType; + let newCount = voteCount; + + if (currentVote === voteType) { + // Toggle off - remove vote + newVote = null; + if (voteType === 'helpful') newCount--; + else newCount++; + } else { + // Switch vote or add new vote + newVote = voteType; + + // Remove old vote effect + if (oldVote === 'helpful') newCount--; + else if (oldVote === 'unhelpful') newCount++; + + // Add new vote effect + if (voteType === 'helpful') newCount++; + else newCount--; + } + + setCurrentVote(newVote); + setVoteCount(newCount); + + // API call + const response = await fetch('/api/ratings/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), + }); + + if (!response.ok) { + throw new Error('Failed to vote'); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to vote'); + } + + // Update with server response + setCurrentVote(data.vote_type); + + // Callback + if (onVote) { + onVote(reviewId, data.vote_type); + } + + } catch (error) { + console.error('Error voting:', error); + // Rollback on error + setCurrentVote(currentVote); + setVoteCount(voteCount); + } finally { + setIsLoading(false); + } + }; + + 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..025b7ce 100644 --- a/src/components/courses/course_page/CoursePageReviews.tsx +++ b/src/components/courses/course_page/CoursePageReviews.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import AddReviewButton from "../AddReviewButton"; import { ChevronRight, ChevronDown } from "lucide-react"; import { supabase } from "@/lib/supabase"; +import { VoteButton } from "@/components/common/VoteButton"; interface CoursePageReviewsProps { id: string; // Course ID @@ -35,9 +36,19 @@ const CourseReviewItem = ({ review }: { review: any }) => { {formattedDate} -

+ +

{review.comment || "No comment provided."}

+ + {/* Vote Button */} +
+ +
); }; @@ -59,6 +70,7 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { id, anonymous_id, comment, + votes, created_at `) .eq("target_id", id) @@ -127,4 +139,4 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { ); }; -export default CoursePageReviews; +export default CoursePageReviews; \ No newline at end of file From 888a1b937596d3e4508aa19cb3589d2814d8364e Mon Sep 17 00:00:00 2001 From: alSN0W Date: Fri, 13 Feb 2026 00:11:16 +0530 Subject: [PATCH 04/17] fixed displaying the user votes incorrectly issue --- .../courses/course_page/CoursePageReviews.tsx | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/components/courses/course_page/CoursePageReviews.tsx b/src/components/courses/course_page/CoursePageReviews.tsx index 025b7ce..51f2a1c 100644 --- a/src/components/courses/course_page/CoursePageReviews.tsx +++ b/src/components/courses/course_page/CoursePageReviews.tsx @@ -12,7 +12,7 @@ interface CoursePageReviewsProps { } /* Single Review Card (vertical format) */ -const CourseReviewItem = ({ review }: { review: any }) => { +const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: string | null }) => { const formattedDate = new Date(review.created_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short", @@ -45,6 +45,7 @@ const CourseReviewItem = ({ review }: { review: any }) => {
@@ -56,15 +57,16 @@ const CourseReviewItem = ({ review }: { review: any }) => { /* Main Reviews Component */ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { const [reviews, setReviews] = useState([]); + const [userVotes, setUserVotes] = useState>({}); const [loading, setLoading] = useState(true); - const [showAll, setShowAll] = useState(false); // Toggle for View All + const [showAll, setShowAll] = useState(false); useEffect(() => { - const fetchReviews = async () => { + const fetchReviewsAndVotes = async () => { setLoading(true); // Fetch all reviews - const { data, error } = await supabase + const { data: reviewsData, error: reviewsError } = await supabase .from("reviews") .select(` id, @@ -77,16 +79,34 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { .eq("target_type", "course") .order("created_at", { ascending: false }); - if (error) { - console.error("Error fetching reviews:", error.message); - } else { - setReviews(data || []); + if (reviewsError) { + console.error("Error fetching reviews:", reviewsError.message); + setLoading(false); + return; + } + + setReviews(reviewsData || []); + + // Fetch user's votes for these reviews + if (reviewsData && reviewsData.length > 0) { + const reviewIds = reviewsData.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(); + fetchReviewsAndVotes(); }, [id]); // Show only 3 unless expanded @@ -114,7 +134,11 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => {

) : ( displayedReviews.map((review) => ( - + )) )}
@@ -139,4 +163,4 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { ); }; -export default CoursePageReviews; \ No newline at end of file +export default CoursePageReviews; From e2659ed172094328c64310232dc47fb6305ded32 Mon Sep 17 00:00:00 2001 From: alSN0W Date: Sat, 14 Feb 2026 23:39:08 +0530 Subject: [PATCH 05/17] Styled Voting buttons and updated migration files --- src/components/common/VoteButton.tsx | 17 +++++-- .../courses/course_page/CoursePageReviews.tsx | 50 +++++++++++-------- src/hooks/useVote.ts | 39 +++++++-------- src/migrations/migration.sql | 21 +++----- 4 files changed, 67 insertions(+), 60 deletions(-) diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx index bc5d6c4..90d9c00 100644 --- a/src/components/common/VoteButton.tsx +++ b/src/components/common/VoteButton.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { ChevronUp, ChevronDown } from 'lucide-react'; import { VoteType } from '@/hooks/useVote'; @@ -23,12 +23,21 @@ export function VoteButton({ 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.5', - icon: 'w-4 h-4', - text: 'text-xs', + container: 'gap-0', + icon: 'w-3.5 h-3.5', + text: 'text-[10px]', padding: 'p-0.5', }, md: { diff --git a/src/components/courses/course_page/CoursePageReviews.tsx b/src/components/courses/course_page/CoursePageReviews.tsx index 51f2a1c..ae8ac90 100644 --- a/src/components/courses/course_page/CoursePageReviews.tsx +++ b/src/components/courses/course_page/CoursePageReviews.tsx @@ -28,27 +28,33 @@ const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: string return (
-
-

- {getAnonymousName(review.anonymous_id)} -

- - {formattedDate} - -
- -

- {review.comment || "No comment provided."} -

- - {/* Vote Button */} -
- +
+ {/* Left side - Review content */} +
+
+

+ {getAnonymousName(review.anonymous_id)} +

+ + {formattedDate} + +
+ +

+ {review.comment || "No comment provided."} +

+
+ + {/* Right side - Vote Button */} +
+ +
); @@ -163,4 +169,4 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { ); }; -export default CoursePageReviews; +export default CoursePageReviews; \ No newline at end of file diff --git a/src/hooks/useVote.ts b/src/hooks/useVote.ts index 57a7b62..347e457 100644 --- a/src/hooks/useVote.ts +++ b/src/hooks/useVote.ts @@ -48,9 +48,9 @@ export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { counts: { helpful: number; unhelpful: number }; }>>(new Map()); - /** - * Fetch user votes for specified review IDs - */ + + //Fetch user votes for specified review IDs + const fetchUserVotes = useCallback(async (ids: string[]) => { if (ids.length === 0) return; @@ -80,18 +80,18 @@ export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { } }, [onVoteError]); - /** - * Initial fetch on mount or when reviewIds change - */ + + //Initial fetch on mount or when reviewIds change + useEffect(() => { if (reviewIds.length > 0) { fetchUserVotes(reviewIds); } }, [reviewIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps - /** - * Update vote counts optimistically - */ + + //Update vote counts optimistically + const updateVoteCountsOptimistically = useCallback( (reviewId: string, oldVote: VoteType, newVote: VoteType) => { setVoteCounts(prev => { @@ -116,9 +116,9 @@ export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { [] ); - /** - * Rollback optimistic updates on error - */ + + //Rollback optimistic updates on error + const rollbackVote = useCallback((reviewId: string) => { const cached = rollbackCache.current.get(reviewId); if (cached) { @@ -134,9 +134,9 @@ export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { } }, []); - /** - * Cast or update a vote - */ + + //Cast or update a vote + const castVote = useCallback( async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { // Prevent concurrent operations on the same review @@ -211,9 +211,7 @@ export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] ); - /** - * Remove a vote - */ + //Remove a vote const removeVote = useCallback( async (reviewId: string) => { if (pendingOperations.current.has(reviewId)) { @@ -281,9 +279,8 @@ export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] ); - /** - * Toggle vote - if same type, remove; otherwise update - */ + //Toggle vote - if same type, remove; otherwise update + const toggleVote = useCallback( async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { const currentVote = votes[reviewId]; diff --git a/src/migrations/migration.sql b/src/migrations/migration.sql index e33b914..c3b6326 100644 --- a/src/migrations/migration.sql +++ b/src/migrations/migration.sql @@ -120,7 +120,7 @@ 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_flags_review_id ON flags(review_id); CREATE INDEX idx_flags_status ON flags(status); -- Create function to update course ratings @@ -252,8 +252,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 @@ -481,15 +486,6 @@ 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 CREATE OR REPLACE FUNCTION handle_new_user() RETURNS TRIGGER AS $$ @@ -516,5 +512,4 @@ $$ 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 + FOR EACH ROW EXECUTE FUNCTION handle_new_user(); \ No newline at end of file From b37d929cda9e744e0a9d076a3746b4759b558efa Mon Sep 17 00:00:00 2001 From: alSN0W Date: Sun, 15 Feb 2026 13:24:38 +0530 Subject: [PATCH 06/17] removed useVote as it's useless for now --- package.json | 8 +- src/components/common/VoteButton.tsx | 139 ++++---- .../courses/course_page/CoursePageReviews.tsx | 8 +- src/hooks/useVote.ts | 332 ------------------ 4 files changed, 80 insertions(+), 407 deletions(-) delete mode 100644 src/hooks/useVote.ts diff --git a/package.json b/package.json index 9902e63..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", @@ -64,9 +64,9 @@ "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/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx index 90d9c00..64becd7 100644 --- a/src/components/common/VoteButton.tsx +++ b/src/components/common/VoteButton.tsx @@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'; import { ChevronUp, ChevronDown } from 'lucide-react'; -import { VoteType } from '@/hooks/useVote'; + +export type VoteType = 'helpful' | 'unhelpful' | null; + interface VoteButtonProps { reviewId: string; @@ -54,74 +56,77 @@ export function VoteButton({ }, }; - const handleVote = async (voteType: 'helpful' | 'unhelpful') => { - if (isLoading) return; - - setIsLoading(true); - - try { - const oldVote = currentVote; - const oldCount = voteCount; - - // Optimistic update - let newVote: VoteType; - let newCount = voteCount; - - if (currentVote === voteType) { - // Toggle off - remove vote - newVote = null; - if (voteType === 'helpful') newCount--; - else newCount++; - } else { - // Switch vote or add new vote - newVote = voteType; - - // Remove old vote effect - if (oldVote === 'helpful') newCount--; - else if (oldVote === 'unhelpful') newCount++; - - // Add new vote effect - if (voteType === 'helpful') newCount++; - else newCount--; - } - - setCurrentVote(newVote); - setVoteCount(newCount); - - // API call - const response = await fetch('/api/ratings/vote', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), - }); - - if (!response.ok) { - throw new Error('Failed to vote'); - } - - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to vote'); - } - - // Update with server response - setCurrentVote(data.vote_type); +const handleVote = async (voteType: 'helpful' | 'unhelpful') => { + if (isLoading) return; + + setIsLoading(true); + + // Save snapshot for rollback (moved outside try) + const oldVote = currentVote; + const oldCount = voteCount; + + try { + // Optimistic update + let newVote: VoteType; + let newCount = voteCount; + + if (currentVote === voteType) { + // Toggle off - remove vote + newVote = null; + if (voteType === 'helpful') newCount--; + else newCount++; + } else { + // Switch vote or add new vote + newVote = voteType; + + // Remove old vote effect + if (oldVote === 'helpful') newCount--; + else if (oldVote === 'unhelpful') newCount++; + + // Add new vote effect + if (voteType === 'helpful') newCount++; + else newCount--; + } - // Callback - if (onVote) { - onVote(reviewId, data.vote_type); - } - - } catch (error) { - console.error('Error voting:', error); - // Rollback on error - setCurrentVote(currentVote); - setVoteCount(voteCount); - } finally { - setIsLoading(false); + setCurrentVote(newVote); + setVoteCount(newCount); + + // API call + const response = await fetch('/api/ratings/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), + }); + + if (!response.ok) { + throw new Error('Failed to vote'); } - }; + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to vote'); + } + + // Update with server response + setCurrentVote(data.vote_type); + + // Callback + if (onVote) { + setCurrentVote(data.vote_type); + setVoteCount(data.vote_count ?? newCount); + onVote(reviewId, data.vote_type); + } + + } catch (error) { + console.error('Error voting:', error); + // Rollback on error using saved snapshot + setCurrentVote(oldVote); + setVoteCount(oldCount); + } finally { + setIsLoading(false); + } +}; return (
{ +const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: VoteType }) => { const formattedDate = new Date(review.created_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short", @@ -50,7 +50,7 @@ const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: string @@ -143,7 +143,7 @@ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { )) )} diff --git a/src/hooks/useVote.ts b/src/hooks/useVote.ts deleted file mode 100644 index 347e457..0000000 --- a/src/hooks/useVote.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { toast } from 'sonner'; - -export type VoteType = 'helpful' | 'unhelpful' | null; - -interface VoteState { - [reviewId: string]: VoteType; -} - -interface VoteCounts { - [reviewId: string]: { - helpful: number; - unhelpful: number; - }; -} - -interface UseVotesOptions { - reviewIds?: string[]; - initialCounts?: VoteCounts; - onVoteSuccess?: (reviewId: string, voteType: VoteType) => void; - onVoteError?: (error: Error) => void; -} - -interface UseVotesReturn { - votes: VoteState; - voteCounts: VoteCounts; - isLoading: boolean; - castVote: (reviewId: string, voteType: 'helpful' | 'unhelpful') => Promise; - removeVote: (reviewId: string) => Promise; - toggleVote: (reviewId: string, voteType: 'helpful' | 'unhelpful') => Promise; - getUserVote: (reviewId: string) => VoteType; - refreshVotes: (reviewIds?: string[]) => Promise; -} - -export function useVotes(options: UseVotesOptions = {}): UseVotesReturn { - const { reviewIds = [], initialCounts = {}, onVoteSuccess, onVoteError } = options; - - const [votes, setVotes] = useState({}); - const [voteCounts, setVoteCounts] = useState(initialCounts); - const [isLoading, setIsLoading] = useState(false); - - // Track pending operations to prevent race conditions - const pendingOperations = useRef>(new Set()); - - // Cache to store previous states for rollback - const rollbackCache = useRef>(new Map()); - - - //Fetch user votes for specified review IDs - - const fetchUserVotes = useCallback(async (ids: string[]) => { - if (ids.length === 0) return; - - try { - setIsLoading(true); - const response = await fetch(`/api/ratings/vote?review_ids=${ids.join(',')}`); - - if (!response.ok) { - throw new Error('Failed to fetch votes'); - } - - const data = await response.json(); - - if (data.success) { - setVotes(prev => ({ - ...prev, - ...data.votes, - })); - } - } catch (error) { - console.error('Error fetching votes:', error); - if (onVoteError) { - onVoteError(error instanceof Error ? error : new Error('Failed to fetch votes')); - } - } finally { - setIsLoading(false); - } - }, [onVoteError]); - - - //Initial fetch on mount or when reviewIds change - - useEffect(() => { - if (reviewIds.length > 0) { - fetchUserVotes(reviewIds); - } - }, [reviewIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps - - - //Update vote counts optimistically - - const updateVoteCountsOptimistically = useCallback( - (reviewId: string, oldVote: VoteType, newVote: VoteType) => { - setVoteCounts(prev => { - const current = prev[reviewId] || { helpful: 0, unhelpful: 0 }; - let helpful = current.helpful; - let unhelpful = current.unhelpful; - - // Remove old vote - if (oldVote === 'helpful') helpful--; - if (oldVote === 'unhelpful') unhelpful--; - - // Add new vote - if (newVote === 'helpful') helpful++; - if (newVote === 'unhelpful') unhelpful++; - - return { - ...prev, - [reviewId]: { helpful, unhelpful }, - }; - }); - }, - [] - ); - - - //Rollback optimistic updates on error - - const rollbackVote = useCallback((reviewId: string) => { - const cached = rollbackCache.current.get(reviewId); - if (cached) { - setVotes(prev => ({ - ...prev, - [reviewId]: cached.vote, - })); - setVoteCounts(prev => ({ - ...prev, - [reviewId]: cached.counts, - })); - rollbackCache.current.delete(reviewId); - } - }, []); - - - //Cast or update a vote - - const castVote = useCallback( - async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { - // Prevent concurrent operations on the same review - if (pendingOperations.current.has(reviewId)) { - return; - } - - pendingOperations.current.add(reviewId); - - try { - const oldVote = votes[reviewId] || null; - const oldCounts = voteCounts[reviewId] || { helpful: 0, unhelpful: 0 }; - - // Save state for potential rollback - rollbackCache.current.set(reviewId, { - vote: oldVote, - counts: oldCounts, - }); - - // Optimistic update - setVotes(prev => ({ - ...prev, - [reviewId]: voteType, - })); - updateVoteCountsOptimistically(reviewId, oldVote, voteType); - - // API call - const response = await fetch('/api/ratings/vote', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), - }); - - if (!response.ok) { - throw new Error('Failed to cast vote'); - } - - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to cast vote'); - } - - // Update state based on server response - setVotes(prev => ({ - ...prev, - [reviewId]: data.vote_type, - })); - - // Clear rollback cache on success - rollbackCache.current.delete(reviewId); - - if (onVoteSuccess) { - onVoteSuccess(reviewId, data.vote_type); - } - - } catch (error) { - console.error('Error casting vote:', error); - - // Rollback on error - rollbackVote(reviewId); - - toast.error('Failed to cast vote. Please try again.'); - - if (onVoteError) { - onVoteError(error instanceof Error ? error : new Error('Failed to cast vote')); - } - } finally { - pendingOperations.current.delete(reviewId); - } - }, - [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] - ); - - //Remove a vote - const removeVote = useCallback( - async (reviewId: string) => { - if (pendingOperations.current.has(reviewId)) { - return; - } - - pendingOperations.current.add(reviewId); - - try { - const oldVote = votes[reviewId] || null; - const oldCounts = voteCounts[reviewId] || { helpful: 0, unhelpful: 0 }; - - // Save state for rollback - rollbackCache.current.set(reviewId, { - vote: oldVote, - counts: oldCounts, - }); - - // Optimistic update - setVotes(prev => ({ - ...prev, - [reviewId]: null, - })); - updateVoteCountsOptimistically(reviewId, oldVote, null); - - // API call - const response = await fetch('/api/ratings/vote', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ review_id: reviewId }), - }); - - if (!response.ok) { - throw new Error('Failed to remove vote'); - } - - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to remove vote'); - } - - // Clear rollback cache - rollbackCache.current.delete(reviewId); - - if (onVoteSuccess) { - onVoteSuccess(reviewId, null); - } - - } catch (error) { - console.error('Error removing vote:', error); - - // Rollback - rollbackVote(reviewId); - - toast.error('Failed to remove vote. Please try again.'); - - if (onVoteError) { - onVoteError(error instanceof Error ? error : new Error('Failed to remove vote')); - } - } finally { - pendingOperations.current.delete(reviewId); - } - }, - [votes, voteCounts, updateVoteCountsOptimistically, rollbackVote, onVoteSuccess, onVoteError] - ); - - //Toggle vote - if same type, remove; otherwise update - - const toggleVote = useCallback( - async (reviewId: string, voteType: 'helpful' | 'unhelpful') => { - const currentVote = votes[reviewId]; - - if (currentVote === voteType) { - // Same vote - remove it - await removeVote(reviewId); - } else { - // Different vote or no vote - cast it - await castVote(reviewId, voteType); - } - }, - [votes, castVote, removeVote] - ); - - /** - * Get user's vote for a specific review - */ - const getUserVote = useCallback( - (reviewId: string): VoteType => { - return votes[reviewId] || null; - }, - [votes] - ); - - /** - * Manually refresh votes - */ - const refreshVotes = useCallback( - async (ids?: string[]) => { - const idsToFetch = ids || reviewIds; - if (idsToFetch.length > 0) { - await fetchUserVotes(idsToFetch); - } - }, - [reviewIds, fetchUserVotes] - ); - - return { - votes, - voteCounts, - isLoading, - castVote, - removeVote, - toggleVote, - getUserVote, - refreshVotes, - }; -} \ No newline at end of file From f2ac58df3f6e32331712e672bf83dc734d98e79f Mon Sep 17 00:00:00 2001 From: alSN0W Date: Sun, 15 Feb 2026 23:34:38 +0530 Subject: [PATCH 07/17] added pagination type and utils --- src/lib/pagination.ts | 67 +++++++++++++++++++++++++++++++++++++++++ src/types/pagination.ts | 27 +++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/lib/pagination.ts create mode 100644 src/types/pagination.ts diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..0eb097b --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,67 @@ +import { PaginationParams, PaginationMeta, PaginationOptions } from '@/types/pagination'; + +/** + * Calculate pagination metadata + */ +export function calculatePagination( + totalItems: number, + page: number, + limit: number +): PaginationMeta { + const totalPages = Math.ceil(totalItems / limit); + const currentPage = Math.max(1, Math.min(page, totalPages)); + + return { + currentPage, + totalPages, + totalItems, + itemsPerPage: limit, + 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/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 From 9f6ce6c60ea5953f255c1e6eba6c4d3e682197db Mon Sep 17 00:00:00 2001 From: alSN0W Date: Tue, 17 Feb 2026 20:49:33 +0530 Subject: [PATCH 08/17] Revert "added pagination type and utils" This reverts commit f2ac58df3f6e32331712e672bf83dc734d98e79f. --- src/lib/pagination.ts | 67 ----------------------------------------- src/types/pagination.ts | 27 ----------------- 2 files changed, 94 deletions(-) delete mode 100644 src/lib/pagination.ts delete mode 100644 src/types/pagination.ts diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts deleted file mode 100644 index 0eb097b..0000000 --- a/src/lib/pagination.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { PaginationParams, PaginationMeta, PaginationOptions } from '@/types/pagination'; - -/** - * Calculate pagination metadata - */ -export function calculatePagination( - totalItems: number, - page: number, - limit: number -): PaginationMeta { - const totalPages = Math.ceil(totalItems / limit); - const currentPage = Math.max(1, Math.min(page, totalPages)); - - return { - currentPage, - totalPages, - totalItems, - itemsPerPage: limit, - 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/types/pagination.ts b/src/types/pagination.ts deleted file mode 100644 index f5bd0ba..0000000 --- a/src/types/pagination.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 From 3c77c720a78ef02bc547f9b8be7de3a5830150ea Mon Sep 17 00:00:00 2001 From: alSN0W Date: Tue, 17 Feb 2026 23:30:14 +0530 Subject: [PATCH 09/17] made changes recommended by code-rabbit --- src/components/common/VoteButton.tsx | 3 - src/lib/pagination.ts | 69 +++++++++++++++++++++ src/lib/withPagination.ts | 91 ++++++++++++++++++++++++++++ src/pages/api/reviews/index.ts | 38 ++++++++++++ src/types/pagination.ts | 29 +++++++++ src/utils/supabase/server-pages.ts | 23 ++++--- tsconfig.json | 2 +- 7 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 src/lib/pagination.ts create mode 100644 src/lib/withPagination.ts create mode 100644 src/pages/api/reviews/index.ts create mode 100644 src/types/pagination.ts diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx index 64becd7..4babbc8 100644 --- a/src/components/common/VoteButton.tsx +++ b/src/components/common/VoteButton.tsx @@ -110,11 +110,8 @@ const handleVote = async (voteType: 'helpful' | 'unhelpful') => { // Update with server response setCurrentVote(data.vote_type); - // Callback if (onVote) { - setCurrentVote(data.vote_type); - setVoteCount(data.vote_count ?? newCount); onVote(reviewId, data.vote_type); } diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..5fa1a7d --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,69 @@ +// src/lib/pagination.ts + +import { PaginationParams, PaginationMeta, PaginationOptions } from '@/types/pagination'; + +/** + * Calculate pagination metadata + */ +export function calculatePagination( + totalItems: number, + page: number, + limit: number +): PaginationMeta { + const totalPages = Math.ceil(totalItems / limit); + const currentPage = Math.max(1, Math.min(page, totalPages)); + + return { + currentPage, + totalPages, + totalItems, + itemsPerPage: limit, + 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..e4c8cb6 --- /dev/null +++ b/src/lib/withPagination.ts @@ -0,0 +1,91 @@ +// src/lib/withPagination.ts +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 }); + } + + // 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/pages/api/reviews/index.ts b/src/pages/api/reviews/index.ts new file mode 100644 index 0000000..03c904c --- /dev/null +++ b/src/pages/api/reviews/index.ts @@ -0,0 +1,38 @@ +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 + 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"' }; + } + + const validSortColumns = ['created_at', 'votes', 'rating_value']; + const sortColumn = validSortColumns.includes(sort_by) ? sort_by : 'created_at'; + + 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', target_id) + .eq('target_type', target_type) + .order(sortColumn, { ascending: sort_order === 'asc' }); + + return { query }; + }, +}); + diff --git a/src/types/pagination.ts b/src/types/pagination.ts new file mode 100644 index 0000000..34a6f01 --- /dev/null +++ b/src/types/pagination.ts @@ -0,0 +1,29 @@ +// src/types/pagination.ts + +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/utils/supabase/server-pages.ts b/src/utils/supabase/server-pages.ts index 71baf8f..d692d26 100644 --- a/src/utils/supabase/server-pages.ts +++ b/src/utils/supabase/server-pages.ts @@ -1,5 +1,7 @@ -import { createServerClient } from '@supabase/ssr' +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( @@ -10,15 +12,20 @@ export function createClient(req: NextApiRequest, res: NextApiResponse) { getAll() { return Object.keys(req.cookies).map((name) => ({ name, - value: req.cookies[name] || '', + value: req.cookies[name] || '' })) }, setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => { - res.setHeader('Set-Cookie', `${name}=${value}; Path=/; ${options ? Object.entries(options).map(([k, v]) => `${k}=${v}`).join('; ') : ''}`) - }) - }, - }, + res.setHeader( + 'Set-Cookie', + cookiesToSet.map(({ name, value, options }) => + serializeCookieHeader(name, value, options) + ) + ) + } + } } ) -} \ No newline at end of file +} + + diff --git a/tsconfig.json b/tsconfig.json index 50d9095..7d936dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { From d461b81f023ff59953df1121e4807cc081de806b Mon Sep 17 00:00:00 2001 From: Alan Panickar <161750706+alSN0W@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:08:25 +0530 Subject: [PATCH 10/17] Update src/lib/withPagination.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/lib/withPagination.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/withPagination.ts b/src/lib/withPagination.ts index e4c8cb6..f0dc7cd 100644 --- a/src/lib/withPagination.ts +++ b/src/lib/withPagination.ts @@ -74,6 +74,10 @@ export function withPagination({ 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) From 4cf96ac754a771a8a1566968b8a95aee96c1f782 Mon Sep 17 00:00:00 2001 From: Alan Panickar <161750706+alSN0W@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:08:51 +0530 Subject: [PATCH 11/17] Update src/components/common/VoteButton.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/components/common/VoteButton.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx index 4babbc8..962faa6 100644 --- a/src/components/common/VoteButton.tsx +++ b/src/components/common/VoteButton.tsx @@ -105,11 +105,9 @@ const handleVote = async (voteType: 'helpful' | 'unhelpful') => { const data = await response.json(); if (!data.success) { - throw new Error(data.error || 'Failed to vote'); - } - // Update with server response setCurrentVote(data.vote_type); + // Callback if (onVote) { onVote(reviewId, data.vote_type); From 46cb5e1f0bc7d142e720d7a3325718e5384960a3 Mon Sep 17 00:00:00 2001 From: Alan Panickar <161750706+alSN0W@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:09:25 +0530 Subject: [PATCH 12/17] Update src/lib/pagination.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/lib/pagination.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts index 5fa1a7d..d8036a8 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -10,14 +10,16 @@ export function calculatePagination( page: number, limit: number ): PaginationMeta { - const totalPages = Math.ceil(totalItems / limit); + 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, - itemsPerPage: limit, + totalItems: safeTotal, + itemsPerPage: safeLimit, hasNextPage: currentPage < totalPages, hasPreviousPage: currentPage > 1, }; From 0dfd0fb58e30817ce93b480906c05ce9c18fa6e9 Mon Sep 17 00:00:00 2001 From: Alan Panickar <161750706+alSN0W@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:39:59 +0530 Subject: [PATCH 13/17] Update src/components/common/VoteButton.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/components/common/VoteButton.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx index 962faa6..c9816ce 100644 --- a/src/components/common/VoteButton.tsx +++ b/src/components/common/VoteButton.tsx @@ -104,13 +104,14 @@ const handleVote = async (voteType: 'helpful' | 'unhelpful') => { const data = await response.json(); - if (!data.success) { - // Update with server response - setCurrentVote(data.vote_type); - - // Callback - if (onVote) { - onVote(reviewId, data.vote_type); + if (data.success) { + // Update with server response + setCurrentVote(data.vote_type); + + // Callback + if (onVote) { + onVote(reviewId, data.vote_type); + } } } catch (error) { From f8da0ebc10ae74f70dc4fda6599ff70f84c925d4 Mon Sep 17 00:00:00 2001 From: alSN0W Date: Wed, 18 Feb 2026 23:58:06 +0530 Subject: [PATCH 14/17] some code-rabbit changes --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 7d936dd..50d9095 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { From 40361b994e052a528da5a6696a4439eb0f693509 Mon Sep 17 00:00:00 2001 From: alSN0W Date: Sat, 21 Feb 2026 23:11:28 +0530 Subject: [PATCH 15/17] Implemented pagination UI for reviews --- src/components/common/Pagination.tsx | 133 ++++++++++++++++ .../courses/course_page/CoursePageReviews.tsx | 137 ++++++++--------- src/hooks/usePaginatedReviews.ts | 142 ++++++++++++++++++ src/lib/pagination.ts | 2 - src/lib/withPagination.ts | 1 - src/pages/api/reviews/index.ts | 60 +++++++- src/types/pagination.ts | 2 - 7 files changed, 395 insertions(+), 82 deletions(-) create mode 100644 src/components/common/Pagination.tsx create mode 100644 src/hooks/usePaginatedReviews.ts 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/courses/course_page/CoursePageReviews.tsx b/src/components/courses/course_page/CoursePageReviews.tsx index 768c9bd..31197fe 100644 --- a/src/components/courses/course_page/CoursePageReviews.tsx +++ b/src/components/courses/course_page/CoursePageReviews.tsx @@ -2,9 +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 @@ -52,7 +52,7 @@ const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: VoteTy reviewId={review.id} initialVoteType={userVote} initialVoteCount={review.votes || 0} - size="md" + size="sm" />
@@ -62,107 +62,98 @@ const CourseReviewItem = ({ review, userVote }: { review: any; userVote?: VoteTy /* Main Reviews Component */ const CoursePageReviews = ({ id, reviewCount }: CoursePageReviewsProps) => { - const [reviews, setReviews] = useState([]); const [userVotes, setUserVotes] = useState>({}); - const [loading, setLoading] = useState(true); - const [showAll, setShowAll] = useState(false); - useEffect(() => { - const fetchReviewsAndVotes = async () => { - setLoading(true); - - // Fetch all reviews - const { data: reviewsData, error: reviewsError } = await supabase - .from("reviews") - .select(` - id, - anonymous_id, - comment, - votes, - created_at - `) - .eq("target_id", id) - .eq("target_type", "course") - .order("created_at", { ascending: false }); - - if (reviewsError) { - console.error("Error fetching reviews:", reviewsError.message); - setLoading(false); - return; - } - - setReviews(reviewsData || []); + // 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's votes for these reviews - if (reviewsData && reviewsData.length > 0) { - const reviewIds = reviewsData.map(r => r.id).join(','); + // Fetch user votes whenever reviews change + useEffect(() => { + 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(); - 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); + if (votesData.success) { + setUserVotes(votesData.votes || {}); } + } catch (error) { + console.error("Error fetching user votes:", error); } - - setLoading(false); }; - fetchReviewsAndVotes(); - }, [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 && ( +
+
)}
diff --git a/src/hooks/usePaginatedReviews.ts b/src/hooks/usePaginatedReviews.ts new file mode 100644 index 0000000..02a8c30 --- /dev/null +++ b/src/hooks/usePaginatedReviews.ts @@ -0,0 +1,142 @@ +// src/hooks/usePaginatedReviews.ts +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 index 5fa1a7d..0eb097b 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -1,5 +1,3 @@ -// src/lib/pagination.ts - import { PaginationParams, PaginationMeta, PaginationOptions } from '@/types/pagination'; /** diff --git a/src/lib/withPagination.ts b/src/lib/withPagination.ts index e4c8cb6..14958a0 100644 --- a/src/lib/withPagination.ts +++ b/src/lib/withPagination.ts @@ -1,4 +1,3 @@ -// src/lib/withPagination.ts import { NextApiRequest, NextApiResponse } from 'next'; import { SupabaseClient } from '@supabase/supabase-js'; import { validatePaginationParams, getOffset, buildPaginationResponse } from '@/lib/pagination'; diff --git a/src/pages/api/reviews/index.ts b/src/pages/api/reviews/index.ts index 03c904c..2d8decd 100644 --- a/src/pages/api/reviews/index.ts +++ b/src/pages/api/reviews/index.ts @@ -9,7 +9,7 @@ export default withPagination({ const sort_by = getParam(req.query.sort_by) ?? 'created_at'; const sort_order = getParam(req.query.sort_order) ?? 'desc'; - // Validate + // Validate required params if (!target_id || !target_type) { return { query: null, error: 'target_id and target_type are required' }; } @@ -17,9 +17,62 @@ export default withPagination({ 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(` @@ -28,11 +81,10 @@ export default withPagination({ knowledge_rating, teaching_rating, approachability_rating, created_at, updated_at `, { count: 'exact' }) - .eq('target_id', target_id) + .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 index 34a6f01..f5bd0ba 100644 --- a/src/types/pagination.ts +++ b/src/types/pagination.ts @@ -1,5 +1,3 @@ -// src/types/pagination.ts - export interface PaginationParams { page: number; limit: number; From 6e6e38aff307575080a4e0c3273747fd00566e98 Mon Sep 17 00:00:00 2001 From: alSN0W Date: Sat, 21 Feb 2026 23:19:07 +0530 Subject: [PATCH 16/17] documentation changes --- src/hooks/usePaginatedReviews.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/usePaginatedReviews.ts b/src/hooks/usePaginatedReviews.ts index 02a8c30..05d214a 100644 --- a/src/hooks/usePaginatedReviews.ts +++ b/src/hooks/usePaginatedReviews.ts @@ -1,4 +1,3 @@ -// src/hooks/usePaginatedReviews.ts import { useState, useEffect, useCallback } from 'react'; import { PaginatedResponse } from '@/types/pagination'; From cf07378d412df4de668be8642a70ec5a722e985e Mon Sep 17 00:00:00 2001 From: alSN0W Date: Mon, 23 Feb 2026 23:04:29 +0530 Subject: [PATCH 17/17] removed all the useless routes and functions --- src/components/common/VoteButton.tsx | 155 +++++++++++-------- src/migrations/migration.sql | 51 +++--- src/pages/api/ratings/route.ts | 222 --------------------------- src/pages/api/ratings/vote/index.ts | 53 ++++--- src/types/reviews.tsx | 103 +++++++++++-- 5 files changed, 236 insertions(+), 348 deletions(-) delete mode 100644 src/pages/api/ratings/route.ts diff --git a/src/components/common/VoteButton.tsx b/src/components/common/VoteButton.tsx index 4babbc8..b39b2a4 100644 --- a/src/components/common/VoteButton.tsx +++ b/src/components/common/VoteButton.tsx @@ -2,10 +2,11 @@ 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; @@ -56,74 +57,94 @@ export function VoteButton({ }, }; -const handleVote = async (voteType: 'helpful' | 'unhelpful') => { - if (isLoading) return; - - setIsLoading(true); - - // Save snapshot for rollback (moved outside try) - const oldVote = currentVote; - const oldCount = voteCount; - - try { - // Optimistic update - let newVote: VoteType; - let newCount = voteCount; - - if (currentVote === voteType) { - // Toggle off - remove vote - newVote = null; - if (voteType === 'helpful') newCount--; - else newCount++; - } else { - // Switch vote or add new vote - newVote = voteType; - - // Remove old vote effect - if (oldVote === 'helpful') newCount--; - else if (oldVote === 'unhelpful') newCount++; - - // Add new vote effect - if (voteType === 'helpful') newCount++; - else newCount--; - } - - setCurrentVote(newVote); - setVoteCount(newCount); - - // API call - const response = await fetch('/api/ratings/vote', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), - }); - - if (!response.ok) { - throw new Error('Failed to vote'); - } - - const data = await response.json(); + const handleVote = async (voteType: 'helpful' | 'unhelpful') => { + if (isLoading) return; - if (!data.success) { - throw new Error(data.error || 'Failed to vote'); + // Check if user is authenticated BEFORE making API call + const { data: { session } } = await supabase.auth.getSession(); + + if (!session) { + toast.error('Please sign in to vote on reviews'); + return; } - // Update with server response - setCurrentVote(data.vote_type); - // Callback - if (onVote) { - onVote(reviewId, data.vote_type); + setIsLoading(true); + + // Save snapshot for rollback + const oldVote = currentVote; + const oldCount = voteCount; + + try { + // Optimistic update + let newVote: VoteType; + let newCount = voteCount; + + if (currentVote === voteType) { + // Toggle off - remove vote + newVote = null; + if (voteType === 'helpful') newCount--; + else newCount++; + } else { + // Switch vote or add new vote + newVote = voteType; + + // Remove old vote effect + if (oldVote === 'helpful') newCount--; + else if (oldVote === 'unhelpful') newCount++; + + // Add new vote effect + if (voteType === 'helpful') newCount++; + else newCount--; + } + + setCurrentVote(newVote); + setVoteCount(newCount); + + // API call + const response = await fetch('/api/ratings/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ review_id: reviewId, vote_type: voteType }), + }); + + // Handle auth errors specifically + if (response.status === 401) { + toast.error('Please sign in to vote on reviews'); + // Rollback + setCurrentVote(oldVote); + setVoteCount(oldCount); + return; + } + + if (!response.ok) { + throw new Error('Failed to vote'); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to vote'); + } + + // Update with server response for vote type + setCurrentVote(data.vote_type); + + // Callback + if (onVote) { + onVote(reviewId, data.vote_type); + } + + } catch (error) { + console.error('Error voting:', error); + toast.error('Failed to vote. Please try again.'); + + // Rollback on error using saved snapshot + setCurrentVote(oldVote); + setVoteCount(oldCount); + } finally { + setIsLoading(false); } - - } catch (error) { - console.error('Error voting:', error); - // Rollback on error using saved snapshot - setCurrentVote(oldVote); - setVoteCount(oldCount); - } finally { - setIsLoading(false); - } -}; + }; return (
{ } ${isLoading ? 'cursor-not-allowed' : 'cursor-pointer'} `} - aria-label="Upvote" + aria-label="Mark as helpful" > { } ${isLoading ? 'cursor-not-allowed' : 'cursor-pointer'} `} - aria-label="Downvote" + aria-label="Mark as unhelpful" > 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 index 8477b61..d80b44d 100644 --- a/src/pages/api/ratings/vote/index.ts +++ b/src/pages/api/ratings/vote/index.ts @@ -2,12 +2,13 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { createClient } from '@/utils/supabase/server-pages'; /** - * Vote API Route + * 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 { @@ -25,9 +26,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Get user session const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError && authError.message !== 'Auth session missing!') { + if (authError || !user) { console.error('Auth error:', authError); - return res.status(401).json({ error: 'Authentication error' }); + return res.status(401).json({ error: 'Authentication required' }); } // Get anonymous ID @@ -39,13 +40,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const anonymous_id = anonData; + const auth_id = user.id; // Track real user to prevent duplicates - // Check if user already voted + // 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('anonymous_id', anonymous_id) + .eq('auth_id', auth_id) // ← Check by real user, not anonymous_id .single(); if (checkError && checkError.code !== 'PGRST116') { @@ -76,7 +78,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (existingVote && existingVote.vote_type !== vote_type) { const { error: updateError } = await supabase .from('votes') - .update({ vote_type, created_at: new Date().toISOString() }) + .update({ + vote_type, + anonymous_id, // Update anonymous_id in case it changed + created_at: new Date().toISOString() + }) .eq('id', existingVote.id); if (updateError) { @@ -97,6 +103,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) .insert({ review_id, anonymous_id, + auth_id, // ← Add auth_id to track real user vote_type, }); @@ -126,22 +133,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ error: 'review_ids parameter is required' }); } - // Get anonymous ID - const { data: anonData, error: anonError } = await supabase.rpc('get_anonymous_id'); + // Get user session + const { data: { user } } = await supabase.auth.getUser(); - if (anonError || !anonData) { - console.error('Error getting anonymous ID:', anonError); - return res.status(500).json({ error: 'Failed to get user identifier' }); + if (!user) { + // If not logged in, return empty votes + return res.status(200).json({ + success: true, + votes: {}, + }); } - const anonymous_id = anonData; + const auth_id = user.id; const reviewIdArray = review_ids.split(',').map(id => id.trim()); - // Batch fetch votes + // Batch fetch votes by auth_id const { data: votes, error: fetchError } = await supabase .from('votes') .select('review_id, vote_type') - .eq('anonymous_id', anonymous_id) + .eq('auth_id', auth_id) // ← Fetch by real user .in('review_id', reviewIdArray); if (fetchError) { @@ -175,22 +185,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ error: 'review_id is required' }); } - // Get anonymous ID - const { data: anonData, error: anonError } = await supabase.rpc('get_anonymous_id'); + // Get user session + const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (anonError || !anonData) { - console.error('Error getting anonymous ID:', anonError); - return res.status(500).json({ error: 'Failed to get user identifier' }); + if (authError || !user) { + return res.status(401).json({ error: 'Authentication required' }); } - const anonymous_id = anonData; + const auth_id = user.id; - // Delete the vote + // Delete the vote by auth_id const { error: deleteError } = await supabase .from('votes') .delete() .eq('review_id', review_id) - .eq('anonymous_id', anonymous_id); + .eq('auth_id', auth_id); // ← Delete by real user if (deleteError) { console.error('Error deleting vote:', deleteError); 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