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 (