diff --git a/package-lock.json b/package-lock.json index be9e409..0769b0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -510,6 +510,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1147,6 +1148,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1190,6 +1192,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2043,6 +2046,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -3655,8 +3659,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3757,6 +3760,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3774,6 +3778,7 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3784,6 +3789,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3841,6 +3847,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -4177,6 +4184,7 @@ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.10", "fflate": "^0.8.2", @@ -4273,6 +4281,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4363,7 +4372,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4606,6 +4614,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5091,6 +5100,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -5371,8 +5381,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-prop": { "version": "5.3.0", @@ -5779,6 +5788,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6696,6 +6706,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -7970,7 +7981,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8009,6 +8019,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10361,6 +10372,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10962,6 +10974,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11010,7 +11023,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11190,6 +11202,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11199,6 +11212,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11211,8 +11225,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -11535,6 +11548,7 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -12860,6 +12874,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13033,6 +13048,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -13117,6 +13133,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13345,6 +13362,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13438,6 +13456,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13451,6 +13470,7 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", @@ -13842,6 +13862,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/App.tsx b/src/App.tsx index aa50352..3b74cb9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,27 @@ -import { useMemo } from 'react' +import { useMemo, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' import { useQuery } from '@apollo/client/react' import { AuthProvider } from '#src/contexts/auth/AuthContext' import { CartProvider } from '#src/contexts/cart/CartContext' import { Layout } from '#src/components/layout/Layout' +import { LoadingState } from '#src/components/LoadingState' import { GET_PRODUCTS } from '#src/graphql/queries' import { routes } from '#src/routes' import type { ProductsQueryResult } from '#src/types' function AppRoutes() { return ( - - {routes.map((route) => ( - {route.element}} - /> - ))} - + }> + + {routes.map((route) => ( + {route.element}} + /> + ))} + + ) } diff --git a/src/ProductCard.tsx b/src/ProductCard.tsx index 13e06f5..3deb5b5 100644 --- a/src/ProductCard.tsx +++ b/src/ProductCard.tsx @@ -8,12 +8,15 @@ type ProductCardProps = { product: Product onAdd: () => void isHighlighted: boolean + averageRating: number | null } -export default function ProductCard({ product, onAdd, isHighlighted }: ProductCardProps) { - // Use product.rating from database (which should be kept up to date) - const averageRating = product.rating || null - +export default function ProductCard({ + product, + onAdd, + isHighlighted, + averageRating, +}: ProductCardProps) { return ( +} + export default function ProductsPage() { const { addToCart, isHighlighted } = useCart() const location = useLocation() const { loading, error, data } = useQuery(GET_PRODUCTS) + const products = useMemo(() => data?.products || [], [data?.products]) + + // Fetch reviews for all products using a single batch query + const [reviewsMap, setReviewsMap] = useState< + Record + >({}) + + const fetchReviews = useCallback(() => { + if (products.length === 0) { + return + } + + // Fetch all reviews for all products in a single query + const productIds = products.map((product) => product.id) + + client + .query({ + query: GET_REVIEWS_BY_PRODUCT_IDS, + variables: { productIds }, + fetchPolicy: 'network-only', // Always fetch fresh data + }) + .then((result) => { + if (!result.data) { + throw new Error('No data returned') + } + // Group reviews by productId + const map: Record = {} + result.data.reviewsByProductIds.forEach((review) => { + if (!map[review.productId]) { + map[review.productId] = [] + } + map[review.productId].push(review) + }) + setReviewsMap(map) + }) + .catch(() => { + // On error, set empty reviews for all products + const map: Record = {} + products.forEach((product) => { + map[product.id] = [] + }) + setReviewsMap(map) + }) + }, [products]) + + useEffect(() => { + fetchReviews() + }, [fetchReviews]) + + // Listen for review added event to refetch reviews + useEffect(() => { + const handleReviewAdded = () => { + fetchReviews() + } + + window.addEventListener('reviewAdded', handleReviewAdded) + + return () => { + window.removeEventListener('reviewAdded', handleReviewAdded) + } + }, [fetchReviews]) + + // Calculate average ratings for each product + const averageRatings = useMemo(() => { + const ratings: Record = {} + products.forEach((product) => { + const reviews = reviewsMap[product.id] || [] + ratings[product.id] = getAverageRating(reviews) + }) + return ratings + }, [products, reviewsMap]) + // Scroll to top when products page loads or location changes useEffect(() => { window.scrollTo({ top: 0, behavior: 'smooth' }) @@ -25,8 +110,6 @@ export default function ProductsPage() { if (loading) return if (error) return - const products = data?.products || [] - return (
@@ -43,6 +126,7 @@ export default function ProductsPage() { product={product} onAdd={() => addToCart(product.id)} isHighlighted={isHighlighted(product.id)} + averageRating={averageRatings[product.id] ?? null} /> ))}
diff --git a/src/components/AdminProtectedRoute.tsx b/src/components/AdminProtectedRoute.tsx new file mode 100644 index 0000000..dbb2df6 --- /dev/null +++ b/src/components/AdminProtectedRoute.tsx @@ -0,0 +1,40 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '#src/hooks/useAuth' +import { usePermissions } from '#src/hooks/usePermissions' +import { LoadingState } from '#src/components/LoadingState' +import { EmptyState } from '#src/components/EmptyState' + +interface AdminProtectedRouteProps { + children: React.ReactNode +} + +export function AdminProtectedRoute({ children }: AdminProtectedRouteProps) { + const { isAuthenticated, loading } = useAuth() + const { hasAdminAccess } = usePermissions() + const location = useLocation() + + if (loading) { + return + } + + if (!isAuthenticated) { + // Redirect to login page, preserving the intended destination + return + } + + if (!hasAdminAccess()) { + return ( + { + window.location.href = '/' + }} + /> + ) + } + + return <>{children} +} + diff --git a/src/components/admin/ProductForm.tsx b/src/components/admin/ProductForm.tsx new file mode 100644 index 0000000..29c6d90 --- /dev/null +++ b/src/components/admin/ProductForm.tsx @@ -0,0 +1,188 @@ +import { useState, type FormEvent } from 'react' +import { useMutation } from '@apollo/client/react' +import { CREATE_PRODUCT, UPDATE_PRODUCT } from '#src/graphql/queries' + +type Product = { + id: string + name: string + category: string + price: number + image: string + description: string + badge?: string + featured: boolean + colors: string[] +} + +type ProductFormProps = { + product?: Product | null + onCancel: () => void + onSuccess: () => void +} + +export default function ProductForm({ product, onCancel, onSuccess }: ProductFormProps) { + const [name, setName] = useState(product?.name || '') + const [category, setCategory] = useState(product?.category || '') + const [price, setPrice] = useState(product?.price.toString() || '') + const [image, setImage] = useState(product?.image || '') + const [description, setDescription] = useState(product?.description || '') + const [badge, setBadge] = useState(product?.badge || '') + const [featured, setFeatured] = useState(product?.featured || false) + const [colors, setColors] = useState(product?.colors.join(', ') || '') + + const [createProduct, { loading: creating }] = useMutation(CREATE_PRODUCT) + const [updateProduct, { loading: updating }] = useMutation(UPDATE_PRODUCT) + + const loading = creating || updating + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + + const productInput = { + name, + category, + price: parseFloat(price), + image, + description, + badge: badge || undefined, + featured, + colors: colors + .split(',') + .map((c) => c.trim()) + .filter(Boolean), + } + + try { + if (product) { + await updateProduct({ + variables: { + id: product.id, + updateProductInput: productInput, + }, + }) + } else { + await createProduct({ + variables: { + createProductInput: productInput, + }, + }) + } + onSuccess() + } catch { + alert('Failed to save product') + } + } + + return ( +
+

{product ? 'Edit Product' : 'Create Product'}

+ +
+
+ + setName(e.target.value)} + required + className="w-full px-4 py-2 border border-slate-200 rounded-lg" + /> +
+ +
+ + setCategory(e.target.value)} + required + className="w-full px-4 py-2 border border-slate-200 rounded-lg" + /> +
+ +
+ + setPrice(e.target.value)} + required + className="w-full px-4 py-2 border border-slate-200 rounded-lg" + /> +
+ +
+ + setImage(e.target.value)} + required + className="w-full px-4 py-2 border border-slate-200 rounded-lg" + /> +
+ +
+ +