diff --git a/.gitignore b/.gitignore index a547bf3..30d38e6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,10 @@ dist-ssr *.njsproj *.sln *.sw? + +# ignore Environmental & setup Files +.env +.env.production +.env.prod +*/certs/** +_cert.pem \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 784284b..a3d0a15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-context-menu": "^2.2.1", @@ -36,6 +36,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@tanstack/react-form": "^1.23.8", "@tanstack/react-query": "^5.83.0", "@types/jszip": "^3.4.1", "@types/markdown-it": "^14.1.2", @@ -63,7 +64,7 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.3", - "zod": "^3.23.8" + "zod": "^4.1.12" }, "devDependencies": { "@biomejs/biome": "2.1.2", @@ -1100,15 +1101,69 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", - "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@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-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "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-avatar/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", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "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-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "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-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1125,6 +1180,54 @@ } } }, + "node_modules/@radix-ui/react-avatar/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", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "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-avatar/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "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-avatar/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "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-checkbox": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", @@ -3281,6 +3384,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "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-layout-effect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", @@ -3917,6 +4038,51 @@ "node": ">=4" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.3.4.tgz", + "integrity": "sha512-eq+PpuutUyubXu+ycC1GIiVwBs86NF/8yYJJAKSpPcJLWl6R/761F1H4F/9ziX6zKezltFUH1ah3Cz8Ah+KJrw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.24.4.tgz", + "integrity": "sha512-+eIR7DiDamit1zvTVgaHxuIRA02YFgJaXMUGxsLRJoBpUjGl/g/nhUocQoNkRyfXqOlh8OCMTanjwDprWSRq6w==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.3.3", + "@tanstack/pacer": "^0.15.3", + "@tanstack/store": "^0.7.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.15.4.tgz", + "integrity": "sha512-vGY+CWsFZeac3dELgB6UZ4c7OacwsLb8hvL2gLS6hTgy8Fl0Bm/aLokHaeDIP+q9F9HUZTnp360z9uv78eg8pg==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.3.2", + "@tanstack/store": "^0.7.5" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.83.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz", @@ -3927,6 +4093,31 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.23.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.23.8.tgz", + "integrity": "sha512-ivfkiOHAI3aIWkCY4FnPWVAL6SkQWGWNVjtwIZpaoJE4ulukZWZ1KB8TQKs8f4STl+egjTsMHrWJuf2fv3Xh1w==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.24.4", + "@tanstack/react-store": "^0.7.7", + "decode-formdata": "^0.9.0", + "devalue": "^5.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-start": "^1.130.10", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, "node_modules/@tanstack/react-query": { "version": "5.83.0", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz", @@ -3943,6 +4134,34 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.7.7", + "use-sync-external-store": "^1.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz", + "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -5340,6 +5559,12 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-formdata": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz", + "integrity": "sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -5368,6 +5593,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devalue": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz", + "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -8415,6 +8646,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8712,9 +8952,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 19e7ac7..e34feea 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-context-menu": "^2.2.1", @@ -40,6 +40,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@tanstack/react-form": "^1.23.8", "@tanstack/react-query": "^5.83.0", "@types/jszip": "^3.4.1", "@types/markdown-it": "^14.1.2", @@ -67,7 +68,7 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.3", - "zod": "^3.23.8" + "zod": "^4.1.12" }, "devDependencies": { "@biomejs/biome": "2.1.2", diff --git a/src/App.tsx b/src/App.tsx index b5bc5a1..121302d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import Privacy from "./pages/Privacy"; import Signup from "./pages/Signup"; import SubmitPlugin from "./pages/SubmitPlugin"; import Terms from "./pages/Terms"; +import { AuthProvider } from "./context/AuthContext"; const queryClient = new QueryClient({ defaultOptions: { @@ -42,39 +43,41 @@ const App = () => ( - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - } /> - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + + + + diff --git a/src/components/dashboard/earnings-overview.tsx b/src/components/dashboard/earnings-overview.tsx index f00bec6..61490c1 100644 --- a/src/components/dashboard/earnings-overview.tsx +++ b/src/components/dashboard/earnings-overview.tsx @@ -1,10 +1,10 @@ import { DollarSign, TrendingUp } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useCurrentMonthEarnings } from "@/hooks/use-earnings"; -import { useLoggedInUser } from "@/hooks/useLoggedInUser"; +import { useAuth } from "@/context/AuthContext"; export function EarningsOverview() { - const { data: user } = useLoggedInUser(); + const { user } = useAuth(); const { data: earnings, isLoading } = useCurrentMonthEarnings( user?.id?.toString() || "", ); diff --git a/src/components/dashboard/profile-management.tsx b/src/components/dashboard/profile-management.tsx new file mode 100644 index 0000000..9eccbc4 --- /dev/null +++ b/src/components/dashboard/profile-management.tsx @@ -0,0 +1,697 @@ +import { + LogOut, + Settings, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, useRef, memo } from "react"; +import { useNavigate } from "react-router-dom"; +import { type QueryClient, useMutation, useQueryClient } from "@tanstack/react-query"; +import { PaymentMethods } from "@/components/dashboard/payment-methods"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "@/hooks/use-toast.ts"; +import type { User } from "@/types"; +import { z } from "zod" +import { useForm } from '@tanstack/react-form' +import type { AnyFieldApi } from '@tanstack/react-form' +import { isValidGithubId } from "@/lib/utils"; +import { AuthContextState, useAuth } from "@/context/AuthContext"; + +/** + * Handles the user log out process. + * This function typically invalidates or removes local authentication data, + * cleans up the cache, and redirects the user. + * @param {import('@tanstack/react-query').QueryClient} queryClient - The TanStack Query client instance to manage and clear the cache. + * @param {React.MouseEvent} e - The React mouse event from the button click. + * @param {(to: string) => void} handleRedirect - A callback function used to redirect the user to a new URL after successful log out. + * It accepts a single string parameter `to` which is the destination URL (e.g., '/login'). + * @returns {Promise} A promise that resolves when the log out process is complete. + */ +const handleLogOut = async ( + queryClient: QueryClient, + e: React.MouseEvent, + handleRedirect: (to: string) => void, +): Promise => { + // invalidate the Access Token received while Login. + try { + const response = await fetch( + `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, + { + method: "DELETE", + credentials: "include", + }, + ); + + const responseData = + response.headers.get("content-type").includes("application/json") + ? await response.json() + : null; + if (responseData?.error || !response.ok) { + toast({ + title: "Unable to Log Out!", + description: + responseData.error || + `Something went wrong, server responded empty (request status code: ${response.status}). Please try again.`, + duration: 1500, + variant: "destructive", + type: "background" + }); + // Bad Request, in this case means: response as {error: 'Not Logged in'} + if (response.status === 400) { + setTimeout(() => { + handleRedirect("/login"); + }, 1000); + } + } else if (response.ok) { + toast({ + title: "Logged Out! Redirecting....", + description: + responseData.message || "Logged Out Successfully", + duration: 4000, + type: "background" + }); + } + + setTimeout(() => { + handleRedirect("/login") + }, 1000); + + await queryClient.invalidateQueries({ + queryKey: ["loggedInUser"], + }); + + } catch (error) { + toast({ + title: "Unable to Log Out!", + description: `Something went wrong, server responded empty (error: ${error?.message}). Please try again.`, + duration: 4000, + }); + } +}; + +/** + * Handles Updating of Profile. + * @param formData the formData received from the event or mutation (from Tanstack query). + * @param {(to: string) => void} handleRedirect - A callback function used to redirect the user to a new URL after successful log out. + * @param emailOtp email OTP only required if Email has been changed/opted to update the email. + * @returns + */ +const handleUpdateProfile = async ( + formData: FormData, + handleRedirect: (to: string) => void, + updateProfile: AuthContextState["updateProfile"], + emailOtp?: number, +) => { + + const response = await updateProfile(formData, handleRedirect, emailOtp); + + const responseData = response.headers + .get("content-type") + .includes("application/json") + ? await response.json() + : null; + + if (responseData?.error || !response.ok) { + // Not Logged-In/Authorized + if (response.status === 401) { + toast({ + title: "Failed to Update Profile", + description: `${responseData?.error} | redirecting....`, + variant: "destructive", + duration: 1500, + type: "background" + }); + setTimeout(() => { + handleRedirect("/login"); + }, 1000); + return { statusCode: response.status }; + } + + const error = new Error( + `${responseData?.error}` || + `Failed to Update Profile (request status code: ${response.status}). Please try again.)`, + ); + error["code"] = response.status; + + throw error; + } + + return { + statusCode: response.status, + body: responseData as { message: string }, + }; +}; + +const hasEmailChanged = (originalEmail: string, currentEmail: string) => { + return originalEmail !== currentEmail; +}; + +function FieldInfo({ field }: { field: AnyFieldApi }) { + return ( + <> + {field.state.meta.isTouched && !field.state.meta.isValid ? ( + {field.state.meta.errors.map(e => e?.message || e).join(", ")} + ) : null} + {field.state.meta.isValidating ? 'Validating...' : null} + + ) +} + +// Helper: convert Zod safeParse result into the validator return shape +function zodToFormValidator(schema: z.ZodTypeAny) { + return async ({ value }: { value: T } | any) => { + const parsed = await schema["~standard"].validate(value); + if (!parsed.issues) return undefined; // no errors + + // Build { fields: { : } } where path is the field name + const fields: Record = {}; + for (const issue of parsed.issues) { + const path = issue.path.length ? issue.path.join(".") : "_form"; + fields[path] = fields[path] ? `${fields[path]}, ${issue.message}` : issue.message; + } + return { fields }; // TanStack Form will map fields -> field.state.meta.errors + }; + } + +type ProfileManagementProps = { + currentUser: User; +}; + +const profileManagementSchema = z.object({ + name: z + .string() + .trim() + .min(3, "Name must be at least 3 characters long") + .max(255, "Name must not exceed 255 characters"), + email: z + .email() + .trim() + .max(255, "Email must not exceed 255 characters") + .transform((s) => s.toLowerCase()), + website: z.preprocess( + (val) => { + if (val === undefined || val === null) return ""; + return String(val).trim(); + }, + z.union([ + z.literal(""), + z.string().max(2048, "Website URL must not exceed 2048 characters.").url("Invalid URL"), + ]) + ), + github: z.preprocess( + (v) => (v == null ? "" : String(v).trim()), + z.string().superRefine((val, ctx) => { + if (val === "") return; // allow empty + if (val.length > 255) { + ctx.addIssue("Github Id must not exceed 255 characters"); + return; + } + if (!isValidGithubId(val)) { + ctx.addIssue({ code: "custom", message: "Github Id must be valid" }); + } + }) + ), + }); + +type ProfileValues = z.infer; + +// Memoized ProfileManagement component to prevent unnecessary re-renders +const ProfileManagement = memo(({ currentUser }: ProfileManagementProps) => { + const name = (currentUser?.name || ""); + const originalEmail = (currentUser?.email || "") + + const { updateProfile } = useAuth() + const navigate = useNavigate() + const form = useForm({ + defaultValues: { + name: currentUser?.name || "", + email: currentUser?.email || "", + website: currentUser?.website || "", + github: currentUser?.github || "", + }, + validators: { + onSubmitAsync: zodToFormValidator(profileManagementSchema), + onChangeAsync: zodToFormValidator(profileManagementSchema) + }, + onSubmit: async ({ value }) => { + try { + // value is the validated ProfileValues + if (hasEmailChanged(originalEmail, value.email)) { + setShowOTPDialog(true); + await sendOTPToNewEmail(value.email).catch((err) => { + toast({ + title: `Email OTP Sending Failed to ${value.email} | Try again, by clicking Submit button.`, + description: err.message, + variant: "destructive", + }); + // not closing the OTP Dialog as User could, click to resend button. + return null; + }); + + return; + } + + await handleActualSubmit(value); + } catch (error: any) { + + } + }, + }); + + // biome-ignore lint: Kept for Reference Only Should be Removed in PROD. + // console.table({ name, originalEmail, currentEmail, website, github }) + + // State to control when the OTP dialog should be open + const [showOTPDialog, setShowOTPDialog] = useState(false); + + // State to track the OTP input value + const otpRef = useRef(null); + + // State to track loading states + const [isSendingOTP, setIsSendingOTP] = useState(false); + const [isVerifyingOTP, setIsVerifyingOTP] = useState(false); + + // State for Error Msgs. + const [otpError, setOtpError] = useState(""); + + const queryClient = useQueryClient(); + + const userMutation = useMutation({ + mutationFn: async (body: { + formData: FormData; + currentUser: User; + emailOtp?: number; + }) => + await handleUpdateProfile(body.formData, (toUrl) => navigate(`${toUrl}`), updateProfile,body.emailOtp), + onSuccess: async (data) => { + const { statusCode, body } = data; + + // handleUpdateProfile, handles 401. & redirects the User. + if (statusCode !== 401 && body.message) { + toast({ + title: "Successfully Updated - User Profile", + description: `${body.message}` || "User Updated", + duration: 5000, + }); + await queryClient.invalidateQueries({ queryKey: ["loggedInUser"] }); + + setIsVerifyingOTP(false); + setShowOTPDialog(false); + // setOriginalEmail(currentEmail); + return; + } + + return; + }, + onError: (error) => { + toast({ + title: "Failed to Update Profile", + description: `${error.message}`, + variant: "destructive", + duration: 5000, + type: "background" + }); + + setOtpError(`${error.message}`); + setIsVerifyingOTP(false) + return; + }, + }); + + // Profile Management Utils - memoize callback functions + + const sendOTPToNewEmail = async ( + email: string, + type: "reset" | (string & {}) = "signup", + ) => { + setIsSendingOTP(true); + const formData = new FormData(); + formData.append("email", email); + + try { + const res = await fetch( + `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/otp?type=${type}`, + { + method: "POST", + credentials: "include", + body: formData, + cache: "no-store", + } + ); + const responseData = res.headers + .get("content-type") + .includes("application/json") + ? await res.json() + : null; + + if (!res.ok) { + throw new Error( + `Email OTP Sending Failed (request status: ${res.status}). As server responded: ${responseData?.error || "empty, Please Try again."}`, + ); + } + + return responseData as { message: string }; + } catch (error) { + setOtpError( + `Failed to send OTP(Please Try Again). Error: ${error.message}`, + ); + } finally { + setIsSendingOTP(false); + } + }; + + const handleActualSubmit = async ({ name, email, website, github }, emailOtp?: number) => { + // Create FormData from current form state + const formData = new FormData(); + formData.append("name", name); + formData.append("email", email); + formData.append("website", website); + formData.append("github", github); + + userMutation.mutate({ + formData, + currentUser, + emailOtp, + }); + + // not resetting the states here, + // as we don't know if it success or failed. We clear/set updated states in mutation itself. + }; + + // const handleSubmit = useCallback(async (e: React.FormEvent) => { + // e.preventDefault(); + + // if (hasEmailChanged(originalEmail, currentEmail)) { + // // Email has changed, show OTP dialog and send OTP + // setShowOTPDialog(true); + + // // send email OTP + // await sendOTPToNewEmail(currentEmail).catch((e) => { + // toast({ + // title: `Email OTP Sending Failed to ${currentEmail} | Try again, by clicking Submit button.`, + // description: e.message, + // variant: "destructive", + // }); + // // not closing the OTP Dialog as User could, click to resend button. + // return null; + // }); + + // return; + // } else { + // await handleActualSubmit(); + // } + // }, [originalEmail, currentEmail, sendOTPToNewEmail, handleActualSubmit]); + + // Handle OTP verification - memoize callback + const handleOTPVerification = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!otpRef.current?.value?.trim()) { + setOtpError("Please enter the OTP"); + return; + } + + setIsVerifyingOTP(true); + setOtpError(""); + + if (otpRef.current?.value.length === 6 && /^\d+$/.test(otpRef.current?.value)) { + // OTP is valid, proceed with form submission + await handleActualSubmit({ + name: form.getFieldValue("name"), + email: form.getFieldValue("email"), + website: form.getFieldValue("website"), + github: form.getFieldValue("github") + }, Number(otpRef.current?.value)); + } else { + setOtpError("Invalid OTP. Please check and try again."); + setIsVerifyingOTP(false); + } + }, [otpRef?.current?.value, handleActualSubmit]); + + // Handle when user cancels the OTP dialog - memoize callback + const handleCancel = useCallback(() => { + setShowOTPDialog(false); + otpRef.current.value = ""; + setOtpError(""); + if(isVerifyingOTP) setIsVerifyingOTP(false) + form.resetField("email") + }, [originalEmail]); + + // Handle resending OTP - memoize callback + const handleResendOTP = useCallback(async () => { + otpRef.current.value = ""; // Clear current OTP input + await sendOTPToNewEmail(currentUser.email); + }, [currentUser?.email, sendOTPToNewEmail]); + + return ( +
+ {/* Profile Information */} + + +
+ + + Profile Information + + +
+
+ +
+ + {currentUser.github ? ( + + ) : null} + + {currentUser.name + .split(" ") + .map((n) => n[0]) + .join("")} + + +
+

