diff --git a/.gitignore b/.gitignore index 4af9b43..e3a7542 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,3 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env*.local - -# email crap - -email.js \ No newline at end of file diff --git a/.idx/dev.nix b/.idx/dev.nix index e5ca7d7..b8172bc 100644 --- a/.idx/dev.nix +++ b/.idx/dev.nix @@ -3,6 +3,8 @@ packages = [ pkgs.nodejs_20 pkgs.gh + pkgs.nano + pkgs.postgresql ]; idx.extensions = [ diff --git a/TODO b/TODO index 2f4fa7f..9c0ea37 100644 --- a/TODO +++ b/TODO @@ -1,2 +1,45 @@ -add a signout button too -fix carrousel bug thing \ No newline at end of file +======================== TO DO LIST ======================== + +- fix carrousel bug thing +- create database table, schema, and drizzle thing to host the courses +- create backend that communicates with database for courses +- create backend for admmin dashboard that adds courses +- replace signout with a profile picture that when you click on it, you get access to settings and a logout button + + + +======================== Ideas ======================== + + +--------------- courses flow --------------- + +Key: + +- is see +> is do ++ is any api requests or backend +| end + +> course is published on admin dashboard +- user sees course on the courses catalog +> user clicks on course +- user sees details +- user decides they want to take it +> user clicks on enroll button ++ enroll button runs a function to create a checkout ++ a secure paypal checkout with designated price and product name gets generated +- user sees paypal payment portal +> user pays ++ payment gets logged into paypal and recieves a receipt with remind code +- user sees remind code +> user joins remind +> admin approves user into class after verifying that they payed on the paypal +| user gets to see meeting links and other details on remind + + +--------------- account settings --------------- + +- profile picture (default generated by jdenticons) +- Name +- email (will require reverif) +- password (will require old password) diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 975c613..a53cc93 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -35,16 +35,16 @@ export const users = pgTable("users", { email: varchar({ length: 255 }).notNull(), password: text().notNull(), name: text().notNull(), - profilePicture: text("profile_picture").default('skibiditoilet').notNull(), - createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), resetKey: text("reset_key"), + createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), resetKeyExpires: timestamp("reset_key_expires", { mode: 'string' }), - lastReset: timestamp("last_reset", { mode: 'string' }), emailVerified: boolean("email_verified").default(false).notNull(), emailVerificationKey: text("email_verification_key"), emailVerificationKeyExpires: timestamp("email_verification_key_expires", { mode: 'string' }), isAdmin: boolean("is_admin").default(false).notNull(), isBanned: boolean("is_banned").default(false).notNull(), + lastReset: timestamp("last_reset", { mode: 'string' }), + profilePicture: text("profile_picture").default('skibiditoilet').notNull(), newEmail: varchar("new_email", { length: 255 }), newEmailVerificationKey: text("new_email_verification_key"), newEmailVerificationKeyExpires: timestamp("new_email_verification_key_expires", { mode: 'string' }), diff --git a/eslint.config.mjs b/eslint.config.mjs index 3a95b32..ec2cd0c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,15 @@ const compat = new FlatCompat({ }); const eslintConfig = [ + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, ...compat.extends( "next/core-web-vitals", "next/typescript", diff --git a/package-lock.json b/package-lock.json index cc2796b..4dd924a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@supabase/supabase-js": "^2.56.0", @@ -28,16 +29,15 @@ "drizzle-orm": "^0.44.4", "jdenticon": "^3.3.0", "lucide-react": "^0.525.0", - "next": "15.3.5", + "next": "15.5.9", "next-auth": "^4.24.11", "next-themes": "^0.4.6", "nodemailer": "^6.10.1", "particles.js": "^2.0.0", "pg": "^8.16.3", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "19.2.3", + "react-dom": "19.2.3", "sonner": "^2.0.7", - "stripe": "^18.3.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -46,13 +46,13 @@ "@types/aos": "^3.0.7", "@types/node": "^20", "@types/pg": "^8.15.5", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", "cross-env": "^10.0.0", "dotenv-cli": "^10.0.0", "drizzle-kit": "^0.31.4", "eslint": "^9", - "eslint-config-next": "15.3.5", + "eslint-config-next": "15.5.9", "eslint-plugin-import": "^2.32.0", "prettier": "^3.6.2", "tailwindcss": "^4", @@ -1280,6 +1280,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1826,15 +1864,15 @@ } }, "node_modules/@next/env": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz", - "integrity": "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.5.tgz", - "integrity": "sha512-BZwWPGfp9po/rAnJcwUBaM+yT/+yTWIkWdyDwc74G9jcfTrNrmsHe+hXHljV066YNdVs8cxROxX5IgMQGX190w==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.9.tgz", + "integrity": "sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1842,9 +1880,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz", - "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1858,9 +1896,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz", - "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1874,9 +1912,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz", - "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1890,9 +1928,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz", - "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1906,9 +1944,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz", - "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1922,9 +1960,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz", - "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1938,9 +1976,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz", - "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1954,9 +1992,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz", - "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -2060,6 +2098,55 @@ } } }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -2126,6 +2213,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", @@ -2153,6 +2255,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -2234,6 +2365,78 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -2305,6 +2508,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -2408,6 +2642,48 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2496,12 +2772,6 @@ "@supabase/storage-js": "^2.10.4" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2875,23 +3145,23 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", - "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", - "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/ws": { @@ -4010,17 +4280,6 @@ "dev": true, "license": "MIT" }, - "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", @@ -4044,6 +4303,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4057,6 +4317,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4306,9 +4567,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT" }, @@ -4790,6 +5051,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4921,6 +5183,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4930,6 +5193,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4967,6 +5231,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5161,13 +5426,13 @@ } }, "node_modules/eslint-config-next": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.5.tgz", - "integrity": "sha512-oQdvnIgP68wh2RlR3MdQpvaJ94R6qEFl+lnu8ZKxPj5fsAHrSF/HlAOZcsimLw3DT6bnEQIUdbZC2Ab6sWyptg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.9.tgz", + "integrity": "sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.3.5", + "@next/eslint-plugin-next": "15.5.9", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -5779,6 +6044,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5812,6 +6078,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5941,6 +6208,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6019,6 +6287,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7074,6 +7343,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7230,15 +7500,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.5.tgz", - "integrity": "sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.3.5", - "@swc/counter": "0.1.3", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -7250,19 +7518,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.3.5", - "@next/swc-darwin-x64": "15.3.5", - "@next/swc-linux-arm64-gnu": "15.3.5", - "@next/swc-linux-arm64-musl": "15.3.5", - "@next/swc-linux-x64-gnu": "15.3.5", - "@next/swc-linux-x64-musl": "15.3.5", - "@next/swc-win32-arm64-msvc": "15.3.5", - "@next/swc-win32-x64-msvc": "15.3.5", - "sharp": "^0.34.1" + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@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", @@ -7391,6 +7659,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7940,21 +8209,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7977,24 +8231,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.3" } }, "node_modules/react-is": { @@ -8287,9 +8541,9 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { @@ -8429,6 +8683,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8448,6 +8703,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8464,6 +8720,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8482,6 +8739,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8583,14 +8841,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": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8759,26 +9009,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.3.0.tgz", - "integrity": "sha512-FkxrTUUcWB4CVN2yzgsfF/YHD6WgYHduaa7VmokCy5TLCgl5UNJkwortxcedrxSavQ8Qfa4Ir4JxcbIYiBsyLg==", - "license": "MIT", - "dependencies": { - "qs": "^6.11.0" - }, - "engines": { - "node": ">=12.*" - }, - "peerDependencies": { - "@types/node": ">=12.x.x" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/package.json b/package.json index 6e12523..d9e1919 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "cross-env NODE_ENV=development next dev --turbopack", "build": "next build", "start": "next start", - "lint": "prettier . --write && next lint", + "lint": "prettier . --write && eslint .", "db:push": "dotenv -e .env.local -- drizzle-kit push", "db:pull": "dotenv -e .env.local -- drizzle-kit introspect", "db:generate": "dotenv -e .env.local -- drizzle-kit generate", @@ -15,6 +15,7 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@supabase/supabase-js": "^2.56.0", @@ -33,16 +34,15 @@ "drizzle-orm": "^0.44.4", "jdenticon": "^3.3.0", "lucide-react": "^0.525.0", - "next": "15.3.5", + "next": "15.5.9", "next-auth": "^4.24.11", "next-themes": "^0.4.6", "nodemailer": "^6.10.1", "particles.js": "^2.0.0", "pg": "^8.16.3", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "19.2.3", + "react-dom": "19.2.3", "sonner": "^2.0.7", - "stripe": "^18.3.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -51,18 +51,22 @@ "@types/aos": "^3.0.7", "@types/node": "^20", "@types/pg": "^8.15.5", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", "cross-env": "^10.0.0", "dotenv-cli": "^10.0.0", "drizzle-kit": "^0.31.4", "eslint": "^9", - "eslint-config-next": "15.3.5", + "eslint-config-next": "15.5.9", "eslint-plugin-import": "^2.32.0", "prettier": "^3.6.2", "tailwindcss": "^4", "tsx": "^4.20.3", "tw-animate-css": "^1.3.5", "typescript": "^5" + }, + "overrides": { + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3" } } diff --git a/public/images/popup.jpg b/public/images/popup.jpg deleted file mode 100644 index 08a93ff..0000000 Binary files a/public/images/popup.jpg and /dev/null differ diff --git a/src/app/actions/create-paypal-payment.ts b/src/app/actions/create-paypal-payment.ts index 0acbad0..5a411f3 100644 --- a/src/app/actions/create-paypal-payment.ts +++ b/src/app/actions/create-paypal-payment.ts @@ -1,105 +1,96 @@ -"use server"; +'use server'; -export async function createPayPalPayment( - amount: number, - customerName: string, -) { - const paypalClientId = process.env.PAYPAL_CLIENT_ID; - const paypalClientSecret = process.env.PAYPAL_CLIENT_SECRET; - const paypalEnvironment = process.env.PAYPAL_ENVIRONMENT || "sandbox"; // sandbox or live +// Fetches a PayPal access token for API requests. +async function getAccessToken() { + const clientId = process.env.PAYPAL_CLIENT_ID; + const clientSecret = process.env.PAYPAL_CLIENT_SECRET; + const environment = process.env.PAYPAL_ENVIRONMENT || 'sandbox'; + const url = + environment === 'sandbox' + ? 'https://api-m.sandbox.paypal.com/v1/oauth2/token' + : 'https://api-m.paypal.com/v1/oauth2/token'; - if (!paypalClientId || !paypalClientSecret) { - throw new Error("PayPal credentials are not set in environment variables"); + if (!clientId || !clientSecret) { + throw new Error('PayPal credentials are not set in environment variables'); } - const baseUrl = - paypalEnvironment === "sandbox" - ? "https://api-m.sandbox.paypal.com" - : "https://api-m.paypal.com"; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64' + )}`, + }, + body: 'grant_type=client_credentials', + cache: 'no-store', + }); - try { - // Get access token - const tokenResponse = await fetch(`${baseUrl}/v1/oauth2/token`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${Buffer.from(`${paypalClientId}:${paypalClientSecret}`).toString("base64")}`, - }, - body: "grant_type=client_credentials", - }); + if (!response.ok) { + const errorDetails = await response.text(); + console.error('Failed to get PayPal access token:', errorDetails); + throw new Error('Failed to get PayPal access token'); + } - if (!tokenResponse.ok) { - throw new Error("Failed to get PayPal access token"); - } + const data = await response.json(); + return data.access_token; +} + +// Creates a PayPal payment order and returns the payment details. +export async function createPayPalPayment( + productName: string, + amount: string, + currency: string, + returnUrl: string, + cancelUrl: string +) { + const environment = process.env.PAYPAL_ENVIRONMENT || 'sandbox'; + const baseUrl = + environment === 'sandbox' + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; - const tokenData = await tokenResponse.json(); - const accessToken = tokenData.access_token; + try { + const accessToken = await getAccessToken(); - // Create payment const paymentResponse = await fetch(`${baseUrl}/v2/checkout/orders`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ - intent: "CAPTURE", + intent: 'CAPTURE', purchase_units: [ { amount: { - currency_code: "USD", - value: amount.toFixed(2), + currency_code: currency, + value: amount, }, - description: - "Donation to ReEnvision - Help us keep education free and accessible for everyone", - custom_id: customerName, + description: `Course enrollment: ${productName}`, + custom_id: productName, }, ], application_context: { - return_url: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/donation/success`, - cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/donate`, - brand_name: "ReEnvision", - landing_page: "BILLING", - user_action: "PAY_NOW", + return_url: returnUrl, + cancel_url: cancelUrl, + brand_name: 'ReEnvision', + landing_page: 'BILLING', + user_action: 'PAY_NOW', }, }), }); - if (!paymentResponse.ok) { - const errorData = await paymentResponse.json(); - console.error("PayPal API Error:", errorData); - throw new Error("Failed to create PayPal payment"); - } - - interface PayPalLink { - href: string; - rel: string; - method?: string; - } - - interface PayPalPaymentResponse { - id: string; - links: PayPalLink[]; - // Add other properties as needed - } + const paymentData = await paymentResponse.json(); - const paymentData: PayPalPaymentResponse = await paymentResponse.json(); - - // Find the approval URL - const approvalUrl = paymentData.links.find( - (link: PayPalLink) => link.rel === "approve", - )?.href; - - if (!approvalUrl) { - throw new Error("No approval URL found in PayPal response"); + if (!paymentResponse.ok) { + console.error('PayPal API Error:', paymentData); + throw new Error('Failed to create payment'); } - return { - orderId: paymentData.id, - approvalUrl: approvalUrl, - }; + return paymentData; } catch (error) { - console.error("Error creating PayPal payment:", error); - throw new Error("Failed to create PayPal payment"); + console.error('Error creating PayPal payment:', error); + throw error; } } diff --git a/src/app/api/courses/[id]/route.ts b/src/app/api/courses/[id]/route.ts new file mode 100644 index 0000000..a003fdb --- /dev/null +++ b/src/app/api/courses/[id]/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import db from "@/db/database"; +import { coursesTable } from "@/db/schema"; +import { StandardResponse } from "@/lib/types"; +import { restrictAdmin } from "@/lib/jwt"; + +export const dynamic = 'force-dynamic'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const [course] = await db + .select() + .from(coursesTable) + .where(eq(coursesTable.id, id)); + + if (!course) { + const response: StandardResponse = { + success: false, + error: "Course not found", + message: null, + data: null, + }; + return NextResponse.json(response, { status: 404 }); + } + + const response: StandardResponse = { + success: true, + data: course, + message: "Course fetched successfully", + error: null, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error("Error fetching course:", error); + const response: StandardResponse = { + success: false, + data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", + }; + return NextResponse.json(response, { status: 500 }); + } +} + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const adminResponse = await restrictAdmin(req); + if (adminResponse) { + return adminResponse; + } + + try { + const { id } = await params; + const { course_name, course_description, course_price, courses_image } = + await req.json(); + + const updateObject: Partial = {}; + if (course_name) updateObject.course_name = course_name; + if (course_description) + updateObject.course_description = course_description; + if (course_price) updateObject.course_price = course_price; + if (courses_image) updateObject.courses_image = courses_image; + + if (Object.keys(updateObject).length === 0) { + const response: StandardResponse = { + success: false, + error: "No fields to update", + message: null, + data: null, + }; + return NextResponse.json(response, { status: 400 }); + } + + const [updatedCourse] = await db + .update(coursesTable) + .set(updateObject) + .where(eq(coursesTable.id, id)) + .returning(); + + if (!updatedCourse) { + const response: StandardResponse = { + success: false, + error: "Course not found", + message: null, + data: null, + }; + return NextResponse.json(response, { status: 404 }); + } + + const response: StandardResponse = { + success: true, + data: updatedCourse, + message: "Course updated successfully", + error: null, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error("Error updating course:", error); + const response: StandardResponse = { + success: false, + data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", + }; + return NextResponse.json(response, { status: 500 }); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const adminResponse = await restrictAdmin(req); + if (adminResponse) { + return adminResponse; + } + + try { + const { id } = await params; + const [deletedCourse] = await db + .delete(coursesTable) + .where(eq(coursesTable.id, id)) + .returning(); + + if (!deletedCourse) { + const response: StandardResponse = { + success: false, + error: "Course not found", + message: null, + data: null, + }; + return NextResponse.json(response, { status: 404 }); + } + + const response: StandardResponse = { + success: true, + data: null, + message: "Course deleted successfully", + error: null, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error("Error deleting course:", error); + const response: StandardResponse = { + success: false, + data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", + }; + return NextResponse.json(response, { status: 500 }); + } +} diff --git a/src/app/api/courses/image/route.ts b/src/app/api/courses/image/route.ts new file mode 100644 index 0000000..1d72a55 --- /dev/null +++ b/src/app/api/courses/image/route.ts @@ -0,0 +1,101 @@ +import { getImageUrl, uploadImage } from "@/db/supabaseStorage"; +import { restrictAdmin } from "@/lib/jwt"; +import { StandardResponse } from "@/lib/types"; +import { NextRequest, NextResponse } from "next/server"; + +const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const path = searchParams.get("path"); + + if (!path) { + const response: StandardResponse = { + success: false, + error: "Missing path parameter", + message: null, + data: null, + }; + + return NextResponse.json(response, { status: 400 }); + } + + const response: StandardResponse = { + success: true, + error: null, + message: null, + data: { url: getImageUrl(path, "courses") }, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (err) { + console.error("Get image error:", err); + + const response: StandardResponse = { + success: false, + error: "Failed to retrieve image", + message: null, + data: null, + }; + + return NextResponse.json(response, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + const res = await restrictAdmin(req); + + if (res) { + return res; + } + + try { + const form = await req.formData(); + const file = form.get("file") as File | null; + + if (!file) { + const response: StandardResponse = { + success: false, + error: "No file uploaded", + message: null, + data: null, + }; + + return NextResponse.json(response, { status: 400 }); + } + + if (!ALLOWED_TYPES.includes(file.type)) { + const response: StandardResponse = { + success: false, + error: "Unsupported file type", + message: "Only PNG, JPEG, JPG, and WEBP formats are allowed", + data: null, + }; + + return NextResponse.json(response, { status: 400 }); + } + + const url = await uploadImage(file, "courses"); + + const response: StandardResponse = { + success: true, + error: null, + message: "Image uploaded successfully", + data: { url }, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (err) { + console.error("Upload error:", err); + + const response: StandardResponse = { + success: false, + error: "Failed to upload image", + message: null, + data: null, + }; + + return NextResponse.json(response, { status: 500 }); + } +} diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts new file mode 100644 index 0000000..c6af7d6 --- /dev/null +++ b/src/app/api/courses/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { desc } from "drizzle-orm"; +import db from "@/db/database"; +import { coursesTable } from "@/db/schema"; +import { StandardResponse } from "@/lib/types"; +import { restrictAdmin } from "@/lib/jwt"; + +export async function GET() { + try { + const allCourses = await db + .select() + .from(coursesTable) + .orderBy(desc(coursesTable.id)); + + const response: StandardResponse = { + success: true, + data: allCourses, + message: "Courses fetched successfully", + error: null, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error("Error fetching courses:", error); + const response: StandardResponse = { + success: false, + data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", + }; + return NextResponse.json(response, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + const adminResponse = await restrictAdmin(req); + if (adminResponse) { + return adminResponse; + } + + try { + const { course_name, course_description, course_price, courses_image } = + await req.json(); + + if ( + !course_name || + !course_description || + !course_price || + !courses_image + ) { + const response: StandardResponse = { + success: false, + data: null, + message: "Missing required fields", + error: "Missing required fields", + }; + return NextResponse.json(response, { status: 400 }); + } + + const [newCourse] = await db + .insert(coursesTable) + .values({ course_name, course_description, course_price, courses_image }) + .returning(); + + const response: StandardResponse = { + success: true, + data: newCourse, + message: "Course created successfully", + error: null, + }; + return NextResponse.json(response, { status: 201 }); + } catch (error) { + console.error("Error creating course:", error); + const response: StandardResponse = { + success: false, + data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", + }; + return NextResponse.json(response, { status: 500 }); + } +} diff --git a/src/app/api/events/[id]/route.ts b/src/app/api/events/[id]/route.ts index 289fae4..108fe5d 100644 --- a/src/app/api/events/[id]/route.ts +++ b/src/app/api/events/[id]/route.ts @@ -1,239 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; import db from "@/db/database"; import { eventsTable } from "@/db/schema"; import { StandardResponse } from "@/lib/types"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; import { restrictAdmin } from "@/lib/jwt"; -interface EventPutBody { - eventTitle?: string; - eventDesc?: string; - eventDate?: Date; - imageUrl?: string; - updatedAt: Date; -} - +// GET a single event by ID export async function GET( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, + { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - try { - const events = await db + const { id } = await params; + const [event] = await db .select() .from(eventsTable) - .where(eq(eventsTable.id, id)) - .limit(1); + .where(eq(eventsTable.id, id)); - if (events.length === 0) { + if (!event) { const response: StandardResponse = { success: false, - message: null, error: "Event not found", + message: null, data: null, }; - - return NextResponse.json(response, { - status: 404, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 404 }); } const response: StandardResponse = { success: true, - message: null, + data: event, + message: "Event fetched successfully", error: null, - data: events[0], }; - return NextResponse.json(response, { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 200 }); } catch (error) { - console.error("Error fetching events:", error); - + console.error("Error fetching event:", error); const response: StandardResponse = { success: false, - message: "Internal server error", - error: "Failed to fetch events", data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", }; - - return NextResponse.json(response, { - status: 500, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 500 }); } } +// UPDATE an event by ID export async function PUT( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, + { params }: { params: Promise<{ id: string }> } ) { - const res = await restrictAdmin(req); - - if (res) { - return res; + const adminResponse = await restrictAdmin(req); + if (adminResponse) { + return adminResponse; } - const { id } = await params; - try { + const { id } = await params; const body = await req.json(); + const { event_title, event_desc, event_date, image_url } = body; + + // Use a partial type inferred from the database schema to avoid `any` + const updateFields: Partial = { + updatedAt: new Date(), + }; - // Validate required fields if they are provided - if (body.event_title !== undefined && body.event_title === "") { + if (event_title !== undefined) updateFields.eventTitle = event_title; + if (event_desc !== undefined) updateFields.eventDesc = event_desc; + if (event_date !== undefined) updateFields.eventDate = new Date(event_date); + // Check if `image_url` was explicitly included in the request body + if ("image_url" in body) { + updateFields.imageUrl = image_url; + } + + // Ensure there's something to update besides the timestamp + if (Object.keys(updateFields).length === 1) { const response: StandardResponse = { success: false, + error: "No update fields provided", message: null, - error: "Event title cannot be empty", data: null, }; - - return NextResponse.json(response, { - status: 400, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 400 }); } - // Map API field names to database field names - const updateData: EventPutBody = { - updatedAt: new Date(), - }; - - if (body.event_title !== undefined) - updateData.eventTitle = body.event_title; - if (body.event_desc !== undefined) updateData.eventDesc = body.event_desc; - if (body.event_date !== undefined) - updateData.eventDate = new Date(body.event_date); - if (body.image_url !== undefined) updateData.imageUrl = body.image_url; - - const events = await db + const [updatedEvent] = await db .update(eventsTable) - .set(updateData) + .set(updateFields) .where(eq(eventsTable.id, id)) .returning(); - if (events.length === 0) { + if (!updatedEvent) { const response: StandardResponse = { success: false, + error: "Event not found or failed to update", message: null, - error: "Event not found", data: null, }; - - return NextResponse.json(response, { - status: 404, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 404 }); } const response: StandardResponse = { success: true, - message: null, + data: updatedEvent, + message: "Event updated successfully", error: null, - data: events[0], }; - return NextResponse.json(response, { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 200 }); } catch (error) { - if ( - error instanceof SyntaxError && - error.message.includes("Unexpected end of JSON input") - ) { - const response: StandardResponse = { - success: false, - message: null, - error: "Missing fields", - data: null, - }; - - return NextResponse.json(response, { - status: 400, - headers: { - "Content-Type": "application/json", - }, - }); - } - console.error("Error updating event:", error); - const response: StandardResponse = { success: false, - message: "Internal server error", - error: "Failed to update event", data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", }; - - return NextResponse.json(response, { - status: 500, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 500 }); } } +// DELETE an event by ID export async function DELETE( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, + { params }: { params: Promise<{ id: string }> } ) { - const res = await restrictAdmin(req); - - if (res) { - return res; + const adminResponse = await restrictAdmin(req); + if (adminResponse) { + return adminResponse; } - const { id } = await params; - try { - await db.delete(eventsTable).where(eq(eventsTable.id, id)); + const { id } = await params; + const [deletedEvent] = await db + .delete(eventsTable) + .where(eq(eventsTable.id, id)) + .returning(); + + if (!deletedEvent) { + const response: StandardResponse = { + success: false, + error: "Event not found", + message: null, + data: null, + }; + return NextResponse.json(response, { status: 404 }); + } const response: StandardResponse = { success: true, - message: null, - error: null, data: null, + message: "Event deleted successfully", + error: null, }; - return NextResponse.json(response, { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 200 }); } catch (error) { - console.error("Error updating event:", error); - + console.error("Error deleting event:", error); const response: StandardResponse = { success: false, - message: "Internal server error", - error: "Failed to fetch events", data: null, + message: "Internal Server Error", + error: error instanceof Error ? error.message : "Internal Server Error", }; - - return NextResponse.json(response, { - status: 500, - headers: { - "Content-Type": "application/json", - }, - }); + return NextResponse.json(response, { status: 500 }); } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts deleted file mode 100644 index c1951d9..0000000 --- a/src/app/api/upload/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -const API_URL = process.env.SUPABASE_URL; -const API_KEY = process.env.SUPABASE_ANON_KEY; - -export async function POST(request: Request) { - try { - const formData = await request.formData(); - const file = formData.get("file") as File; - - if (!file) { - return Response.json({ error: "No file provided" }, { status: 400 }); - } - - // Validate file type - if (!file.type.includes("jpeg") && !file.type.includes("jpg")) { - return Response.json( - { error: "Only JPEG files are allowed" }, - { status: 400 }, - ); - } - - // Validate file size (400KB max) - if (file.size > 400 * 1024) { - return Response.json( - { error: "File size must be less than 400KB" }, - { status: 400 }, - ); - } - - // Generate filename with current date - const today = new Date().toISOString().split("T")[0]; - const timestamp = Date.now(); - const filename = `${today}-${timestamp}.jpg`; - - // Convert file to buffer - const buffer = await file.arrayBuffer(); - - // Upload to storage - const response = await fetch( - `${API_URL}/storage/v1/object/event-images/${filename}`, - { - method: "POST", - headers: { - apikey: API_KEY || "", - Authorization: `Bearer ${API_KEY}`, - "Content-Type": file.type, - }, - body: buffer, - }, - ); - - if (!response.ok) { - throw new Error("Failed to upload image"); - } - - // Get public URL - const publicUrl = `${API_URL}/storage/v1/object/public/event-images/${filename}`; - - return Response.json({ url: publicUrl }); - } catch (error) { - console.error("Upload error:", error); - return Response.json({ error: "Failed to upload image" }, { status: 500 }); - } -} diff --git a/src/app/courses/[id]/page.tsx b/src/app/courses/[id]/page.tsx new file mode 100644 index 0000000..4e88fb8 --- /dev/null +++ b/src/app/courses/[id]/page.tsx @@ -0,0 +1,120 @@ +import CourseDetail from "@/components/courses/course-detail"; +import { StandardResponse } from "@/lib/types"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; + +// This Server Component fetches a single course and handles errors correctly. + +// The course data structure from the API +interface ApiCourse { + id: string; + course_name: string; + course_description: string; + course_price: string; + courses_image: string | null; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +// The data structure expected by the CourseDetail component +export interface MappedCourse { + id: string; + title: string; + description: string; + price: string; + imageUrl: string | null; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +// Reusable error component for non-404 errors. +function ErrorDisplay({ message }: { message: string }) { + return ( +
+
+

