Fix builder UX
This commit is contained in:
1297
ui/package-lock.json
generated
1297
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,17 @@
|
|||||||
"@mui/material": "^7.1.2",
|
"@mui/material": "^7.1.2",
|
||||||
"@mui/material-nextjs": "^7.1.1",
|
"@mui/material-nextjs": "^7.1.1",
|
||||||
"@mui/system": "^7.1.1",
|
"@mui/system": "^7.1.1",
|
||||||
|
"@stripe/react-stripe-js": "^3.7.0",
|
||||||
|
"@stripe/stripe-js": "^7.4.0",
|
||||||
|
"@types/fabric": "^5.3.10",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"fabric": "^6.7.0",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
|
"konva": "^9.3.20",
|
||||||
"next": "15.3.4",
|
"next": "15.3.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-konva": "^19.0.6",
|
||||||
"yup": "^1.6.1"
|
"yup": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
179
ui/src/app/components/gallery/CategorySidebar.tsx
Normal file
179
ui/src/app/components/gallery/CategorySidebar.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Slider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||||
|
import { ChangeEvent } from 'react';
|
||||||
|
import { GalleryCategory, Filters } from '@/types';
|
||||||
|
|
||||||
|
interface CategorySidebarProps {
|
||||||
|
categories: GalleryCategory[];
|
||||||
|
filters: Filters;
|
||||||
|
expandedCategories: Set<string>;
|
||||||
|
priceRange: number[];
|
||||||
|
onFilterChange: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
|
onToggleCategoryExpansion: (categoryId: string) => void;
|
||||||
|
onPriceRangeChange: (event: Event, newValue: number | number[]) => void;
|
||||||
|
onPriceRangeCommitted: (event: Event | React.SyntheticEvent, newValue: number | number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategorySidebar({
|
||||||
|
categories,
|
||||||
|
filters,
|
||||||
|
expandedCategories,
|
||||||
|
priceRange,
|
||||||
|
onFilterChange,
|
||||||
|
onToggleCategoryExpansion,
|
||||||
|
onPriceRangeChange,
|
||||||
|
onPriceRangeCommitted
|
||||||
|
}: CategorySidebarProps) {
|
||||||
|
const getChildCategories = (parentId: string): GalleryCategory[] => {
|
||||||
|
return categories.filter(cat => cat.parentCategoryId === parentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParentCategories = (): GalleryCategory[] => {
|
||||||
|
return categories.filter(cat => !cat.parentCategoryId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={() => onFilterChange('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={() => onFilterChange('categoryId', category.id)}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText primary={category.name} />
|
||||||
|
{hasChildren && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleCategoryExpansion(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={() => onFilterChange('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={onPriceRangeChange}
|
||||||
|
onChangeCommitted={onPriceRangeCommitted}
|
||||||
|
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: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onFilterChange('isCustomizable', e.target.checked ? true : null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Customizable Only"
|
||||||
|
sx={{ mb: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
ui/src/app/components/gallery/MobileFilterDrawer.tsx
Normal file
79
ui/src/app/components/gallery/MobileFilterDrawer.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
|
import CategorySidebar from './CategorySidebar';
|
||||||
|
import { GalleryCategory, Filters } from '@/types';
|
||||||
|
|
||||||
|
interface MobileFilterDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
categories: GalleryCategory[];
|
||||||
|
categoriesLoading: boolean;
|
||||||
|
filters: Filters;
|
||||||
|
expandedCategories: Set<string>;
|
||||||
|
priceRange: number[];
|
||||||
|
onClose: () => void;
|
||||||
|
onFilterChange: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
|
onToggleCategoryExpansion: (categoryId: string) => void;
|
||||||
|
onPriceRangeChange: (event: Event, newValue: number | number[]) => void;
|
||||||
|
onPriceRangeCommitted: (event: Event | React.SyntheticEvent, newValue: number | number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileFilterDrawer({
|
||||||
|
open,
|
||||||
|
categories,
|
||||||
|
categoriesLoading,
|
||||||
|
filters,
|
||||||
|
expandedCategories,
|
||||||
|
priceRange,
|
||||||
|
onClose,
|
||||||
|
onFilterChange,
|
||||||
|
onToggleCategoryExpansion,
|
||||||
|
onPriceRangeChange,
|
||||||
|
onPriceRangeCommitted
|
||||||
|
}: MobileFilterDrawerProps) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="left"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
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={onClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{categoriesLoading ? (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress size={40} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<CategorySidebar
|
||||||
|
categories={categories}
|
||||||
|
filters={filters}
|
||||||
|
expandedCategories={expandedCategories}
|
||||||
|
priceRange={priceRange}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onToggleCategoryExpansion={onToggleCategoryExpansion}
|
||||||
|
onPriceRangeChange={onPriceRangeChange}
|
||||||
|
onPriceRangeCommitted={onPriceRangeCommitted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
ui/src/app/components/gallery/ProductCard.tsx
Normal file
219
ui/src/app/components/gallery/ProductCard.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Box,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useUser } from '@auth0/nextjs-auth0';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { GalleryProduct } from '@/types';
|
||||||
|
|
||||||
|
interface ProductCardProps {
|
||||||
|
product: GalleryProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductCard({ product }: ProductCardProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const { user, isLoading } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleViewProduct = () => {
|
||||||
|
router.push(`/products/${product.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginRedirect = () => {
|
||||||
|
router.push('/auth/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuild = () => {
|
||||||
|
router.push(`/builder/${product.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
width: {
|
||||||
|
xs: 'calc(50% - 6px)',
|
||||||
|
sm: 'calc(50% - 8px)',
|
||||||
|
lg: 'calc(33.333% - 12px)'
|
||||||
|
},
|
||||||
|
maxWidth: { xs: 'none', sm: 280, lg: 320 },
|
||||||
|
height: { xs: 240, sm: 300, lg: 340 },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
transform: { xs: 'none', sm: 'translateY(-2px)', md: 'translateY(-4px)' },
|
||||||
|
boxShadow: { xs: 1, sm: 3, md: 4 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
image={product.imageUrl || '/placeholder-product.jpg'}
|
||||||
|
alt={product.name}
|
||||||
|
sx={{
|
||||||
|
objectFit: 'cover',
|
||||||
|
height: { xs: 80, sm: 120, lg: 140 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardContent sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
p: { xs: 0.75, sm: 1, lg: 1.25 },
|
||||||
|
'&:last-child': { pb: { xs: 0.75, sm: 1, lg: 1.25 } }
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.85rem', md: '0.9rem' },
|
||||||
|
mb: { xs: 0.25, sm: 0.5 },
|
||||||
|
lineHeight: 1.2
|
||||||
|
}}>
|
||||||
|
{product.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{product.category && (
|
||||||
|
<Chip
|
||||||
|
label={product.category.name}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
mb: { xs: 0.25, sm: 0.5 },
|
||||||
|
fontSize: { xs: '0.55rem', sm: '0.65rem' },
|
||||||
|
height: { xs: 16, sm: 20 },
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
px: { xs: 0.5, sm: 0.75 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
mb: { xs: 0.5, sm: 0.75 },
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: { xs: 1, sm: 2, md: 2 },
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
minHeight: { xs: 16, sm: 24, md: 32 },
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.7rem', md: '0.75rem' },
|
||||||
|
lineHeight: 1.3
|
||||||
|
}}>
|
||||||
|
{product.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: { xs: 0.5, sm: 0.75 }
|
||||||
|
}}>
|
||||||
|
<Typography variant="subtitle1" color="primary" sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '0.7rem', sm: '0.8rem', md: '0.85rem' }
|
||||||
|
}}>
|
||||||
|
From ${product.basePrice?.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
{product.isCustomizable && (
|
||||||
|
<Chip
|
||||||
|
label="Custom"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.55rem', sm: '0.65rem' },
|
||||||
|
height: { xs: 16, sm: 20 },
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
px: { xs: 0.5, sm: 0.75 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: { xs: 0.5, sm: 0.75 },
|
||||||
|
flexDirection: { xs: 'column', sm: user ? 'row' : 'column' }
|
||||||
|
}}>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleViewProduct}
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||||
|
py: { xs: 0.25, sm: 0.5 },
|
||||||
|
flex: 1,
|
||||||
|
minHeight: { xs: 24, sm: 32 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleBuild}
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||||
|
py: { xs: 0.25, sm: 0.5 },
|
||||||
|
flex: 1,
|
||||||
|
minHeight: { xs: 24, sm: 32 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Build
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleViewProduct}
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||||
|
py: { xs: 0.25, sm: 0.5 },
|
||||||
|
minHeight: { xs: 24, sm: 32 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Product
|
||||||
|
</Button>
|
||||||
|
<Typography
|
||||||
|
component="button"
|
||||||
|
onClick={handleLoginRedirect}
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '0.6rem', sm: '0.65rem' },
|
||||||
|
color: 'primary.main',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
p: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'primary.dark'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login to customize
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
ui/src/app/components/gallery/SearchFilters.tsx
Normal file
190
ui/src/app/components/gallery/SearchFilters.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
InputLabel,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
SelectChangeEvent,
|
||||||
|
Box
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Tune as TuneIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useState, ChangeEvent, KeyboardEvent } from 'react';
|
||||||
|
import { Filters } from '@/types';
|
||||||
|
import { SORT_OPTIONS, PAGE_SIZE_OPTIONS } from '@/constants';
|
||||||
|
|
||||||
|
interface SearchFiltersProps {
|
||||||
|
filters: Filters;
|
||||||
|
totalCount: number;
|
||||||
|
productsCount: number;
|
||||||
|
onFilterChange: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
|
onOpenMobileDrawer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchFilters({
|
||||||
|
filters,
|
||||||
|
totalCount,
|
||||||
|
productsCount,
|
||||||
|
onFilterChange,
|
||||||
|
onOpenMobileDrawer
|
||||||
|
}: SearchFiltersProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const [searchInput, setSearchInput] = useState<string>('');
|
||||||
|
|
||||||
|
const handleSearch = (): void => {
|
||||||
|
onFilterChange('searchTerm', searchInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (value: string): void => {
|
||||||
|
const [sortBy, sortDirection] = value.split('-');
|
||||||
|
onFilterChange('sortBy', sortBy);
|
||||||
|
onFilterChange('sortDirection', sortDirection);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={1} sx={{
|
||||||
|
p: { xs: 0.75, sm: 1, md: 1.25 },
|
||||||
|
mb: { xs: 1, sm: 1.5 }
|
||||||
|
}}>
|
||||||
|
<Grid container spacing={{ xs: 0.75, sm: 1, md: 1.5 }} alignItems="center">
|
||||||
|
{isMobile && (
|
||||||
|
<Grid>
|
||||||
|
<IconButton
|
||||||
|
onClick={onOpenMobileDrawer}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
mr: { xs: 0.5, sm: 1 },
|
||||||
|
p: { xs: 0.5, sm: 0.75 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TuneIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid size={{ xs: isMobile ? 8 : 12, sm: 6, md: 5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
||||||
|
onKeyPress={(e: KeyboardEvent) => e.key === 'Enter' && handleSearch()}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||||
|
py: { xs: 0.75, sm: 1 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: searchInput && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleSearch}
|
||||||
|
edge="end"
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
<Search fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 6, sm: 3, md: 3 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}>
|
||||||
|
Sort
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||||
|
label="Sort"
|
||||||
|
onChange={(e: SelectChangeEvent) => handleSortChange(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||||
|
py: { xs: 0.75, sm: 1 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}
|
||||||
|
>
|
||||||
|
{isSmall ? option.label.split(' ')[0] : option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 6, sm: 3, md: 2 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}>
|
||||||
|
{isSmall ? 'Per' : 'Per Page'}
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filters.pageSize}
|
||||||
|
label={isSmall ? 'Per' : 'Per Page'}
|
||||||
|
onChange={(e: SelectChangeEvent<number>) =>
|
||||||
|
onFilterChange('pageSize', e.target.value as number)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||||
|
py: { xs: 0.75, sm: 1 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<MenuItem
|
||||||
|
key={size}
|
||||||
|
value={size}
|
||||||
|
sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 2 }} sx={{
|
||||||
|
textAlign: { xs: 'center', md: 'right' },
|
||||||
|
mt: { xs: 0.5, md: 0 }
|
||||||
|
}}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||||
|
lineHeight: 1.2
|
||||||
|
}}>
|
||||||
|
{isSmall ? (
|
||||||
|
`${productsCount}/${totalCount}`
|
||||||
|
) : (
|
||||||
|
`Showing ${productsCount} of ${totalCount}`
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
ui/src/app/components/orderbuilder/AddAddressDialog.tsx
Normal file
81
ui/src/app/components/orderbuilder/AddAddressDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
Button, Grid, TextField
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { NewAddress } from '@/types'; // Or inline if needed
|
||||||
|
|
||||||
|
export default function AddAddressDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
newAddress,
|
||||||
|
setNewAddress,
|
||||||
|
}: {
|
||||||
|
open: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
onAdd: () => void,
|
||||||
|
newAddress: NewAddress,
|
||||||
|
setNewAddress: (address: NewAddress) => void,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Add New Address</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'First Name', field: 'firstName' },
|
||||||
|
{ label: 'Last Name', field: 'lastName' },
|
||||||
|
{ label: 'Company (Optional)', field: 'company' },
|
||||||
|
{ label: 'Address Line 1', field: 'addressLine1' },
|
||||||
|
{ label: 'Address Line 2 (Optional)', field: 'addressLine2' },
|
||||||
|
{ label: 'Apartment #', field: 'apartmentNumber' },
|
||||||
|
{ label: 'Building #', field: 'buildingNumber' },
|
||||||
|
{ label: 'Floor', field: 'floor' },
|
||||||
|
{ label: 'City', field: 'city' },
|
||||||
|
{ label: 'State', field: 'state' },
|
||||||
|
{ label: 'Postal Code', field: 'postalCode' },
|
||||||
|
{ label: 'Country', field: 'country' },
|
||||||
|
{ label: 'Phone Number', field: 'phoneNumber' },
|
||||||
|
].map(({ label, field }, index) => (
|
||||||
|
<Grid size={{ xs:12 }} key={index}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
value={(newAddress as any)[field]}
|
||||||
|
onChange={(e) => setNewAddress({ ...newAddress, [field]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
<Grid size={{ xs:12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
label="Delivery Instructions (Optional)"
|
||||||
|
value={newAddress.instructions}
|
||||||
|
onChange={(e) => setNewAddress({ ...newAddress, instructions: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onAdd}
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!newAddress.firstName ||
|
||||||
|
!newAddress.lastName ||
|
||||||
|
!newAddress.addressLine1 ||
|
||||||
|
!newAddress.city
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Address
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
ui/src/app/components/orderbuilder/StepChooseQuantity.tsx
Normal file
124
ui/src/app/components/orderbuilder/StepChooseQuantity.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, IconButton, TextField, Fade, Stack, Divider } from '@mui/material';
|
||||||
|
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
|
||||||
|
import { Variant } from '@/types';
|
||||||
|
|
||||||
|
export default function StepChooseQuantity({
|
||||||
|
selectedVariant,
|
||||||
|
quantity,
|
||||||
|
handleQuantityChange,
|
||||||
|
getTotalPrice,
|
||||||
|
setQuantity,
|
||||||
|
}: {
|
||||||
|
selectedVariant: Variant | null,
|
||||||
|
quantity: number,
|
||||||
|
handleQuantityChange: (delta: number) => void,
|
||||||
|
getTotalPrice: () => number,
|
||||||
|
setQuantity: (val: number) => void,
|
||||||
|
}) {
|
||||||
|
if (!selectedVariant) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||||
|
Choose Quantity
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems="flex-start">
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ flex: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={selectedVariant.imageUrl}
|
||||||
|
alt={selectedVariant.size}
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
{selectedVariant.product.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selectedVariant.size} - {selectedVariant.color}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
|
||||||
|
${selectedVariant.price.toFixed(2)} each
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={2} alignItems="center" sx={{ minWidth: 200 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||||
|
Quantity
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleQuantityChange(-1)}
|
||||||
|
disabled={quantity <= 1}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
'&:hover': { borderColor: 'primary.main' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<TextField
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value) || 1;
|
||||||
|
if (val >= 1) setQuantity(val);
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
style: { textAlign: 'center', fontSize: '1.2rem', fontWeight: 600 },
|
||||||
|
min: 1
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
'& fieldset': {
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
},
|
||||||
|
'&:hover fieldset': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleQuantityChange(1)}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
'&:hover': { borderColor: 'primary.main' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||||
|
Total Price:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" color="primary" sx={{ fontWeight: 700 }}>
|
||||||
|
${getTotalPrice().toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
ui/src/app/components/orderbuilder/StepCustomization.tsx
Normal file
465
ui/src/app/components/orderbuilder/StepCustomization.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
IconButton,
|
||||||
|
Grid,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Fade,
|
||||||
|
Alert,
|
||||||
|
Divider
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CloudUpload as UploadIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
Save as SaveIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface CustomizationImage {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
fabric: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepCustomization({
|
||||||
|
images,
|
||||||
|
setImages,
|
||||||
|
finalImageUrl,
|
||||||
|
setFinalImageUrl,
|
||||||
|
customizationDescription,
|
||||||
|
setCustomizationDescription,
|
||||||
|
loading,
|
||||||
|
setLoading
|
||||||
|
}: {
|
||||||
|
images: CustomizationImage[];
|
||||||
|
setImages: (images: CustomizationImage[]) => void;
|
||||||
|
finalImageUrl: string;
|
||||||
|
setFinalImageUrl: (url: string) => void;
|
||||||
|
customizationDescription: string;
|
||||||
|
setCustomizationDescription: (desc: string) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [fabricLoaded, setFabricLoaded] = useState(false);
|
||||||
|
const [canvasInitialized, setCanvasInitialized] = useState(false);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const fabricCanvasRef = useRef<any>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFabric = async () => {
|
||||||
|
if (typeof window !== 'undefined' && !window.fabric) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js';
|
||||||
|
script.onload = () => {
|
||||||
|
console.log('Fabric.js loaded');
|
||||||
|
setFabricLoaded(true);
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
console.error('Failed to load Fabric.js');
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else if (window.fabric) {
|
||||||
|
console.log('Fabric.js already available');
|
||||||
|
setFabricLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFabric();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (fabricCanvasRef.current) {
|
||||||
|
fabricCanvasRef.current.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize canvas when Fabric is loaded AND canvas ref is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (fabricLoaded && canvasRef.current && !canvasInitialized) {
|
||||||
|
initCanvas();
|
||||||
|
}
|
||||||
|
}, [fabricLoaded, canvasInitialized]);
|
||||||
|
|
||||||
|
const initCanvas = () => {
|
||||||
|
if (canvasRef.current && window.fabric && !fabricCanvasRef.current) {
|
||||||
|
console.log('Initializing canvas');
|
||||||
|
try {
|
||||||
|
fabricCanvasRef.current = new window.fabric.Canvas(canvasRef.current, {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
backgroundColor: 'white'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add some visual feedback that canvas is working
|
||||||
|
fabricCanvasRef.current.renderAll();
|
||||||
|
setCanvasInitialized(true);
|
||||||
|
console.log('Canvas initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize canvas:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadImage = async (file: File): Promise<string> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('https://impr.ink/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = async (files: FileList) => {
|
||||||
|
if (images.length + files.length > 10) {
|
||||||
|
alert('Maximum 10 images allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const newImages: CustomizationImage[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const url = await uploadImage(file);
|
||||||
|
newImages.push({
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
url,
|
||||||
|
file
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImages([...images, ...newImages]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
alert('Upload failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addImageToCanvas = (imageUrl: string) => {
|
||||||
|
console.log('Adding image to canvas:', imageUrl);
|
||||||
|
console.log('Canvas available:', !!fabricCanvasRef.current);
|
||||||
|
console.log('Fabric available:', !!window.fabric);
|
||||||
|
|
||||||
|
if (!fabricCanvasRef.current || !window.fabric) {
|
||||||
|
console.error('Canvas or Fabric not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fabric.Image.fromURL(imageUrl, (img: any) => {
|
||||||
|
if (img) {
|
||||||
|
console.log('Image loaded successfully');
|
||||||
|
|
||||||
|
// Scale the image to fit reasonably on canvas
|
||||||
|
const maxWidth = 200;
|
||||||
|
const maxHeight = 200;
|
||||||
|
const scaleX = maxWidth / img.width;
|
||||||
|
const scaleY = maxHeight / img.height;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
img.set({
|
||||||
|
left: Math.random() * (800 - maxWidth),
|
||||||
|
top: Math.random() * (600 - maxHeight),
|
||||||
|
scaleX: scale,
|
||||||
|
scaleY: scale
|
||||||
|
});
|
||||||
|
|
||||||
|
fabricCanvasRef.current.add(img);
|
||||||
|
fabricCanvasRef.current.setActiveObject(img);
|
||||||
|
fabricCanvasRef.current.renderAll();
|
||||||
|
console.log('Image added to canvas');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load image');
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
crossOrigin: 'anonymous'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (id: string) => {
|
||||||
|
setImages(images.filter(img => img.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCanvas = () => {
|
||||||
|
if (fabricCanvasRef.current) {
|
||||||
|
fabricCanvasRef.current.clear();
|
||||||
|
fabricCanvasRef.current.backgroundColor = 'white';
|
||||||
|
fabricCanvasRef.current.renderAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateFinalImage = async () => {
|
||||||
|
if (!fabricCanvasRef.current) return;
|
||||||
|
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const dataURL = fabricCanvasRef.current.toDataURL({
|
||||||
|
format: 'png',
|
||||||
|
quality: 0.9
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(dataURL);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], 'customization.png', { type: 'image/png' });
|
||||||
|
const url = await uploadImage(file);
|
||||||
|
setFinalImageUrl(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate final image:', error);
|
||||||
|
alert('Failed to generate final image. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFileSelect(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fabricLoaded) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight={400}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography ml={2}>Loading canvas editor...</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>Customize Your Product</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs:12, md:8 }}>
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h6">Design Canvas</Typography>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={clearCanvas}
|
||||||
|
disabled={!canvasInitialized}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={generating ? <CircularProgress size={16} /> : <SaveIcon />}
|
||||||
|
onClick={generateFinalImage}
|
||||||
|
disabled={generating || !canvasInitialized}
|
||||||
|
>
|
||||||
|
{generating ? 'Saving...' : 'Save Design'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '2px solid #ddd',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: canvasInitialized ? 'transparent' : '#f5f5f5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!canvasInitialized && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bgcolor="rgba(0,0,0,0.1)"
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Initializing canvas...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary" mt={1} display="block">
|
||||||
|
Drag, resize, and rotate images on the canvas. Use the controls around selected images to manipulate them.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs:12, md:4 }}>
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Image Library</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadIcon />}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={images.length >= 10 || uploading}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
Add Images ({images.length}/10)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<UploadIcon sx={{ fontSize: 40, color: 'grey.500', mb: 1 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Drop images here or click to browse
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<Box key={image.id} mb={1}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
cursor: canvasInitialized ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: canvasInitialized ? 1 : 0.6,
|
||||||
|
'&:hover': canvasInitialized ? { bgcolor: 'grey.50' } : {}
|
||||||
|
}}
|
||||||
|
onClick={() => canvasInitialized && addImageToCanvas(image.url)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={`Image ${index + 1}`}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" flex={1}>
|
||||||
|
Image {index + 1}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeImage(image.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{canvasInitialized
|
||||||
|
? 'Click on any image to add it to the canvas'
|
||||||
|
: 'Canvas is initializing...'
|
||||||
|
}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{finalImageUrl && (
|
||||||
|
<Paper sx={{ p: 2, mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Saved Design</Typography>
|
||||||
|
<img
|
||||||
|
src={finalImageUrl}
|
||||||
|
alt="Final design"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #ddd'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
Uploading images...
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
handleFileSelect(e.target.files);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
ui/src/app/components/orderbuilder/StepDeliveryAddress.tsx
Normal file
241
ui/src/app/components/orderbuilder/StepDeliveryAddress.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box, Typography, FormControl, RadioGroup, FormControlLabel,
|
||||||
|
Radio, Grid, Card, CardContent, Chip, Fade, Stack, Divider, Alert,
|
||||||
|
Avatar, IconButton, Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
LocationOn as LocationIcon,
|
||||||
|
AddLocation as AddLocationIcon,
|
||||||
|
Person as PersonIcon,
|
||||||
|
Business as BusinessIcon,
|
||||||
|
Phone as PhoneIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { Address } from '@/types';
|
||||||
|
|
||||||
|
export default function StepDeliveryAddress({
|
||||||
|
addresses,
|
||||||
|
selectedAddress,
|
||||||
|
setSelectedAddress,
|
||||||
|
setShowAddressDialog,
|
||||||
|
}: {
|
||||||
|
addresses: Address[],
|
||||||
|
selectedAddress: Address | null,
|
||||||
|
setSelectedAddress: (addr: Address | null) => void,
|
||||||
|
setShowAddressDialog: (open: boolean) => void,
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const formatAddress = (address: Address) => {
|
||||||
|
const parts = [
|
||||||
|
address.addressLine1,
|
||||||
|
address.addressLine2,
|
||||||
|
`${address.city}, ${address.state} ${address.postalCode}`,
|
||||||
|
address.country
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' • ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (firstName: string, lastName: string) => {
|
||||||
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||||
|
Select Delivery Address
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{addresses.length === 0 && (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
No addresses found. Please add a delivery address to continue.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl component="fieldset" sx={{ width: '100%' }}>
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedAddress?.id || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const addr = addresses.find(a => a.id === e.target.value);
|
||||||
|
setSelectedAddress(addr || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{addresses.map((address) => (
|
||||||
|
<Grid key={address.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
height: 180,
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: selectedAddress?.id === address.id ? 'primary.main' : 'grey.200',
|
||||||
|
bgcolor: selectedAddress?.id === address.id ? 'primary.50' : 'background.paper',
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: selectedAddress?.id === address.id ? 'primary.main' : 'primary.light',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: 2
|
||||||
|
},
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedAddress(address)}
|
||||||
|
>
|
||||||
|
<CardContent sx={{
|
||||||
|
p: 2,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'&:last-child': { pb: 2 }
|
||||||
|
}}>
|
||||||
|
<FormControlLabel
|
||||||
|
value={address.id}
|
||||||
|
control={
|
||||||
|
<Radio
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
p: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label=""
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5, pr: 4 }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(address.firstName, address.lastName)}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.firstName} {address.lastName}
|
||||||
|
</Typography>
|
||||||
|
{address.company && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.company}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{address.isDefault && (
|
||||||
|
<Chip
|
||||||
|
label="Default"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
'& .MuiChip-label': { px: 1 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flex: 1 }}>
|
||||||
|
<LocationIcon sx={{ fontSize: 16, color: 'text.secondary', mt: 0.25, flexShrink: 0 }} />
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: 'text.secondary',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAddress(address)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{ mt: 'auto' }}
|
||||||
|
>
|
||||||
|
{address.phoneNumber ? (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||||
|
<PhoneIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{address.phoneNumber}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: 180,
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
bgcolor: 'primary.25',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.50',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: 2
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => setShowAddressDialog(true)}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ textAlign: 'center' }}>
|
||||||
|
<Stack alignItems="center" spacing={1.5}>
|
||||||
|
<AddLocationIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||||
|
<Typography variant="subtitle1" color="primary" sx={{ fontWeight: 600 }}>
|
||||||
|
Add New Address
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
Click to add delivery address
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
ui/src/app/components/orderbuilder/StepPayment.tsx
Normal file
352
ui/src/app/components/orderbuilder/StepPayment.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { lightTheme } from '../theme/lightTheme';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Fade,
|
||||||
|
Stack,
|
||||||
|
Divider,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
useTheme,
|
||||||
|
ThemeProvider,
|
||||||
|
createTheme
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
Payment as PaymentIcon,
|
||||||
|
Receipt as ReceiptIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import {
|
||||||
|
useStripe,
|
||||||
|
useElements,
|
||||||
|
PaymentElement,
|
||||||
|
AddressElement,
|
||||||
|
} from '@stripe/react-stripe-js';
|
||||||
|
import { Variant, Address } from '@/types';
|
||||||
|
|
||||||
|
interface CustomizationImage {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepPayment({
|
||||||
|
orderId,
|
||||||
|
selectedVariant,
|
||||||
|
quantity,
|
||||||
|
getTotalPrice,
|
||||||
|
selectedAddress,
|
||||||
|
customizationImages = [],
|
||||||
|
finalImageUrl = '',
|
||||||
|
onSuccess
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
selectedVariant: Variant | null;
|
||||||
|
quantity: number;
|
||||||
|
getTotalPrice: () => number;
|
||||||
|
selectedAddress: Address | null;
|
||||||
|
customizationImages?: CustomizationImage[];
|
||||||
|
finalImageUrl?: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}) {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const theme = useTheme();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!stripe || !elements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
const { error } = await stripe.confirmPayment({
|
||||||
|
elements,
|
||||||
|
confirmParams: {
|
||||||
|
return_url: `${window.location.origin}/payment-success`,
|
||||||
|
},
|
||||||
|
redirect: 'if_required',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.type === 'card_error' || error.type === 'validation_error') {
|
||||||
|
setMessage(error.message || 'An error occurred');
|
||||||
|
} else {
|
||||||
|
setMessage('An unexpected error occurred.');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
} else {
|
||||||
|
setMessage('Payment successful! 🎉');
|
||||||
|
setIsSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Stripe appearance object with light theme forced for payment sections
|
||||||
|
const stripeAppearance = {
|
||||||
|
theme: 'stripe' as const, // Always use light theme
|
||||||
|
variables: {
|
||||||
|
colorPrimary: '#1976d2', // Use a consistent primary color
|
||||||
|
colorBackground: '#ffffff', // Force white background
|
||||||
|
colorText: '#212121', // Force dark text
|
||||||
|
colorDanger: '#d32f2f', // Red for errors
|
||||||
|
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||||
|
spacingUnit: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'.Input': {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#212121',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '12px',
|
||||||
|
},
|
||||||
|
'.Input:focus': {
|
||||||
|
borderColor: '#1976d2',
|
||||||
|
boxShadow: '0 0 0 1px #1976d2',
|
||||||
|
},
|
||||||
|
'.Label': {
|
||||||
|
color: '#424242',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
'.Tab': {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
color: '#757575',
|
||||||
|
},
|
||||||
|
'.Tab:hover': {
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
'.Tab--selected': {
|
||||||
|
backgroundColor: '#1976d2',
|
||||||
|
color: '#ffffff',
|
||||||
|
borderColor: '#1976d2',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentElementOptions = {
|
||||||
|
layout: 'tabs' as const,
|
||||||
|
appearance: stripeAppearance,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addressElementOptions = {
|
||||||
|
mode: 'billing' as const,
|
||||||
|
// Remove allowedCountries to accept all countries
|
||||||
|
fields: {
|
||||||
|
phone: 'always' as const,
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
phone: {
|
||||||
|
required: 'never' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appearance: stripeAppearance,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box sx={{ p: { xs: 2, md: 3 }, textAlign: 'center' }}>
|
||||||
|
<Stack spacing={4} alignItems="center" sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||||
|
<CheckCircleIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 80,
|
||||||
|
color: 'success.main',
|
||||||
|
filter: 'drop-shadow(0 4px 8px rgba(76, 175, 80, 0.3))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'success.main',
|
||||||
|
fontSize: { xs: '1.75rem', md: '2.125rem' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Payment Successful!
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '1rem', md: '1.25rem' } }}
|
||||||
|
>
|
||||||
|
Thank you for your purchase!
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Card sx={{ width: '100%', bgcolor: 'success.50', border: '1px solid', borderColor: 'success.200' }}>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<ReceiptIcon color="success" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
Order Details
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body1">
|
||||||
|
<strong>Order ID:</strong> {orderId}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
You will receive a confirmation email shortly with your order details and tracking information.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedVariant || !selectedAddress) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||||
|
Complete Your Payment
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Card sx={{ borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
|
Order Summary
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={selectedVariant.imageUrl}
|
||||||
|
alt={selectedVariant.size}
|
||||||
|
sx={{
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
{selectedVariant.product.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selectedVariant.size} - {selectedVariant.color} × {quantity}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
|
||||||
|
${getTotalPrice().toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
Total Amount:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" color="primary" sx={{ fontWeight: 700 }}>
|
||||||
|
${getTotalPrice().toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Billing Address - Always Light Theme */}
|
||||||
|
<ThemeProvider theme={lightTheme}>
|
||||||
|
<Paper sx={{ p: 3, borderRadius: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 3 }}>
|
||||||
|
<PaymentIcon color="primary" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
Billing Information
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<AddressElement options={addressElementOptions} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</ThemeProvider>
|
||||||
|
|
||||||
|
{/* Payment Information - Always Light Theme */}
|
||||||
|
<ThemeProvider theme={lightTheme}>
|
||||||
|
<Paper sx={{ p: 3, borderRadius: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 3 }}>
|
||||||
|
Payment Information
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<PaymentElement options={paymentElementOptions} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</ThemeProvider>
|
||||||
|
|
||||||
|
<ThemeProvider theme={lightTheme}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={!stripe || !elements || isLoading}
|
||||||
|
sx={{
|
||||||
|
minWidth: 200,
|
||||||
|
height: 48,
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
sx={{ color: 'white' }}
|
||||||
|
>
|
||||||
|
<CircularProgress size={20} color="inherit" />
|
||||||
|
<span>Processing...</span>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
`Pay ${getTotalPrice().toFixed(2)}$`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<Alert
|
||||||
|
severity={isSuccess ? "success" : "error"}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
ui/src/app/components/orderbuilder/StepProductDetails.tsx
Normal file
116
ui/src/app/components/orderbuilder/StepProductDetails.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Fade,
|
||||||
|
Grid,
|
||||||
|
Stack
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Product } from '@/types';
|
||||||
|
|
||||||
|
export default function StepProductDetails({ product }: { product: Product | null }) {
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box sx={{ p: { xs: 2, sm: 3, md: 4 } }}>
|
||||||
|
<Grid container spacing={{ xs: 3, md: 4 }} alignItems="flex-start">
|
||||||
|
<Grid size={{ xs: 12, md: 5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: { xs: 250, sm: 300, md: 350 },
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 2,
|
||||||
|
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: 4
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 7 }}>
|
||||||
|
<Stack spacing={{ xs: 2, md: 3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h3"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: 'text.primary',
|
||||||
|
fontSize: { xs: '1.75rem', sm: '2.125rem', md: '2.5rem' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip
|
||||||
|
label={product.category.name}
|
||||||
|
color="primary"
|
||||||
|
variant="filled"
|
||||||
|
size="medium"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: { xs: '0.8rem', md: '0.875rem' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{product.isCustomizable && (
|
||||||
|
<Chip
|
||||||
|
label="Customizable"
|
||||||
|
color="primary"
|
||||||
|
variant="filled"
|
||||||
|
size="medium"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: { xs: '0.8rem', md: '0.875rem' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h2"
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: { xs: '2.5rem', sm: '3rem', md: '3.5rem' },
|
||||||
|
lineHeight: 1.1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${product.basePrice.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontSize: { xs: '1rem', md: '1.125rem' },
|
||||||
|
maxWidth: { md: '90%' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.description}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
ui/src/app/components/orderbuilder/StepReviewOrder.tsx
Normal file
160
ui/src/app/components/orderbuilder/StepReviewOrder.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Grid, Card, CardContent, Fade, Stack, Divider } from '@mui/material';
|
||||||
|
import { Variant, Address } from '@/types';
|
||||||
|
|
||||||
|
interface CustomizationImage {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepReviewOrder({
|
||||||
|
selectedVariant,
|
||||||
|
quantity,
|
||||||
|
getTotalPrice,
|
||||||
|
selectedAddress,
|
||||||
|
customizationImages = [],
|
||||||
|
finalImageUrl = '',
|
||||||
|
customizationDescription = ''
|
||||||
|
}: {
|
||||||
|
selectedVariant: Variant | null,
|
||||||
|
quantity: number,
|
||||||
|
getTotalPrice: () => number,
|
||||||
|
selectedAddress: Address | null,
|
||||||
|
customizationImages?: CustomizationImage[],
|
||||||
|
finalImageUrl?: string,
|
||||||
|
customizationDescription?: string
|
||||||
|
}) {
|
||||||
|
if (!selectedVariant || !selectedAddress) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||||
|
Review Your Order
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 3 }}>
|
||||||
|
Order Summary
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={selectedVariant.imageUrl}
|
||||||
|
alt={selectedVariant.size}
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
{selectedVariant.product.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selectedVariant.size} - {selectedVariant.color}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Quantity: {quantity}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="h5" color="primary" sx={{ fontWeight: 700 }}>
|
||||||
|
${getTotalPrice().toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{customizationImages.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
|
||||||
|
Customization Details
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{customizationImages.length} original image{customizationImages.length > 1 ? 's' : ''} uploaded
|
||||||
|
</Typography>
|
||||||
|
{customizationDescription && (
|
||||||
|
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||||
|
{customizationDescription}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{finalImageUrl && (
|
||||||
|
<Card sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||||
|
<CardContent sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
|
Preview
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={finalImageUrl}
|
||||||
|
alt="Final customization"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card sx={{ borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
|
Delivery Address
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
{selectedAddress.firstName} {selectedAddress.lastName}
|
||||||
|
</Typography>
|
||||||
|
{selectedAddress.company && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selectedAddress.company}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2">
|
||||||
|
{selectedAddress.addressLine1}
|
||||||
|
</Typography>
|
||||||
|
{selectedAddress.addressLine2 && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{selectedAddress.addressLine2}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2">
|
||||||
|
{selectedAddress.city}, {selectedAddress.state} {selectedAddress.postalCode}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{selectedAddress.country}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
ui/src/app/components/orderbuilder/StepSelectVariant.tsx
Normal file
110
ui/src/app/components/orderbuilder/StepSelectVariant.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Grid, Card, Fade, Stack, Chip } from '@mui/material';
|
||||||
|
import { Variant } from '@/types';
|
||||||
|
|
||||||
|
export default function StepSelectVariant({
|
||||||
|
variants,
|
||||||
|
selectedVariant,
|
||||||
|
setSelectedVariant,
|
||||||
|
}: {
|
||||||
|
variants: Variant[],
|
||||||
|
selectedVariant: Variant | null,
|
||||||
|
setSelectedVariant: (variant: Variant) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Fade in>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||||
|
Select Variant
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{variants.map((variant) => (
|
||||||
|
<Grid key={variant.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'transparent',
|
||||||
|
bgcolor: selectedVariant?.id === variant.id ? 'primary.50' : 'background.paper',
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'primary.light',
|
||||||
|
bgcolor: selectedVariant?.id === variant.id ? 'primary.50' : 'primary.25',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: 3
|
||||||
|
},
|
||||||
|
p: 2
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedVariant(variant)}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={variant.imageUrl}
|
||||||
|
alt={`${variant.size} - ${variant.color}`}
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: selectedVariant?.id === variant.id ? 'primary.main' : 'text.primary'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{variant.size} - {variant.color}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
SKU: {variant.sku}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
fontWeight: 700
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${variant.price.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
label={variant.stockQuantity > 0 ? `${variant.stockQuantity} in stock` : 'Out of stock'}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="filled"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
height: 24
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
ui/src/app/components/orderbuilder/StepperHeader.tsx
Normal file
15
ui/src/app/components/orderbuilder/StepperHeader.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Stepper, Step, StepLabel } from '@mui/material';
|
||||||
|
|
||||||
|
export default function StepperHeader({ activeStep, steps }: { activeStep: number, steps: string[] }) {
|
||||||
|
return (
|
||||||
|
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||||
|
{steps.map((label) => (
|
||||||
|
<Step key={label}>
|
||||||
|
<StepLabel>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
FormHelperText,
|
|
||||||
Chip,
|
|
||||||
Avatar,
|
|
||||||
Container,
|
|
||||||
SelectChangeEvent,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useFormik, FormikHelpers } from 'formik';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
|
||||||
import EmailIcon from '@mui/icons-material/Email';
|
|
||||||
import PhoneIcon from '@mui/icons-material/Phone';
|
|
||||||
import WorkIcon from '@mui/icons-material/Work';
|
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
|
||||||
|
|
||||||
interface FormValues {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
age: string | number;
|
|
||||||
department: string;
|
|
||||||
skills: string[];
|
|
||||||
bio: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationSchema = yup.object<FormValues>({
|
|
||||||
firstName: yup
|
|
||||||
.string()
|
|
||||||
.min(2, 'First name should be at least 2 characters')
|
|
||||||
.required('First name is required'),
|
|
||||||
lastName: yup
|
|
||||||
.string()
|
|
||||||
.min(2, 'Last name should be at least 2 characters')
|
|
||||||
.required('Last name is required'),
|
|
||||||
email: yup
|
|
||||||
.string()
|
|
||||||
.email('Enter a valid email')
|
|
||||||
.required('Email is required'),
|
|
||||||
phone: yup
|
|
||||||
.string()
|
|
||||||
.matches(/^[\+]?[1-9][\d]{0,15}$/, 'Enter a valid phone number')
|
|
||||||
.required('Phone number is required'),
|
|
||||||
age: yup
|
|
||||||
.number()
|
|
||||||
.min(18, 'Must be at least 18 years old')
|
|
||||||
.max(120, 'Must be less than 120 years old')
|
|
||||||
.required('Age is required'),
|
|
||||||
department: yup
|
|
||||||
.string()
|
|
||||||
.required('Department is required'),
|
|
||||||
skills: yup
|
|
||||||
.array()
|
|
||||||
.of(yup.string())
|
|
||||||
.min(1, 'Select at least one skill')
|
|
||||||
.required('Skills are required'),
|
|
||||||
bio: yup
|
|
||||||
.string()
|
|
||||||
.min(10, 'Bio should be at least 10 characters')
|
|
||||||
.max(500, 'Bio should not exceed 500 characters')
|
|
||||||
.required('Bio is required'),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const departments: string[] = [
|
|
||||||
'Engineering',
|
|
||||||
'Marketing',
|
|
||||||
'Sales',
|
|
||||||
'HR',
|
|
||||||
'Finance',
|
|
||||||
'Operations',
|
|
||||||
'Design',
|
|
||||||
];
|
|
||||||
|
|
||||||
const skillOptions: string[] = [
|
|
||||||
'JavaScript',
|
|
||||||
'React',
|
|
||||||
'Node.js',
|
|
||||||
'Python',
|
|
||||||
'UI/UX Design',
|
|
||||||
'Project Management',
|
|
||||||
'Data Analysis',
|
|
||||||
'Marketing',
|
|
||||||
'Sales',
|
|
||||||
'Communication',
|
|
||||||
];
|
|
||||||
|
|
||||||
const FormPage: React.FC = () => {
|
|
||||||
const formik = useFormik<FormValues>({
|
|
||||||
initialValues: {
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
age: '',
|
|
||||||
department: '',
|
|
||||||
skills: [],
|
|
||||||
bio: '',
|
|
||||||
},
|
|
||||||
validationSchema: validationSchema,
|
|
||||||
onSubmit: (values: FormValues, { setSubmitting, resetForm }: FormikHelpers<FormValues>) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('Form submitted:', values);
|
|
||||||
alert('Form submitted successfully!');
|
|
||||||
setSubmitting(false);
|
|
||||||
resetForm();
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSkillChange = (event: SelectChangeEvent<string[]>): void => {
|
|
||||||
const value = event.target.value;
|
|
||||||
formik.setFieldValue('skills', typeof value === 'string' ? value.split(',') : value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%)'
|
|
||||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
py: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container maxWidth="sm">
|
|
||||||
<Card
|
|
||||||
elevation={8}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'rgba(26, 26, 46, 0.95)'
|
|
||||||
: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
border: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? '1px solid rgba(99, 102, 241, 0.2)'
|
|
||||||
: '1px solid rgba(255, 255, 255, 0.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardContent sx={{ p: 4 }}>
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
flexDirection="column"
|
|
||||||
alignItems="center"
|
|
||||||
mb={4}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PersonIcon fontSize="large" />
|
|
||||||
</Avatar>
|
|
||||||
<Typography
|
|
||||||
variant="h4"
|
|
||||||
component="h1"
|
|
||||||
fontWeight="bold"
|
|
||||||
color="primary"
|
|
||||||
textAlign="center"
|
|
||||||
>
|
|
||||||
User Registration
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="subtitle1" color="text.secondary" textAlign="center">
|
|
||||||
Please fill out all required fields
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<form onSubmit={formik.handleSubmit}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<Typography variant="h6" color="primary" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<PersonIcon /> Personal Information
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid size={{ xs: 6 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="firstName"
|
|
||||||
name="firstName"
|
|
||||||
label="First Name"
|
|
||||||
value={formik.values.firstName}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.firstName && Boolean(formik.errors.firstName)}
|
|
||||||
helperText={formik.touched.firstName && formik.errors.firstName}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="lastName"
|
|
||||||
name="lastName"
|
|
||||||
label="Last Name"
|
|
||||||
value={formik.values.lastName}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.lastName && Boolean(formik.errors.lastName)}
|
|
||||||
helperText={formik.touched.lastName && formik.errors.lastName}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
label="Email Address"
|
|
||||||
type="email"
|
|
||||||
value={formik.values.email}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.email && Boolean(formik.errors.email)}
|
|
||||||
helperText={formik.touched.email && formik.errors.email}
|
|
||||||
variant="outlined"
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <EmailIcon sx={{ color: 'action.active', mr: 1 }} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
label="Phone Number"
|
|
||||||
value={formik.values.phone}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.phone && Boolean(formik.errors.phone)}
|
|
||||||
helperText={formik.touched.phone && formik.errors.phone}
|
|
||||||
variant="outlined"
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <PhoneIcon sx={{ color: 'action.active', mr: 1 }} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="age"
|
|
||||||
name="age"
|
|
||||||
label="Age"
|
|
||||||
type="number"
|
|
||||||
value={formik.values.age}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.age && Boolean(formik.errors.age)}
|
|
||||||
helperText={formik.touched.age && formik.errors.age}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="h6" color="primary" sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
|
||||||
<WorkIcon /> Professional Information
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<FormControl
|
|
||||||
fullWidth
|
|
||||||
error={formik.touched.department && Boolean(formik.errors.department)}
|
|
||||||
>
|
|
||||||
<InputLabel id="department-label">Department</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="department-label"
|
|
||||||
id="department"
|
|
||||||
name="department"
|
|
||||||
value={formik.values.department}
|
|
||||||
label="Department"
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
>
|
|
||||||
{departments.map((dept: string) => (
|
|
||||||
<MenuItem key={dept} value={dept}>
|
|
||||||
{dept}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{formik.touched.department && formik.errors.department && (
|
|
||||||
<FormHelperText>{formik.errors.department}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl
|
|
||||||
fullWidth
|
|
||||||
error={formik.touched.skills && Boolean(formik.errors.skills)}
|
|
||||||
>
|
|
||||||
<InputLabel id="skills-label">Skills</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="skills-label"
|
|
||||||
id="skills"
|
|
||||||
multiple
|
|
||||||
value={formik.values.skills}
|
|
||||||
onChange={handleSkillChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
label="Skills"
|
|
||||||
renderValue={(selected: string[]) => (
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{selected.map((value: string) => (
|
|
||||||
<Chip key={value} label={value} size="small" />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{skillOptions.map((skill: string) => (
|
|
||||||
<MenuItem key={skill} value={skill}>
|
|
||||||
{skill}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{formik.touched.skills && formik.errors.skills && (
|
|
||||||
<FormHelperText>{formik.errors.skills}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="bio"
|
|
||||||
name="bio"
|
|
||||||
label="Bio"
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
value={formik.values.bio}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.bio && Boolean(formik.errors.bio)}
|
|
||||||
helperText={
|
|
||||||
formik.touched.bio && formik.errors.bio
|
|
||||||
? formik.errors.bio
|
|
||||||
: `${formik.values.bio.length}/500 characters`
|
|
||||||
}
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Tell us about yourself..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
size="large"
|
|
||||||
type="submit"
|
|
||||||
disabled={formik.isSubmitting}
|
|
||||||
startIcon={<SendIcon />}
|
|
||||||
sx={{
|
|
||||||
mt: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderRadius: 3,
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'linear-gradient(45deg, #6366f1 30%, #8b5cf6 90%)'
|
|
||||||
: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
|
|
||||||
boxShadow: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? '0 3px 5px 2px rgba(99, 102, 241, .3)'
|
|
||||||
: '0 3px 5px 2px rgba(255, 105, 135, .3)',
|
|
||||||
'&:hover': {
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'linear-gradient(45deg, #5b21b6 60%, #7c3aed 100%)'
|
|
||||||
: 'linear-gradient(45deg, #FE6B8B 60%, #FF8E53 100%)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formik.isSubmitting ? 'Submitting...' : 'Submit Registration'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FormPage;
|
|
||||||
@@ -4,125 +4,36 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardMedia,
|
|
||||||
Grid,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
TextField,
|
|
||||||
InputAdornment,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
FormControl,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
InputLabel,
|
|
||||||
Drawer,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
Collapse,
|
|
||||||
IconButton,
|
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useTheme,
|
useTheme,
|
||||||
Paper,
|
Paper
|
||||||
Slider,
|
|
||||||
Switch,
|
|
||||||
FormControlLabel,
|
|
||||||
SelectChangeEvent
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {useState, useEffect, useCallback, KeyboardEvent, ChangeEvent, JSX} from 'react';
|
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 clientApi from "@/lib/clientApi";
|
||||||
import useRoles from "@/app/components/hooks/useRoles";
|
import useRoles from "@/app/components/hooks/useRoles";
|
||||||
|
import ProductCard from '@/app/components/gallery/ProductCard';
|
||||||
|
import CategorySidebar from '@/app/components/gallery/CategorySidebar';
|
||||||
|
import SearchFilters from '@/app/components/gallery/SearchFilters';
|
||||||
|
import MobileFilterDrawer from '@/app/components/gallery/MobileFilterDrawer';
|
||||||
|
import { GalleryProduct, GalleryCategory, Filters, ProductsResponse, ApiParams } from '@/types';
|
||||||
|
|
||||||
interface SortOption {
|
export default function GalleryPage() {
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Category {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parentCategoryId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
basePrice: number;
|
|
||||||
imageUrl?: string;
|
|
||||||
isCustomizable: boolean;
|
|
||||||
category?: Category;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductsResponse {
|
|
||||||
items: Product[];
|
|
||||||
totalPages: number;
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Filters {
|
|
||||||
pageNumber: number;
|
|
||||||
pageSize: number;
|
|
||||||
searchTerm: string;
|
|
||||||
categoryId: string;
|
|
||||||
minPrice: number;
|
|
||||||
maxPrice: number;
|
|
||||||
isActive: boolean;
|
|
||||||
isCustomizable: boolean | null;
|
|
||||||
sortBy: string;
|
|
||||||
sortDirection: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiParams {
|
|
||||||
PageNumber: number;
|
|
||||||
PageSize: number;
|
|
||||||
IsActive: boolean;
|
|
||||||
SortBy: string;
|
|
||||||
SortDirection: string;
|
|
||||||
SearchTerm?: string;
|
|
||||||
CategoryId?: string;
|
|
||||||
MinPrice?: number;
|
|
||||||
MaxPrice?: number;
|
|
||||||
IsCustomizable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SORT_OPTIONS: SortOption[] = [
|
|
||||||
{ 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: number[] = [12, 24, 48, 96];
|
|
||||||
|
|
||||||
export default function GalleryPage(): JSX.Element {
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const { isAdmin } = useRoles();
|
const { isAdmin } = useRoles();
|
||||||
|
|
||||||
const heightOffset = isAdmin ? 192 : 128;
|
const heightOffset = isAdmin ? 192 : 128;
|
||||||
|
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<GalleryProduct[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [totalPages, setTotalPages] = useState<number>(0);
|
const [totalPages, setTotalPages] = useState<number>(0);
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<GalleryCategory[]>([]);
|
||||||
const [categoriesLoading, setCategoriesLoading] = useState<boolean>(true);
|
const [categoriesLoading, setCategoriesLoading] = useState<boolean>(true);
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -141,12 +52,11 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
|
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState<boolean>(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState<boolean>(false);
|
||||||
const [priceRange, setPriceRange] = useState<number[]>([0, 1000]);
|
const [priceRange, setPriceRange] = useState<number[]>([0, 1000]);
|
||||||
const [searchInput, setSearchInput] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCategories = async (): Promise<void> => {
|
const fetchCategories = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await clientApi.get<Category[]>('/products/categories');
|
const response = await clientApi.get<GalleryCategory[]>('/products/categories');
|
||||||
setCategories(response.data);
|
setCategories(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching categories:', err);
|
console.error('Error fetching categories:', err);
|
||||||
@@ -200,10 +110,6 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (): void => {
|
|
||||||
handleFilterChange('searchTerm', searchInput);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePriceRangeChange = (event: Event, newValue: number | number[]): void => {
|
const handlePriceRangeChange = (event: Event, newValue: number | number[]): void => {
|
||||||
setPriceRange(newValue as number[]);
|
setPriceRange(newValue as number[]);
|
||||||
};
|
};
|
||||||
@@ -214,12 +120,6 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
handleFilterChange('maxPrice', range[1]);
|
handleFilterChange('maxPrice', range[1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSortChange = (value: string): void => {
|
|
||||||
const [sortBy, sortDirection] = value.split('-');
|
|
||||||
handleFilterChange('sortBy', sortBy);
|
|
||||||
handleFilterChange('sortDirection', sortDirection);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCategoryExpansion = (categoryId: string): void => {
|
const toggleCategoryExpansion = (categoryId: string): void => {
|
||||||
const newExpanded = new Set(expandedCategories);
|
const newExpanded = new Set(expandedCategories);
|
||||||
if (newExpanded.has(categoryId)) {
|
if (newExpanded.has(categoryId)) {
|
||||||
@@ -230,152 +130,14 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
setExpandedCategories(newExpanded);
|
setExpandedCategories(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChildCategories = (parentId: string): Category[] => {
|
|
||||||
return categories.filter(cat => cat.parentCategoryId === parentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getParentCategories = (): Category[] => {
|
|
||||||
return categories.filter(cat => !cat.parentCategoryId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CategorySidebar = (): JSX.Element => (
|
|
||||||
<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: ChangeEvent<HTMLInputElement>) =>
|
|
||||||
handleFilterChange('isCustomizable', e.target.checked ? true : null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Customizable Only"
|
|
||||||
sx={{ mb: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ py: { xs: 1, sm: 2, md: 4 } }}>
|
<Container maxWidth="xl" sx={{
|
||||||
|
py: { xs: 0.75, sm: 1.5, md: 3 },
|
||||||
|
px: { xs: 1, sm: 2 }
|
||||||
|
}}>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: { xs: 0, md: 3 },
|
gap: { xs: 0, md: 2 },
|
||||||
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
|
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
|
||||||
}}>
|
}}>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
@@ -390,11 +152,20 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{categoriesLoading ? (
|
{categoriesLoading ? (
|
||||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||||
<CircularProgress size={40} />
|
<CircularProgress size={32} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<CategorySidebar />
|
<CategorySidebar
|
||||||
|
categories={categories}
|
||||||
|
filters={filters}
|
||||||
|
expandedCategories={expandedCategories}
|
||||||
|
priceRange={priceRange}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onToggleCategoryExpansion={toggleCategoryExpansion}
|
||||||
|
onPriceRangeChange={handlePriceRangeChange}
|
||||||
|
onPriceRangeCommitted={handlePriceRangeCommitted}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -411,132 +182,58 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: { xs: 'visible', md: 'auto' },
|
overflowY: { xs: 'visible', md: 'auto' },
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
pr: { xs: 0, md: 1 }
|
pr: { xs: 0, md: 0.5 }
|
||||||
}}>
|
}}>
|
||||||
<Box sx={{ mb: { xs: 2, sm: 2, md: 3 } }}>
|
<Box sx={{ mb: { xs: 1, sm: 1.5, md: 2 } }}>
|
||||||
<Typography variant="h3" gutterBottom sx={{
|
<Typography variant="h3" gutterBottom sx={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 700,
|
||||||
fontSize: { xs: '2rem', sm: '2rem', md: '2rem' }
|
fontSize: { xs: '1.5rem', sm: '1.75rem', md: '2rem' },
|
||||||
|
lineHeight: 1.2,
|
||||||
|
mb: { xs: 0.5, sm: 0.75 }
|
||||||
}}>
|
}}>
|
||||||
Product Gallery
|
Product Gallery
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" color="text.secondary" sx={{
|
<Typography variant="h6" color="text.secondary" sx={{
|
||||||
fontSize: { xs: '1rem', sm: '1rem' }
|
fontSize: { xs: '0.8rem', sm: '0.9rem' },
|
||||||
|
lineHeight: 1.3
|
||||||
}}>
|
}}>
|
||||||
Explore our complete collection of customizable products
|
Explore our complete collection of customizable products
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper elevation={1} sx={{
|
<SearchFilters
|
||||||
p: { xs: 1, sm: 1, md: 1.5 },
|
filters={filters}
|
||||||
mb: { xs: 1, sm: 2 }
|
totalCount={totalCount}
|
||||||
}}>
|
productsCount={products.length}
|
||||||
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
onFilterChange={handleFilterChange}
|
||||||
{isMobile && (
|
onOpenMobileDrawer={() => setMobileDrawerOpen(true)}
|
||||||
<Grid>
|
/>
|
||||||
<IconButton
|
|
||||||
onClick={() => setMobileDrawerOpen(true)}
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<TuneIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid size={{ xs:12, sm:6, md:4 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
placeholder="Search products..."
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
|
||||||
onKeyPress={(e: KeyboardEvent) => 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 size={{ 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: SelectChangeEvent) => handleSortChange(e.target.value)}
|
|
||||||
>
|
|
||||||
{SORT_OPTIONS.map((option) => (
|
|
||||||
<MenuItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ 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: SelectChangeEvent<number>) =>
|
|
||||||
handleFilterChange('pageSize', e.target.value as number)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
|
||||||
<MenuItem key={size} value={size}>
|
|
||||||
{size}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ 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 && (
|
{loading && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 4, md: 8 } }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 3, md: 6 } }}>
|
||||||
<CircularProgress size={isMobile ? 40 : 60} />
|
<CircularProgress size={isMobile ? 32 : 48} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: { xs: 2, md: 4 } }}>
|
<Alert severity="error" sx={{
|
||||||
|
mb: { xs: 1.5, md: 3 },
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||||
|
}}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && products.length === 0 && (
|
{!loading && !error && products.length === 0 && (
|
||||||
<Paper sx={{ p: { xs: 3, md: 6 }, textAlign: 'center' }}>
|
<Paper sx={{ p: { xs: 2, md: 4 }, textAlign: 'center' }}>
|
||||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
<Typography variant="h6" color="text.secondary" gutterBottom sx={{
|
||||||
|
fontSize: { xs: '0.9rem', sm: '1rem' }
|
||||||
|
}}>
|
||||||
No products found
|
No products found
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.85rem' }
|
||||||
|
}}>
|
||||||
Try adjusting your search criteria or filters
|
Try adjusting your search criteria or filters
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -546,121 +243,11 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: { xs: 1, sm: 1.5, md: 2 },
|
gap: { xs: 0.75, sm: 1, md: 1.5 },
|
||||||
mb: { xs: 2, md: 4 }
|
mb: { xs: 1.5, md: 3 }
|
||||||
}}>
|
}}>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<Card
|
<ProductCard key={product.id} product={product} />
|
||||||
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -669,14 +256,14 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
pb: { xs: 2, md: 4 }
|
pb: { xs: 1.5, md: 3 }
|
||||||
}}>
|
}}>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={totalPages}
|
count={totalPages}
|
||||||
page={filters.pageNumber}
|
page={filters.pageNumber}
|
||||||
onChange={(e, page) => handleFilterChange('pageNumber', page)}
|
onChange={(e, page) => handleFilterChange('pageNumber', page)}
|
||||||
color="primary"
|
color="primary"
|
||||||
size={isMobile ? 'small' : 'large'}
|
size={isMobile ? 'small' : 'medium'}
|
||||||
showFirstButton={!isMobile}
|
showFirstButton={!isMobile}
|
||||||
showLastButton={!isMobile}
|
showLastButton={!isMobile}
|
||||||
/>
|
/>
|
||||||
@@ -686,35 +273,19 @@ export default function GalleryPage(): JSX.Element {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Drawer
|
<MobileFilterDrawer
|
||||||
anchor="left"
|
|
||||||
open={mobileDrawerOpen}
|
open={mobileDrawerOpen}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={categoriesLoading}
|
||||||
|
filters={filters}
|
||||||
|
expandedCategories={expandedCategories}
|
||||||
|
priceRange={priceRange}
|
||||||
onClose={() => setMobileDrawerOpen(false)}
|
onClose={() => setMobileDrawerOpen(false)}
|
||||||
ModalProps={{ keepMounted: true }}
|
onFilterChange={handleFilterChange}
|
||||||
PaperProps={{
|
onToggleCategoryExpansion={toggleCategoryExpansion}
|
||||||
sx: {
|
onPriceRangeChange={handlePriceRangeChange}
|
||||||
width: 280,
|
onPriceRangeCommitted={handlePriceRangeCommitted}
|
||||||
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>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,27 +17,6 @@ import {useState, useEffect, JSX} from 'react';
|
|||||||
import { ShoppingCart, Palette, ImageOutlined, CreditCard, LocalShipping, CheckCircle } from '@mui/icons-material';
|
import { ShoppingCart, Palette, ImageOutlined, CreditCard, LocalShipping, CheckCircle } from '@mui/icons-material';
|
||||||
import clientApi from "@/lib/clientApi";
|
import clientApi from "@/lib/clientApi";
|
||||||
|
|
||||||
interface Product {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
basePrice: number;
|
|
||||||
isCustomizable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiResponse {
|
|
||||||
items: Product[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Step {
|
|
||||||
number: number;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
details: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage(): JSX.Element {
|
export default function HomePage(): JSX.Element {
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|||||||
12
ui/src/constants/index.ts
Normal file
12
ui/src/constants/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { SortOption } from '@/types';
|
||||||
|
|
||||||
|
export const SORT_OPTIONS: SortOption[] = [
|
||||||
|
{ 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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PAGE_SIZE_OPTIONS: number[] = [12, 24, 48, 96];
|
||||||
137
ui/src/types/index.ts
Normal file
137
ui/src/types/index.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {JSX} from 'react';
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
parentCategoryId: string;
|
||||||
|
createdAt: string;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
basePrice: number;
|
||||||
|
isCustomizable: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
imageUrl: string;
|
||||||
|
categoryId: string;
|
||||||
|
category: Category;
|
||||||
|
createdAt: string;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Variant {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
size: string;
|
||||||
|
color: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl: string;
|
||||||
|
sku: string;
|
||||||
|
stockQuantity: number;
|
||||||
|
isActive: boolean;
|
||||||
|
product: Product;
|
||||||
|
createdAt: string;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
addressType: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
company: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
apartmentNumber: string;
|
||||||
|
buildingNumber: string;
|
||||||
|
floor: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
instructions: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewAddress {
|
||||||
|
addressType: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
company: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
apartmentNumber: string;
|
||||||
|
buildingNumber: string;
|
||||||
|
floor: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
instructions: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GalleryCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentCategoryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GalleryProduct {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
basePrice: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
isCustomizable: boolean;
|
||||||
|
category?: Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductsResponse {
|
||||||
|
items: Product[];
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
pageNumber: number;
|
||||||
|
pageSize: number;
|
||||||
|
searchTerm: string;
|
||||||
|
categoryId: string;
|
||||||
|
minPrice: number;
|
||||||
|
maxPrice: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isCustomizable: boolean | null;
|
||||||
|
sortBy: string;
|
||||||
|
sortDirection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiParams {
|
||||||
|
PageNumber: number;
|
||||||
|
PageSize: number;
|
||||||
|
IsActive: boolean;
|
||||||
|
SortBy: string;
|
||||||
|
SortDirection: string;
|
||||||
|
SearchTerm?: string;
|
||||||
|
CategoryId?: string;
|
||||||
|
MinPrice?: number;
|
||||||
|
MaxPrice?: number;
|
||||||
|
IsCustomizable?: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user