Fix builder UX

This commit is contained in:
lumijiez
2025-06-29 21:58:37 +03:00
parent 460d13c143
commit 4cb6dd0759
21 changed files with 4098 additions and 1577 deletions

1297
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,17 @@
"@mui/material": "^7.1.2",
"@mui/material-nextjs": "^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",
"fabric": "^6.7.0",
"formik": "^2.4.6",
"konva": "^9.3.20",
"next": "15.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-konva": "^19.0.6",
"yup": "^1.6.1"
},
"devDependencies": {

View File

@@ -2,126 +2,44 @@
import React, { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import clientApi from '@/lib/clientApi';
import {
Box,
Button,
Card,
CardContent,
CardMedia,
Typography,
Stepper,
Step,
StepLabel,
Grid,
TextField,
IconButton,
Radio,
RadioGroup,
FormControlLabel,
FormControl,
FormLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip,
Container,
Paper,
Fade,
CircularProgress
Box,
Button,
CircularProgress,
} from '@mui/material';
import {
Add as AddIcon,
Remove as RemoveIcon,
LocationOn as LocationIcon,
AddLocation as AddLocationIcon
} from '@mui/icons-material';
import { Product, Variant, Address, NewAddress } from '@/types';
import StepperHeader from '@/app/components/orderbuilder/StepperHeader';
import AddAddressDialog from '@/app/components/orderbuilder/AddAddressDialog';
import StepProductDetails from '@/app/components/orderbuilder/StepProductDetails';
import StepSelectVariant from '@/app/components/orderbuilder/StepSelectVariant';
import StepCustomization from '@/app/components/orderbuilder/StepCustomization';
import StepChooseQuantity from '@/app/components/orderbuilder/StepChooseQuantity';
import StepDeliveryAddress from '@/app/components/orderbuilder/StepDeliveryAddress';
import StepReviewOrder from '@/app/components/orderbuilder/StepReviewOrder';
import StepPayment from '@/app/components/orderbuilder/StepPayment';
interface Category {
interface CustomizationImage {
id: string;
name: string;
description: string;
imageUrl: string;
sortOrder: number;
isActive: boolean;
parentCategoryId: string;
createdAt: string;
modifiedAt: string;
url: string;
file: File;
}
interface Product {
id: string;
name: string;
description: string;
basePrice: number;
isCustomizable: boolean;
isActive: boolean;
imageUrl: string;
categoryId: string;
category: Category;
createdAt: string;
modifiedAt: string;
}
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
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;
}
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;
}
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;
}
const steps = ['Product Details', 'Select Variant', 'Choose Quantity', 'Delivery Address', 'Review & Order'];
const steps = [
'Product Details',
'Select Variant',
'Customization',
'Choose Quantity',
'Delivery Address',
'Review & Order',
'Payment'
];
export default function OrderBuilder() {
const router = useRouter();
@@ -137,6 +55,11 @@ export default function OrderBuilder() {
const [quantity, setQuantity] = useState(1);
const [selectedAddress, setSelectedAddress] = useState<Address | null>(null);
const [showAddressDialog, setShowAddressDialog] = useState(false);
const [customizationImages, setCustomizationImages] = useState<CustomizationImage[]>([]);
const [finalImageUrl, setFinalImageUrl] = useState('');
const [customizationDescription, setCustomizationDescription] = useState('');
const [orderId, setOrderId] = useState<string>('');
const [clientSecret, setClientSecret] = useState<string>('');
const [newAddress, setNewAddress] = useState<NewAddress>({
addressType: 'Home',
firstName: '',
@@ -154,22 +77,18 @@ export default function OrderBuilder() {
phoneNumber: '',
instructions: '',
isDefault: false,
isActive: true
isActive: true,
});
useEffect(() => {
if (productId) {
loadProduct();
}
if (productId) loadProduct();
}, [productId]);
const loadProduct = async () => {
setLoading(true);
try {
const productData = await clientApi.get(`/products/${productId}`);
setProduct(productData.data);
} catch (error) {
console.error('Failed to load product:', error);
const { data } = await clientApi.get(`/products/${productId}`);
setProduct(data);
} finally {
setLoading(false);
}
@@ -178,10 +97,8 @@ export default function OrderBuilder() {
const loadVariants = async () => {
setLoading(true);
try {
const variantsData = await clientApi.get(`/products/variants/${productId}`);
setVariants(variantsData.data);
} catch (error) {
console.error('Failed to load variants:', error);
const { data } = await clientApi.get(`/products/variants/${productId}`);
setVariants(data);
} finally {
setLoading(false);
}
@@ -190,47 +107,52 @@ export default function OrderBuilder() {
const loadAddresses = async () => {
setLoading(true);
try {
const addressesData = await clientApi.get('/addresses/me');
setAddresses(addressesData.data);
if (addressesData.data.length > 0) {
const defaultAddress = addressesData.data.find((addr: Address) => addr.isDefault) || addressesData.data[0];
setSelectedAddress(defaultAddress);
console.log('Loading addresses...');
const response = await clientApi.get('/addresses/me');
console.log('API Response:', response);
console.log('API Data:', response.data);
const data = response.data;
setAddresses(data);
if (data.length > 0) {
const defaultAddr = data.find((addr: Address) => addr.isDefault) || data[0];
setSelectedAddress(defaultAddr);
console.log('Selected address:', defaultAddr);
} else {
console.log('No addresses found');
}
} catch (error) {
console.error('Failed to load addresses:', error);
console.error('Error loading addresses:', error);
} finally {
setLoading(false);
}
};
const handleNext = () => {
const handleNext = async () => {
if (activeStep === 0 && product) {
loadVariants();
} else if (activeStep === 2) {
loadAddresses();
} else if (activeStep === 2 && product?.isCustomizable && customizationImages.length > 0 && !finalImageUrl) {
return;
} else if (activeStep === 3) {
await loadAddresses();
}
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
setActiveStep((prev) => prev + 1);
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleBack = () => setActiveStep((prev) => prev - 1);
const handleQuantityChange = (delta: number) => {
const newQuantity = quantity + delta;
if (newQuantity >= 1) {
setQuantity(newQuantity);
}
setQuantity((prev) => Math.max(1, prev + delta));
};
const handleAddAddress = async () => {
try {
const addedAddress = await clientApi.post('/addresses', newAddress);
setAddresses([...addresses, addedAddress.data]);
setSelectedAddress(addedAddress.data);
const added = await clientApi.post('/addresses', newAddress);
setAddresses([...addresses, added.data]);
setSelectedAddress(added.data);
setShowAddressDialog(false);
setNewAddress({
addressType: 'shipping',
addressType: 'Home',
firstName: '',
lastName: '',
company: '',
@@ -246,376 +168,188 @@ export default function OrderBuilder() {
phoneNumber: '',
instructions: '',
isDefault: false,
isActive: true
isActive: true,
});
} catch (error) {
console.error('Failed to add address:', error);
}
};
const handlePlaceOrder = async () => {
if (!selectedVariant || !selectedAddress) return;
const orderData = {
quantity,
productId: product!.id,
productVariantId: selectedVariant.id,
quantity: quantity,
originalImageUrls: customizationImages.map(img => img.url),
customizationImageUrl: finalImageUrl,
customizationDescription,
addressId: selectedAddress.id,
totalPrice: selectedVariant.price * quantity
};
try {
setLoading(true);
await clientApi.post('/orders', orderData);
router.push('/orders/success');
try {
const orderResponse = await clientApi.post('/orders', orderData);
const newOrderId = orderResponse.data.id;
setOrderId(newOrderId);
const paymentResponse = await clientApi.post('/stripe/create-payment-intent', {
orderId: newOrderId
});
setClientSecret(paymentResponse.data.clientSecret);
setActiveStep((prev) => prev + 1);
} catch (error) {
console.error('Failed to place order:', error);
console.error('Error creating order or payment intent:', error);
alert('Failed to create order. Please try again.');
} finally {
setLoading(false);
}
};
const getTotalPrice = () => {
if (!selectedVariant) return 0;
return selectedVariant.price * quantity;
const handlePaymentSuccess = () => {
router.push(`/orders/success?orderId=${orderId}`);
};
const getTotalPrice = () => (selectedVariant ? selectedVariant.price * quantity : 0);
const canProceed = () => {
switch (activeStep) {
case 0: return product !== null;
case 1: return selectedVariant !== null;
case 2: return quantity > 0;
case 3: return selectedAddress !== null;
case 2:
if (!product?.isCustomizable) return true;
return customizationImages.length === 0 || finalImageUrl !== '';
case 3: return quantity > 0;
case 4: return selectedAddress !== null;
case 5: return true;
case 6: return false;
default: return true;
}
};
const shouldShowCustomization = () => {
return product?.isCustomizable;
};
const renderStepContent = () => {
switch (activeStep) {
case 0:
return (
<Fade in={true}>
<Box>
{product && (
<Card>
<CardMedia
component="img"
height="400"
image={product.imageUrl}
alt={product.name}
/>
<CardContent>
<Typography variant="h4" gutterBottom>
{product.name}
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
{product.description}
</Typography>
<Typography variant="h5" color="primary">
${product.basePrice.toFixed(2)}
</Typography>
<Box mt={2}>
<Chip label={product.category.name} color="primary" variant="outlined" />
{product.isCustomizable && <Chip label="Customizable" color="secondary" sx={{ ml: 1 }} />}
</Box>
</CardContent>
</Card>
)}
</Box>
</Fade>
);
return <StepProductDetails product={product} />;
case 1:
return (
<Fade in={true}>
<Box>
<Typography variant="h5" gutterBottom>
Select Variant
</Typography>
<Grid container spacing={3}>
{variants.map((variant) => (
<Grid size={{ xs:12, sm:6, md:4 }} key={variant.id}>
<Card
sx={{
cursor: 'pointer',
border: selectedVariant?.id === variant.id ? 2 : 1,
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'grey.300'
}}
onClick={() => setSelectedVariant(variant)}
>
<CardMedia
component="img"
height="200"
image={variant.imageUrl}
alt={`${variant.size} - ${variant.color}`}
<StepSelectVariant
variants={variants}
selectedVariant={selectedVariant}
setSelectedVariant={setSelectedVariant}
/>
<CardContent>
<Typography variant="h6">
{variant.size} - {variant.color}
</Typography>
<Typography variant="body2" color="text.secondary">
SKU: {variant.sku}
</Typography>
<Typography variant="h6" color="primary">
${variant.price.toFixed(2)}
</Typography>
<Typography variant="body2" color={variant.stockQuantity > 0 ? 'success.main' : 'error.main'}>
{variant.stockQuantity > 0 ? `${variant.stockQuantity} in stock` : 'Out of stock'}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
</Fade>
);
case 2:
if (!shouldShowCustomization()) {
setActiveStep(3);
return null;
}
return (
<Fade in={true}>
<Box>
<Typography variant="h5" gutterBottom>
Choose Quantity
</Typography>
{selectedVariant && (
<Card>
<CardContent>
<Grid container spacing={3} alignItems="center">
<Grid size={{ xs:12, md:6 }}>
<Box display="flex" alignItems="center" gap={2}>
<img
src={selectedVariant.imageUrl}
alt={selectedVariant.size}
style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 8 }}
<StepCustomization
images={customizationImages}
setImages={setCustomizationImages}
finalImageUrl={finalImageUrl}
setFinalImageUrl={setFinalImageUrl}
customizationDescription={customizationDescription}
setCustomizationDescription={setCustomizationDescription}
loading={loading}
setLoading={setLoading}
/>
<Box>
<Typography variant="h6">
{selectedVariant.product.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{selectedVariant.size} - {selectedVariant.color}
</Typography>
<Typography variant="h6" color="primary">
${selectedVariant.price.toFixed(2)} each
</Typography>
</Box>
</Box>
</Grid>
<Grid size={{ xs:12, md:6 }}>
<Box display="flex" alignItems="center" justifyContent="center" gap={2}>
<IconButton
onClick={() => handleQuantityChange(-1)}
disabled={quantity <= 1}
>
<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' },
min: 1
}}
sx={{ width: 80 }}
/>
<IconButton onClick={() => handleQuantityChange(1)}>
<AddIcon />
</IconButton>
</Box>
</Grid>
</Grid>
<Box mt={3} textAlign="center">
<Typography variant="h4" color="primary">
Total: ${getTotalPrice().toFixed(2)}
</Typography>
</Box>
</CardContent>
</Card>
)}
</Box>
</Fade>
);
case 3:
return (
<Fade in={true}>
<Box>
<Typography variant="h5" gutterBottom>
Select Delivery Address
</Typography>
<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 size={{ xs:12, md:6 }} key={address.id}>
<Card sx={{ position: 'relative' }}>
<CardContent>
<FormControlLabel
value={address.id}
control={<Radio />}
label=""
sx={{ position: 'absolute', top: 8, right: 8 }}
<StepChooseQuantity
selectedVariant={selectedVariant}
quantity={quantity}
handleQuantityChange={handleQuantityChange}
getTotalPrice={getTotalPrice}
setQuantity={setQuantity}
/>
<Box display="flex" alignItems="flex-start" gap={1} mb={1}>
<LocationIcon color="primary" />
<Typography variant="h6">
{address.firstName} {address.lastName}
</Typography>
{address.isDefault && <Chip label="Default" size="small" color="primary" />}
</Box>
{address.company && (
<Typography variant="body2" color="text.secondary">
{address.company}
</Typography>
)}
<Typography variant="body2">
{address.addressLine1}
</Typography>
{address.addressLine2 && (
<Typography variant="body2">
{address.addressLine2}
</Typography>
)}
<Typography variant="body2">
{address.city}, {address.state} {address.postalCode}
</Typography>
<Typography variant="body2">
{address.country}
</Typography>
{address.phoneNumber && (
<Typography variant="body2" color="text.secondary">
Phone: {address.phoneNumber}
</Typography>
)}
</CardContent>
</Card>
</Grid>
))}
<Grid size={{ xs:12, md:6 }}>
<Card
sx={{
cursor: 'pointer',
border: '2px dashed',
borderColor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200
}}
onClick={() => setShowAddressDialog(true)}
>
<CardContent>
<Box textAlign="center">
<AddLocationIcon sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
<Typography variant="h6" color="primary">
Add New Address
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
</RadioGroup>
</FormControl>
</Box>
</Fade>
);
case 4:
return (
<Fade in={true}>
<Box>
<Typography variant="h5" gutterBottom>
Review Your Order
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs:12, md:8 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Order Summary
</Typography>
{selectedVariant && (
<Box display="flex" alignItems="center" gap={2} mb={2}>
<img
src={selectedVariant.imageUrl}
alt={selectedVariant.size}
style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 8 }}
<StepDeliveryAddress
addresses={addresses}
selectedAddress={selectedAddress}
setSelectedAddress={setSelectedAddress}
setShowAddressDialog={setShowAddressDialog}
/>
<Box flex={1}>
<Typography variant="subtitle1">
{selectedVariant.product.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{selectedVariant.size} - {selectedVariant.color}
</Typography>
<Typography variant="body2">
Quantity: {quantity}
</Typography>
</Box>
<Typography variant="h6">
${getTotalPrice().toFixed(2)}
</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs:12, md:4 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Delivery Address
</Typography>
{selectedAddress && (
<Box>
<Typography variant="subtitle2">
{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>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
</Fade>
);
case 5:
return (
<StepReviewOrder
selectedVariant={selectedVariant}
quantity={quantity}
getTotalPrice={getTotalPrice}
selectedAddress={selectedAddress}
customizationImages={customizationImages}
finalImageUrl={finalImageUrl}
customizationDescription={customizationDescription}
/>
);
case 6:
if (clientSecret) {
return (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#1976d2',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Roboto, system-ui, sans-serif',
spacingUnit: '4px',
borderRadius: '8px',
},
}
}}
>
<StepPayment
orderId={orderId}
selectedVariant={selectedVariant}
quantity={quantity}
getTotalPrice={getTotalPrice}
selectedAddress={selectedAddress}
customizationImages={customizationImages}
finalImageUrl={finalImageUrl}
onSuccess={handlePaymentSuccess}
/>
</Elements>
);
}
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={200}>
<CircularProgress />
</Box>
);
default:
return null;
}
};
const getStepButtonText = () => {
if (activeStep === steps.length - 2) return 'Place Order';
if (activeStep === steps.length - 1) return 'Payment';
return 'Next';
};
const handleStepAction = () => {
if (activeStep === steps.length - 2) {
handlePlaceOrder();
} else if (activeStep === steps.length - 1) {
return;
} else {
handleNext();
}
};
if (loading && !product) {
return (
<Container maxWidth="lg" sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
@@ -627,174 +361,36 @@ export default function OrderBuilder() {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Paper sx={{ p: 3 }}>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ mb: 4 }}>
{renderStepContent()}
</Box>
<StepperHeader activeStep={activeStep} steps={steps} />
<Box sx={{ mb: 4 }}>{renderStepContent()}</Box>
{activeStep < steps.length - 1 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
color="inherit"
disabled={activeStep === 0}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
<Button
variant="contained"
onClick={activeStep === steps.length - 1 ? handlePlaceOrder : handleNext}
onClick={handleStepAction}
disabled={!canProceed() || loading}
>
{loading ? (
<CircularProgress size={24} />
) : activeStep === steps.length - 1 ? (
'Place Order'
) : (
'Next'
)}
{loading ? <CircularProgress size={24} /> : getStepButtonText()}
</Button>
</Box>
)}
</Paper>
<Dialog open={showAddressDialog} onClose={() => setShowAddressDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>Add New Address</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="First Name"
value={newAddress.firstName}
onChange={(e) => setNewAddress({...newAddress, firstName: e.target.value})}
<AddAddressDialog
open={showAddressDialog}
onClose={() => setShowAddressDialog(false)}
onAdd={handleAddAddress}
newAddress={newAddress}
setNewAddress={setNewAddress}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Last Name"
value={newAddress.lastName}
onChange={(e) => setNewAddress({...newAddress, lastName: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Company (Optional)"
value={newAddress.company}
onChange={(e) => setNewAddress({...newAddress, company: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Address Line 1"
value={newAddress.addressLine1}
onChange={(e) => setNewAddress({...newAddress, addressLine1: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Address Line 2 (Optional)"
value={newAddress.addressLine2}
onChange={(e) => setNewAddress({...newAddress, addressLine2: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Apartment #"
value={newAddress.apartmentNumber}
onChange={(e) => setNewAddress({...newAddress, apartmentNumber: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Building #"
value={newAddress.buildingNumber}
onChange={(e) => setNewAddress({...newAddress, buildingNumber: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Floor"
value={newAddress.floor}
onChange={(e) => setNewAddress({...newAddress, floor: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="City"
value={newAddress.city}
onChange={(e) => setNewAddress({...newAddress, city: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="State"
value={newAddress.state}
onChange={(e) => setNewAddress({...newAddress, state: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Postal Code"
value={newAddress.postalCode}
onChange={(e) => setNewAddress({...newAddress, postalCode: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12, sm:6 }}>
<TextField
fullWidth
label="Country"
value={newAddress.country}
onChange={(e) => setNewAddress({...newAddress, country: e.target.value})}
/>
</Grid>
<Grid size={{ xs:12 }}>
<TextField
fullWidth
label="Phone Number"
value={newAddress.phoneNumber}
onChange={(e) => setNewAddress({...newAddress, phoneNumber: 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={() => setShowAddressDialog(false)}>Cancel</Button>
<Button
onClick={handleAddAddress}
variant="contained"
disabled={!newAddress.firstName || !newAddress.lastName || !newAddress.addressLine1 || !newAddress.city}
>
Add Address
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;

View File

@@ -4,125 +4,36 @@ import {
Box,
Container,
Typography,
Button,
Card,
CardContent,
CardMedia,
Grid,
Chip,
CircularProgress,
Alert,
TextField,
InputAdornment,
Pagination,
FormControl,
Select,
MenuItem,
InputLabel,
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
Collapse,
IconButton,
useMediaQuery,
useTheme,
Paper,
Slider,
Switch,
FormControlLabel,
SelectChangeEvent
Paper
} from '@mui/material';
import {useState, useEffect, useCallback, KeyboardEvent, ChangeEvent, JSX} from 'react';
import {
Search,
ExpandLess,
ExpandMore,
Close as CloseIcon,
Tune as TuneIcon
} from '@mui/icons-material';
import {useState, useEffect, useCallback} from 'react';
import clientApi from "@/lib/clientApi";
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 {
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 {
export default function GalleryPage() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { isAdmin } = useRoles();
const heightOffset = isAdmin ? 192 : 128;
const [products, setProducts] = useState<Product[]>([]);
const [products, setProducts] = useState<GalleryProduct[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [totalPages, setTotalPages] = 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 [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
@@ -141,12 +52,11 @@ export default function GalleryPage(): JSX.Element {
const [mobileDrawerOpen, setMobileDrawerOpen] = useState<boolean>(false);
const [priceRange, setPriceRange] = useState<number[]>([0, 1000]);
const [searchInput, setSearchInput] = useState<string>('');
useEffect(() => {
const fetchCategories = async (): Promise<void> => {
try {
const response = await clientApi.get<Category[]>('/products/categories');
const response = await clientApi.get<GalleryCategory[]>('/products/categories');
setCategories(response.data);
} catch (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 => {
setPriceRange(newValue as number[]);
};
@@ -214,12 +120,6 @@ export default function GalleryPage(): JSX.Element {
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 newExpanded = new Set(expandedCategories);
if (newExpanded.has(categoryId)) {
@@ -230,152 +130,14 @@ export default function GalleryPage(): JSX.Element {
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'
<Container maxWidth="xl" sx={{
py: { xs: 0.75, sm: 1.5, md: 3 },
px: { xs: 1, sm: 2 }
}}>
<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 (
<Container maxWidth="xl" sx={{ py: { xs: 1, sm: 2, md: 4 } }}>
<Box sx={{
display: 'flex',
gap: { xs: 0, md: 3 },
gap: { xs: 0, md: 2 },
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
}}>
{!isMobile && (
@@ -390,11 +152,20 @@ export default function GalleryPage(): JSX.Element {
}}
>
{categoriesLoading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={40} />
<Box sx={{ p: 2, textAlign: 'center' }}>
<CircularProgress size={32} />
</Box>
) : (
<CategorySidebar />
<CategorySidebar
categories={categories}
filters={filters}
expandedCategories={expandedCategories}
priceRange={priceRange}
onFilterChange={handleFilterChange}
onToggleCategoryExpansion={toggleCategoryExpansion}
onPriceRangeChange={handlePriceRangeChange}
onPriceRangeCommitted={handlePriceRangeCommitted}
/>
)}
</Paper>
</Box>
@@ -411,132 +182,58 @@ export default function GalleryPage(): JSX.Element {
flex: 1,
overflowY: { xs: 'visible', md: 'auto' },
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={{
fontWeight: 'bold',
fontSize: { xs: '2rem', sm: '2rem', md: '2rem' }
fontWeight: 700,
fontSize: { xs: '1.5rem', sm: '1.75rem', md: '2rem' },
lineHeight: 1.2,
mb: { xs: 0.5, sm: 0.75 }
}}>
Product Gallery
</Typography>
<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
</Typography>
</Box>
<Paper elevation={1} sx={{
p: { xs: 1, sm: 1, md: 1.5 },
mb: { xs: 1, sm: 2 }
}}>
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
{isMobile && (
<Grid>
<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>
)
}}
<SearchFilters
filters={filters}
totalCount={totalCount}
productsCount={products.length}
onFilterChange={handleFilterChange}
onOpenMobileDrawer={() => setMobileDrawerOpen(true)}
/>
</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 && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 4, md: 8 } }}>
<CircularProgress size={isMobile ? 40 : 60} />
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 3, md: 6 } }}>
<CircularProgress size={isMobile ? 32 : 48} />
</Box>
)}
{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}
</Alert>
)}
{!loading && !error && products.length === 0 && (
<Paper sx={{ p: { xs: 3, md: 6 }, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
<Paper sx={{ p: { xs: 2, md: 4 }, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom sx={{
fontSize: { xs: '0.9rem', sm: '1rem' }
}}>
No products found
</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
</Typography>
</Paper>
@@ -546,121 +243,11 @@ export default function GalleryPage(): JSX.Element {
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: { xs: 1, sm: 1.5, md: 2 },
mb: { xs: 2, md: 4 }
gap: { xs: 0.75, sm: 1, md: 1.5 },
mb: { xs: 1.5, md: 3 }
}}>
{products.map((product) => (
<Card
key={product.id}
sx={{
width: {
xs: 'calc(50% - 4px)',
sm: 'calc(50% - 12px)',
lg: 'calc(33.333% - 16px)'
},
maxWidth: { xs: 'none', sm: 350, lg: 370 },
height: { xs: 300, sm: 380, lg: 420 },
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
'&:hover': {
transform: { xs: 'none', sm: 'translateY(-4px)', md: 'translateY(-8px)' },
boxShadow: { xs: 2, sm: 4, md: 6 }
}
}}
>
<CardMedia
component="img"
image={product.imageUrl || '/placeholder-product.jpg'}
alt={product.name}
sx={{
objectFit: 'cover',
height: { xs: 120, sm: 160, lg: 180 }
}}
/>
<CardContent sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
p: { xs: 1, sm: 1.5, lg: 2 },
'&:last-child': { pb: { xs: 1, sm: 1.5, lg: 2 } }
}}>
<Typography variant="h6" gutterBottom sx={{
fontWeight: 'bold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: { xs: '0.9rem', sm: '1rem', md: '1.1rem' },
mb: { xs: 0.5, sm: 1 }
}}>
{product.name}
</Typography>
{product.category && (
<Chip
label={product.category.name}
size="small"
color="secondary"
sx={{
alignSelf: 'flex-start',
mb: { xs: 0.5, sm: 1 },
fontSize: { xs: '0.65rem', sm: '0.75rem' },
height: { xs: 20, sm: 24 }
}}
/>
)}
<Typography variant="body2" color="text.secondary" sx={{
flexGrow: 1,
mb: { xs: 1, sm: 1.5 },
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: { xs: 2, sm: 2, md: 3 },
WebkitBoxOrient: 'vertical',
minHeight: { xs: 28, sm: 32, md: 48 },
fontSize: { xs: '0.75rem', sm: '0.8rem', md: '0.875rem' }
}}>
{product.description}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: { xs: 1, sm: 1.5 }
}}>
<Typography variant="subtitle1" color="primary" sx={{
fontWeight: 'bold',
fontSize: { xs: '0.85rem', sm: '0.95rem', md: '1rem' }
}}>
From ${product.basePrice?.toFixed(2)}
</Typography>
{product.isCustomizable && (
<Chip
label="Custom"
color="primary"
size="small"
sx={{
fontSize: { xs: '0.65rem', sm: '0.75rem' },
height: { xs: 20, sm: 24 }
}}
/>
)}
</Box>
<Button
variant="contained"
fullWidth
size={isMobile ? "small" : "medium"}
sx={{
fontSize: { xs: '0.75rem', sm: '0.875rem', md: '1rem' },
py: { xs: 0.5, sm: 1, md: 1.5 }
}}
>
Customize
</Button>
</CardContent>
</Card>
<ProductCard key={product.id} product={product} />
))}
</Box>
)}
@@ -669,14 +256,14 @@ export default function GalleryPage(): JSX.Element {
<Box sx={{
display: 'flex',
justifyContent: 'center',
pb: { xs: 2, md: 4 }
pb: { xs: 1.5, md: 3 }
}}>
<Pagination
count={totalPages}
page={filters.pageNumber}
onChange={(e, page) => handleFilterChange('pageNumber', page)}
color="primary"
size={isMobile ? 'small' : 'large'}
size={isMobile ? 'small' : 'medium'}
showFirstButton={!isMobile}
showLastButton={!isMobile}
/>
@@ -686,35 +273,19 @@ export default function GalleryPage(): JSX.Element {
</Box>
</Box>
<Drawer
anchor="left"
<MobileFilterDrawer
open={mobileDrawerOpen}
categories={categories}
categoriesLoading={categoriesLoading}
filters={filters}
expandedCategories={expandedCategories}
priceRange={priceRange}
onClose={() => setMobileDrawerOpen(false)}
ModalProps={{ keepMounted: true }}
PaperProps={{
sx: {
width: 280,
display: 'flex',
flexDirection: 'column'
}
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
Filters
</Typography>
<IconButton onClick={() => setMobileDrawerOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
{categoriesLoading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={40} />
</Box>
) : (
<CategorySidebar />
)}
</Drawer>
onFilterChange={handleFilterChange}
onToggleCategoryExpansion={toggleCategoryExpansion}
onPriceRangeChange={handlePriceRangeChange}
onPriceRangeCommitted={handlePriceRangeCommitted}
/>
</Container>
);
}

View File

@@ -17,27 +17,6 @@ import {useState, useEffect, JSX} from 'react';
import { ShoppingCart, Palette, ImageOutlined, CreditCard, LocalShipping, CheckCircle } from '@mui/icons-material';
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 {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);

12
ui/src/constants/index.ts Normal file
View 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
View 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;
}