+ Could Not Load Course +

+

{message}

+
+
+ ); +} + +// The page is an async server function that fetches its own data. +export default async function CourseDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + if (!id) { + notFound(); + } + + try { + // Construct the absolute URL for server-side fetching. + const headersList = await headers(); + const host = headersList.get("host") || ""; + const protocol = host.includes("localhost") ? "http" : "https"; + const baseUrl = `${protocol}://${host}`; + const apiUrl = `${baseUrl}/api/courses/${id}`; + + const response = await fetch(apiUrl, { cache: "no-store" }); + + // If the API returns a 404, trigger the Not Found page. + if (response.status === 404) { + notFound(); + return; + } + + const result: StandardResponse = await response.json(); + + // For other server errors, display an error on the page. + if (!response.ok || !result.success) { + return ( + + ); + } + + if (!result.data || Array.isArray(result.data)) { + return ( + + ); + } + + const apiCourse = result.data as unknown as ApiCourse; + + // Map the API data to a more friendly structure for the detail component. + const mappedCourse: MappedCourse = { + id: apiCourse.id, + title: apiCourse.course_name, + description: apiCourse.course_description, + price: apiCourse.course_price, + imageUrl: apiCourse.courses_image, + createdAt: apiCourse.createdAt, + updatedAt: apiCourse.updatedAt, + createdBy: apiCourse.createdBy, + }; + + // Render the CourseDetail component with the fetched data. + return ; + } catch (err: unknown) { + console.error("Catastrophic error in CourseDetailPage:", err); + const message = + err instanceof Error + ? err.message + : "An unexpected internal error occurred."; + return ; + } +} diff --git a/src/app/courses/page.tsx b/src/app/courses/page.tsx index 09523b7..2f0d428 100644 --- a/src/app/courses/page.tsx +++ b/src/app/courses/page.tsx @@ -1,82 +1,216 @@ "use client"; -import { useEffect } from "react"; +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ShoppingCart, Video, Users, BookOpen } from "lucide-react"; +import Link from "next/link"; +import Image from "next/image"; import AOS from "aos"; import "aos/dist/aos.css"; +import MarkdownRenderer from "@/components/markdown-renderer"; +import { createPayPalPayment } from "@/app/actions/create-paypal-payment"; + +// Define the Course type based on your database schema +interface Course { + id: string; + course_name: string; + courses_image: string | null; + course_description: string; + course_price: string; // Drizzle returns numeric as string +} + +// Define the type for PayPal links +interface PayPalLink { + href: string; + rel: string; + method: string; +} + +export default function CoursesPage() { + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); -export default function ComingSoonPage() { useEffect(() => { AOS.init({ duration: 1000, easing: "ease-out-cubic", once: true, offset: 50, - delay: 0, }); + + const fetchCourses = async () => { + setLoading(true); + try { + const response = await fetch("/api/courses", { cache: "no-store" }); + if (!response.ok) { + throw new Error("Failed to fetch courses"); + } + const result = await response.json(); + setCourses(result.data || []); // Ensure data is an array + } catch (error) { + console.error("Failed to load courses:", error); + setCourses([]); // Set to empty array on error + } finally { + setLoading(false); + } + }; + + fetchCourses(); }, []); + const handleEnroll = async (course: Course) => { + try { + const payment = await createPayPalPayment( + course.course_name, + course.course_price, + "USD", + `${window.location.origin}/payment/success`, + `${window.location.origin}/courses` + ); + const approvalLink = payment.links.find( + (link: PayPalLink) => link.rel === "approve" + ); + if (approvalLink) { + window.location.href = approvalLink.href; + } else { + console.error("Could not find PayPal approval link."); + // Handle the error, e.g., show a message to the user + } + } catch (error) { + console.error("Failed to create PayPal payment:", error); + // Handle the error, e.g., show a message to the user + } + }; + return ( -
-
-
-
- {/* Left Section - Mission Statement */} -
-
-

