From 1067ee3d935b6e5a7cabc33b0f861d7a3ab5caaf Mon Sep 17 00:00:00 2001 From: Aleksandr Kryukov Date: Mon, 13 Apr 2026 20:25:59 +0300 Subject: [PATCH] Homework 13 -Localstorage, Privateroots --- package-lock.json | 97 ++++++++++++++++- package.json | 2 + src/App.tsx | 23 ++++ src/components/providers/cart-provider.tsx | 118 ++++++++------------- src/hooks/use-auth-session.ts | 91 ++++++++++++++-- src/hooks/use-products-feed.ts | 62 +++-------- src/lib/auth-session.ts | 87 +++++++++++++++ src/main.tsx | 18 ++-- src/pages/products-page.tsx | 75 +++++++++++-- src/pages/profile-page.tsx | 39 ++++--- src/services/account/account-service.ts | 6 +- src/store/hooks.ts | 7 ++ src/store/slices/app-slice.ts | 25 +++++ src/store/slices/auth-slice.ts | 25 +++++ src/store/slices/cart-slice.ts | 105 ++++++++++++++++++ src/store/slices/products-slice.ts | 100 +++++++++++++++++ src/store/slices/profile-slice.ts | 39 +++++++ src/store/store.ts | 22 ++++ src/types/profile.ts | 7 ++ 19 files changed, 780 insertions(+), 168 deletions(-) create mode 100644 src/lib/auth-session.ts create mode 100644 src/store/hooks.ts create mode 100644 src/store/slices/app-slice.ts create mode 100644 src/store/slices/auth-slice.ts create mode 100644 src/store/slices/cart-slice.ts create mode 100644 src/store/slices/products-slice.ts create mode 100644 src/store/slices/profile-slice.ts create mode 100644 src/store/store.ts diff --git a/package-lock.json b/package-lock.json index f57c793..a8056a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "vite8", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -17,6 +18,7 @@ "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.11.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1" @@ -3335,6 +3337,32 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -3714,7 +3742,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "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/@storybook/addon-a11y": { @@ -4696,6 +4729,12 @@ "dev": true, "license": "MIT" }, + "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/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -7555,6 +7594,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9644,6 +9693,30 @@ "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "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-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -9808,6 +9881,22 @@ "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", + "peer": true + }, + "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", @@ -9828,6 +9917,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", diff --git a/package.json b/package.json index d90302b..d510260 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -23,6 +24,7 @@ "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.11.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1" diff --git a/src/App.tsx b/src/App.tsx index 8735e27..9df4572 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ function App() { const { products, isLoadingMore, loadError, loadMore } = useProductsFeed(); const { + isSessionReady, isAuthenticated, isAdmin, authLabel, @@ -38,6 +39,28 @@ function App() { [counts, products], ); + if (!isSessionReady) { + return ( + } + headerActions={ + + } + > +
+ Инициализация приложения... +
+
+ ); + } + return ( } diff --git a/src/components/providers/cart-provider.tsx b/src/components/providers/cart-provider.tsx index 1b4f118..bd9b1c0 100644 --- a/src/components/providers/cart-provider.tsx +++ b/src/components/providers/cart-provider.tsx @@ -1,6 +1,14 @@ -import * as React from "react"; - -type CartCounts = Record; +import * as React from "react"; +import { useAppDispatch, useAppSelector } from "@/store/hooks"; +import { + clearCart, + decrementCartItem, + hydrateCart, + incrementCartItem, + removeCartItem, + setCartItemCount, + type CartCounts, +} from "@/store/slices/cart-slice"; type CartContextValue = { counts: CartCounts; @@ -18,67 +26,19 @@ type CartProviderProps = { initialCounts?: CartCounts; }; -type CartAction = - | { type: "increment"; productId: string } - | { type: "decrement"; productId: string } - | { type: "set"; productId: string; count: number } - | { type: "remove"; productId: string } - | { type: "clear" }; - const CartContext = React.createContext(null); -const normalizeCount = (count: number): number => - Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; - -const setItemCount = ( - current: CartCounts, - productId: string, - nextCount: number, -): CartCounts => { - if (nextCount <= 0) { - if (!(productId in current)) { - return current; - } - - const nextState = { ...current }; - delete nextState[productId]; - return nextState; - } - - if (current[productId] === nextCount) { - return current; - } +function CartProvider({ children, initialCounts }: CartProviderProps) { + const dispatch = useAppDispatch(); + const counts = useAppSelector((state) => state.cart.counts); - return { ...current, [productId]: nextCount }; -}; - -const cartReducer = (state: CartCounts, action: CartAction): CartCounts => { - switch (action.type) { - case "increment": { - const currentCount = state[action.productId] ?? 0; - return setItemCount(state, action.productId, currentCount + 1); - } - case "decrement": { - const currentCount = state[action.productId] ?? 0; - return setItemCount(state, action.productId, currentCount - 1); + React.useEffect(() => { + if (!initialCounts || Object.keys(initialCounts).length === 0) { + return; } - case "set": { - return setItemCount(state, action.productId, normalizeCount(action.count)); - } - case "remove": { - return setItemCount(state, action.productId, 0); - } - case "clear": { - return Object.keys(state).length > 0 ? {} : state; - } - default: { - return state; - } - } -}; -function CartProvider({ children, initialCounts = {} }: CartProviderProps) { - const [counts, dispatch] = React.useReducer(cartReducer, initialCounts); + dispatch(hydrateCart(initialCounts)); + }, [dispatch, initialCounts]); const totalItems = React.useMemo( () => Object.values(counts).reduce((sum, count) => sum + count, 0), @@ -90,25 +50,37 @@ function CartProvider({ children, initialCounts = {} }: CartProviderProps) { [counts], ); - const increment = React.useCallback((productId: string) => { - dispatch({ type: "increment", productId }); - }, []); + const increment = React.useCallback( + (productId: string) => { + dispatch(incrementCartItem({ productId })); + }, + [dispatch], + ); - const decrement = React.useCallback((productId: string) => { - dispatch({ type: "decrement", productId }); - }, []); + const decrement = React.useCallback( + (productId: string) => { + dispatch(decrementCartItem({ productId })); + }, + [dispatch], + ); - const setCount = React.useCallback((productId: string, count: number) => { - dispatch({ type: "set", productId, count }); - }, []); + const setCount = React.useCallback( + (productId: string, count: number) => { + dispatch(setCartItemCount({ productId, count })); + }, + [dispatch], + ); - const remove = React.useCallback((productId: string) => { - dispatch({ type: "remove", productId }); - }, []); + const remove = React.useCallback( + (productId: string) => { + dispatch(removeCartItem({ productId })); + }, + [dispatch], + ); const clear = React.useCallback(() => { - dispatch({ type: "clear" }); - }, []); + dispatch(clearCart()); + }, [dispatch]); const contextValue = React.useMemo( () => ({ diff --git a/src/hooks/use-auth-session.ts b/src/hooks/use-auth-session.ts index 4ec76ca..fc22ac4 100644 --- a/src/hooks/use-auth-session.ts +++ b/src/hooks/use-auth-session.ts @@ -1,7 +1,22 @@ import * as React from "react"; import type { AuthRole } from "@/app/routes"; +import { + AUTH_TOKEN_STORAGE_KEY, + buildFakeProfile, + createFakeToken, + normalizeToken, + parseRoleFromToken, + readTokenFromStorage, + writeTokenToStorage, +} from "@/lib/auth-session"; +import { useAppDispatch, useAppSelector } from "@/store/hooks"; +import { markInitialized } from "@/store/slices/app-slice"; +import { setToken } from "@/store/slices/auth-slice"; +import { clearCart } from "@/store/slices/cart-slice"; +import { clearProfile, setProfile } from "@/store/slices/profile-slice"; type UseAuthSessionResult = { + isSessionReady: boolean; isAuthenticated: boolean; role: AuthRole; isAdmin: boolean; @@ -13,26 +28,79 @@ type UseAuthSessionResult = { }; function useAuthSession(onLogout?: () => void): UseAuthSessionResult { - const [isAuthenticated, setIsAuthenticated] = React.useState(false); - const [role, setRole] = React.useState("user"); + const dispatch = useAppDispatch(); + const token = useAppSelector((state) => state.auth.token); + const isInitialized = useAppSelector((state) => state.app.isInitialized); + + const isAuthenticated = token !== null; + const role: AuthRole = token ? parseRoleFromToken(token) : "user"; + + React.useEffect(() => { + if (isInitialized) { + return; + } + + const storedToken = readTokenFromStorage(); + dispatch(setToken(storedToken)); + dispatch(markInitialized()); + }, [dispatch, isInitialized]); + + React.useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key !== AUTH_TOKEN_STORAGE_KEY) { + return; + } + + dispatch(setToken(normalizeToken(event.newValue))); + }; + + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener("storage", handleStorage); + }; + }, [dispatch]); + + React.useEffect(() => { + if (!isInitialized) { + return; + } + + writeTokenToStorage(token); + + if (!token) { + dispatch(clearProfile()); + dispatch(clearCart()); + return; + } + + dispatch(setProfile(buildFakeProfile(token))); + }, [dispatch, isInitialized, token]); const login = React.useCallback(() => { - setIsAuthenticated(true); - }, []); + dispatch(setToken(createFakeToken("user"))); + }, [dispatch]); const logout = React.useCallback(() => { - setIsAuthenticated(false); - setRole("user"); + dispatch(setToken(null)); onLogout?.(); - }, [onLogout]); + }, [dispatch, onLogout]); const becomeAdmin = React.useCallback(() => { - setRole("admin"); - }, []); + if (!isAuthenticated) { + return; + } + + dispatch(setToken(createFakeToken("admin"))); + }, [dispatch, isAuthenticated]); const becomeUser = React.useCallback(() => { - setRole("user"); - }, []); + if (!isAuthenticated) { + return; + } + + dispatch(setToken(createFakeToken("user"))); + }, [dispatch, isAuthenticated]); const isAdmin = isAuthenticated && role === "admin"; @@ -45,6 +113,7 @@ function useAuthSession(onLogout?: () => void): UseAuthSessionResult { }, [isAdmin, isAuthenticated]); return { + isSessionReady: isInitialized, isAuthenticated, role, isAdmin, diff --git a/src/hooks/use-products-feed.ts b/src/hooks/use-products-feed.ts index 7f9307c..b868948 100644 --- a/src/hooks/use-products-feed.ts +++ b/src/hooks/use-products-feed.ts @@ -1,5 +1,6 @@ -import * as React from "react"; -import { PRODUCTS_BATCH_SIZE, loadRandomProductsBatch } from "@/lib/products-api"; +import * as React from "react"; +import { useAppDispatch, useAppSelector } from "@/store/hooks"; +import { loadMoreProducts } from "@/store/slices/products-slice"; import type { Product } from "@/types/shop"; type UseProductsFeedResult = { @@ -10,57 +11,26 @@ type UseProductsFeedResult = { }; function useProductsFeed(): UseProductsFeedResult { - const [products, setProducts] = React.useState([]); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); - const [loadError, setLoadError] = React.useState(null); - - const pendingRequestRef = React.useRef(null); - const isLoadingMoreRef = React.useRef(false); + const dispatch = useAppDispatch(); + const products = useAppSelector((state) => state.products.items); + const isLoadingMore = useAppSelector((state) => state.products.isLoadingMore); + const loadError = useAppSelector((state) => state.products.loadError); const loadMore = React.useCallback(async () => { - if (isLoadingMoreRef.current) { - return; - } - - const requestController = new AbortController(); - - isLoadingMoreRef.current = true; - setIsLoadingMore(true); - setLoadError(null); - pendingRequestRef.current = requestController; - try { - const nextProducts = await loadRandomProductsBatch( - requestController.signal, - PRODUCTS_BATCH_SIZE, - ); - - setProducts((currentProducts) => [...currentProducts, ...nextProducts]); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - return; - } - - setLoadError( - error instanceof Error ? error.message : "Не удалось загрузить товары.", - ); - } finally { - if (pendingRequestRef.current === requestController) { - pendingRequestRef.current = null; - } - - isLoadingMoreRef.current = false; - setIsLoadingMore(false); + await dispatch(loadMoreProducts()).unwrap(); + } catch { + // Error state is already captured in the slice. } - }, []); + }, [dispatch]); React.useEffect(() => { - void loadMore(); + if (products.length > 0 || isLoadingMore) { + return; + } - return () => { - pendingRequestRef.current?.abort(); - }; - }, [loadMore]); + void loadMore(); + }, [isLoadingMore, loadMore, products.length]); return { products, diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts new file mode 100644 index 0000000..8f618d2 --- /dev/null +++ b/src/lib/auth-session.ts @@ -0,0 +1,87 @@ +import type { AuthRole } from "@/app/routes"; +import type { AppProfile } from "@/types/profile"; + +const AUTH_TOKEN_STORAGE_KEY = "react-shop.auth.token"; +const TOKEN_ROLE_INDEX = 1; + +const isRole = (value: string): value is AuthRole => + value === "admin" || value === "user"; + +const normalizeToken = (value: string | null | undefined): string | null => { + if (typeof value !== "string") { + return null; + } + + const token = value.trim(); + return token.length > 0 ? token : null; +}; + +const createTokenEntropy = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; + +const createFakeToken = (role: AuthRole): string => + `fake.${role}.${createTokenEntropy()}`; + +const parseRoleFromToken = (token: string): AuthRole => { + const role = token.split(".")[TOKEN_ROLE_INDEX] ?? ""; + return isRole(role) ? role : "user"; +}; + +const buildFakeProfile = (token: string): AppProfile => { + const role = parseRoleFromToken(token); + const tokenSuffix = token.split(".").at(-1)?.slice(0, 8) ?? "session"; + const isAdmin = role === "admin"; + + return { + id: `usr_${tokenSuffix}`, + firstName: isAdmin ? "Admin" : "Ivan", + lastName: isAdmin ? "User" : "Ivanov", + displayName: isAdmin ? "Administrator" : "Demo User", + email: isAdmin + ? `admin.${tokenSuffix}@example.com` + : `user.${tokenSuffix}@example.com`, + emailVerified: true, + role, + }; +}; + +const readTokenFromStorage = (): string | null => { + if (typeof window === "undefined") { + return null; + } + + try { + return normalizeToken(window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)); + } catch { + return null; + } +}; + +const writeTokenToStorage = (token: string | null): void => { + if (typeof window === "undefined") { + return; + } + + try { + if (token) { + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); + return; + } + + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + // localStorage may be disabled in private mode or restricted environments. + } +}; + +export { + AUTH_TOKEN_STORAGE_KEY, + buildFakeProfile, + createFakeToken, + normalizeToken, + parseRoleFromToken, + readTokenFromStorage, + writeTokenToStorage, +}; diff --git a/src/main.tsx b/src/main.tsx index 5d61e5c..8ba915e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,19 +1,23 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; import { BrowserRouter } from "react-router-dom"; import { CartProvider } from "@/components/providers/cart-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; +import { store } from "@/store/store"; import "./index.css"; import App from "./App.tsx"; createRoot(document.getElementById("root")!).render( - - - - - - - + + + + + + + + + , ); diff --git a/src/pages/products-page.tsx b/src/pages/products-page.tsx index 221214a..94f2061 100644 --- a/src/pages/products-page.tsx +++ b/src/pages/products-page.tsx @@ -1,12 +1,35 @@ import * as React from "react"; +import { formatPrice } from "@/app/formatters"; import { Button } from "@/components/ui/button"; import { Modal } from "@/components/ui/modal"; -import { ProductFormModal } from "@/components/ui/product-form-modal"; import { ProductDetailsCard } from "@/components/ui/product-details-card"; +import { + ProductFormModal, + type ProductFormSubmitPayload, +} from "@/components/ui/product-form-modal"; import { ProductList } from "@/components/ui/product-list"; -import { formatPrice } from "@/app/formatters"; +import { PRODUCT_IMAGE_REMOTE_BASE_URL } from "@/lib/image"; +import { useAppDispatch } from "@/store/hooks"; +import { addProduct, updateProduct } from "@/store/slices/products-slice"; import type { Product } from "@/types/shop"; +const createProductId = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? `prd_${crypto.randomUUID()}` + : `prd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; + +const toCategoryId = (categoryName: string): string => { + const normalized = categoryName.trim().toLowerCase().replace(/\s+/g, "_"); + return `cat_${normalized || "custom"}`; +}; + +const createRandomProductImage = (): string => { + const randomIndex = Math.floor(Math.random() * 999) + 1; + return `${PRODUCT_IMAGE_REMOTE_BASE_URL}/product-${randomIndex + .toString() + .padStart(3, "0")}.jpg`; +}; + type ProductsPageProps = { products: Product[]; isLoadingMore: boolean; @@ -22,6 +45,8 @@ function ProductsPage({ onLoadMore, isAdmin, }: ProductsPageProps) { + const dispatch = useAppDispatch(); + const [selectedProduct, setSelectedProduct] = React.useState( null, ); @@ -65,6 +90,45 @@ function ProductsPage({ setEditingProduct(null); }, []); + const handleSubmit = React.useCallback( + (payload: ProductFormSubmitPayload) => { + const category = { + id: toCategoryId(payload.categoryName), + name: payload.categoryName, + }; + + if (productFormMode === "edit" && editingProduct) { + dispatch( + updateProduct({ + ...editingProduct, + name: payload.name, + desc: payload.description, + category, + price: payload.price, + oldPrice: payload.oldPrice, + createdAt: payload.createdAt, + }), + ); + + return; + } + + dispatch( + addProduct({ + id: createProductId(), + name: payload.name, + photo: createRandomProductImage(), + desc: payload.description, + category, + price: payload.price, + oldPrice: payload.oldPrice, + createdAt: payload.createdAt, + }), + ); + }, + [dispatch, editingProduct, productFormMode], + ); + return ( <>
@@ -159,12 +223,7 @@ function ProductsPage({ : undefined } onClose={closeProductForm} - onSubmit={(payload) => { - console.log("[ProductsPage] product submit", payload); - }} - onValidation={(result) => { - console.log("[ProductsPage] product validation", result); - }} + onSubmit={handleSubmit} /> ) : null} diff --git a/src/pages/profile-page.tsx b/src/pages/profile-page.tsx index 843d4d4..1314fac 100644 --- a/src/pages/profile-page.tsx +++ b/src/pages/profile-page.tsx @@ -1,39 +1,36 @@ -import * as React from "react"; -import { Card } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { ProfileForm } from "@/components/ui/profile-form"; -import type { ProfileFormValues } from "@/types/profile"; - -const initialProfileValues: ProfileFormValues = { - firstName: "Иван", - lastName: "Иванов", - displayName: "Иван И.", - email: "ivan@example.com", - emailVerified: false, -}; +import { useAppDispatch, useAppSelector } from "@/store/hooks"; +import { updateProfile } from "@/store/slices/profile-slice"; function ProfilePage() { - const [profileValues, setProfileValues] = React.useState( - initialProfileValues, - ); + const dispatch = useAppDispatch(); + const profile = useAppSelector((state) => state.profile.value); + + if (!profile) { + return null; + } return (

Профиль

- Форма профиля теперь доступна на отдельном маршруте. + Форма профиля доступна только авторизованным пользователям.

{ - setProfileValues(values); - console.log("[ProfilePage] profile submit", values); + initialValues={{ + firstName: profile.firstName, + lastName: profile.lastName, + displayName: profile.displayName, + email: profile.email, + emailVerified: profile.emailVerified, }} - onValidation={(result) => { - console.log("[ProfilePage] profile validation", result); + onSubmit={(values) => { + dispatch(updateProfile(values)); }} /> diff --git a/src/services/account/account-service.ts b/src/services/account/account-service.ts index 2010633..8190d5a 100644 --- a/src/services/account/account-service.ts +++ b/src/services/account/account-service.ts @@ -8,7 +8,11 @@ const validateDiscount = (discount: number): void => { }; export class AccountService { - constructor(private readonly repository: AccountDiscountRepository) {} + private readonly repository: AccountDiscountRepository; + + constructor(repository: AccountDiscountRepository) { + this.repository = repository; + } setUserDiscount(userType: UserType, discount: number): void { validateDiscount(discount); diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 0000000..5beebb0 --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,7 @@ +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; +import type { AppDispatch, RootState } from "@/store/store"; + +const useAppDispatch = () => useDispatch(); +const useAppSelector: TypedUseSelectorHook = useSelector; + +export { useAppDispatch, useAppSelector }; diff --git a/src/store/slices/app-slice.ts b/src/store/slices/app-slice.ts new file mode 100644 index 0000000..ede1ef6 --- /dev/null +++ b/src/store/slices/app-slice.ts @@ -0,0 +1,25 @@ +import { createSlice } from "@reduxjs/toolkit"; + +type AppState = { + isInitialized: boolean; +}; + +const initialState: AppState = { + isInitialized: false, +}; + +const appSlice = createSlice({ + name: "app", + initialState, + reducers: { + markInitialized(state) { + state.isInitialized = true; + }, + }, +}); + +const { markInitialized } = appSlice.actions; +const appReducer = appSlice.reducer; + +export { appReducer, markInitialized }; +export type { AppState }; diff --git a/src/store/slices/auth-slice.ts b/src/store/slices/auth-slice.ts new file mode 100644 index 0000000..9fe1acf --- /dev/null +++ b/src/store/slices/auth-slice.ts @@ -0,0 +1,25 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; + +type AuthState = { + token: string | null; +}; + +const initialState: AuthState = { + token: null, +}; + +const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + setToken(state, action: PayloadAction) { + state.token = action.payload; + }, + }, +}); + +const { setToken } = authSlice.actions; +const authReducer = authSlice.reducer; + +export { authReducer, setToken }; +export type { AuthState }; diff --git a/src/store/slices/cart-slice.ts b/src/store/slices/cart-slice.ts new file mode 100644 index 0000000..3d07e28 --- /dev/null +++ b/src/store/slices/cart-slice.ts @@ -0,0 +1,105 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; + +type CartCounts = Record; + +type CartState = { + counts: CartCounts; +}; + +type ProductIdPayload = { + productId: string; +}; + +type SetCartItemCountPayload = ProductIdPayload & { + count: number; +}; + +const normalizeCount = (count: number): number => + Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; + +const setItemCount = ( + current: CartCounts, + productId: string, + nextCount: number, +): CartCounts => { + if (nextCount <= 0) { + if (!(productId in current)) { + return current; + } + + const nextState = { ...current }; + delete nextState[productId]; + return nextState; + } + + if (current[productId] === nextCount) { + return current; + } + + return { ...current, [productId]: nextCount }; +}; + +const initialState: CartState = { + counts: {}, +}; + +const cartSlice = createSlice({ + name: "cart", + initialState, + reducers: { + hydrateCart(state, action: PayloadAction) { + state.counts = action.payload; + }, + incrementCartItem(state, action: PayloadAction) { + const currentCount = state.counts[action.payload.productId] ?? 0; + state.counts = setItemCount( + state.counts, + action.payload.productId, + currentCount + 1, + ); + }, + decrementCartItem(state, action: PayloadAction) { + const currentCount = state.counts[action.payload.productId] ?? 0; + state.counts = setItemCount( + state.counts, + action.payload.productId, + currentCount - 1, + ); + }, + setCartItemCount(state, action: PayloadAction) { + state.counts = setItemCount( + state.counts, + action.payload.productId, + normalizeCount(action.payload.count), + ); + }, + removeCartItem(state, action: PayloadAction) { + state.counts = setItemCount(state.counts, action.payload.productId, 0); + }, + clearCart(state) { + state.counts = {}; + }, + }, +}); + +const { + clearCart, + decrementCartItem, + hydrateCart, + incrementCartItem, + removeCartItem, + setCartItemCount, +} = cartSlice.actions; + +const cartReducer = cartSlice.reducer; + +export { + cartReducer, + clearCart, + decrementCartItem, + hydrateCart, + incrementCartItem, + removeCartItem, + setCartItemCount, +}; +export type { CartCounts, CartState }; diff --git a/src/store/slices/products-slice.ts b/src/store/slices/products-slice.ts new file mode 100644 index 0000000..ac80b8c --- /dev/null +++ b/src/store/slices/products-slice.ts @@ -0,0 +1,100 @@ +import { + createAsyncThunk, + createSlice, + type PayloadAction, +} from "@reduxjs/toolkit"; +import { + PRODUCTS_BATCH_SIZE, + loadRandomProductsBatch, +} from "@/lib/products-api"; +import type { Product } from "@/types/shop"; + +type ProductsState = { + items: Product[]; + isLoadingMore: boolean; + loadError: string | null; +}; + +type LoadMoreProductsThunkConfig = { + state: { + products: ProductsState; + }; + rejectValue: string; +}; + +const initialState: ProductsState = { + items: [], + isLoadingMore: false, + loadError: null, +}; + +const loadMoreProducts = createAsyncThunk< + Product[], + void, + LoadMoreProductsThunkConfig +>( + "products/loadMore", + async (_, { rejectWithValue, signal }) => { + try { + return await loadRandomProductsBatch(signal, PRODUCTS_BATCH_SIZE); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return rejectWithValue("aborted"); + } + + return rejectWithValue( + error instanceof Error ? error.message : "Failed to load products.", + ); + } + }, + { + condition: (_, { getState }) => !getState().products.isLoadingMore, + }, +); + +const productsSlice = createSlice({ + name: "products", + initialState, + reducers: { + addProduct(state, action: PayloadAction) { + state.items = [action.payload, ...state.items]; + }, + updateProduct(state, action: PayloadAction) { + const index = state.items.findIndex( + (product) => product.id === action.payload.id, + ); + + if (index < 0) { + return; + } + + state.items[index] = action.payload; + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadMoreProducts.pending, (state) => { + state.isLoadingMore = true; + state.loadError = null; + }) + .addCase(loadMoreProducts.fulfilled, (state, action) => { + state.items = [...state.items, ...action.payload]; + state.isLoadingMore = false; + }) + .addCase(loadMoreProducts.rejected, (state, action) => { + state.isLoadingMore = false; + if (action.payload === "aborted") { + return; + } + + state.loadError = + action.payload ?? action.error.message ?? "Failed to load products."; + }); + }, +}); + +const { addProduct, updateProduct } = productsSlice.actions; +const productsReducer = productsSlice.reducer; + +export { addProduct, loadMoreProducts, productsReducer, updateProduct }; +export type { ProductsState }; diff --git a/src/store/slices/profile-slice.ts b/src/store/slices/profile-slice.ts new file mode 100644 index 0000000..3c4c44c --- /dev/null +++ b/src/store/slices/profile-slice.ts @@ -0,0 +1,39 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { AppProfile, ProfileFormValues } from "@/types/profile"; + +type ProfileState = { + value: AppProfile | null; +}; + +const initialState: ProfileState = { + value: null, +}; + +const profileSlice = createSlice({ + name: "profile", + initialState, + reducers: { + setProfile(state, action: PayloadAction) { + state.value = action.payload; + }, + clearProfile(state) { + state.value = null; + }, + updateProfile(state, action: PayloadAction) { + if (!state.value) { + return; + } + + state.value = { + ...state.value, + ...action.payload, + }; + }, + }, +}); + +const { clearProfile, setProfile, updateProfile } = profileSlice.actions; +const profileReducer = profileSlice.reducer; + +export { clearProfile, profileReducer, setProfile, updateProfile }; +export type { ProfileState }; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..29b1028 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,22 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { appReducer } from "@/store/slices/app-slice"; +import { authReducer } from "@/store/slices/auth-slice"; +import { cartReducer } from "@/store/slices/cart-slice"; +import { productsReducer } from "@/store/slices/products-slice"; +import { profileReducer } from "@/store/slices/profile-slice"; + +const store = configureStore({ + reducer: { + app: appReducer, + auth: authReducer, + profile: profileReducer, + cart: cartReducer, + products: productsReducer, + }, +}); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; + +export { store }; +export type { AppDispatch, RootState }; diff --git a/src/types/profile.ts b/src/types/profile.ts index 73dcd9c..30befa5 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -1,3 +1,5 @@ +import type { AuthRole } from "@/app/routes"; + export type ProfileFormValues = { firstName: string; lastName: string; @@ -6,6 +8,11 @@ export type ProfileFormValues = { emailVerified: boolean; }; +export type AppProfile = ProfileFormValues & { + id: string; + role: AuthRole; +}; + export type ProfileFormErrors = Partial< Record<"firstName" | "lastName" | "displayName" | "email", string> >;