From c137a03a0ad7ffd9bfa0f63d325270f271a040aa Mon Sep 17 00:00:00 2001 From: lumijiez <59575049+lumijiez@users.noreply.github.com> Date: Thu, 26 Jun 2025 02:51:14 +0300 Subject: [PATCH] Orders work... --- .../Commands/Orders/CreateOrder.cs | 1 - .../Commands/Products/GetProductById.cs | 2 +- .../Orders/CreateOrderCommandValidator.cs | 7 - .../Controllers/ProductVariantsController.cs | 5 +- .../Controllers/ProductsController.cs | 2 +- ui/src/app/builder/[id]/page.tsx | 800 ++++++++++++++++++ 6 files changed, 805 insertions(+), 12 deletions(-) create mode 100644 ui/src/app/builder/[id]/page.tsx diff --git a/src/Imprink.Application/Commands/Orders/CreateOrder.cs b/src/Imprink.Application/Commands/Orders/CreateOrder.cs index 024a32e..098e5c8 100644 --- a/src/Imprink.Application/Commands/Orders/CreateOrder.cs +++ b/src/Imprink.Application/Commands/Orders/CreateOrder.cs @@ -12,7 +12,6 @@ public class CreateOrderCommand : IRequest public int Quantity { get; set; } public Guid ProductId { get; set; } public Guid ProductVariantId { get; set; } - public string? ComposingImageUrl { get; set; } public string[]? OriginalImageUrls { get; set; } = []; public string? CustomizationImageUrl { get; set; } = null!; public string? CustomizationDescription { get; set; } = null!; diff --git a/src/Imprink.Application/Commands/Products/GetProductById.cs b/src/Imprink.Application/Commands/Products/GetProductById.cs index 44f2d7a..d8b5bb3 100644 --- a/src/Imprink.Application/Commands/Products/GetProductById.cs +++ b/src/Imprink.Application/Commands/Products/GetProductById.cs @@ -17,7 +17,7 @@ public class GetProductById( CancellationToken cancellationToken) { var product = await unitOfWork.ProductRepository - .GetByIdAsync(request.ProductId, cancellationToken); + .GetByIdWithCategoryAsync(request.ProductId, cancellationToken); if (product == null) return null; diff --git a/src/Imprink.Application/Validation/Orders/CreateOrderCommandValidator.cs b/src/Imprink.Application/Validation/Orders/CreateOrderCommandValidator.cs index 5f30658..71ec02e 100644 --- a/src/Imprink.Application/Validation/Orders/CreateOrderCommandValidator.cs +++ b/src/Imprink.Application/Validation/Orders/CreateOrderCommandValidator.cs @@ -25,13 +25,6 @@ public class CreateOrderCommandValidator : AbstractValidator .NotEmpty() .WithMessage("Address ID is required."); - RuleFor(x => x.ComposingImageUrl) - .MaximumLength(2048) - .WithMessage("Composing image URL must not exceed 2048 characters.") - .Must(BeValidUrl) - .When(x => !string.IsNullOrEmpty(x.ComposingImageUrl)) - .WithMessage("Composing image URL must be a valid URL."); - RuleFor(x => x.CustomizationImageUrl) .MaximumLength(2048) .WithMessage("Customization image URL must not exceed 2048 characters.") diff --git a/src/Imprink.WebApi/Controllers/ProductVariantsController.cs b/src/Imprink.WebApi/Controllers/ProductVariantsController.cs index a1c8636..fa9c198 100644 --- a/src/Imprink.WebApi/Controllers/ProductVariantsController.cs +++ b/src/Imprink.WebApi/Controllers/ProductVariantsController.cs @@ -10,11 +10,12 @@ namespace Imprink.WebApi.Controllers; [Route("/api/products/variants")] public class ProductVariantsController(IMediator mediator) : ControllerBase { - [HttpGet] + [HttpGet("{id:guid}")] [AllowAnonymous] public async Task>> GetProductVariants( - [FromQuery] GetProductVariantsQuery query) + Guid id) { + var query = new GetProductVariantsQuery { ProductId = id }; return Ok(await mediator.Send(query)); } diff --git a/src/Imprink.WebApi/Controllers/ProductsController.cs b/src/Imprink.WebApi/Controllers/ProductsController.cs index 9a4fef9..6d23d6c 100644 --- a/src/Imprink.WebApi/Controllers/ProductsController.cs +++ b/src/Imprink.WebApi/Controllers/ProductsController.cs @@ -23,7 +23,7 @@ public class ProductsController(IMediator mediator) : ControllerBase [HttpGet("{id:guid}")] [AllowAnonymous] - public async Task>> GetProductById( + public async Task> GetProductById( Guid id, CancellationToken cancellationToken) { diff --git a/ui/src/app/builder/[id]/page.tsx b/ui/src/app/builder/[id]/page.tsx new file mode 100644 index 0000000..2268fff --- /dev/null +++ b/ui/src/app/builder/[id]/page.tsx @@ -0,0 +1,800 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +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 +} from '@mui/material'; +import { + Add as AddIcon, + Remove as RemoveIcon, + LocationOn as LocationIcon, + AddLocation as AddLocationIcon +} from '@mui/icons-material'; + +interface Category { + id: string; + name: string; + description: string; + imageUrl: string; + sortOrder: number; + isActive: boolean; + parentCategoryId: string; + createdAt: string; + modifiedAt: string; +} + +interface Product { + id: string; + name: string; + description: string; + basePrice: number; + isCustomizable: boolean; + isActive: boolean; + imageUrl: string; + categoryId: string; + category: Category; + createdAt: string; + modifiedAt: string; +} + +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']; + +export default function OrderBuilder() { + const router = useRouter(); + const params = useParams(); + const productId = params.id as string; + + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(false); + const [product, setProduct] = useState(null); + const [variants, setVariants] = useState([]); + const [addresses, setAddresses] = useState([]); + const [selectedVariant, setSelectedVariant] = useState(null); + const [quantity, setQuantity] = useState(1); + const [selectedAddress, setSelectedAddress] = useState
(null); + const [showAddressDialog, setShowAddressDialog] = useState(false); + const [newAddress, setNewAddress] = useState({ + addressType: 'Home', + firstName: '', + lastName: '', + company: '', + addressLine1: '', + addressLine2: '', + apartmentNumber: '', + buildingNumber: '', + floor: '', + city: '', + state: '', + postalCode: '', + country: '', + phoneNumber: '', + instructions: '', + isDefault: false, + isActive: true + }); + + useEffect(() => { + 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); + } finally { + setLoading(false); + } + }; + + 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); + } finally { + setLoading(false); + } + }; + + 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); + } + } catch (error) { + console.error('Failed to load addresses:', error); + } finally { + setLoading(false); + } + }; + + const handleNext = () => { + if (activeStep === 0 && product) { + loadVariants(); + } else if (activeStep === 2) { + loadAddresses(); + } + setActiveStep((prevActiveStep) => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handleQuantityChange = (delta: number) => { + const newQuantity = quantity + delta; + if (newQuantity >= 1) { + setQuantity(newQuantity); + } + }; + + const handleAddAddress = async () => { + try { + const addedAddress = await clientApi.post('/addresses', newAddress); + setAddresses([...addresses, addedAddress.data]); + setSelectedAddress(addedAddress.data); + setShowAddressDialog(false); + setNewAddress({ + addressType: 'shipping', + firstName: '', + lastName: '', + company: '', + addressLine1: '', + addressLine2: '', + apartmentNumber: '', + buildingNumber: '', + floor: '', + city: '', + state: '', + postalCode: '', + country: '', + phoneNumber: '', + instructions: '', + isDefault: false, + isActive: true + }); + } catch (error) { + console.error('Failed to add address:', error); + } + }; + + const handlePlaceOrder = async () => { + if (!selectedVariant || !selectedAddress) return; + + const orderData = { + productId: product!.id, + productVariantId: selectedVariant.id, + quantity: quantity, + addressId: selectedAddress.id, + totalPrice: selectedVariant.price * quantity + }; + + try { + setLoading(true); + await clientApi.post('/orders', orderData); + router.push('/orders/success'); + } catch (error) { + console.error('Failed to place order:', error); + } finally { + setLoading(false); + } + }; + + const getTotalPrice = () => { + if (!selectedVariant) return 0; + return selectedVariant.price * quantity; + }; + + const canProceed = () => { + switch (activeStep) { + case 0: return product !== null; + case 1: return selectedVariant !== null; + case 2: return quantity > 0; + case 3: return selectedAddress !== null; + default: return true; + } + }; + + const renderStepContent = () => { + switch (activeStep) { + case 0: + return ( + + + {product && ( + + + + + {product.name} + + + {product.description} + + + ${product.basePrice.toFixed(2)} + + + + {product.isCustomizable && } + + + + )} + + + ); + + case 1: + return ( + + + + Select Variant + + + {variants.map((variant) => ( + + setSelectedVariant(variant)} + > + + + + {variant.size} - {variant.color} + + + SKU: {variant.sku} + + + ${variant.price.toFixed(2)} + + 0 ? 'success.main' : 'error.main'}> + {variant.stockQuantity > 0 ? `${variant.stockQuantity} in stock` : 'Out of stock'} + + + + + ))} + + + + ); + + case 2: + return ( + + + + Choose Quantity + + {selectedVariant && ( + + + + + + {selectedVariant.size} + + + {selectedVariant.product.name} + + + {selectedVariant.size} - {selectedVariant.color} + + + ${selectedVariant.price.toFixed(2)} each + + + + + + + handleQuantityChange(-1)} + disabled={quantity <= 1} + > + + + { + const val = parseInt(e.target.value) || 1; + if (val >= 1) setQuantity(val); + }} + inputProps={{ + style: { textAlign: 'center', fontSize: '1.2rem' }, + min: 1 + }} + sx={{ width: 80 }} + /> + handleQuantityChange(1)}> + + + + + + + + Total: ${getTotalPrice().toFixed(2)} + + + + + )} + + + ); + + case 3: + return ( + + + + Select Delivery Address + + + { + const addr = addresses.find(a => a.id === e.target.value); + setSelectedAddress(addr || null); + }} + > + + {addresses.map((address) => ( + + + + } + label="" + sx={{ position: 'absolute', top: 8, right: 8 }} + /> + + + + {address.firstName} {address.lastName} + + {address.isDefault && } + + {address.company && ( + + {address.company} + + )} + + {address.addressLine1} + + {address.addressLine2 && ( + + {address.addressLine2} + + )} + + {address.city}, {address.state} {address.postalCode} + + + {address.country} + + {address.phoneNumber && ( + + Phone: {address.phoneNumber} + + )} + + + + ))} + + setShowAddressDialog(true)} + > + + + + + Add New Address + + + + + + + + + + + ); + + case 4: + return ( + + + + Review Your Order + + + + + + + Order Summary + + {selectedVariant && ( + + {selectedVariant.size} + + + {selectedVariant.product.name} + + + {selectedVariant.size} - {selectedVariant.color} + + + Quantity: {quantity} + + + + ${getTotalPrice().toFixed(2)} + + + )} + + + + + + + + Delivery Address + + {selectedAddress && ( + + + {selectedAddress.firstName} {selectedAddress.lastName} + + {selectedAddress.company && ( + + {selectedAddress.company} + + )} + + {selectedAddress.addressLine1} + + {selectedAddress.addressLine2 && ( + + {selectedAddress.addressLine2} + + )} + + {selectedAddress.city}, {selectedAddress.state} {selectedAddress.postalCode} + + + {selectedAddress.country} + + + )} + + + + + + + ); + + default: + return null; + } + }; + + if (loading && !product) { + return ( + + + + ); + } + + return ( + + + + {steps.map((label) => ( + + {label} + + ))} + + + + {renderStepContent()} + + + + + + + + + setShowAddressDialog(false)} maxWidth="md" fullWidth> + Add New Address + + + + setNewAddress({...newAddress, firstName: e.target.value})} + /> + + + setNewAddress({...newAddress, lastName: e.target.value})} + /> + + + setNewAddress({...newAddress, company: e.target.value})} + /> + + + setNewAddress({...newAddress, addressLine1: e.target.value})} + /> + + + setNewAddress({...newAddress, addressLine2: e.target.value})} + /> + + + setNewAddress({...newAddress, apartmentNumber: e.target.value})} + /> + + + setNewAddress({...newAddress, buildingNumber: e.target.value})} + /> + + + setNewAddress({...newAddress, floor: e.target.value})} + /> + + + setNewAddress({...newAddress, city: e.target.value})} + /> + + + setNewAddress({...newAddress, state: e.target.value})} + /> + + + setNewAddress({...newAddress, postalCode: e.target.value})} + /> + + + setNewAddress({...newAddress, country: e.target.value})} + /> + + + setNewAddress({...newAddress, phoneNumber: e.target.value})} + /> + + + setNewAddress({...newAddress, instructions: e.target.value})} + /> + + + + + + + + + + ); +} \ No newline at end of file