- Coming Soon -

-

- We're working hard to bring you something amazing. Our - team is putting the finishing touches on this page to ensure - we deliver the best possible experience. Check back soon to - see what we've been working on! -

-
+
+
+
+

+ Explore Our Courses & Boot Camps +

+

+ Learn tech skills live with expert mentors and volunteers. Find the + perfect course to start your journey in technology. +

+
+
+
+
+ + + Expert Mentors +
+
+ + + Hands-on Learning + +
+
+
+
+ +
+
+
+

+ Available Courses & Boot Camps +

+

+ Interactive, mentor-led courses designed to build technical + skills. +

+
- {/* Right Section - Placeholder Content */} -
-
-
-
- - - + {loading ? ( +
+

Loading courses...

+
+ ) : courses.length > 0 ? ( +
+ {courses.map((course, index) => ( + +
+ {course.course_name}
-

- Something Great is Coming -

-

- We're preparing something special for you. Stay tuned - for updates! -

-
+ + + {course.course_name} + + + + + + -
-

- "The best things in life are worth waiting for" -

-
-
+ +
+ {`$${course.course_price}`} +
+
+ + + + +
+
+ + ))}
-
+ ) : ( +
+

+ No courses are available at the moment. Please check back later! +

+
+ )}
-
+
); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 57ad7b5..bffff02 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,5 @@ import EventManagement from "@/components/admin/event-management"; +import CourseManagement from "@/components/admin/course-management"; // Import the new component import { checkAuth } from "@/lib/check-auth"; import { authOptions } from "@/lib/auth.config"; import { getServerSession } from "next-auth/next"; @@ -16,7 +17,15 @@ export default async function Page() { Welcome, administrator! You have special access privileges.

