diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bfaa9b..6f4e6b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.14.0", "@tailwindcss/vite": "^4.3.3", "backend": "^0.0.0", "firebase": "^13.2.0", "lucide-react": "^1.64.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-redux": "^9.3.1", "react-router": "^8.5.1", "tailwind-merge": "^3.7.0", "tailwindcss": "^4.3.3" @@ -2487,6 +2489,36 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz", + "integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.2.9", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -2596,9 +2628,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2615,9 +2644,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2634,9 +2660,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2653,9 +2676,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2672,9 +2692,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2691,9 +2708,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2757,6 +2771,18 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -3465,6 +3491,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -4820,6 +4852,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.2.10", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.2.10.tgz", + "integrity": "sha512-pzANBj+0A9u+prdhBJ14DVi44vCvVUQzBa8DKt6I0AMCokPHtt722BK9IX5bFC70xUSU9TnzLRIJLTGqIzjEFw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-in-the-middle": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.6.0.tgz", @@ -5785,6 +5827,29 @@ "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz", + "integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz", @@ -5829,6 +5894,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5861,6 +5941,12 @@ "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/rettime": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", @@ -6345,6 +6431,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index e8fb6b5..9fbe076 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,12 +13,14 @@ "format:check": "prettier --check . --ignore-path ../.prettierignore" }, "dependencies": { + "@reduxjs/toolkit": "^2.14.0", "@tailwindcss/vite": "^4.3.3", "backend": "^0.0.0", "firebase": "^13.2.0", "lucide-react": "^1.64.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-redux": "^9.3.1", "react-router": "^8.5.1", "tailwind-merge": "^3.7.0", "tailwindcss": "^4.3.3" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abb729e..4b8c57a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,9 @@ import BackToTopButton from "./components/buttons/BackToTopButton"; import LoginPage from "./pages/loginPage/loginPage"; import AccountPage from "./pages/accountPage/accountPage"; import { Routes, Route, useLocation, useParams } from "react-router"; +import Notification from "./components/Notification"; +import { useSelector } from "react-redux"; +import type { RootState } from "./state/store"; function CategoryRoute() { const { category } = useParams<{ category: string }>(); @@ -21,13 +24,16 @@ function BuilderRoute() { } function App() { + const accountKey = useSelector( + (state: RootState) => state.auth.user?.uid ?? "guest", + ); return (
- + } /> } />
); } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..f45779b --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,84 @@ +import { + GoogleAuthProvider, + createUserWithEmailAndPassword, + onAuthStateChanged, + signInWithEmailAndPassword, + signInWithPopup, + signOut, +} from "firebase/auth"; +import { auth } from "@/lib/firebase"; +import { authFailed, authResolved } from "@/state/authSlice"; +import { restoreBuild } from "@/state/selectedPartsSlice"; +import { dismissNotification } from "@/state/notificationSlice"; +import { loadBuildDraft, saveBuildDraft } from "@/state/buildStorage"; +import type { createBuildStore } from "@/state/store"; + +// Called once at startup. Firebase remains responsible for restoring the session +// and issuing tokens; Redux contains only the profile used by the interface. +export function startAuthSync(store: ReturnType) { + let storageKey: string | null = null; + let previousDraft = store.getState().selectedParts; + + const stopSaving = store.subscribe(() => { + const draft = store.getState().selectedParts; + if (storageKey && draft !== previousDraft) { + previousDraft = draft; + saveBuildDraft(storageKey, draft); + } + }); + + const stopAuth = onAuthStateChanged( + auth, + (user) => { + const nextKey = `pc-builder-v2:${user?.uid ?? "guest"}`; + if (nextKey !== storageKey) { + // Pause persistence while replacing the previous account's draft. + storageKey = null; + store.dispatch(restoreBuild(loadBuildDraft(nextKey))); + store.dispatch(dismissNotification()); + previousDraft = store.getState().selectedParts; + storageKey = nextKey; + saveBuildDraft(storageKey, previousDraft); + } + store.dispatch( + authResolved( + user + ? { + uid: user.uid, + displayName: user.displayName, + email: user.email, + } + : null, + ), + ); + }, + () => { + store.dispatch( + authFailed( + "Could not check your sign-in status. Please refresh and try again.", + ), + ); + }, + ); + + return () => { + stopAuth(); + stopSaving(); + }; +} + +export async function login() { + await signInWithPopup(auth, new GoogleAuthProvider()); +} + +export async function loginWithEmail(email: string, password: string) { + await signInWithEmailAndPassword(auth, email.trim(), password); +} + +export async function registerWithEmail(email: string, password: string) { + await createUserWithEmailAndPassword(auth, email.trim(), password); +} + +export async function logout() { + await signOut(auth); +} diff --git a/frontend/src/api/graphql.ts b/frontend/src/api/graphql.ts index e699333..ecdd5d1 100644 --- a/frontend/src/api/graphql.ts +++ b/frontend/src/api/graphql.ts @@ -30,4 +30,4 @@ export async function graphql( } return result.data; -} \ No newline at end of file +} diff --git a/frontend/src/api/parts.ts b/frontend/src/api/parts.ts index 4a1a0c4..5738522 100644 --- a/frontend/src/api/parts.ts +++ b/frontend/src/api/parts.ts @@ -140,10 +140,10 @@ export async function getParts( export async function getPartSpecifications( id: string, signal?: AbortSignal, -): Promise { - const response = await fetch('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, +): Promise { + const response = await fetch("/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, signal, body: JSON.stringify({ query: ` @@ -155,26 +155,26 @@ export async function getPartSpecifications( `, variables: { id }, }), - }) + }); if (!response.ok) { - throw new Error(`Could not load specifications (HTTP ${response.status}).`) + throw new Error(`Could not load specifications (HTTP ${response.status}).`); } const result: { - data?: { part: { specs: PcPart['details'] } | null } - errors?: { message: string }[] - } = await response.json() + data?: { part: { specs: PcPart["details"] } | null }; + errors?: { message: string }[]; + } = await response.json(); if (result.errors?.length) { - throw new Error(result.errors.map((error) => error.message).join(', ')) + throw new Error(result.errors.map((error) => error.message).join(", ")); } if (!result.data) { - throw new Error('The server returned no data.') + throw new Error("The server returned no data."); } if (!result.data.part) { - throw new Error('Part not found.') + throw new Error("Part not found."); } - return result.data.part.specs + return result.data.part.specs; } diff --git a/frontend/src/components/AddToBuildFeedback.tsx b/frontend/src/components/AddToBuildFeedback.tsx index acfe6a8..058ef52 100644 --- a/frontend/src/components/AddToBuildFeedback.tsx +++ b/frontend/src/components/AddToBuildFeedback.tsx @@ -1,9 +1,8 @@ import type { PcPart } from "@/types/types"; -import { useBuild } from "@/context/BuildContext"; import { useEffect, useId, useRef } from "react"; -import { flushSync } from "react-dom"; import { ArrowRight, X } from "lucide-react"; -import SuccessNotification from "./SuccessNotification"; +import { useSelector } from "react-redux"; +import { selectPartByCategory } from "@/state/selectedPartsSlice"; type AddToBuildFeedbackProps = { part: PcPart; @@ -12,14 +11,22 @@ type AddToBuildFeedbackProps = { onClose: () => void; }; -export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: AddToBuildFeedbackProps) { +export default function AddToBuildFeedback({ + part, + stage, + onReplace, + onClose, +}: AddToBuildFeedbackProps) { const dialogRef = useRef(null); const titleId = useId(); const descriptionId = useId(); - const { parts } = useBuild(); - const savedPart = parts[part.category]; - const secondaryButton = "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; - const primaryButton = "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; + const savedPart = useSelector((state) => + selectPartByCategory(state, part.category), + ); + const secondaryButton = + "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; + const primaryButton = + "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; useEffect(() => { dialogRef.current?.showModal(); @@ -29,23 +36,6 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: dialogRef.current?.close(); } - function goToBuild() { - // Remove the feedback before the browser saves this page for Back navigation. - flushSync(() => onClose()); - window.location.href = "/project2/builder"; - } - - if (stage === "success") { - return ( - - ); - } - return (
-
-

+
+

{stage === "confirm" ? "Replace component?" : "Adding component…"}

-

+

{stage === "confirm" ? `You have already selected ${savedPart?.name}. Would you like to replace it with ${part.name}?` : `Adding ${part.name} to your build.`} @@ -91,10 +88,19 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: )}

Current component

-

{savedPart?.name}

+

+ {savedPart?.name} +

-