Fix builder UX
This commit is contained in:
81
ui/src/app/components/orderbuilder/AddAddressDialog.tsx
Normal file
81
ui/src/app/components/orderbuilder/AddAddressDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, Grid, TextField
|
||||
} from '@mui/material';
|
||||
|
||||
import { NewAddress } from '@/types'; // Or inline if needed
|
||||
|
||||
export default function AddAddressDialog({
|
||||
open,
|
||||
onClose,
|
||||
onAdd,
|
||||
newAddress,
|
||||
setNewAddress,
|
||||
}: {
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onAdd: () => void,
|
||||
newAddress: NewAddress,
|
||||
setNewAddress: (address: NewAddress) => void,
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Add New Address</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
{[
|
||||
{ label: 'First Name', field: 'firstName' },
|
||||
{ label: 'Last Name', field: 'lastName' },
|
||||
{ label: 'Company (Optional)', field: 'company' },
|
||||
{ label: 'Address Line 1', field: 'addressLine1' },
|
||||
{ label: 'Address Line 2 (Optional)', field: 'addressLine2' },
|
||||
{ label: 'Apartment #', field: 'apartmentNumber' },
|
||||
{ label: 'Building #', field: 'buildingNumber' },
|
||||
{ label: 'Floor', field: 'floor' },
|
||||
{ label: 'City', field: 'city' },
|
||||
{ label: 'State', field: 'state' },
|
||||
{ label: 'Postal Code', field: 'postalCode' },
|
||||
{ label: 'Country', field: 'country' },
|
||||
{ label: 'Phone Number', field: 'phoneNumber' },
|
||||
].map(({ label, field }, index) => (
|
||||
<Grid size={{ xs:12 }} key={index}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={(newAddress as any)[field]}
|
||||
onChange={(e) => setNewAddress({ ...newAddress, [field]: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
<Grid size={{ xs:12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Delivery Instructions (Optional)"
|
||||
value={newAddress.instructions}
|
||||
onChange={(e) => setNewAddress({ ...newAddress, instructions: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
variant="contained"
|
||||
disabled={
|
||||
!newAddress.firstName ||
|
||||
!newAddress.lastName ||
|
||||
!newAddress.addressLine1 ||
|
||||
!newAddress.city
|
||||
}
|
||||
>
|
||||
Add Address
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
124
ui/src/app/components/orderbuilder/StepChooseQuantity.tsx
Normal file
124
ui/src/app/components/orderbuilder/StepChooseQuantity.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, IconButton, TextField, Fade, Stack, Divider } from '@mui/material';
|
||||
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
|
||||
import { Variant } from '@/types';
|
||||
|
||||
export default function StepChooseQuantity({
|
||||
selectedVariant,
|
||||
quantity,
|
||||
handleQuantityChange,
|
||||
getTotalPrice,
|
||||
setQuantity,
|
||||
}: {
|
||||
selectedVariant: Variant | null,
|
||||
quantity: number,
|
||||
handleQuantityChange: (delta: number) => void,
|
||||
getTotalPrice: () => number,
|
||||
setQuantity: (val: number) => void,
|
||||
}) {
|
||||
if (!selectedVariant) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||
Choose Quantity
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={4}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems="flex-start">
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ flex: 1 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${selectedVariant.price.toFixed(2)} each
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2} alignItems="center" sx={{ minWidth: 200 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
Quantity
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<IconButton
|
||||
onClick={() => handleQuantityChange(-1)}
|
||||
disabled={quantity <= 1}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
'&:hover': { borderColor: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<TextField
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 1;
|
||||
if (val >= 1) setQuantity(val);
|
||||
}}
|
||||
inputProps={{
|
||||
style: { textAlign: 'center', fontSize: '1.2rem', fontWeight: 600 },
|
||||
min: 1
|
||||
}}
|
||||
sx={{
|
||||
width: 80,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': {
|
||||
borderColor: 'grey.300',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleQuantityChange(1)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
'&:hover': { borderColor: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Total Price:
|
||||
</Typography>
|
||||
<Typography variant="h3" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
465
ui/src/app/components/orderbuilder/StepCustomization.tsx
Normal file
465
ui/src/app/components/orderbuilder/StepCustomization.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
IconButton,
|
||||
Grid,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Fade,
|
||||
Alert,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload as UploadIcon,
|
||||
Delete as DeleteIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Save as SaveIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fabric: any;
|
||||
}
|
||||
}
|
||||
|
||||
export default function StepCustomization({
|
||||
images,
|
||||
setImages,
|
||||
finalImageUrl,
|
||||
setFinalImageUrl,
|
||||
customizationDescription,
|
||||
setCustomizationDescription,
|
||||
loading,
|
||||
setLoading
|
||||
}: {
|
||||
images: CustomizationImage[];
|
||||
setImages: (images: CustomizationImage[]) => void;
|
||||
finalImageUrl: string;
|
||||
setFinalImageUrl: (url: string) => void;
|
||||
customizationDescription: string;
|
||||
setCustomizationDescription: (desc: string) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [fabricLoaded, setFabricLoaded] = useState(false);
|
||||
const [canvasInitialized, setCanvasInitialized] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricCanvasRef = useRef<any>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFabric = async () => {
|
||||
if (typeof window !== 'undefined' && !window.fabric) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js';
|
||||
script.onload = () => {
|
||||
console.log('Fabric.js loaded');
|
||||
setFabricLoaded(true);
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load Fabric.js');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
} else if (window.fabric) {
|
||||
console.log('Fabric.js already available');
|
||||
setFabricLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadFabric();
|
||||
|
||||
return () => {
|
||||
if (fabricCanvasRef.current) {
|
||||
fabricCanvasRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize canvas when Fabric is loaded AND canvas ref is available
|
||||
useEffect(() => {
|
||||
if (fabricLoaded && canvasRef.current && !canvasInitialized) {
|
||||
initCanvas();
|
||||
}
|
||||
}, [fabricLoaded, canvasInitialized]);
|
||||
|
||||
const initCanvas = () => {
|
||||
if (canvasRef.current && window.fabric && !fabricCanvasRef.current) {
|
||||
console.log('Initializing canvas');
|
||||
try {
|
||||
fabricCanvasRef.current = new window.fabric.Canvas(canvasRef.current, {
|
||||
width: 800,
|
||||
height: 600,
|
||||
backgroundColor: 'white'
|
||||
});
|
||||
|
||||
// Add some visual feedback that canvas is working
|
||||
fabricCanvasRef.current.renderAll();
|
||||
setCanvasInitialized(true);
|
||||
console.log('Canvas initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize canvas:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadImage = async (file: File): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('https://impr.ink/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
};
|
||||
|
||||
const handleFileSelect = async (files: FileList) => {
|
||||
if (images.length + files.length > 10) {
|
||||
alert('Maximum 10 images allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const newImages: CustomizationImage[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const url = await uploadImage(file);
|
||||
newImages.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
url,
|
||||
file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setImages([...images, ...newImages]);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addImageToCanvas = (imageUrl: string) => {
|
||||
console.log('Adding image to canvas:', imageUrl);
|
||||
console.log('Canvas available:', !!fabricCanvasRef.current);
|
||||
console.log('Fabric available:', !!window.fabric);
|
||||
|
||||
if (!fabricCanvasRef.current || !window.fabric) {
|
||||
console.error('Canvas or Fabric not available');
|
||||
return;
|
||||
}
|
||||
|
||||
window.fabric.Image.fromURL(imageUrl, (img: any) => {
|
||||
if (img) {
|
||||
console.log('Image loaded successfully');
|
||||
|
||||
// Scale the image to fit reasonably on canvas
|
||||
const maxWidth = 200;
|
||||
const maxHeight = 200;
|
||||
const scaleX = maxWidth / img.width;
|
||||
const scaleY = maxHeight / img.height;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
img.set({
|
||||
left: Math.random() * (800 - maxWidth),
|
||||
top: Math.random() * (600 - maxHeight),
|
||||
scaleX: scale,
|
||||
scaleY: scale
|
||||
});
|
||||
|
||||
fabricCanvasRef.current.add(img);
|
||||
fabricCanvasRef.current.setActiveObject(img);
|
||||
fabricCanvasRef.current.renderAll();
|
||||
console.log('Image added to canvas');
|
||||
} else {
|
||||
console.error('Failed to load image');
|
||||
}
|
||||
}, {
|
||||
crossOrigin: 'anonymous'
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
setImages(images.filter(img => img.id !== id));
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
if (fabricCanvasRef.current) {
|
||||
fabricCanvasRef.current.clear();
|
||||
fabricCanvasRef.current.backgroundColor = 'white';
|
||||
fabricCanvasRef.current.renderAll();
|
||||
}
|
||||
};
|
||||
|
||||
const generateFinalImage = async () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const dataURL = fabricCanvasRef.current.toDataURL({
|
||||
format: 'png',
|
||||
quality: 0.9
|
||||
});
|
||||
|
||||
const response = await fetch(dataURL);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'customization.png', { type: 'image/png' });
|
||||
const url = await uploadImage(file);
|
||||
setFinalImageUrl(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate final image:', error);
|
||||
alert('Failed to generate final image. Please try again.');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (!fabricLoaded) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={400}>
|
||||
<CircularProgress />
|
||||
<Typography ml={2}>Loading canvas editor...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Customize Your Product</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs:12, md:8 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">Design Canvas</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={clearCanvas}
|
||||
disabled={!canvasInitialized}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={generating ? <CircularProgress size={16} /> : <SaveIcon />}
|
||||
onClick={generateFinalImage}
|
||||
disabled={generating || !canvasInitialized}
|
||||
>
|
||||
{generating ? 'Saving...' : 'Save Design'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
backgroundColor: canvasInitialized ? 'transparent' : '#f5f5f5'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
{!canvasInitialized && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bgcolor="rgba(0,0,0,0.1)"
|
||||
>
|
||||
<Typography color="text.secondary">
|
||||
Initializing canvas...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" mt={1} display="block">
|
||||
Drag, resize, and rotate images on the canvas. Use the controls around selected images to manipulate them.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:12, md:4 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Image Library</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={images.length >= 10 || uploading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Add Images ({images.length}/10)
|
||||
</Button>
|
||||
|
||||
{images.length === 0 ? (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '2px dashed',
|
||||
borderColor: 'grey.300',
|
||||
bgcolor: 'grey.50',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<UploadIcon sx={{ fontSize: 40, color: 'grey.500', mb: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Drop images here or click to browse
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box>
|
||||
{images.map((image, index) => (
|
||||
<Box key={image.id} mb={1}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: canvasInitialized ? 'pointer' : 'not-allowed',
|
||||
opacity: canvasInitialized ? 1 : 0.6,
|
||||
'&:hover': canvasInitialized ? { bgcolor: 'grey.50' } : {}
|
||||
}}
|
||||
onClick={() => canvasInitialized && addImageToCanvas(image.url)}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Image ${index + 1}`}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" flex={1}>
|
||||
Image {index + 1}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(image.id);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{canvasInitialized
|
||||
? 'Click on any image to add it to the canvas'
|
||||
: 'Canvas is initializing...'
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{finalImageUrl && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Saved Design</Typography>
|
||||
<img
|
||||
src={finalImageUrl}
|
||||
alt="Final design"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #ddd'
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{uploading && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<CircularProgress size={16} />
|
||||
Uploading images...
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
handleFileSelect(e.target.files);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
241
ui/src/app/components/orderbuilder/StepDeliveryAddress.tsx
Normal file
241
ui/src/app/components/orderbuilder/StepDeliveryAddress.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box, Typography, FormControl, RadioGroup, FormControlLabel,
|
||||
Radio, Grid, Card, CardContent, Chip, Fade, Stack, Divider, Alert,
|
||||
Avatar, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LocationOn as LocationIcon,
|
||||
AddLocation as AddLocationIcon,
|
||||
Person as PersonIcon,
|
||||
Business as BusinessIcon,
|
||||
Phone as PhoneIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Address } from '@/types';
|
||||
|
||||
export default function StepDeliveryAddress({
|
||||
addresses,
|
||||
selectedAddress,
|
||||
setSelectedAddress,
|
||||
setShowAddressDialog,
|
||||
}: {
|
||||
addresses: Address[],
|
||||
selectedAddress: Address | null,
|
||||
setSelectedAddress: (addr: Address | null) => void,
|
||||
setShowAddressDialog: (open: boolean) => void,
|
||||
}) {
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
const parts = [
|
||||
address.addressLine1,
|
||||
address.addressLine2,
|
||||
`${address.city}, ${address.state} ${address.postalCode}`,
|
||||
address.country
|
||||
].filter(Boolean);
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const getInitials = (firstName: string, lastName: string) => {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Select Delivery Address
|
||||
</Typography>
|
||||
|
||||
{addresses.length === 0 && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
No addresses found. Please add a delivery address to continue.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl component="fieldset" sx={{ width: '100%' }}>
|
||||
<RadioGroup
|
||||
value={selectedAddress?.id || ''}
|
||||
onChange={(e) => {
|
||||
const addr = addresses.find(a => a.id === e.target.value);
|
||||
setSelectedAddress(addr || null);
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
{addresses.map((address) => (
|
||||
<Grid key={address.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: 180,
|
||||
border: '2px solid',
|
||||
borderColor: selectedAddress?.id === address.id ? 'primary.main' : 'grey.200',
|
||||
bgcolor: selectedAddress?.id === address.id ? 'primary.50' : 'background.paper',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
borderColor: selectedAddress?.id === address.id ? 'primary.main' : 'primary.light',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 2
|
||||
},
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
onClick={() => setSelectedAddress(address)}
|
||||
>
|
||||
<CardContent sx={{
|
||||
p: 2,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&:last-child': { pb: 2 }
|
||||
}}>
|
||||
<FormControlLabel
|
||||
value={address.id}
|
||||
control={
|
||||
<Radio
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
p: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5, pr: 4 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{getInitials(address.firstName, address.lastName)}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{address.firstName} {address.lastName}
|
||||
</Typography>
|
||||
{address.company && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{address.company}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{address.isDefault && (
|
||||
<Chip
|
||||
label="Default"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.6875rem',
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flex: 1 }}>
|
||||
<LocationIcon sx={{ fontSize: 16, color: 'text.secondary', mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.3,
|
||||
color: 'text.secondary',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{formatAddress(address)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ mt: 'auto' }}
|
||||
>
|
||||
{address.phoneNumber ? (
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<PhoneIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{address.phoneNumber}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box />
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
height: 180,
|
||||
border: '2px dashed',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'primary.25',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.50',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 2
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowAddressDialog(true)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<AddLocationIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" color="primary" sx={{ fontWeight: 600 }}>
|
||||
Add New Address
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8125rem' }}>
|
||||
Click to add delivery address
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
352
ui/src/app/components/orderbuilder/StepPayment.tsx
Normal file
352
ui/src/app/components/orderbuilder/StepPayment.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { lightTheme } from '../theme/lightTheme';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Fade,
|
||||
Stack,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
useTheme,
|
||||
ThemeProvider,
|
||||
createTheme
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Payment as PaymentIcon,
|
||||
Receipt as ReceiptIcon
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
useStripe,
|
||||
useElements,
|
||||
PaymentElement,
|
||||
AddressElement,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { Variant, Address } from '@/types';
|
||||
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export default function StepPayment({
|
||||
orderId,
|
||||
selectedVariant,
|
||||
quantity,
|
||||
getTotalPrice,
|
||||
selectedAddress,
|
||||
customizationImages = [],
|
||||
finalImageUrl = '',
|
||||
onSuccess
|
||||
}: {
|
||||
orderId: string;
|
||||
selectedVariant: Variant | null;
|
||||
quantity: number;
|
||||
getTotalPrice: () => number;
|
||||
selectedAddress: Address | null;
|
||||
customizationImages?: CustomizationImage[];
|
||||
finalImageUrl?: string;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const theme = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setMessage('');
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/payment-success`,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.type === 'card_error' || error.type === 'validation_error') {
|
||||
setMessage(error.message || 'An error occurred');
|
||||
} else {
|
||||
setMessage('An unexpected error occurred.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setMessage('Payment successful! 🎉');
|
||||
setIsSuccess(true);
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Create Stripe appearance object with light theme forced for payment sections
|
||||
const stripeAppearance = {
|
||||
theme: 'stripe' as const, // Always use light theme
|
||||
variables: {
|
||||
colorPrimary: '#1976d2', // Use a consistent primary color
|
||||
colorBackground: '#ffffff', // Force white background
|
||||
colorText: '#212121', // Force dark text
|
||||
colorDanger: '#d32f2f', // Red for errors
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
rules: {
|
||||
'.Input': {
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
color: '#212121',
|
||||
fontSize: '14px',
|
||||
padding: '12px',
|
||||
},
|
||||
'.Input:focus': {
|
||||
borderColor: '#1976d2',
|
||||
boxShadow: '0 0 0 1px #1976d2',
|
||||
},
|
||||
'.Label': {
|
||||
color: '#424242',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
'.Tab': {
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
color: '#757575',
|
||||
},
|
||||
'.Tab:hover': {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
'.Tab--selected': {
|
||||
backgroundColor: '#1976d2',
|
||||
color: '#ffffff',
|
||||
borderColor: '#1976d2',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const paymentElementOptions = {
|
||||
layout: 'tabs' as const,
|
||||
appearance: stripeAppearance,
|
||||
};
|
||||
|
||||
const addressElementOptions = {
|
||||
mode: 'billing' as const,
|
||||
// Remove allowedCountries to accept all countries
|
||||
fields: {
|
||||
phone: 'always' as const,
|
||||
},
|
||||
validation: {
|
||||
phone: {
|
||||
required: 'never' as const,
|
||||
},
|
||||
},
|
||||
appearance: stripeAppearance,
|
||||
};
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, textAlign: 'center' }}>
|
||||
<Stack spacing={4} alignItems="center" sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||
<CheckCircleIcon
|
||||
sx={{
|
||||
fontSize: 80,
|
||||
color: 'success.main',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(76, 175, 80, 0.3))'
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'success.main',
|
||||
fontSize: { xs: '1.75rem', md: '2.125rem' }
|
||||
}}
|
||||
>
|
||||
Payment Successful!
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: { xs: '1rem', md: '1.25rem' } }}
|
||||
>
|
||||
Thank you for your purchase!
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ width: '100%', bgcolor: 'success.50', border: '1px solid', borderColor: 'success.200' }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<ReceiptIcon color="success" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Order Details
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body1">
|
||||
<strong>Order ID:</strong> {orderId}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
You will receive a confirmation email shortly with your order details and tracking information.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariant || !selectedAddress) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||
Complete Your Payment
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Stack spacing={4}>
|
||||
<Card sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Order Summary
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color} × {quantity}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Total Amount:
|
||||
</Typography>
|
||||
<Typography variant="h5" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Billing Address - Always Light Theme */}
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Paper sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 3 }}>
|
||||
<PaymentIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Billing Information
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<AddressElement options={addressElementOptions} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</ThemeProvider>
|
||||
|
||||
{/* Payment Information - Always Light Theme */}
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Paper sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Payment Information
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<PaymentElement options={paymentElementOptions} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</ThemeProvider>
|
||||
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!stripe || !elements || isLoading}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
height: 48,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
<span>Processing...</span>
|
||||
</Stack>
|
||||
) : (
|
||||
`Pay ${getTotalPrice().toFixed(2)}$`
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
|
||||
{message && (
|
||||
<Alert
|
||||
severity={isSuccess ? "success" : "error"}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
116
ui/src/app/components/orderbuilder/StepProductDetails.tsx
Normal file
116
ui/src/app/components/orderbuilder/StepProductDetails.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Fade,
|
||||
Grid,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import { Product } from '@/types';
|
||||
|
||||
export default function StepProductDetails({ product }: { product: Product | null }) {
|
||||
if (!product) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, sm: 3, md: 4 } }}>
|
||||
<Grid container spacing={{ xs: 3, md: 4 }} alignItems="flex-start">
|
||||
<Grid size={{ xs: 12, md: 5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={product.imageUrl}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: { xs: 250, sm: 300, md: 350 },
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
boxShadow: 2,
|
||||
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 4
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 7 }}>
|
||||
<Stack spacing={{ xs: 2, md: 3 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
color: 'text.primary',
|
||||
fontSize: { xs: '1.75rem', sm: '2.125rem', md: '2.5rem' }
|
||||
}}
|
||||
>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="medium"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.8rem', md: '0.875rem' }
|
||||
}}
|
||||
/>
|
||||
{product.isCustomizable && (
|
||||
<Chip
|
||||
label="Customizable"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="medium"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.8rem', md: '0.875rem' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="h2"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '2.5rem', sm: '3rem', md: '3.5rem' },
|
||||
lineHeight: 1.1
|
||||
}}
|
||||
>
|
||||
${product.basePrice.toFixed(2)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
fontSize: { xs: '1rem', md: '1.125rem' },
|
||||
maxWidth: { md: '90%' }
|
||||
}}
|
||||
>
|
||||
{product.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
160
ui/src/app/components/orderbuilder/StepReviewOrder.tsx
Normal file
160
ui/src/app/components/orderbuilder/StepReviewOrder.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Grid, Card, CardContent, Fade, Stack, Divider } from '@mui/material';
|
||||
import { Variant, Address } from '@/types';
|
||||
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export default function StepReviewOrder({
|
||||
selectedVariant,
|
||||
quantity,
|
||||
getTotalPrice,
|
||||
selectedAddress,
|
||||
customizationImages = [],
|
||||
finalImageUrl = '',
|
||||
customizationDescription = ''
|
||||
}: {
|
||||
selectedVariant: Variant | null,
|
||||
quantity: number,
|
||||
getTotalPrice: () => number,
|
||||
selectedAddress: Address | null,
|
||||
customizationImages?: CustomizationImage[],
|
||||
finalImageUrl?: string,
|
||||
customizationDescription?: string
|
||||
}) {
|
||||
if (!selectedVariant || !selectedAddress) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||
Review Your Order
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Stack spacing={4}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Order Summary
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Quantity: {quantity}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h5" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{customizationImages.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Customization Details
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{customizationImages.length} original image{customizationImages.length > 1 ? 's' : ''} uploaded
|
||||
</Typography>
|
||||
{customizationDescription && (
|
||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||
{customizationDescription}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
{finalImageUrl && (
|
||||
<Card sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Preview
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={finalImageUrl}
|
||||
alt="Final customization"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Delivery Address
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{selectedAddress.firstName} {selectedAddress.lastName}
|
||||
</Typography>
|
||||
{selectedAddress.company && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedAddress.company}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.addressLine1}
|
||||
</Typography>
|
||||
{selectedAddress.addressLine2 && (
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.addressLine2}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.city}, {selectedAddress.state} {selectedAddress.postalCode}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.country}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
110
ui/src/app/components/orderbuilder/StepSelectVariant.tsx
Normal file
110
ui/src/app/components/orderbuilder/StepSelectVariant.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Grid, Card, Fade, Stack, Chip } from '@mui/material';
|
||||
import { Variant } from '@/types';
|
||||
|
||||
export default function StepSelectVariant({
|
||||
variants,
|
||||
selectedVariant,
|
||||
setSelectedVariant,
|
||||
}: {
|
||||
variants: Variant[],
|
||||
selectedVariant: Variant | null,
|
||||
setSelectedVariant: (variant: Variant) => void
|
||||
}) {
|
||||
return (
|
||||
<Fade in>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Select Variant
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{variants.map((variant) => (
|
||||
<Grid key={variant.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'transparent',
|
||||
bgcolor: selectedVariant?.id === variant.id ? 'primary.50' : 'background.paper',
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'primary.light',
|
||||
bgcolor: selectedVariant?.id === variant.id ? 'primary.50' : 'primary.25',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 3
|
||||
},
|
||||
p: 2
|
||||
}}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Box
|
||||
component="img"
|
||||
src={variant.imageUrl}
|
||||
alt={`${variant.size} - ${variant.color}`}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1.5,
|
||||
flexShrink: 0,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack spacing={1} sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
color: selectedVariant?.id === variant.id ? 'primary.main' : 'text.primary'
|
||||
}}
|
||||
>
|
||||
{variant.size} - {variant.color}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.85rem' }}
|
||||
>
|
||||
SKU: {variant.sku}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
${variant.price.toFixed(2)}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={variant.stockQuantity > 0 ? `${variant.stockQuantity} in stock` : 'Out of stock'}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
15
ui/src/app/components/orderbuilder/StepperHeader.tsx
Normal file
15
ui/src/app/components/orderbuilder/StepperHeader.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Stepper, Step, StepLabel } from '@mui/material';
|
||||
|
||||
export default function StepperHeader({ activeStep, steps }: { activeStep: number, steps: string[] }) {
|
||||
return (
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user