+ + {/* Existing Event Management */} + + {/* Spacer and Divider */} +
+ + {/* New Course Management */} +
); } diff --git a/src/app/donate/page.tsx b/src/app/donate/page.tsx index 621a2e5..0f5059b 100644 --- a/src/app/donate/page.tsx +++ b/src/app/donate/page.tsx @@ -5,6 +5,13 @@ import "aos/dist/aos.css"; import PaymentModal from "@/components/PaymentModal"; import { createPayPalPayment } from "@/app/actions/create-paypal-payment"; +// Define the type for PayPal links +interface PayPalLink { + href: string; + rel: string; + method: string; +} + export default function DonatePage() { const [selectedAmount, setSelectedAmount] = useState(""); const [customAmount, setCustomAmount] = useState(""); @@ -61,9 +68,24 @@ export default function DonatePage() { setIsLoading(true); try { - const result = await createPayPalPayment(amount, "Anonymous Donor"); - setApprovalUrl(result.approvalUrl); - setIsModalOpen(true); + const payment = await createPayPalPayment( + "Donation", + amount.toFixed(2), + "USD", + `${window.location.origin}/payment/success`, + window.location.href + ); + const approvalLink = payment.links.find( + (link: PayPalLink) => link.rel === "approve" + ); + + if (approvalLink) { + setApprovalUrl(approvalLink.href); + setIsModalOpen(true); + } else { + console.error("Could not find PayPal approval link."); + alert("There was an error processing your donation. Please try again."); + } } catch (error) { console.error("Error creating PayPal payment:", error); alert("There was an error processing your donation. Please try again."); diff --git a/src/app/events/[id]/page.tsx b/src/app/events/[id]/page.tsx index 750c7c3..8130d04 100644 --- a/src/app/events/[id]/page.tsx +++ b/src/app/events/[id]/page.tsx @@ -1,52 +1,117 @@ -import { notFound } from "next/navigation"; import EventDetail from "@/components/events/event-detail"; +import { StandardResponse } from "@/lib/types"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; + +// This is the correct Server Component implementation that handles all error cases as requested. + +interface ApiEvent { + id: string; + eventTitle: string; + eventDesc: string; + eventDate: string; + imageUrl: string | null; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +interface MappedEvent { + id: string; + eventTitle: string; + eventDesc: string; + eventDate: string; + imageUrl: string | null; + createdAt: string; + updatedAt: string; + createdBy: string; +} -interface EventPageProps { - params: Promise<{ - id: string; - }>; +// A reusable error component, rendered on the server for non-404 errors. +function ErrorDisplay({ message }: { message: string }) { + return ( +
+
+