{currentUser.name}

+

{currentUser.email}

+
+ {currentUser.role} + {currentUser.verified && ( + + Verified + + ) || ""} +
+
+ +
+
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} className="space-y-4"> +
+ { + return ( + <> +
+ + field.handleChange(e.target.value)} + /> + +
+ + ) + }} + /> + +
+ { + return ( + <> + + field.handleChange(e.target.value)} + /> + + {/* FIX: Checks on blurs */} + {/* Visual indicator when email has changed */} + {hasEmailChanged(originalEmail, field.state.value) && ( +

+ Email will be changed from: {originalEmail} +

+ )} + + ) + }} + /> +
+
+ { + return ( + <> + + field.handleChange(e.target.value)} + /> + + + ) + }} + /> +
+
+ { + return ( + <> + + field.handleChange(e.target.value)} + /> + + + ) + }} + /> +
+
+ + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + + + {/* Radix-UI Dialog for OTP Verification */} + + !open && handleCancel()}> + + Verify Your New Email + + We've sent a 6-digit verification code to{" "} + {currentUser.email}. Please enter the code below to + confirm your email change. + + +
+
+ + { + const value = e.target.value + .replace(/\D/g, "") + .slice(0, 6); + otpRef.current.value = value; + setOtpError(""); + }} + placeholder="Enter 6-digit code" + maxLength={6} + className="text-center text-lg tracking-widest" + /> + {otpError && ( +

{otpError}

+ )} +
+ +
+ +
+ +
+ + + + + + +
+
+
+
+
+
+ + {/* Payment Methods */} + +
+ ); +}); + +export default ProfileManagement; \ No newline at end of file diff --git a/src/components/dashboard/user-plugins-overview.tsx b/src/components/dashboard/user-plugins-overview.tsx index b18f677..98972ac 100644 --- a/src/components/dashboard/user-plugins-overview.tsx +++ b/src/components/dashboard/user-plugins-overview.tsx @@ -54,9 +54,10 @@ import { import { toast } from "@/hooks/use-toast"; import { useDeletePlugin, useUserPlugins } from "@/hooks/use-user-plugins"; import { useLoggedInUser } from "@/hooks/useLoggedInUser"; +import { useAuth } from "@/context/AuthContext"; export function UserPluginsOverview() { - const { data: user } = useLoggedInUser(); + const { user } = useAuth(); const { data: plugins = [], isLoading } = useUserPlugins( user?.id?.toString() || "", ); @@ -119,6 +120,7 @@ export function UserPluginsOverview() { ); // Reset pagination when filters change + // biome-ignore lint/correctness/useExhaustiveDependencies: It's necessary, for resetting of pagination useEffect(() => { setCurrentPage(1); }, [searchQuery, statusFilter, sortBy, sortOrder]); @@ -244,7 +246,7 @@ export function UserPluginsOverview() { {/* Stats */} -
+
{totalPlugins} @@ -328,6 +330,7 @@ export function UserPluginsOverview() { const target = e.target as HTMLImageElement; target.style.display = "none"; }} + loading="lazy" />
{plugin.name}
@@ -444,6 +447,7 @@ export function UserPluginsOverview() { const target = e.target as HTMLImageElement; target.style.display = "none"; }} + loading="lazy" />
{plugin.name}
diff --git a/src/components/navigation/floating-nav.tsx b/src/components/navigation/floating-nav.tsx index 2cf4d0e..88b774b 100644 --- a/src/components/navigation/floating-nav.tsx +++ b/src/components/navigation/floating-nav.tsx @@ -26,6 +26,7 @@ import { EXTERNAL_LINKS } from "@/config/links"; import { useToast } from "@/hooks/use-toast"; import { useLoggedInUser } from "@/hooks/useLoggedInUser"; import { cn } from "@/lib/utils"; +import { useAuth } from "@/context/AuthContext"; const baseNavItems = [ { name: "FAQ", href: "/faq", external: false }, @@ -45,9 +46,9 @@ export function FloatingNav() { const location = useLocation(); const navigate = useNavigate(); const { toast } = useToast(); - const { data: user, isLoading, error } = useLoggedInUser(); + const { user, isLoading, isError, logout } = useAuth(); - const isLoggedIn = !isLoading && !error && user; + const isLoggedIn = !isLoading && !isError && user; useEffect(() => { const handleScroll = () => { @@ -59,13 +60,7 @@ export function FloatingNav() { const handleLogout = async () => { try { - const response = await fetch( - `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, - { - method: "DELETE", - credentials: "include", - }, - ); + const response = await logout() if (response.ok) { toast({ @@ -165,6 +160,7 @@ export function FloatingNav() { )} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..3997090 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,111 @@ +import React, { createContext, useCallback, useContext, useMemo } from "react" +import { useQuery, useQueryClient, QueryObserverResult, RefetchOptions, useMutation } from "@tanstack/react-query" +import { fetchLoggedInUser } from "@/hooks/useLoggedInUser" +import { User } from "@/types" + +export type AuthContextState = { + user: User, + login: ({ formData }: { formData: FormData}) => Promise + logout: () => Promise + updateProfile: ( + formData: FormData, + handleRedirect: (to: string) => void, + emailOtp?: number + ) => Promise, + refetchUser: (options?: RefetchOptions) => Promise> + isLoading: boolean, + isError: boolean +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const queryClient = useQueryClient() + + const { data: user, isLoading, isError, refetch } = useQuery({ + queryKey: ["loggedInUser"], + queryFn: fetchLoggedInUser, + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: false, + }) + + const loginMutation = useMutation({ + mutationFn: async ({ formData }: { formData: FormData }) => { + const res = await fetch( + `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, + { + method: "POST", + credentials: "include", + body: formData, + } + ); + if (!res.ok) throw new Error("Login failed"); + // Invalidate / refetch current user to hydrate client + await queryClient.invalidateQueries({ queryKey: ["loggedInUser"] }); + return res; + }, + }); + + const login = useCallback( + async ({ formData }: { formData: FormData}): Promise => { + return loginMutation.mutateAsync({ formData }); + }, + [loginMutation] + ); + + + const logout = async (): Promise => { + const response = await fetch( + `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, + { + method: "DELETE", + credentials: "include", + }, + ); + // no need to await; AS we don't need to block it. I want the response to return As soon as possible. + queryClient.invalidateQueries({ queryKey: ["loggedInUser"] }); + + return response; + } + + const updateProfile = async ( + formData: FormData, + handleRedirect: (to: string) => void, + emailOtp?: number + ): Promise => { + if(!(formData instanceof FormData) || typeof handleRedirect !== "function" || (emailOtp && typeof emailOtp !== "number")) throw Error(`[updateProfile] Params used must be: 'formData' as FormData, 'handleRedirect' as a typeof Function, (optionally) emailOTP? as a number`) + + if (emailOtp) formData.append("otp", emailOtp.toString()); + + const response = await fetch( + `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/user`, + { + method: "PUT", + body: formData, + credentials: "include", + }, + ); + + return response; + } + + const value = useMemo(() => ({ + user, + isLoading, + isError, + login, + logout, + updateProfile, + refetchUser: refetch + }), [user, isLoading, isError, login, logout, refetch]) + + return ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext); +} \ No newline at end of file diff --git a/src/hooks/use-plugins.ts b/src/hooks/use-plugins.ts index 253b7a2..82631e2 100644 --- a/src/hooks/use-plugins.ts +++ b/src/hooks/use-plugins.ts @@ -1,6 +1,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useLoggedInUser } from "@/hooks/useLoggedInUser"; import type { Plugin, PluginFilterType } from "@/types"; +import { useAuth } from "@/context/AuthContext"; const fallbackPlugins: Plugin[] = [ { @@ -111,7 +112,7 @@ const fetchPlugins = async ( }; export const usePlugins = (filter: PluginFilterType = "default") => { - const { data: loggedInUser } = useLoggedInUser(); + const { user: loggedInUser } = useAuth(); const isAuthenticated = !!loggedInUser; return useInfiniteQuery({ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ac680b3..980abf5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function isValidGithubId(id: string) { + if (!id) return true; + return /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(id); + } \ No newline at end of file diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 05c93c5..2931d20 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -55,9 +55,9 @@ import { import { VerificationToggle } from "@/components/ui/verification-toggle"; import { usePluginsByStatus } from "@/hooks/use-plugins-by-status"; import { toast } from "@/hooks/use-toast"; -import { useLoggedInUser } from "@/hooks/useLoggedInUser"; import { formatCurrency, formatDate } from "@/lib/date-utils"; import { Plugin, User } from "@/types"; +import { useAuth } from "@/context/AuthContext"; interface AdminStats { users: number; @@ -79,7 +79,7 @@ const LoadingSpinner = () => ( ); export default function AdminDashboard() { - const { data: currentUser, isLoading: userLoading } = useLoggedInUser(); + const { user: currentUser, isLoading: userLoading } = useAuth(); const [currentPage, setCurrentPage] = useState(1); const [searchName, setSearchName] = useState(""); const [searchEmail, setSearchEmail] = useState(""); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index dc97f45..16c21bb 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,79 +1,20 @@ -import { - type QueryClient, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; import { BarChart3, - Building2, - CheckCircle, - Clock, - CreditCard, - DollarSign, - Edit, - Eye, - LogOut, - Package, Plus, - Search, - Settings, - Shield, - Star, - Trash2, - Upload, - Users, - Wallet, - X, XCircle, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo } from "react"; import { Link } from "react-router-dom"; import { EarningsOverview } from "@/components/dashboard/earnings-overview"; -import { PaymentMethods } from "@/components/dashboard/payment-methods"; import { UserPluginsOverview } from "@/components/dashboard/user-plugins-overview"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { DeletePluginDialog } from "@/components/ui/delete-plugin-dialog"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogOverlay, - DialogPortal, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/hooks/use-toast.ts"; -import { useDeletePlugin } from "@/hooks/use-user-plugins"; import { useLoggedInUser } from "@/hooks/useLoggedInUser.ts"; -import { User } from "@/types"; - -// Mock user data -const currentMockUser = { - name: "John Doe", - email: "john@example.com", - role: "user", // "user" or "admin" - avatar: "JD", - joinDate: "2024-01-15", - bio: "Full-stack developer passionate about mobile development and creating tools that enhance productivity.", - website: "https://johndoe.dev", - github: "johndoe", - location: "San Francisco, CA", - totalEarnings: 245.67, - bankAccount: { - accountHolder: "John Doe", - bankName: "Chase Bank", - accountNumber: "****1234", - routingNumber: "****567", - }, -}; +import ProfileManagement from "@/components/dashboard/profile-management"; +import { useAuth } from "@/context/AuthContext"; // Note: Mock data removed - using real API data @@ -88,271 +29,380 @@ const LoadingDashboard = () => { ); }; -const handleLogOut = async ( - queryClient: QueryClient, - e: React.MouseEvent, -) => { - // invalidate the Access Token received while Login. - try { - const response = await fetch( - `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, - { - method: "DELETE", - credentials: "include", - }, - ); - - const responseData = - response.headers.get("content-type") === "application/json" - ? await response.json() - : null; - if (responseData?.error || !response.ok) { - toast({ - title: "Unable to Log Out!", - description: - responseData.error || - `Something went wrong, server responded empty (request status code: ${response.status}). Please try again.`, - }); - // Bad Request, in this case means: response as {error: 'Not Logged in'} - if (response.status === 400) { - setTimeout(() => { - window.location.href = "/login"; - }, 1000); - } - } else if (response.ok) { - toast({ - title: "Logged Out!", - description: - responseData.message || "Logged Out Successfully, redirecting....", - }); - } - - await queryClient.invalidateQueries({ - queryKey: ["loggedInUser"], - }); - - setTimeout(() => { - window.location.href = "/login"; - }, 1000); - } catch (error) { - toast({ - title: "Unable to Log Out!", - description: `Something went wrong, server responded empty (error: ${error.message}). Please try again.`, - }); - } -}; - -const handleUpdateProfile = async ( - formData: FormData, - currentUser: User, - emailOtp?: number, -) => { - if (emailOtp) formData.append("otp", emailOtp.toString()); - - const response = await fetch( - `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}api/user`, - { - method: "PUT", - body: formData, - credentials: "include", - }, - ); - - const responseData = response.headers - .get("content-type") - .includes("application/json") - ? await response.json() - : null; - - if (responseData?.error || !response.ok) { - // Not Logged-In/Authorized - if (response.status === 401) { - toast({ - title: "Failed to Update Profile", - description: `${responseData?.error} | redirecting....`, - variant: "destructive", - }); - setTimeout(() => { - window.location.href = "/login"; - }, 1000); - return { statusCode: response.status }; - } - - const error = new Error( - `${responseData?.error}` || - `Failed to Update Profile (request status code: ${response.status}). Please try again.)`, - ); - error["code"] = response.status; +// Kept for Reference. +// biome-ignore lint: Kept for Reference Only Should be Removed in PROD. +// const handleLogOut = async ( +// queryClient: QueryClient, +// e: React.MouseEvent, +// ) => { +// // invalidate the Access Token received while Login. +// try { +// const response = await fetch( +// `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, +// { +// method: "DELETE", +// credentials: "include", +// }, +// ); + +// const responseData = +// response.headers.get("content-type") === "application/json" +// ? await response.json() +// : null; +// if (responseData?.error || !response.ok) { +// toast({ +// title: "Unable to Log Out!", +// description: +// responseData.error || +// `Something went wrong, server responded empty (request status code: ${response.status}). Please try again.`, +// }); +// // Bad Request, in this case means: response as {error: 'Not Logged in'} +// if (response.status === 400) { +// setTimeout(() => { +// window.location.href = "/login"; +// }, 1000); +// } +// } else if (response.ok) { +// toast({ +// title: "Logged Out!", +// description: +// responseData.message || "Logged Out Successfully, redirecting....", +// }); +// } + +// await queryClient.invalidateQueries({ +// queryKey: ["loggedInUser"], +// }); + +// setTimeout(() => { +// window.location.href = "/login"; +// }, 1000); +// } catch (error) { +// toast({ +// title: "Unable to Log Out!", +// description: `Something went wrong, server responded empty (error: ${error.message}). Please try again.`, +// }); +// } +// }; + +// const handleUpdateProfile = async ( +// formData: FormData, +// currentUser: User, +// emailOtp?: number, +// ) => { +// if (emailOtp) formData.append("otp", emailOtp.toString()); + +// const response = await fetch( +// `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/user`, +// { +// method: "PUT", +// body: formData, +// credentials: "include", +// }, +// ); + +// const responseData = response.headers +// .get("content-type") +// .includes("application/json") +// ? await response.json() +// : null; + +// if (responseData?.error || !response.ok) { +// // Not Logged-In/Authorized +// if (response.status === 401) { +// toast({ +// title: "Failed to Update Profile", +// description: `${responseData?.error} | redirecting....`, +// variant: "destructive", +// }); +// setTimeout(() => { +// window.location.href = "/login"; +// }, 1000); +// return { statusCode: response.status }; +// } + +// const error = new Error( +// `${responseData?.error}` || +// `Failed to Update Profile (request status code: ${response.status}). Please try again.)`, +// ); +// error["code"] = response.status; + +// throw error; +// } + +// return { +// statusCode: response.status, +// body: responseData as { message: string }, +// }; +// }; + +// const hasEmailChanged = (originalEmail: string, currentEmail: string) => { +// return originalEmail !== currentEmail; +// }; + +// Memoized ProfileManagement component to prevent unnecessary re-renders +// const ProfileManagement = memo(({ +// currentUser, +// name, +// currentEmail, +// originalEmail, +// website, +// github, +// handleSubmit, +// isSubmitting, +// showOTPDialog, +// otpValue, +// otpError, +// isSendingOTP, +// isVerifyingOTP, +// handleOTPVerification, +// handleResendOTP, +// handleCancel, +// queryClient, +// handleLogOut, +// setName, +// setCurrentEmail, +// setWebsite, +// setGithub, +// setOtpValue, +// setOtpError +// }: { +// currentUser: any; +// name: string; +// currentEmail: string; +// originalEmail: string; +// website: string; +// github: string; +// handleSubmit: (e: React.FormEvent) => void; +// isSubmitting: boolean; +// showOTPDialog: boolean; +// otpValue: string; +// otpError: string; +// isSendingOTP: boolean; +// isVerifyingOTP: boolean; +// handleOTPVerification: () => void; +// handleResendOTP: () => void; +// handleCancel: () => void; +// queryClient: any; +// handleLogOut: (queryClient: any, e: React.MouseEvent) => void; +// setName: (name: string) => void; +// setCurrentEmail: (email: string) => void; +// setWebsite: (website: string) => void; +// setGithub: (github: string) => void; +// setOtpValue: (value: string) => void; +// setOtpError: (error: string) => void; +// }) => ( +//
+// {/* Profile Information */} +// +// +//
+// +// +// Profile Information +// +// +//
+//
+// +//
+// +// {currentUser.github ? ( +// +// ) : null} +// +// {currentUser.name +// .split(" ") +// .map((n) => n[0]) +// .join("")} +// +// +//
+//

{currentUser.name}

+//

{currentUser.email}

+//
+// {currentUser.role} +// {currentUser.verified && ( +// +// Verified +// +// ) || ""} +//
+//
+// +//
+//
handleSubmit(e)} className="space-y-4"> +//
+//
+// +// setName(e.target.value)} +// /> +//
+//
+// +// setCurrentEmail(e.target.value)} +// /> +// {/* Visual indicator when email has changed */} +// {hasEmailChanged(originalEmail, currentEmail) && ( +//

+// Email will be changed from: {originalEmail} +//

+// )} +//
+//
+// +// setWebsite(e.target.value)} +// /> +//
+//
+// +// setGithub(e.target.value)} +// /> +//
+//
+ +// +//
+ +// {/* Radix-UI Dialog for OTP Verification */} + +// {}}> +// +// Verify Your New Email +// +// We've sent a 6-digit verification code to{" "} +// {currentEmail}. Please enter the code below to +// confirm your email change. +// + +//
+//
+// +// { +// const value = e.target.value +// .replace(/\D/g, "") +// .slice(0, 6); +// setOtpValue(value); +// setOtpError(""); +// }} +// placeholder="Enter 6-digit code" +// maxLength={6} +// className="text-center text-lg tracking-widest" +// /> +// {otpError && ( +//

{otpError}

+// )} +//
+ +//
+// +//
+ +//
+// +// +//
+//
+//
+//
+//
+//
+ +// {/* Payment Methods */} +// +//
+// )); + +// All hooks must be called before any conditional returns +export default function Dashboard() { + // states & Hooks + // const [activeTab, setActiveTab] = useState("overview"); + // const [pluginSearchQuery, setPluginSearchQuery] = useState(""); - throw error; - } + // const queryClient = useQueryClient(); + // const deletePluginMutation = useDeletePlugin(); - return { - statusCode: response.status, - body: responseData as { message: string }, - }; -}; - -export default function Dashboard() { - // states - const [activeTab, setActiveTab] = useState("overview"); - const [formData, setFormData] = useState(new FormData()); - const [originalEmail, setOriginalEmail] = useState(); - const [currentEmail, setCurrentEmail] = useState(""); - const [pluginSearchQuery, setPluginSearchQuery] = useState(""); - - // State to control when the OTP dialog should be open - const [showOTPDialog, setShowOTPDialog] = useState(false); - - // State to track the OTP input value - const [otpValue, setOtpValue] = useState(""); - - // State to track loading states - const [isSubmitting, setIsSubmitting] = useState(false); - const [isSendingOTP, setIsSendingOTP] = useState(false); - const [isVerifyingOTP, setIsVerifyingOTP] = useState(false); - - // State for Error Msgs. - const [otpError, setOtpError] = useState(""); - - const userMutation = useMutation({ - mutationFn: async (body: { - formData: FormData; - currentUser: User; - emailOtp?: number; - }) => - await handleUpdateProfile(body.formData, body.currentUser, body.emailOtp), - onSuccess: async (data) => { - const { statusCode, body } = data; - - // handleUpdateProfile, handles 401. & redirects the User. - if (statusCode !== 401 && body.message) { - toast({ - title: "Successfully Updated - User Profile", - description: `${body.message}` || "User Updated", - }); - await queryClient.invalidateQueries({ queryKey: ["LoggedInUser"] }); - - setIsSubmitting(false); - setIsVerifyingOTP(false); - setOriginalEmail(currentEmail); - return; - } - - return; - }, - onError: (error) => { - toast({ - title: "Failed to Update Profile", - description: `${error.message}`, - variant: "destructive", - }); - - setOtpError(`${error.message}`); - return; - }, - }); - - const queryClient = useQueryClient(); - const deletePluginMutation = useDeletePlugin(); const { - data: currentLoggedUser, + user: currentLoggedUser, isError, isLoading, ...args - } = useLoggedInUser(); - const { - data: userPlugins, - isLoading: isPluginsLoading, - error: pluginsError, - ...pluginArgs - } = useQuery({ - queryKey: ["loggedInUser", "plugins"], - staleTime: 2 * 60 * 1000, // 2 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - refetchOnWindowFocus: false, - queryFn: async () => { - const response = await fetch( - `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/plugin?user=${currentLoggedUser.id}`, - ); - const data = response.headers - .get("content-type") - .includes("application/json") - ? await response.json() - : null; - - if (!response.ok) { - throw new Error( - `Could not get Your Plugins (request status code: ${response.status})`, - ); - } - - return data; - }, - enabled: !!currentLoggedUser?.id, - }); + } = useAuth(); - console.log( - "Logged In User: ", - currentLoggedUser, - "IsError: ", - isError, - "isLoading: ", - isLoading, - "Args: ", - args, - ); - console.log(userPlugins, isPluginsLoading, pluginsError, pluginArgs); - if (isError) { - toast({ - title: "User Not Logged in. redirecting...", - }); - // - // setTimeout(() => { - // window.location.href = "/login" - // }, 1000) - return; - } + // Memoize currentUser to prevent unnecessary re-renders + const currentUser = useMemo(() => ({ + ...currentLoggedUser, + }), [currentLoggedUser]); - if (isLoading) { - return ; - } + // biome-ignore lint: Kept for Reference Only Should be Removed in PROD. + console.log(currentUser) - // also using mocked data for now. - const currentUser = { - ...currentMockUser, - ...currentLoggedUser, - }; - - // Filter plugins based on a search query - const filteredUserPlugins = - userPlugins?.filter( - (plugin) => - plugin.name.toLowerCase().includes(pluginSearchQuery.toLowerCase()) || - plugin.author?.toLowerCase().includes(pluginSearchQuery.toLowerCase()), - ) || []; - // useEffect(() => setCurrentEmail(currentUser.email), [currentLoggedUser.email]); - - const handleDeletePlugin = async ( - pluginId: string, - mode: "soft" | "hard", - ) => { - try { - await deletePluginMutation.mutateAsync({ pluginId, mode }); - toast({ - title: "Plugin Deleted", - description: `Plugin ${mode === "hard" ? "permanently deleted" : "deleted"} successfully`, - }); - } catch (error) { - toast({ - title: "Delete Failed", - description: "Failed to delete plugin. Please try again.", - variant: "destructive", - }); + useEffect(() => { + if (currentLoggedUser?.email) { + console.log("useEffect :: ran on change of currentUser") } - }; + }, [currentLoggedUser?.email]); - const UserDashboard = () => ( + // Memoize UserDashboard component to prevent unnecessary re-renders + const UserDashboard = useMemo(() => (
{/* Overview Grid */}
@@ -387,414 +437,95 @@ export default function Dashboard() {
+ ), []); + + + // const { + // data: userPlugins, + // isLoading: isPluginsLoading, + // error: pluginsError, + // ...pluginArgs + // } = useQuery({ + // queryKey: ["loggedInUser", "plugins"], + // staleTime: 2 * 60 * 1000, // 2 minutes + // gcTime: 10 * 60 * 1000, // 10 minutes + // refetchOnWindowFocus: false, + // queryFn: async () => { + // const response = await fetch( + // `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/plugin?user=${currentLoggedUser.id}`, + // ); + // const data = response.headers + // .get("content-type") + // .includes("application/json") + // ? await response.json() + // : null; + + // if (!response.ok) { + // throw new Error( + // `Could not get Your Plugins (request status code: ${response.status})`, + // ); + // } + + // return data; + // }, + // enabled: !!currentLoggedUser?.id, + // }); + + // biome-ignore lint: Kept for Reference Only Should be Removed in PROD. + console.log( + "Logged In User: ", + currentLoggedUser, + "IsError: ", + isError, + "isLoading: ", + isLoading, + "Args: ", + args, ); - // Profile Management Utils - const hasEmailChanged = () => { - return originalEmail !== currentEmail; - }; - - const sendOTPToNewEmail = async ( - email: string, - type: "reset" | (string & {}) = "signup", - ) => { - setIsSendingOTP(true); - const formData = new FormData(); - formData.append("email", email); - - try { - const res = await fetch( - `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/otp?type=${type}`, - ); - const responseData = res.headers - .get("content-type") - .includes("application/json") - ? await res.json() - : null; - - if (!res.ok) { - throw new Error( - `Email OTP Sending Failed (request status: ${res.status}). As server responded: ${responseData?.error || "empty, Please Try again."}`, - ); - } - - return responseData as { message: string }; - } catch (error) { - setOtpError( - `Failed to send OTP(Please Try Again). Error: ${error.message}`, - ); - } finally { - setIsSendingOTP(false); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - setFormData(new FormData(e.target as HTMLFormElement)); - - if (hasEmailChanged()) { - // Email has changed, show OTP dialog and send OTP - setShowOTPDialog(true); - - // send email OTP - await sendOTPToNewEmail(currentEmail).catch((e) => { - toast({ - title: `Email OTP Sending Failed to ${currentEmail} | Try again, by clicking Submit button.`, - description: e.message, - variant: "destructive", - }); - // not closing the OTP Dialog as User could, click to resend button. - return null; - }); - - return; - } else { - await handleActualSubmit(); - } - }; - - const handleActualSubmit = async (emailOtp?: number) => { - setIsSubmitting(true); - - userMutation.mutate({ - formData, - currentUser, - emailOtp, - }); + // biome-ignore lint: Kept for Reference Only Should be Removed in PROD. + // console.log(userPlugins, isPluginsLoading, pluginsError, pluginArgs); + + // const handleDeletePlugin = async ( + // pluginId: string, + // mode: "soft" | "hard", + // ) => { + // try { + // await deletePluginMutation.mutateAsync({ pluginId, mode }); + // toast({ + // title: "Plugin Deleted", + // description: `Plugin ${mode === "hard" ? "permanently deleted" : "deleted"} successfully`, + // }); + // } catch (error) { + // toast({ + // title: "Delete Failed", + // description: "Failed to delete plugin. Please try again.", + // variant: "destructive", + // }); + // } + // }; - // not resetting the states here, - // as we don't know if it success or failed. We clear/set updated states in mutation itself. - }; - // Handle OTP verification - - const handleOTPVerification = async () => { - if (!otpValue.trim()) { - setOtpError("Please enter the OTP"); - return; - } - - setIsVerifyingOTP(true); - setOtpError(""); - - if (otpValue.length === 6 && /^\d+$/.test(otpValue)) { - // OTP is valid, proceed with form submission - await handleActualSubmit(Number(otpValue)); - } else { - setOtpError("Invalid OTP. Please check and try again."); - } - }; - - // Handle when user cancels the OTP dialog - const handleCancel = () => { - setShowOTPDialog(false); - setOtpValue(""); - setOtpError(""); - // Optionally reset the email to original value - setCurrentEmail(originalEmail); - }; - - // Handle resending OTP - const handleResendOTP = async () => { - setOtpValue(""); // Clear current OTP input - await sendOTPToNewEmail(currentEmail); - }; - - const ProfileManagement = () => ( -
- {/* Profile Information */} - - -
- - - Profile Information - - -
-
- -
- - {currentUser.github ? ( - - ) : null} - - {currentUser.name - .split(" ") - .map((n) => n[0]) - .join("")} - - -
-

{currentUser.name}

-

{currentUser.email}

-
- {currentUser.role} - {currentUser.verified && ( - - Verified - - )} -
-
- -
-
handleSubmit(e)} className="space-y-4"> -
-
- - -
-
- - setCurrentEmail(e.target.value)} - /> - {/* Visual indicator when email has changed */} - {hasEmailChanged() && ( -

- Email will be changed from: {originalEmail} -

- )} -
-
- - -
-
- - -
-
- - -
- - {/* Radix-UI Dialog for OTP Verification */} - - - - Verify Your New Email - - We've sent a 6-digit verification code to{" "} - {currentEmail}. Please enter the code below to - confirm your email change. - - -
-
- - { - const value = e.target.value - .replace(/\D/g, "") - .slice(0, 6); - setOtpValue(value); - setOtpError(""); - }} - placeholder="Enter 6-digit code" - maxLength={6} - className="text-center text-lg tracking-widest" - /> - {otpError && ( -

{otpError}

- )} -
- -
- -
- -
- - -
-
-
-
-
-
- - {/* Payment Methods */} - -
- ); - - const DetailedEarningsOverview = () => ( -
- {/* Quick Stats */} -
- - - - Total Earnings - - - - - {/*
${currentUser.totalEarnings.toFixed(2)}
*/} -
0
-

- Available for withdrawal -

-
-
- - - - This Month - - - -
$89.99
-

- +12% from last month -

-
-
- - - - - Pending Payment - - - - -
$156.78
-

- Next payment: Feb 15 -

-
-
-
- - {/* Quick Actions */} - - - Quick Actions - - -
- - - - - -
-
-
- - {/* Recent Transactions */} - - - Recent Transactions - - -
- {[ - { - date: "2024-01-28", - amount: 2.99, - plugin: "Git Manager", - type: "Sale", - }, - { - date: "2024-01-27", - amount: 1.99, - plugin: "Code Formatter Pro", - type: "Sale", - }, - { - date: "2024-01-26", - amount: 4.99, - plugin: "My Theme Studio", - type: "Sale", - }, - ].map((transaction, index) => ( -
-
-

{transaction.plugin}

-

- {transaction.type} • {transaction.date} -

-
-
-

- +${transaction.amount.toFixed(2)} -

-
-
- ))} -
-
-
-
- ); + if (isError) { + // TODO: Move this to Middleware or make use of contexts. + // biome-ignore lint: Kept for Reference Only Should be Removed in PROD. + console.log("User Not Logged in. redirecting...") + // setTimeout(() => { + // navigate("/login") + // }, 1000) + return ( +
+ +

User Not Logged In.

+

+ You're not logged in! Redirecting... +

+
+ ); + } - return isLoading ? ( -
-
-
-

Loading dashboard...

-
-
+ return isLoading || isError ? ( + ) : (
{/* Header */} @@ -812,6 +543,7 @@ export default function Dashboard() { ) : null} @@ -830,18 +562,20 @@ export default function Dashboard() {
{/* Tabs */} - + Overview Profile - + {UserDashboard} - +
diff --git a/src/pages/DeveloperProfile.tsx b/src/pages/DeveloperProfile.tsx index 2016715..a2627de 100644 --- a/src/pages/DeveloperProfile.tsx +++ b/src/pages/DeveloperProfile.tsx @@ -26,6 +26,7 @@ import { useDeletePlugin } from "@/hooks/use-user-plugins"; import { useLoggedInUser } from "@/hooks/useLoggedInUser"; import { DeveloperProfile as DeveloperType } from "@/types/developer"; import { Plugin } from "@/types/plugin"; +import { useAuth } from "@/context/AuthContext"; interface Developer extends DeveloperType { role: string; @@ -62,7 +63,7 @@ const fetchDeveloperPlugins = async (userId: string): Promise => { export default function DeveloperProfile() { const { email } = useParams<{ email: string }>(); const { toast } = useToast(); - const { data: loggedInUser } = useLoggedInUser(); + const { user: loggedInUser } = useAuth(); const deleteMutation = useDeletePlugin(); const { diff --git a/src/pages/FAQ.tsx b/src/pages/FAQ.tsx index 6dcfd78..63e8c01 100644 --- a/src/pages/FAQ.tsx +++ b/src/pages/FAQ.tsx @@ -60,8 +60,8 @@ import { Textarea } from "@/components/ui/textarea"; import { EXTERNAL_LINKS, openExternalLink } from "@/config/links"; import "highlight.js/styles/github-dark.css"; -import { useLoggedInUser } from "@/hooks/useLoggedInUser"; import type { FAQ } from "@/types"; +import { useAuth } from "@/context/AuthContext"; const md = new MarkdownIt({ html: true, @@ -91,7 +91,7 @@ export default function FAQ() { const [loading, setLoading] = useState(true); // Admin functionality - const { data: currentUser } = useLoggedInUser(); + const { user: currentUser } = useAuth(); const isAdmin = currentUser?.role === "admin"; // Form state diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index fd78e8f..407b713 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,48 +1,39 @@ import { Eye, EyeOff, Github, Lock, LogIn, Mail } from "lucide-react"; import { useState } from "react"; -import { Link, redirect, useParams } from "react-router-dom"; +import { Link, redirect, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/context/AuthContext"; export default function Login() { + // States & Hooks. const [showPassword, setShowPassword] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); + const { login } = useAuth() const { toast } = useToast(); - const params = useParams(); - + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + console.log(searchParams) const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); try { const formData = new FormData(e.target as HTMLFormElement); - console.log( - formData.get("email"), - formData.get("password"), - formData.get("password"), - ); - console.log(import.meta.env); - const response = await fetch( - `${import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : ""}/api/login`, - { - method: "POST", - body: formData, - credentials: "include", - }, - ); + const response = await login({ formData }) const responseData = response.headers .get("content-type") .includes("application/json") ? await response.json() : null; - console.log(responseData); + if (responseData?.error || !response.ok) { setIsLoading(false); return toast({ @@ -61,13 +52,16 @@ export default function Login() { }); setTimeout(() => { - let redirectUrl = params?.redirect as string; + let redirectUrl = searchParams.get("redirect") as string; setIsLoading(false); - if (params.redirect === "app") { + console.log(searchParams, redirectUrl) + if (searchParams.get("redirect") === "app") { redirectUrl = `acode://user/login/${responseData.token}`; + window.location.href = `${redirectUrl}` + return; } - window.location.href = redirectUrl || "/dashboard"; + navigate(`${redirectUrl || "/dashboard"}`); }, 1000); } catch (error) { console.error(`Login attempt Failed: `, error); @@ -106,11 +100,13 @@ export default function Login() {
@@ -129,7 +125,7 @@ export default function Login() {
- + setEmail(e.target.value)} required + autoComplete="email" className="bg-background/50" />
- +
setPassword(e.target.value)} required + autoComplete="current-password" className="bg-background/50 pr-10" />