Gallery page, somewhat workable on mobile
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
BIN
webui/public/logo.png
Normal file
BIN
webui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -15,7 +15,7 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
Paper
|
Paper
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { useUser } from "@auth0/nextjs-auth0";
|
import { useUser } from "@auth0/nextjs-auth0";
|
||||||
import {
|
import {
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
@@ -29,43 +29,16 @@ import {
|
|||||||
BugReport
|
BugReport
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import ThemeToggleButton from "@/app/components/theme/ThemeToggleButton";
|
import ThemeToggleButton from "@/app/components/theme/ThemeToggleButton";
|
||||||
import clientApi from "@/lib/clientApi";
|
import useRoles from "@/app/components/hooks/useRoles";
|
||||||
|
|
||||||
export default function ImprinkAppBar() {
|
export default function ImprinkAppBar() {
|
||||||
const { user, error, isLoading } = useUser();
|
const { user, error, isLoading } = useUser();
|
||||||
|
const { isMerchant, isAdmin } = useRoles();
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [userRoles, setUserRoles] = useState([]);
|
|
||||||
const [rolesLoading, setRolesLoading] = useState(false);
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { isDarkMode, toggleTheme } = useTheme();
|
const { isDarkMode, toggleTheme } = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchUserRoles = async () => {
|
|
||||||
if (!user) {
|
|
||||||
setUserRoles([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRolesLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await clientApi.get('/users/me/roles');
|
|
||||||
const roles = response.data.map(role => role.roleName.toLowerCase());
|
|
||||||
setUserRoles(roles);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user roles:', error);
|
|
||||||
setUserRoles([]);
|
|
||||||
} finally {
|
|
||||||
setRolesLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUserRoles();
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const isMerchant = userRoles.includes('merchant') || userRoles.includes('admin');
|
|
||||||
const isAdmin = userRoles.includes('admin');
|
|
||||||
|
|
||||||
const handleMenuOpen = (event) => {
|
const handleMenuOpen = (event) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
};
|
};
|
||||||
@@ -74,7 +47,6 @@ export default function ImprinkAppBar() {
|
|||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Regular navigation links (excluding admin-specific ones)
|
|
||||||
const navigationLinks = [
|
const navigationLinks = [
|
||||||
{ label: 'Home', href: '/', icon: <Home />, show: true },
|
{ label: 'Home', href: '/', icon: <Home />, show: true },
|
||||||
{ label: 'Gallery', href: '/gallery', icon: <PhotoLibrary />, show: true },
|
{ label: 'Gallery', href: '/gallery', icon: <PhotoLibrary />, show: true },
|
||||||
@@ -82,7 +54,6 @@ export default function ImprinkAppBar() {
|
|||||||
{ label: 'Merchant', href: '/merchant', icon: <Store />, show: isMerchant },
|
{ label: 'Merchant', href: '/merchant', icon: <Store />, show: isMerchant },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Admin-specific links for the separate bar
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
{ label: 'Dashboard', href: '/dashboard', icon: <Dashboard />, show: isMerchant },
|
{ label: 'Dashboard', href: '/dashboard', icon: <Dashboard />, show: isMerchant },
|
||||||
{ label: 'Admin', href: '/admin', icon: <AdminPanelSettings />, show: isAdmin },
|
{ label: 'Admin', href: '/admin', icon: <AdminPanelSettings />, show: isAdmin },
|
||||||
@@ -206,7 +177,6 @@ export default function ImprinkAppBar() {
|
|||||||
>
|
>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* All navigation links - regular and admin mixed together */}
|
|
||||||
{[...visibleLinks, ...visibleAdminLinks].map((link) => (
|
{[...visibleLinks, ...visibleAdminLinks].map((link) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={link.label}
|
key={link.label}
|
||||||
@@ -376,7 +346,6 @@ export default function ImprinkAppBar() {
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
{/* Admin Bar - appears below main app bar */}
|
|
||||||
{renderAdminBar()}
|
{renderAdminBar()}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
68
webui/src/app/components/hooks/useRoles.js
Normal file
68
webui/src/app/components/hooks/useRoles.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useUser } from '@auth0/nextjs-auth0';
|
||||||
|
import clientApi from '@/lib/clientApi';
|
||||||
|
|
||||||
|
export const useRoles = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
const [roles, setRoles] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserRoles = async () => {
|
||||||
|
if (!user) {
|
||||||
|
setRoles([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await clientApi.get('/users/me/roles');
|
||||||
|
const userRoles = response.data.map(role => role.roleName.toLowerCase());
|
||||||
|
setRoles(userRoles);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user roles:', err);
|
||||||
|
setError(err);
|
||||||
|
setRoles([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserRoles().then(r => console.log(r));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const hasRole = (roleName) => {
|
||||||
|
return roles.includes(roleName.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyRole = (roleNames) => {
|
||||||
|
return roleNames.some(roleName => hasRole(roleName));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAllRoles = (roleNames) => {
|
||||||
|
return roleNames.every(roleName => hasRole(roleName));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common role checks based on your existing logic
|
||||||
|
const isMerchant = hasAnyRole(['merchant', 'admin']);
|
||||||
|
const isAdmin = hasRole('admin');
|
||||||
|
const isCustomer = hasRole('customer');
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
hasRole,
|
||||||
|
hasAnyRole,
|
||||||
|
hasAllRoles,
|
||||||
|
isMerchant,
|
||||||
|
isAdmin,
|
||||||
|
isCustomer
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useRoles;
|
||||||
661
webui/src/app/gallery/page.js
Normal file
661
webui/src/app/gallery/page.js
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Grid,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Pagination,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
InputLabel,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
Paper,
|
||||||
|
Slider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
ExpandLess,
|
||||||
|
ExpandMore,
|
||||||
|
Close as CloseIcon,
|
||||||
|
Tune as TuneIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import clientApi from "@/lib/clientApi";
|
||||||
|
import useRoles from "@/app/components/hooks/useRoles";
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'Name-ASC', label: 'Name (A-Z)' },
|
||||||
|
{ value: 'Name-DESC', label: 'Name (Z-A)' },
|
||||||
|
{ value: 'Price-ASC', label: 'Price (Low to High)' },
|
||||||
|
{ value: 'Price-DESC', label: 'Price (High to Low)' },
|
||||||
|
{ value: 'CreatedDate-DESC', label: 'Newest First' },
|
||||||
|
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [12, 24, 48, 96];
|
||||||
|
|
||||||
|
export default function GalleryPage() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const { isAdmin } = useRoles();
|
||||||
|
|
||||||
|
const heightOffset = isAdmin ? 192 : 128;
|
||||||
|
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [categoriesLoading, setCategoriesLoading] = useState(true);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState(new Set());
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 24,
|
||||||
|
searchTerm: '',
|
||||||
|
categoryId: '',
|
||||||
|
minPrice: 0,
|
||||||
|
maxPrice: 1000,
|
||||||
|
isActive: true,
|
||||||
|
isCustomizable: null,
|
||||||
|
sortBy: 'Name',
|
||||||
|
sortDirection: 'ASC'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
|
const [priceRange, setPriceRange] = useState([0, 1000]);
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await clientApi.get('/products/categories');
|
||||||
|
setCategories(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching categories:', err);
|
||||||
|
} finally {
|
||||||
|
setCategoriesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProducts = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
PageNumber: filters.pageNumber,
|
||||||
|
PageSize: filters.pageSize,
|
||||||
|
IsActive: filters.isActive,
|
||||||
|
SortBy: filters.sortBy,
|
||||||
|
SortDirection: filters.sortDirection
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.searchTerm) params.SearchTerm = filters.searchTerm;
|
||||||
|
if (filters.categoryId) params.CategoryId = filters.categoryId;
|
||||||
|
if (filters.minPrice > 0) params.MinPrice = filters.minPrice;
|
||||||
|
if (filters.maxPrice < 1000) params.MaxPrice = filters.maxPrice;
|
||||||
|
if (filters.isCustomizable !== null) params.IsCustomizable = filters.isCustomizable;
|
||||||
|
|
||||||
|
const response = await clientApi.get('/products/', { params });
|
||||||
|
|
||||||
|
setProducts(response.data.items);
|
||||||
|
setTotalPages(response.data.totalPages);
|
||||||
|
setTotalCount(response.data.totalCount);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load products');
|
||||||
|
console.error('Error fetching products:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts();
|
||||||
|
}, [fetchProducts]);
|
||||||
|
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
pageNumber: key !== 'pageNumber' ? 1 : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
handleFilterChange('searchTerm', searchInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriceRangeChange = (event, newValue) => {
|
||||||
|
setPriceRange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriceRangeCommitted = (event, newValue) => {
|
||||||
|
handleFilterChange('minPrice', newValue[0]);
|
||||||
|
handleFilterChange('maxPrice', newValue[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (value) => {
|
||||||
|
const [sortBy, sortDirection] = value.split('-');
|
||||||
|
handleFilterChange('sortBy', sortBy);
|
||||||
|
handleFilterChange('sortDirection', sortDirection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategoryExpansion = (categoryId) => {
|
||||||
|
const newExpanded = new Set(expandedCategories);
|
||||||
|
if (newExpanded.has(categoryId)) {
|
||||||
|
newExpanded.delete(categoryId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(categoryId);
|
||||||
|
}
|
||||||
|
setExpandedCategories(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChildCategories = (parentId) => {
|
||||||
|
return categories.filter(cat => cat.parentCategoryId === parentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParentCategories = () => {
|
||||||
|
return categories.filter(cat => !cat.parentCategoryId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CategorySidebar = () => (
|
||||||
|
<Box sx={{
|
||||||
|
width: 300,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
mb: 2,
|
||||||
|
p: 2,
|
||||||
|
pb: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}>
|
||||||
|
Categories
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
px: 2,
|
||||||
|
minHeight: 0
|
||||||
|
}}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={!filters.categoryId}
|
||||||
|
onClick={() => handleFilterChange('categoryId', '')}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText primary="All Products" />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{getParentCategories().map((category) => {
|
||||||
|
const childCategories = getChildCategories(category.id);
|
||||||
|
const hasChildren = childCategories.length > 0;
|
||||||
|
const isExpanded = expandedCategories.has(category.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={category.id}>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={filters.categoryId === category.id}
|
||||||
|
onClick={() => handleFilterChange('categoryId', category.id)}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText primary={category.name} />
|
||||||
|
{hasChildren && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCategoryExpansion(category.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
|
<List component="div" disablePadding>
|
||||||
|
{childCategories.map((childCategory) => (
|
||||||
|
<ListItem key={childCategory.id} disablePadding sx={{ pl: 3 }}>
|
||||||
|
<ListItemButton
|
||||||
|
selected={filters.categoryId === childCategory.id}
|
||||||
|
onClick={() => handleFilterChange('categoryId', childCategory.id)}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={childCategory.name}
|
||||||
|
sx={{ '& .MuiListItemText-primary': { fontSize: '0.9rem' } }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: 'background.paper'
|
||||||
|
}}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Price Range
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ px: 1, mb: 2 }}>
|
||||||
|
<Slider
|
||||||
|
value={priceRange}
|
||||||
|
onChange={handlePriceRangeChange}
|
||||||
|
onChangeCommitted={handlePriceRangeCommitted}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
step={5}
|
||||||
|
valueLabelFormat={(value) => `$${value}`}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||||
|
<Typography variant="caption">${priceRange[0]}</Typography>
|
||||||
|
<Typography variant="caption">${priceRange[1]}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={filters.isCustomizable === true}
|
||||||
|
onChange={(e) => handleFilterChange('isCustomizable', e.target.checked ? true : null)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Customizable Only"
|
||||||
|
sx={{ mb: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: { xs: 1, sm: 2, md: 4 } }}>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: { xs: 0, md: 3 },
|
||||||
|
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
|
||||||
|
}}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={1}
|
||||||
|
sx={{
|
||||||
|
height: `calc(100vh - ${heightOffset}px)`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoriesLoading ? (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress size={40} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<CategorySidebar />
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: { xs: 'none', md: `calc(100vh - ${heightOffset}px)` }
|
||||||
|
}}>
|
||||||
|
<Box sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: { xs: 'visible', md: 'auto' },
|
||||||
|
minHeight: 0,
|
||||||
|
pr: { xs: 0, md: 1 }
|
||||||
|
}}>
|
||||||
|
<Box sx={{ mb: { xs: 2, sm: 2, md: 3 } }}>
|
||||||
|
<Typography variant="h3" gutterBottom sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: { xs: '2rem', sm: '2rem', md: '2rem' }
|
||||||
|
}}>
|
||||||
|
Product Gallery
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="text.secondary" sx={{
|
||||||
|
fontSize: { xs: '1rem', sm: '1rem' }
|
||||||
|
}}>
|
||||||
|
Explore our complete collection of customizable products
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper elevation={1} sx={{
|
||||||
|
p: { xs: 1, sm: 1, md: 1.5 },
|
||||||
|
mb: { xs: 1, sm: 2 }
|
||||||
|
}}>
|
||||||
|
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
||||||
|
{isMobile && (
|
||||||
|
<Grid item>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setMobileDrawerOpen(true)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<TuneIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search fontSize={isMobile ? "small" : "medium"} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: searchInput && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleSearch}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
<Search fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3} md={3}>
|
||||||
|
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||||
|
<InputLabel>Sort By</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||||
|
label="Sort By"
|
||||||
|
onChange={(e) => handleSortChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3} md={2}>
|
||||||
|
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||||
|
<InputLabel>Per Page</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filters.pageSize}
|
||||||
|
label="Per Page"
|
||||||
|
onChange={(e) => handleFilterChange('pageSize', e.target.value)}
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<MenuItem key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={3} sx={{ textAlign: { xs: 'center', md: 'right' } }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||||
|
}}>
|
||||||
|
Showing {products.length} of {totalCount} products
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 4, md: 8 } }}>
|
||||||
|
<CircularProgress size={isMobile ? 40 : 60} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: { xs: 2, md: 4 } }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && products.length === 0 && (
|
||||||
|
<Paper sx={{ p: { xs: 3, md: 6 }, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No products found
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Try adjusting your search criteria or filters
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && products.length > 0 && (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: { xs: 1, sm: 1.5, md: 2 },
|
||||||
|
mb: { xs: 2, md: 4 }
|
||||||
|
}}>
|
||||||
|
{products.map((product) => (
|
||||||
|
<Card
|
||||||
|
key={product.id}
|
||||||
|
sx={{
|
||||||
|
width: {
|
||||||
|
xs: 'calc(50% - 4px)',
|
||||||
|
sm: 'calc(50% - 12px)',
|
||||||
|
lg: 'calc(33.333% - 16px)'
|
||||||
|
},
|
||||||
|
maxWidth: { xs: 'none', sm: 350, lg: 370 },
|
||||||
|
height: { xs: 300, sm: 380, lg: 420 },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
transform: { xs: 'none', sm: 'translateY(-4px)', md: 'translateY(-8px)' },
|
||||||
|
boxShadow: { xs: 2, sm: 4, md: 6 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
image={product.imageUrl || '/placeholder-product.jpg'}
|
||||||
|
alt={product.name}
|
||||||
|
sx={{
|
||||||
|
objectFit: 'cover',
|
||||||
|
height: { xs: 120, sm: 160, lg: 180 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardContent sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
p: { xs: 1, sm: 1.5, lg: 2 },
|
||||||
|
'&:last-child': { pb: { xs: 1, sm: 1.5, lg: 2 } }
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: { xs: '0.9rem', sm: '1rem', md: '1.1rem' },
|
||||||
|
mb: { xs: 0.5, sm: 1 }
|
||||||
|
}}>
|
||||||
|
{product.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{product.category && (
|
||||||
|
<Chip
|
||||||
|
label={product.category.name}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
mb: { xs: 0.5, sm: 1 },
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||||
|
height: { xs: 20, sm: 24 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
mb: { xs: 1, sm: 1.5 },
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: { xs: 2, sm: 2, md: 3 },
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
minHeight: { xs: 28, sm: 32, md: 48 },
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.8rem', md: '0.875rem' }
|
||||||
|
}}>
|
||||||
|
{product.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: { xs: 1, sm: 1.5 }
|
||||||
|
}}>
|
||||||
|
<Typography variant="subtitle1" color="primary" sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: { xs: '0.85rem', sm: '0.95rem', md: '1rem' }
|
||||||
|
}}>
|
||||||
|
From ${product.basePrice?.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
{product.isCustomizable && (
|
||||||
|
<Chip
|
||||||
|
label="Custom"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||||
|
height: { xs: 20, sm: 24 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.875rem', md: '1rem' },
|
||||||
|
py: { xs: 0.5, sm: 1, md: 1.5 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Customize
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && totalPages > 1 && (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pb: { xs: 2, md: 4 }
|
||||||
|
}}>
|
||||||
|
<Pagination
|
||||||
|
count={totalPages}
|
||||||
|
page={filters.pageNumber}
|
||||||
|
onChange={(e, page) => handleFilterChange('pageNumber', page)}
|
||||||
|
color="primary"
|
||||||
|
size={isMobile ? 'small' : 'large'}
|
||||||
|
showFirstButton={!isMobile}
|
||||||
|
showLastButton={!isMobile}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
anchor="left"
|
||||||
|
open={mobileDrawerOpen}
|
||||||
|
onClose={() => setMobileDrawerOpen(false)}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: 280,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||||
|
Filters
|
||||||
|
</Typography>
|
||||||
|
<IconButton onClick={() => setMobileDrawerOpen(false)}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{categoriesLoading ? (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress size={40} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<CategorySidebar />
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -157,6 +157,7 @@ export default function HomePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
|
href="/gallery"
|
||||||
sx={{ px: 5, py: 2, fontSize: '1.2rem' }}
|
sx={{ px: 5, py: 2, fontSize: '1.2rem' }}
|
||||||
>
|
>
|
||||||
View Gallery
|
View Gallery
|
||||||
|
|||||||
Reference in New Issue
Block a user