+ Could Not Load Event +

+

{message}

+
+
+ ); } -export default async function EventPage({ params }: EventPageProps) { +export default async function EventDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { const { id } = await params; - // Basic validation for cuid format (starts with 'c' and has reasonable length) - // Adjusting the validation to be less strict since cuid length can vary - if (!id || id.length < 10 || !id.startsWith("c")) { + // If there's no ID in the path, it's a 404. + if (!id) { notFound(); } try { - // Use NEXT_PUBLIC_BASE_URL for consistent URL construction - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; - const response = await fetch(`${baseUrl}/api/events/${id}`, { - cache: "no-store", - }); - - if (!response.ok) { - // If event is not found, show 404 page - if (response.status === 404) { - notFound(); - } - // For other errors, throw to catch block - throw new Error(`API request failed with status ${response.status}`); - } + const headersList = await headers(); + const host = headersList.get("host") || ""; + const protocol = host.includes("localhost") ? "http" : "https"; + const baseUrl = `${protocol}://${host}`; + const apiUrl = `${baseUrl}/api/events/${id}`; - const result = await response.json(); + const response = await fetch(apiUrl, { cache: "no-store" }); - // Check if we actually got event data - if (!result.data) { + // CORRECT: As you instructed, if the API returns a 404, use the notFound() function. + if (response.status === 404) { notFound(); + return; // Eslint requires a return after notFound() } - return ; - } catch (error: unknown) { - // Log the error for debugging purposes - if (error instanceof Error) { - console.error("Error fetching event:", error.message); - } else { - console.error("Unknown error fetching event:", error); + const result: StandardResponse = await response.json(); + + // CORRECT: For other errors (e.g., 500), display the error on the page. + if (!response.ok || !result.success) { + return ( + + ); } - notFound(); + + // FIX: The data from the API can be null or an array, which is not a valid event. + if (!result.data || Array.isArray(result.data)) { + return ( + + ); + } + + const apiEvent = result.data as unknown as ApiEvent; + + const mappedEvent: MappedEvent = { + id: apiEvent.id, + eventTitle: apiEvent.eventTitle, + eventDesc: apiEvent.eventDesc, + eventDate: apiEvent.eventDate, + imageUrl: apiEvent.imageUrl, + createdAt: apiEvent.createdAt, + updatedAt: apiEvent.updatedAt, + createdBy: apiEvent.createdBy, + }; + + return ; + } catch (err: unknown) { + console.error("Catastrophic error in EventDetailPage:", err); + const message = + err instanceof Error + ? err.message + : "An unexpected internal error occurred."; + // CORRECT: For catastrophic failures (like the original 'fetch failed'), show the error on the page. + return ; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e208a3b..d1ccefd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,7 @@ import Header from "@/components/Header"; import Footer from "@/components/Footer"; import AuthProvider from "@/components/AuthProvider"; import { Analytics } from "@vercel/analytics/next"; -import { SpeedInsights } from "@vercel/speed-insights/next" +import { SpeedInsights } from "@vercel/speed-insights/next"; // Configure EB Garamond const ebGaramond = EB_Garamond({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 575db6d..78c27ca 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -228,16 +228,8 @@ export default function HomePage() { data-aos="fade-up" data-aos-delay="400" > - + - - - diff --git a/src/app/payment/success/page.tsx b/src/app/payment/success/page.tsx new file mode 100644 index 0000000..5cfea31 --- /dev/null +++ b/src/app/payment/success/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CheckCircle } from "lucide-react"; +import Link from "next/link"; + +export default function PaymentSuccessPage() { + return ( +
+
+ +

+ Payment Successful! +

+

+ You will recieve an email containing your reciept and class codes to join. +

+ + + Return to Courses + + +
+
+ ); +} diff --git a/src/components/admin/course-management.tsx b/src/components/admin/course-management.tsx new file mode 100644 index 0000000..87e17da --- /dev/null +++ b/src/components/admin/course-management.tsx @@ -0,0 +1,407 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Plus, Edit, Trash2 } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; + +interface Course { + id: string; + course_name: string; + course_description: string; + course_price: string; + courses_image: string; +} + +export default function CourseManagement() { + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [editingCourse, setEditingCourse] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const [courseToDelete, setCourseToDelete] = useState(null); + // Form state + const [courseName, setCourseName] = useState(""); + const [courseDescription, setCourseDescription] = useState(""); + const [coursePrice, setCoursePrice] = useState(""); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + + const fetchCourses = async () => { + setLoading(true); + try { + const response = await fetch("/api/courses"); + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || "Failed to fetch courses."); + } + + setCourses(result.data || []); + } catch (error) { + setCourses([]); // Set to empty array on error to prevent render issues + toast.error( + error instanceof Error ? error.message : "An unknown error occurred.", + ); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCourses(); + }, []); + + const resetForm = () => { + setCourseName(""); + setCourseDescription(""); + setCoursePrice(""); + setImageFile(null); + setImagePreview(null); + setEditingCourse(null); + }; + + const handleDialogOpenChange = (open: boolean) => { + setIsDialogOpen(open); + if (!open) { + resetForm(); + } + }; + + const handleEdit = (course: Course) => { + setEditingCourse(course); + setCourseName(course.course_name); + setCourseDescription(course.course_description); + setCoursePrice(course.course_price); + setImagePreview(course.courses_image); + setIsDialogOpen(true); + }; + + const confirmDelete = (courseId: string) => { + setCourseToDelete(courseId); + }; + + const executeDelete = async (courseId: string | null) => { + if (courseId === null) return; + try { + const response = await fetch(`/api/courses/${courseId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to delete course."); + } + + toast.success("Course deleted successfully!"); + fetchCourses(); // Refresh the list + setCourseToDelete(null); + } catch (error) { + setCourseToDelete(null); // Reset even on error + toast.error( + error instanceof Error ? error.message : "An unknown error occurred.", + ); + } + }; + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const ALLOWED_TYPES = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + ]; + if (!ALLOWED_TYPES.includes(file.type)) { + toast.error("Only PNG, JPEG, JPG, and WEBP formats are allowed."); + e.target.value = ""; + return; + } + if (file.size > 400 * 1024) { + toast.error("File size must be less than 400KB."); + e.target.value = ""; + return; + } + setImageFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleSaveCourse = async () => { + if (!courseName || !courseDescription || !coursePrice) { + toast.error("All fields are required."); + return; + } + if (!editingCourse && !imageFile) { + toast.error("An image is required for new courses."); + return; + } + + let finalImageUrl = editingCourse?.courses_image; + + try { + if (imageFile) { + const imageFormData = new FormData(); + imageFormData.append("file", imageFile); + + const uploadRes = await fetch("/api/courses/image", { + method: "POST", + body: imageFormData, + }); + + if (!uploadRes.ok) { + const errorData = await uploadRes.json(); + throw new Error(errorData.error || "Failed to upload image."); + } + const uploadData = await uploadRes.json(); + finalImageUrl = uploadData.data.url; + } + + if (!finalImageUrl) { + throw new Error("Image URL is missing."); + } + + const courseData = { + course_name: courseName, + course_description: courseDescription, + course_price: coursePrice, + courses_image: finalImageUrl, + }; + + const url = editingCourse + ? `/api/courses/${editingCourse.id}` + : "/api/courses"; + const method = editingCourse ? "PUT" : "POST"; + + const courseRes = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(courseData), + }); + + if (!courseRes.ok) { + const errorData = await courseRes.json(); + throw new Error( + errorData.error || + `Failed to ${editingCourse ? "update" : "create"} course.`, + ); + } + + toast.success( + `Course ${editingCourse ? "updated" : "created"} successfully!`, + ); + handleDialogOpenChange(false); + fetchCourses(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "An unknown error occurred.", + ); + } + }; + + return ( +
+
+

+ Course Management +

+ + + + + + + + {editingCourse ? "Edit Course" : "Add a New Course"} + + +
+
+ + setCourseName(e.target.value)} + placeholder="e.g., Introduction to Web Development" + /> +
+
+ + setCoursePrice(e.target.value)} + placeholder="e.g., 99.99" + /> +
+
+ + + {imagePreview && ( +
+ Image Preview +
+ )} +
+
+ +