+
+
+
+ {/* Order Summary */}
+
+
+
+ Order Summary
+
+
+
+
+
+ Subtotal:
+
+ ${cart.subtotal.toFixed(2)}
+
+
+
+
+ Tax (10%):
+
+ ${cart.tax.toFixed(2)}
+
+
+
+
+ Shipping:
+
+ {cart.shipping === 0 ? 'FREE' : `$${cart.shipping.toFixed(2)}`}
+
+
+
+ {cart.subtotal < 50 && cart.subtotal > 0 && (
+
+ Add ${(50 - cart.subtotal).toFixed(2)} more for free shipping!
+
+ )}
+
+
+
+
+
+ Total:
+
+
+ ${cart.total.toFixed(2)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Clear Cart Confirmation Dialog */}
+
+
+ {/* Remove Individual Item Confirmation Dialog */}
+
+
+ {/* Remove Selected Items Confirmation Dialog */}
+
+
+ )
+}
+
+export default CartPage
diff --git a/augment-store/client/src/features/cart/types/index.ts b/augment-store/client/src/features/cart/types/index.ts
new file mode 100644
index 000000000..404037ed1
--- /dev/null
+++ b/augment-store/client/src/features/cart/types/index.ts
@@ -0,0 +1,28 @@
+import type { Product } from '@features/products/types'
+
+export interface CartItem {
+ id: string
+ product: Product
+ quantity: number
+ price: number
+ subtotal: number
+}
+
+export interface Cart {
+ id: string
+ items: CartItem[]
+ subtotal: number
+ tax: number
+ shipping: number
+ total: number
+ itemCount: number
+}
+
+export interface AddToCartRequest {
+ productId: string
+ quantity: number
+}
+
+export interface UpdateCartItemRequest {
+ quantity: number
+}
diff --git a/augment-store/client/src/features/checkout/components/CheckoutPage.tsx b/augment-store/client/src/features/checkout/components/CheckoutPage.tsx
new file mode 100644
index 000000000..9ccf99e74
--- /dev/null
+++ b/augment-store/client/src/features/checkout/components/CheckoutPage.tsx
@@ -0,0 +1,14 @@
+import { Container, Typography } from '@mui/material'
+
+const CheckoutPage = () => {
+ return (
+
+
+ Checkout
+
+ Checkout form will be displayed here
+
+ )
+}
+
+export default CheckoutPage
diff --git a/augment-store/client/src/features/info/about/components/AboutPage.tsx b/augment-store/client/src/features/info/about/components/AboutPage.tsx
new file mode 100644
index 000000000..1748f12ca
--- /dev/null
+++ b/augment-store/client/src/features/info/about/components/AboutPage.tsx
@@ -0,0 +1,53 @@
+import { Container, Typography, Box, Paper } from '@mui/material'
+
+const AboutPage = () => {
+ return (
+
+
+
+ About Augment Store
+
+
+
+
+ Our Story
+
+
+ Welcome to Augment Store, your trusted destination for quality products and exceptional
+ service. We are committed to providing our customers with the best shopping experience
+ possible.
+
+
+
+
+
+ Our Mission
+
+
+ Our mission is to deliver high-quality products at competitive prices while maintaining
+ the highest standards of customer service. We believe in building lasting relationships
+ with our customers through trust, transparency, and excellence.
+
+
+
+
+
+ Why Choose Us
+
+
+
+
Wide selection of quality products
+
Competitive pricing
+
Fast and reliable shipping
+
Excellent customer support
+
Secure payment processing
+
Easy returns and exchanges
+
+
+
+
+
+ )
+}
+
+export default AboutPage
diff --git a/augment-store/client/src/features/info/contact/components/ContactPage.tsx b/augment-store/client/src/features/info/contact/components/ContactPage.tsx
new file mode 100644
index 000000000..ac0fc878f
--- /dev/null
+++ b/augment-store/client/src/features/info/contact/components/ContactPage.tsx
@@ -0,0 +1,93 @@
+import { Container, Typography, Box, Paper, Grid, TextField, Button } from '@mui/material'
+import { Email, Phone, LocationOn } from '@mui/icons-material'
+
+const ContactPage = () => {
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ // TODO: Implement contact form submission
+ console.log('Contact form submitted')
+ }
+
+ return (
+
+
+ Contact Us
+
+
+
+
+
+
+ Get in Touch
+
+
+ Have a question or need assistance? We're here to help! Fill out the form and we'll
+ get back to you as soon as possible.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Contact Information
+
+
+
+
+
+
+ Email
+ support@augmentstore.com
+
+
+
+
+
+
+ Phone
+ +1 (555) 123-4567
+
+
+
+
+
+
+ Address
+
+ 123 Commerce Street
+
+ San Francisco, CA 94102
+
+ United States
+
+
+
+
+
+
+
+ Business Hours
+
+ Monday - Friday: 9:00 AM - 6:00 PM PST
+ Saturday: 10:00 AM - 4:00 PM PST
+ Sunday: Closed
+
+
+
+
+
+ )
+}
+
+export default ContactPage
diff --git a/augment-store/client/src/features/info/help/components/HelpPage.tsx b/augment-store/client/src/features/info/help/components/HelpPage.tsx
new file mode 100644
index 000000000..c0f19c6b2
--- /dev/null
+++ b/augment-store/client/src/features/info/help/components/HelpPage.tsx
@@ -0,0 +1,113 @@
+import {
+ Container,
+ Typography,
+ Box,
+ Paper,
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+} from '@mui/material'
+import { ExpandMore } from '@mui/icons-material'
+
+const HelpPage = () => {
+ const faqs = [
+ {
+ question: 'How do I place an order?',
+ answer:
+ "Browse our products, add items to your cart, and proceed to checkout. You'll need to create an account or log in to complete your purchase.",
+ },
+ {
+ question: 'What payment methods do you accept?',
+ answer:
+ 'We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and other secure payment methods.',
+ },
+ {
+ question: 'How can I track my order?',
+ answer:
+ 'Once your order ships, you\'ll receive a tracking number via email. You can also view your order status in the "My Orders" section of your account.',
+ },
+ {
+ question: 'What is your return policy?',
+ answer:
+ 'We offer a 30-day return policy for most items. Products must be unused and in original packaging. See our Returns page for full details.',
+ },
+ {
+ question: 'How long does shipping take?',
+ answer:
+ 'Standard shipping typically takes 5-7 business days. Express shipping options are available at checkout for faster delivery.',
+ },
+ {
+ question: 'Do you ship internationally?',
+ answer:
+ 'Yes, we ship to many countries worldwide. Shipping costs and delivery times vary by location. International orders may be subject to customs fees.',
+ },
+ {
+ question: 'How do I reset my password?',
+ answer:
+ 'Click on "Forgot Password" on the login page. Enter your email address and we\'ll send you instructions to reset your password.',
+ },
+ {
+ question: 'Can I cancel or modify my order?',
+ answer:
+ 'Orders can be cancelled or modified within 1 hour of placement. After that, please contact customer support for assistance.',
+ },
+ {
+ question: 'Are my payment details secure?',
+ answer:
+ 'Yes, we use industry-standard SSL encryption to protect your payment information. We never store your full credit card details on our servers.',
+ },
+ {
+ question: 'How do I contact customer support?',
+ answer:
+ 'You can reach us via email at support@augmentstore.com, by phone at +1 (555) 123-4567, or through our Contact page.',
+ },
+ ]
+
+ return (
+
+
+
+ Help Center
+
+
+
+ Find answers to frequently asked questions below. If you need additional assistance,
+ please don't hesitate to contact our customer support team.
+
+
+
+
+ Frequently Asked Questions
+
+
+ {faqs.map((faq, index) => (
+
+ }>
+
+ {faq.question}
+
+
+
+
+ {faq.answer}
+
+
+
+ ))}
+
+
+
+
+ Still Need Help?
+
+
+ If you couldn't find the answer you're looking for, our customer support team is ready
+ to assist you. Contact us via email, phone, or our contact form.
+
+
+
+
+ )
+}
+
+export default HelpPage
diff --git a/augment-store/client/src/features/info/privacy/components/PrivacyPage.tsx b/augment-store/client/src/features/info/privacy/components/PrivacyPage.tsx
new file mode 100644
index 000000000..3bce24852
--- /dev/null
+++ b/augment-store/client/src/features/info/privacy/components/PrivacyPage.tsx
@@ -0,0 +1,193 @@
+import { Box, Container, Typography, Paper } from '@mui/material'
+import { Colors } from '@config/colors'
+
+const PrivacyPage = () => {
+ return (
+
+
+
+ Privacy Policy
+
+
+
+ Last Updated: {new Date().toLocaleDateString()}
+
+
+ *': { mb: 3 } }}>
+
+
+ 1. Introduction
+
+
+ We respect your privacy and are committed to protecting your personal data. This privacy policy will
+ inform you about how we look after your personal data when you visit our platform and tell you about
+ your privacy rights and how the law protects you.
+
+
+
+
+
+ 2. Information We Collect
+
+
+ We may collect, use, store and transfer different kinds of personal data about you:
+
+
+
+ Identity Data: First name, last name, username or similar identifier
+
+ Transaction Data: Details about payments and products you have purchased from us
+
+
+ Technical Data: IP address, browser type and version, time zone setting, browser
+ plug-in types and versions, operating system and platform
+
+
+ Usage Data: Information about how you use our platform, products and services
+
+
+ Marketing Data: Your preferences in receiving marketing from us and your communication
+ preferences
+
+
+
+
+
+
+ 3. How We Use Your Information
+
+
+ We will only use your personal data when the law allows us to. Most commonly, we will use your personal
+ data in the following circumstances:
+
+
+
To process and deliver your orders
+
To manage your account and provide customer support
+
To send you important information regarding your purchases
+
To improve our platform and services
+
To personalize your experience
+
To send you marketing communications (with your consent)
+
To detect and prevent fraud
+
+
+
+
+
+ 4. Data Security
+
+
+ We have put in place appropriate security measures to prevent your personal data from being accidentally
+ lost, used or accessed in an unauthorized way, altered or disclosed. We limit access to your personal
+ data to those employees, agents, contractors and other third parties who have a business need to know.
+
+
+ All payment transactions are encrypted using SSL technology. We do not store complete payment card
+ details on our servers.
+
+
+
+
+
+ 5. Data Retention
+
+
+ We will only retain your personal data for as long as necessary to fulfill the purposes we collected it
+ for, including for the purposes of satisfying any legal, accounting, or reporting requirements.
+
+
+
+
+
+ 6. Your Legal Rights
+
+
+ Under certain circumstances, you have rights under data protection laws in relation to your personal
+ data:
+
+
+
Request access to your personal data
+
Request correction of your personal data
+
Request erasure of your personal data
+
Object to processing of your personal data
+
Request restriction of processing your personal data
+
Request transfer of your personal data
+
Right to withdraw consent
+
+
+
+
+
+ 7. Cookies
+
+
+ Our platform uses cookies to distinguish you from other users. This helps us to provide you with a good
+ experience when you browse our platform and also allows us to improve our site. A cookie is a small file
+ of letters and numbers that we store on your browser or the hard drive of your computer.
+
+
+
+
+
+ 8. Third-Party Links
+
+
+ Our platform may include links to third-party websites, plug-ins and applications. Clicking on those
+ links or enabling those connections may allow third parties to collect or share data about you. We do not
+ control these third-party websites and are not responsible for their privacy statements.
+
+
+
+
+
+ 9. Children's Privacy
+
+
+ Our Service is not intended for children under 13 years of age. We do not knowingly collect personal
+ information from children under 13. If you are a parent or guardian and you are aware that your child has
+ provided us with personal data, please contact us.
+
+
+
+
+
+ 10. Changes to This Privacy Policy
+
+
+ We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new
+ Privacy Policy on this page and updating the "Last Updated" date at the top of this Privacy Policy.
+
+
+
+
+
+ 11. Contact Us
+
+
+ If you have any questions about this Privacy Policy or our privacy practices, please contact us through
+ our Contact page.
+
+
+
+
+
+ )
+}
+
+export default PrivacyPage
+
diff --git a/augment-store/client/src/features/info/returns/components/ReturnsPage.tsx b/augment-store/client/src/features/info/returns/components/ReturnsPage.tsx
new file mode 100644
index 000000000..6e84109e7
--- /dev/null
+++ b/augment-store/client/src/features/info/returns/components/ReturnsPage.tsx
@@ -0,0 +1,120 @@
+import { Container, Typography, Box, Paper, Alert } from '@mui/material'
+
+const ReturnsPage = () => {
+ return (
+
+
+
+ Returns & Refunds
+
+
+
+ We want you to be completely satisfied with your purchase. If you're not happy with your
+ order, we're here to help.
+
+
+
+
+ Return Policy
+
+
+ We offer a 30-day return policy for most items. To be eligible for a return, your item
+ must be:
+
+
+
+
Unused and in the same condition that you received it
+
In the original packaging
+
Accompanied by the receipt or proof of purchase
+
+
+
+
+
+
+ Non-Returnable Items
+
+
+ Certain items cannot be returned, including:
+
+
+
+
Perishable goods (food, flowers, etc.)
+
Custom or personalized items
+
Personal care items (for hygiene reasons)
+
Hazardous materials
+
Gift cards
+
Downloadable software or digital products
+
+
+
+
+
+
+ How to Return an Item
+
+
+
+
Log in to your account and go to "My Orders"
+
Select the order containing the item you wish to return
+
Click "Request Return" and follow the instructions
+
Pack the item securely in its original packaging
+
Ship the item to the address provided in your return confirmation
+
+
+
+
+
+
+ Refunds
+
+
+ Once we receive your return, we will inspect the item and notify you of the approval or
+ rejection of your refund.
+
+
+ If approved, your refund will be processed and a credit will automatically be applied to
+ your original method of payment within 5-10 business days.
+
+
+
+
+
+ Exchanges
+
+
+ We only replace items if they are defective or damaged. If you need to exchange an item
+ for the same product, please contact us at support@augmentstore.com.
+
+
+
+
+
+ Shipping Costs
+
+
+ You will be responsible for paying your own shipping costs for returning your item.
+ Shipping costs are non-refundable. If you receive a refund, the cost of return shipping
+ will be deducted from your refund.
+
+
+ If the item was defective or damaged upon arrival, we will cover the return shipping
+ costs.
+
+
+
+
+
+ Need Help?
+
+
+ If you have any questions about our return policy, please contact our customer support
+ team at support@augmentstore.com or call +1 (555) 123-4567.
+
+
+
+
+ )
+}
+
+export default ReturnsPage
diff --git a/augment-store/client/src/features/info/shipping/components/ShippingPage.tsx b/augment-store/client/src/features/info/shipping/components/ShippingPage.tsx
new file mode 100644
index 000000000..9357f29f7
--- /dev/null
+++ b/augment-store/client/src/features/info/shipping/components/ShippingPage.tsx
@@ -0,0 +1,166 @@
+import {
+ Container,
+ Typography,
+ Box,
+ Paper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+} from '@mui/material'
+
+const ShippingPage = () => {
+ const domesticRates = [
+ { method: 'Standard Shipping', time: '5-7 business days', cost: '$5.99' },
+ { method: 'Express Shipping', time: '2-3 business days', cost: '$12.99' },
+ { method: 'Overnight Shipping', time: '1 business day', cost: '$24.99' },
+ ]
+
+ const internationalRates = [
+ { region: 'Canada', time: '7-14 business days', cost: '$15.99' },
+ { region: 'Europe', time: '10-21 business days', cost: '$29.99' },
+ { region: 'Asia', time: '10-21 business days', cost: '$29.99' },
+ { region: 'Australia', time: '10-21 business days', cost: '$34.99' },
+ { region: 'Rest of World', time: '14-28 business days', cost: '$39.99' },
+ ]
+
+ return (
+
+
+
+ Shipping Information
+
+
+
+ We offer various shipping options to meet your needs. All orders are processed within 1-2
+ business days (excluding weekends and holidays).
+
+
+
+
+ Domestic Shipping (United States)
+
+
+
+
+
+ * International orders may be subject to customs fees and import duties
+
+
+
+
+
+ Order Tracking
+
+
+ Once your order has shipped, you will receive a confirmation email with a tracking
+ number. You can track your package using this number on our website or the carrier's
+ website.
+
+
+ You can also view your order status anytime by logging into your account and visiting
+ the "My Orders" section.
+
+
+
+
+
+ Shipping Restrictions
+
+
+ We currently ship to most countries worldwide. However, some items may have shipping
+ restrictions due to size, weight, or local regulations. These restrictions will be noted
+ on the product page.
+
+
+ We do not ship to P.O. boxes for certain items. Please provide a physical address for
+ delivery when possible.
+
+
+
+
+
+ Damaged or Lost Packages
+
+
+ If your package arrives damaged or goes missing during transit, please contact us
+ immediately at support@augmentstore.com. We will work with the carrier to resolve the
+ issue and ensure you receive your order.
+
+
+
+
+
+ Questions About Shipping?
+
+
+ If you have any questions about shipping or need assistance with your order, please
+ contact our customer support team at support@augmentstore.com or call +1 (555) 123-4567.
+
+
+
+
+ )
+}
+
+export default ShippingPage
diff --git a/augment-store/client/src/features/info/terms/components/TermsPage.tsx b/augment-store/client/src/features/info/terms/components/TermsPage.tsx
new file mode 100644
index 000000000..d869c7257
--- /dev/null
+++ b/augment-store/client/src/features/info/terms/components/TermsPage.tsx
@@ -0,0 +1,171 @@
+import { Box, Container, Typography, Paper } from '@mui/material'
+import { Colors } from '@config/colors'
+
+const TermsPage = () => {
+ return (
+
+
+
+ Terms and Conditions
+
+
+
+ Last Updated: {new Date().toLocaleDateString()}
+
+
+ *': { mb: 3 } }}>
+
+
+ 1. Acceptance of Terms
+
+
+ By accessing and using this e-commerce platform ("Service"), you accept and agree to be bound by the
+ terms and provision of this agreement. If you do not agree to abide by the above, please do not use
+ this service.
+
+
+
+
+
+ 2. Use License
+
+
+ Permission is granted to temporarily access the materials (information or software) on our platform for
+ personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of
+ title, and under this license you may not:
+
+
+
Modify or copy the materials
+
Use the materials for any commercial purpose or for any public display
+
Attempt to reverse engineer any software contained on our platform
+
Remove any copyright or other proprietary notations from the materials
+
Transfer the materials to another person or "mirror" the materials on any other server
+
+
+
+
+
+ 3. Account Terms
+
+
+ You are responsible for maintaining the security of your account and password. We cannot and will not be
+ liable for any loss or damage from your failure to comply with this security obligation.
+
+
+ You are responsible for all content posted and activity that occurs under your account.
+
+
+
+
+
+ 4. Product Information
+
+
+ We strive to provide accurate product descriptions and pricing. However, we do not warrant that product
+ descriptions, pricing, or other content is accurate, complete, reliable, current, or error-free. If a
+ product offered by us is not as described, your sole remedy is to return it in unused condition.
+
+
+
+
+
+ 5. Pricing and Payment
+
+
+ All prices are subject to change without notice. We reserve the right to modify or discontinue products
+ without notice. We shall not be liable to you or any third party for any modification, price change,
+ suspension, or discontinuance of any product.
+
+
+ Payment must be received by us before your order is dispatched. We accept various payment methods as
+ indicated during checkout.
+
+
+
+
+
+ 6. Shipping and Delivery
+
+
+ We will arrange for shipment of ordered products to you. Please check the individual product page for
+ specific delivery options. Title and risk of loss pass to you upon our delivery to the carrier. Shipping
+ and handling charges are non-refundable.
+
+
+
+
+
+ 7. Returns and Refunds
+
+
+ Please review our Returns Policy for detailed information about returns and refunds. In general, items
+ may be returned within 30 days of receipt in their original condition.
+
+
+
+
+
+ 8. Limitation of Liability
+
+
+ In no event shall our company or its suppliers be liable for any damages (including, without limitation,
+ damages for loss of data or profit, or due to business interruption) arising out of the use or inability
+ to use the materials on our platform.
+
+
+
+
+
+ 9. Privacy
+
+
+ Your use of our Service is also governed by our Privacy Policy. Please review our Privacy Policy, which
+ also governs the Service and informs users of our data collection practices.
+
+
+
+
+
+ 10. Modifications to Terms
+
+
+ We reserve the right to revise these terms of service at any time without notice. By using this Service
+ you are agreeing to be bound by the then current version of these terms of service.
+
+
+
+
+
+ 11. Governing Law
+
+
+ These terms and conditions are governed by and construed in accordance with the laws and you irrevocably
+ submit to the exclusive jurisdiction of the courts in that location.
+
+
+
+
+
+ 12. Contact Information
+
+
+ If you have any questions about these Terms and Conditions, please contact us through our Contact page.
+
+
+
+
+
+ )
+}
+
+export default TermsPage
+
diff --git a/augment-store/client/src/features/orders/order-detail/components/OrderDetailPage.tsx b/augment-store/client/src/features/orders/order-detail/components/OrderDetailPage.tsx
new file mode 100644
index 000000000..11923c107
--- /dev/null
+++ b/augment-store/client/src/features/orders/order-detail/components/OrderDetailPage.tsx
@@ -0,0 +1,17 @@
+import { Container, Typography } from '@mui/material'
+import { useParams } from 'react-router-dom'
+
+const OrderDetailPage = () => {
+ const { id } = useParams()
+
+ return (
+
+
+ Order Detail
+
+ Order ID: {id}
+
+ )
+}
+
+export default OrderDetailPage
diff --git a/augment-store/client/src/features/orders/order-list/components/OrdersPage.tsx b/augment-store/client/src/features/orders/order-list/components/OrdersPage.tsx
new file mode 100644
index 000000000..b88547ae3
--- /dev/null
+++ b/augment-store/client/src/features/orders/order-list/components/OrdersPage.tsx
@@ -0,0 +1,14 @@
+import { Container, Typography } from '@mui/material'
+
+const OrdersPage = () => {
+ return (
+
+
+ My Orders
+
+ Order list will be displayed here
+
+ )
+}
+
+export default OrdersPage
diff --git a/augment-store/client/src/features/orders/types/index.ts b/augment-store/client/src/features/orders/types/index.ts
new file mode 100644
index 000000000..34c3c7a62
--- /dev/null
+++ b/augment-store/client/src/features/orders/types/index.ts
@@ -0,0 +1,41 @@
+import type { CartItem } from '@features/cart/types'
+import type { Address } from '@features/user/types'
+
+export type OrderStatus =
+ | 'pending'
+ | 'confirmed'
+ | 'processing'
+ | 'shipped'
+ | 'delivered'
+ | 'cancelled'
+
+export interface Order {
+ id: string
+ orderNumber: string
+ items: CartItem[]
+ subtotal: number
+ tax: number
+ shipping: number
+ total: number
+ status: OrderStatus
+ shippingAddress: Address
+ billingAddress: Address
+ paymentMethod: string
+ paymentStatus: 'pending' | 'paid' | 'failed' | 'refunded'
+ createdAt: string
+ updatedAt: string
+}
+
+export interface CreateOrderRequest {
+ shippingAddressId: string
+ billingAddressId: string
+ paymentMethodId: string
+}
+
+export interface OrderListResponse {
+ orders: Order[]
+ total: number
+ page: number
+ limit: number
+ totalPages: number
+}
diff --git a/augment-store/client/src/features/products/product-detail/components/ImageGallery.tsx b/augment-store/client/src/features/products/product-detail/components/ImageGallery.tsx
new file mode 100644
index 000000000..09bc89e24
--- /dev/null
+++ b/augment-store/client/src/features/products/product-detail/components/ImageGallery.tsx
@@ -0,0 +1,335 @@
+import { useState, useRef, MouseEvent } from 'react'
+import { Box, IconButton, Dialog } from '@mui/material'
+import { Close as CloseIcon, ZoomIn as ZoomInIcon } from '@mui/icons-material'
+import { Swiper, SwiperSlide } from 'swiper/react'
+import { Navigation, Pagination, Keyboard, Mousewheel } from 'swiper/modules'
+import type { Swiper as SwiperType } from 'swiper'
+
+// Import Swiper styles - using bundle for better compatibility
+import 'swiper/swiper-bundle.css'
+
+interface ImageGalleryProps {
+ images: string[]
+ productName: string
+}
+
+const ImageGallery = ({ images, productName }: ImageGalleryProps) => {
+ const [activeStep, setActiveStep] = useState(0)
+ const [isZoomed, setIsZoomed] = useState(false)
+ const [zoomPosition, setZoomPosition] = useState({ x: 0, y: 0 })
+ const [isFullscreen, setIsFullscreen] = useState(false)
+ const imageRef = useRef(null)
+ const swiperRef = useRef(null)
+ const maxSteps = images.length
+
+ const handleSlideChange = (swiper: SwiperType) => {
+ setActiveStep(swiper.activeIndex)
+ setIsZoomed(false) // Reset zoom when changing images
+ }
+
+ const handleThumbnailClick = (index: number) => {
+ setIsZoomed(false)
+ swiperRef.current?.slideTo(index)
+ }
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!imageRef.current) return
+
+ const rect = imageRef.current.getBoundingClientRect()
+ const x = ((e.clientX - rect.left) / rect.width) * 100
+ const y = ((e.clientY - rect.top) / rect.height) * 100
+
+ setZoomPosition({ x, y })
+ }
+
+ const handleMouseEnter = () => {
+ setIsZoomed(true)
+ }
+
+ const handleMouseLeave = () => {
+ setIsZoomed(false)
+ }
+
+ const handleFullscreenOpen = () => {
+ setIsFullscreen(true)
+ }
+
+ const handleFullscreenClose = () => {
+ setIsFullscreen(false)
+ }
+
+ return (
+
+ {/* Main Image Swiper */}
+
+ (swiperRef.current = swiper)}
+ onSlideChange={handleSlideChange}
+ spaceBetween={0}
+ slidesPerView={1}
+ style={{ width: '100%', height: '100%' }}
+ >
+ {images.map((image, index) => (
+
+
+
+ ))}
+
+
+ {/* Fullscreen Button */}
+
+
+
+
+
+ {/* Thumbnail Navigation */}
+ {maxSteps > 1 && (
+
+ {images.map((image, index) => (
+ handleThumbnailClick(index)}
+ sx={{
+ minWidth: 80,
+ height: 80,
+ borderRadius: 1,
+ overflow: 'hidden',
+ cursor: 'pointer',
+ border: 2,
+ borderColor: activeStep === index ? 'primary.main' : 'transparent',
+ opacity: activeStep === index ? 1 : 0.6,
+ transition: 'all 0.2s',
+ '&:hover': {
+ opacity: 1,
+ borderColor: activeStep === index ? 'primary.main' : 'grey.400',
+ },
+ }}
+ >
+
+
+ ))}
+
+ )}
+
+ {/* Fullscreen Dialog */}
+
+
+ )
+}
+
+export default ImageGallery
diff --git a/augment-store/client/src/features/products/product-detail/components/ProductDetailPage.tsx b/augment-store/client/src/features/products/product-detail/components/ProductDetailPage.tsx
new file mode 100644
index 000000000..aed45a20d
--- /dev/null
+++ b/augment-store/client/src/features/products/product-detail/components/ProductDetailPage.tsx
@@ -0,0 +1,486 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import {
+ Container,
+ Grid,
+ Box,
+ Typography,
+ Button,
+ Rating,
+ Chip,
+ Divider,
+ IconButton,
+ CircularProgress,
+ Alert,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogContentText,
+ DialogActions,
+} from '@mui/material'
+import {
+ Add as AddIcon,
+ Remove as RemoveIcon,
+ ShoppingCart as CartIcon,
+ ArrowBack as ArrowBackIcon,
+ LocalShipping as ShippingIcon,
+} from '@mui/icons-material'
+import { useCartStore } from '@store/cartStore'
+import { mockProductService } from '@services/api/products/mockProductService'
+import type { Product } from '@features/products/types'
+import { mockReviews } from '@data/mockReviews'
+import ImageGallery from './ImageGallery'
+import ReviewSection from './ReviewSection'
+
+const ProductDetailPage = () => {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const [product, setProduct] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [quantity, setQuantity] = useState(1)
+ const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
+
+ const { cart, addItem, removeItem, isInCart, getCartItem } = useCartStore()
+ const productInCart = id ? isInCart(id) : false
+ const cartItem = id ? getCartItem(id) : undefined
+
+ useEffect(() => {
+ const fetchProduct = async () => {
+ if (!id) return
+
+ try {
+ setLoading(true)
+ setError(null)
+ const data = await mockProductService.getProductById(id)
+
+ // Add reviews to product
+ const productWithReviews = {
+ ...data,
+ reviews: mockReviews[id] || [],
+ }
+
+ setProduct(productWithReviews)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load product')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchProduct()
+ }, [id])
+
+ // Sync quantity with cart item when product is in cart
+ useEffect(() => {
+ if (cartItem) {
+ setQuantity(cartItem.quantity)
+ } else {
+ setQuantity(1)
+ }
+ }, [cartItem])
+
+ const handleQuantityChange = (delta: number) => {
+ setQuantity((prev) => Math.max(1, Math.min(product?.stock || 1, prev + delta)))
+ }
+
+ const handleAddToCart = () => {
+ if (!product || !cart) return
+
+ const cartItem = {
+ id: `cart-${product.id}-${Date.now()}`,
+ product,
+ quantity,
+ price: product.discountPrice || product.price,
+ subtotal: (product.discountPrice || product.price) * quantity,
+ }
+
+ addItem(cartItem)
+ }
+
+ const handleRemoveClick = () => {
+ setRemoveDialogOpen(true)
+ }
+
+ const handleRemoveConfirm = () => {
+ if (!cartItem) return
+ removeItem(cartItem.id)
+ setRemoveDialogOpen(false)
+ }
+
+ const handleRemoveCancel = () => {
+ setRemoveDialogOpen(false)
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error || !product) {
+ return (
+
+
+ {/* Illustration/Icon */}
+
+ {/* Empty Box Illustration */}
+
+
+ ๐ฆ
+
+
+
+ {/* Error Message */}
+
+ Product Not Found
+
+
+
+ Uh-oh! Looks like the product you are looking for isn't available right now.
+
+
+ {/* Action Buttons */}
+
+
+ }
+ onClick={() => navigate(-1)}
+ sx={{
+ px: 3,
+ py: 1,
+ borderRadius: 2,
+ fontWeight: 600,
+ textTransform: 'none',
+ fontSize: '0.875rem',
+ }}
+ >
+ Go Back
+
+
+
+
+ )
+ }
+
+ const displayPrice = product.discountPrice || product.price
+ const hasDiscount = !!product.discountPrice
+ const discountPercentage = hasDiscount
+ ? Math.round(((product.price - product.discountPrice!) / product.price) * 100)
+ : 0
+
+ return (
+
+ {/* Back Button */}
+ } onClick={() => navigate('/products')} sx={{ mb: 3 }}>
+ Back to Products
+
+
+
+ {/* Image Gallery */}
+
+
+
+
+ {/* Product Info */}
+
+
+ {/* Category */}
+
+
+ {/* Product Name */}
+
+ {product.name}
+
+
+ {/* Rating */}
+
+
+
+ {product.rating} ({product.reviewCount} reviews)
+
+
+
+ {/* Price */}
+
+
+
+ ${displayPrice.toFixed(2)}
+
+ {hasDiscount && (
+ <>
+
+ ${product.price.toFixed(2)}
+
+
+ >
+ )}
+
+
+
+ {/* Stock Status */}
+
+ {product.stock > 0 ? (
+
+ 20 ? 'In Stock' : `Only ${product.stock} left`}
+ color={product.stock > 20 ? 'success' : 'warning'}
+ size="small"
+ />
+
+
+ Free shipping on orders over $50
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Description */}
+
+ Description
+
+
+ {product.description}
+
+
+
+
+ {/* Quantity Selector & Add to Cart */}
+ {product.stock > 0 && (
+
+
+ Quantity
+
+
+
+ handleQuantityChange(-1)}
+ disabled={quantity <= 1}
+ size="small"
+ >
+
+
+
+ {quantity}
+
+ handleQuantityChange(1)}
+ disabled={quantity >= product.stock}
+ size="small"
+ >
+
+
+
+
+ {product.stock} available
+
+
+
+
+ }
+ onClick={handleAddToCart}
+ sx={{
+ py: 1.5,
+ px: 4,
+ borderRadius: 2,
+ fontWeight: 600,
+ textTransform: 'none',
+ fontSize: '1rem',
+ minWidth: 200,
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
+ '&:hover': {
+ boxShadow: '0 4px 16px rgba(0,0,0,0.25)',
+ transform: 'translateY(-1px)',
+ },
+ transition: 'all 0.2s ease-in-out',
+ }}
+ >
+ {productInCart ? 'Update Cart' : 'Add to Cart'}
+
+ {productInCart && (
+
+ )}
+
+
+ )}
+
+ {/* Specifications */}
+ {product.specifications && Object.keys(product.specifications).length > 0 && (
+ <>
+
+
+ Specifications
+
+
+ {Object.entries(product.specifications).map(([key, value]) => (
+
+
+ {key}
+
+
+ {value}
+
+
+ ))}
+
+ >
+ )}
+
+
+
+
+ {/* Reviews Section */}
+
+
+
+
+ {/* Remove Confirmation Dialog */}
+
+
+ )
+}
+
+export default ProductDetailPage
diff --git a/augment-store/client/src/features/products/product-detail/components/ReviewSection.tsx b/augment-store/client/src/features/products/product-detail/components/ReviewSection.tsx
new file mode 100644
index 000000000..27fe987a6
--- /dev/null
+++ b/augment-store/client/src/features/products/product-detail/components/ReviewSection.tsx
@@ -0,0 +1,151 @@
+import { Box, Typography, Rating, Avatar, Chip, Divider, LinearProgress, Paper } from '@mui/material'
+import { Verified as VerifiedIcon, ThumbUp as ThumbUpIcon } from '@mui/icons-material'
+import type { Review } from '@features/products/types'
+import { formatDistanceToNow } from 'date-fns'
+
+interface ReviewSectionProps {
+ reviews: Review[]
+ rating: number
+}
+
+const ReviewSection = ({ reviews, rating }: ReviewSectionProps) => {
+ // Calculate rating distribution
+ const ratingDistribution = [5, 4, 3, 2, 1].map((stars) => {
+ const count = reviews.filter((r) => Math.floor(r.rating) === stars).length
+ const percentage = reviews.length > 0 ? (count / reviews.length) * 100 : 0
+ return { stars, count, percentage }
+ })
+
+ return (
+
+
+ Customer Reviews
+
+
+
+ {/* Rating Summary */}
+
+
+
+ {rating.toFixed(1)}
+
+
+
+ Based on {reviews.length} review{reviews.length !== 1 ? 's' : ''}
+
+
+
+ {/* Rating Distribution */}
+
+ {ratingDistribution.map(({ stars, count, percentage }) => (
+
+
+ {stars} star{stars !== 1 ? 's' : ''}
+
+
+
+ {count}
+
+
+ ))}
+
+
+
+ {/* Reviews List */}
+
+ {reviews.length === 0 ? (
+
+ No reviews yet. Be the first to review!
+
+ ) : (
+
+ {reviews.map((review, index) => (
+
+
+ {/* Avatar */}
+
+
+ {/* Review Content */}
+
+ {/* Header */}
+
+
+ {review.userName}
+
+ {review.verified && (
+ }
+ label="Verified Purchase"
+ size="small"
+ color="success"
+ variant="outlined"
+ sx={{ height: 20, fontSize: '0.7rem' }}
+ />
+ )}
+
+ {formatDistanceToNow(new Date(review.createdAt), { addSuffix: true })}
+
+
+
+ {/* Rating */}
+
+
+ {/* Title */}
+
+ {review.title}
+
+
+ {/* Comment */}
+
+ {review.comment}
+
+
+ {/* Helpful */}
+
+
+
+ {review.helpful} {review.helpful === 1 ? 'person' : 'people'} found this
+ helpful
+
+
+
+
+
+ {/* Divider between reviews */}
+ {index < reviews.length - 1 && }
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
+export default ReviewSection
+
diff --git a/augment-store/client/src/features/products/product-list/components/BannerCard.tsx b/augment-store/client/src/features/products/product-list/components/BannerCard.tsx
new file mode 100644
index 000000000..f054cf72a
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/BannerCard.tsx
@@ -0,0 +1,158 @@
+import { Box, Typography, Button, Card, CardContent, CardMedia } from '@mui/material'
+import { useNavigate } from 'react-router-dom'
+import type { PromotionalBanner } from '@features/products/types/banner'
+
+interface BannerCardProps {
+ banner: PromotionalBanner
+}
+
+const BannerCard = ({ banner }: BannerCardProps) => {
+ const navigate = useNavigate()
+
+ const handleClick = () => {
+ if (banner.ctaLink) {
+ navigate(banner.ctaLink)
+ }
+ }
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ handleClick()
+ }
+ }
+
+ const isLarge = banner.size === 'large'
+ const isCardClickable = banner.ctaLink && !banner.ctaText
+
+ return (
+
+ {/* Background Image */}
+
+
+ {/* Overlay */}
+
+
+ {/* Content */}
+
+
+ {banner.title}
+
+
+ {banner.subtitle && (
+
+ {banner.subtitle}
+
+ )}
+
+ {banner.description && isLarge && (
+
+ {banner.description}
+
+ )}
+
+ {banner.ctaText && banner.ctaLink && (
+
+ )}
+
+
+ )
+}
+
+export default BannerCard
diff --git a/augment-store/client/src/features/products/product-list/components/BannerCarousel.tsx b/augment-store/client/src/features/products/product-list/components/BannerCarousel.tsx
new file mode 100644
index 000000000..4e74763a9
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/BannerCarousel.tsx
@@ -0,0 +1,42 @@
+import { Box } from '@mui/material'
+import { Swiper, SwiperSlide } from 'swiper/react'
+import { Navigation, Pagination, Autoplay } from 'swiper/modules'
+import type { PromotionalBanner } from '@features/products/types/banner'
+import BannerCard from './BannerCard'
+
+// Import Swiper styles
+import 'swiper/css'
+import 'swiper/css/navigation'
+import 'swiper/css/pagination'
+
+interface BannerCarouselProps {
+ banners: PromotionalBanner[]
+}
+
+const BannerCarousel = ({ banners }: BannerCarouselProps) => {
+ return (
+
+
+ {banners.map((banner) => (
+
+
+
+ ))}
+
+
+ )
+}
+
+export default BannerCarousel
diff --git a/augment-store/client/src/features/products/product-list/components/HomePage.tsx b/augment-store/client/src/features/products/product-list/components/HomePage.tsx
new file mode 100644
index 000000000..da850eeaa
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/HomePage.tsx
@@ -0,0 +1,51 @@
+import type { Product } from '@features/products/types'
+import { Box, Container, Grid, Typography } from '@mui/material'
+import { mockProductService } from '@services/api/products/mockProductService'
+import { useEffect, useState } from 'react'
+import ProductCard from './ProductCard'
+import PromotionalBanners from './PromotionalBanners'
+
+const HomePage = () => {
+ const [featuredProducts, setFeaturedProducts] = useState([])
+
+ useEffect(() => {
+ const fetchFeaturedProducts = async () => {
+ try {
+ const { products } = await mockProductService.getProducts()
+ // Get first 6 products as featured
+ setFeaturedProducts(products.slice(0, 6))
+ } catch (error) {
+ console.error('Failed to fetch featured products:', error)
+ }
+ }
+
+ fetchFeaturedProducts()
+ }, [])
+
+ return (
+
+
+ {/* Promotional Banners Section */}
+
+
+
+ {/* Featured Products */}
+ {featuredProducts.length > 0 && (
+
+
+ Featured Products
+
+
+ {featuredProducts.map((product, index) => (
+
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+export default HomePage
diff --git a/augment-store/client/src/features/products/product-list/components/PriceRangeFilter.tsx b/augment-store/client/src/features/products/product-list/components/PriceRangeFilter.tsx
new file mode 100644
index 000000000..8172ed391
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/PriceRangeFilter.tsx
@@ -0,0 +1,76 @@
+import { useState, useEffect, SyntheticEvent } from 'react'
+import { Box, Typography, Slider } from '@mui/material'
+
+interface PriceRangeFilterProps {
+ minPrice: number
+ maxPrice: number
+ value: [number, number]
+ onChange: (value: [number, number]) => void
+}
+
+const PriceRangeFilter = ({ minPrice, maxPrice, value, onChange }: PriceRangeFilterProps) => {
+ const [localValue, setLocalValue] = useState<[number, number]>(value)
+
+ // Update local value when prop changes (e.g., reset filters)
+ useEffect(() => {
+ setLocalValue(value)
+ }, [value])
+
+ const handleChange = (_event: Event, newValue: number | number[]) => {
+ setLocalValue(newValue as [number, number])
+ }
+
+ const handleChangeCommitted = (_event: Event | SyntheticEvent, newValue: number | number[]) => {
+ onChange(newValue as [number, number])
+ }
+
+ return (
+
+
+ Price Range
+
+
+ `$${value}`}
+ min={minPrice}
+ max={maxPrice}
+ step={1}
+ disableSwap
+ sx={{
+ '& .MuiSlider-thumb': {
+ width: 20,
+ height: 20,
+ '&:hover, &.Mui-focusVisible': {
+ boxShadow: '0 0 0 8px rgba(25, 118, 210, 0.16)',
+ },
+ '&.Mui-active': {
+ boxShadow: '0 0 0 14px rgba(25, 118, 210, 0.16)',
+ },
+ },
+ '& .MuiSlider-track': {
+ height: 4,
+ },
+ '& .MuiSlider-rail': {
+ height: 4,
+ opacity: 0.3,
+ },
+ }}
+ />
+
+
+ ${localValue[0].toFixed(2)}
+
+
+ ${localValue[1].toFixed(2)}
+
+
+
+
+ )
+}
+
+export default PriceRangeFilter
diff --git a/augment-store/client/src/features/products/product-list/components/ProductCard.tsx b/augment-store/client/src/features/products/product-list/components/ProductCard.tsx
new file mode 100644
index 000000000..595463a12
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/ProductCard.tsx
@@ -0,0 +1,163 @@
+import {
+ Card,
+ CardMedia,
+ CardContent,
+ Typography,
+ Box,
+ Rating,
+ Chip,
+ CardActionArea,
+ Fade,
+} from '@mui/material'
+import { useNavigate } from 'react-router-dom'
+import type { Product } from '@features/products/types'
+
+interface ProductCardProps {
+ product: Product
+ index?: number
+}
+
+const ProductCard = ({ product, index = 0 }: ProductCardProps) => {
+ const navigate = useNavigate()
+
+ const handleClick = () => {
+ navigate(`/products/${product.id}`)
+ }
+
+ const displayPrice = product.discountPrice || product.price
+ const hasDiscount = !!product.discountPrice
+
+ return (
+
+
+
+ {/* Discount Badge */}
+ {hasDiscount && (
+
+ )}
+
+ {/* Stock Badge */}
+ {product.stock === 0 && (
+
+ )}
+
+ {/* Product Image */}
+
+
+ {/* Product Details */}
+
+ {/* Category */}
+
+ {product.category.name}
+
+
+ {/* Product Name */}
+
+ {product.name}
+
+
+ {/* Rating */}
+
+
+
+ ({product.reviewCount})
+
+
+
+ {/* Price */}
+
+ {hasDiscount ? (
+
+
+ ${product.price.toFixed(2)}
+
+
+ ${displayPrice.toFixed(2)}
+
+
+ ) : (
+
+ ${displayPrice.toFixed(2)}
+
+ )}
+
+
+ {/* Stock Info */}
+ {product.stock > 0 && product.stock < 20 && (
+
+ Only {product.stock} left in stock
+
+ )}
+
+
+
+
+ )
+}
+
+export default ProductCard
diff --git a/augment-store/client/src/features/products/product-list/components/ProductListPage.tsx b/augment-store/client/src/features/products/product-list/components/ProductListPage.tsx
new file mode 100644
index 000000000..24caa124d
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/ProductListPage.tsx
@@ -0,0 +1,14 @@
+import { Container, Typography } from '@mui/material'
+
+const ProductListPage = () => {
+ return (
+
+
+ Products
+
+ Product list will be displayed here
+
+ )
+}
+
+export default ProductListPage
diff --git a/augment-store/client/src/features/products/product-list/components/PromotionalBanners.tsx b/augment-store/client/src/features/products/product-list/components/PromotionalBanners.tsx
new file mode 100644
index 000000000..1b76677a8
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/PromotionalBanners.tsx
@@ -0,0 +1,44 @@
+import { Box, Grid } from '@mui/material'
+import { mockBanners } from '@data/mockBanners'
+import BannerCard from './BannerCard'
+import BannerCarousel from './BannerCarousel'
+
+const PromotionalBanners = () => {
+ // Split banners into left (2), center (3 for carousel), right (2)
+ const leftBanners = mockBanners.filter((b) => b.id === 'banner-1' || b.id === 'banner-2')
+ const centerBanners = mockBanners.filter(
+ (b) => b.id === 'banner-3' || b.id === 'banner-6' || b.id === 'banner-7'
+ )
+ const rightBanners = mockBanners.filter((b) => b.id === 'banner-4' || b.id === 'banner-5')
+
+ return (
+
+
+ {/* Left Side - 2 Small Banners */}
+
+
+ {leftBanners.map((banner) => (
+
+ ))}
+
+
+
+ {/* Center - Banner Carousel */}
+
+
+
+
+ {/* Right Side - 2 Small Banners */}
+
+
+ {rightBanners.map((banner) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+export default PromotionalBanners
diff --git a/augment-store/client/src/features/products/product-list/components/RatingFilter.tsx b/augment-store/client/src/features/products/product-list/components/RatingFilter.tsx
new file mode 100644
index 000000000..7458cd2ea
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/RatingFilter.tsx
@@ -0,0 +1,90 @@
+import { Box, Rating, Slider, Typography } from '@mui/material'
+import { SyntheticEvent, useEffect, useState } from 'react'
+
+interface RatingFilterProps {
+ value: [number, number]
+ onChange: (value: [number, number]) => void
+}
+
+const RatingFilter = ({ value, onChange }: RatingFilterProps) => {
+ const [localValue, setLocalValue] = useState<[number, number]>(value)
+
+ // Update local value when prop changes (e.g., reset filters)
+ useEffect(() => {
+ setLocalValue(value)
+ }, [value])
+
+ const handleChange = (_event: Event, newValue: number | number[]) => {
+ setLocalValue(newValue as [number, number])
+ }
+
+ const handleChangeCommitted = (_event: Event | SyntheticEvent, newValue: number | number[]) => {
+ onChange(newValue as [number, number])
+ }
+
+ return (
+
+
+ Rating Range
+
+
+
+
+
+
+
+ ({localValue[0]})
+
+
+
+
+
+ ({localValue[1]})
+
+
+
+
+
+ )
+}
+
+export default RatingFilter
diff --git a/augment-store/client/src/features/products/product-list/components/ShopPage.tsx b/augment-store/client/src/features/products/product-list/components/ShopPage.tsx
new file mode 100644
index 000000000..e2aec737b
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/ShopPage.tsx
@@ -0,0 +1,219 @@
+import { mockProducts } from '@data/mockProducts'
+import type { ProductFilters, SortBy } from '@features/products/types'
+import { FilterList as FilterListIcon } from '@mui/icons-material'
+import {
+ Box,
+ Button,
+ Container,
+ Divider,
+ Drawer,
+ Grid,
+ Paper,
+ Typography,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material'
+import { useMemo, useState } from 'react'
+import PriceRangeFilter from './PriceRangeFilter'
+import ProductCard from './ProductCard'
+import RatingFilter from './RatingFilter'
+import SortDropdown from './SortDropdown'
+
+const ShopPage = () => {
+ const theme = useTheme()
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'))
+ const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
+
+ // Calculate min and max prices from products
+ const priceRange = useMemo(() => {
+ const prices = mockProducts.map((p) => p.discountPrice || p.price)
+ return {
+ min: Math.floor(Math.min(...prices)),
+ max: Math.ceil(Math.max(...prices)),
+ }
+ }, [])
+
+ // Filter state
+ const [filters, setFilters] = useState({
+ minPrice: priceRange.min,
+ maxPrice: priceRange.max,
+ minRating: 0,
+ maxRating: 5,
+ })
+
+ // Sort state
+ const [sortBy, setSortBy] = useState('newest')
+
+ // Filter and sort products
+ const filteredAndSortedProducts = useMemo(() => {
+ let result = [...mockProducts]
+
+ // Apply filters
+ result = result.filter((product) => {
+ const price = product.discountPrice || product.price
+
+ // Price filter
+ if (price < (filters.minPrice || 0) || price > (filters.maxPrice || Infinity)) {
+ return false
+ }
+
+ // Rating filter
+ if (product.rating < (filters.minRating || 0) || product.rating > (filters.maxRating || 5)) {
+ return false
+ }
+
+ return true
+ })
+
+ // Apply sorting
+ result.sort((a, b) => {
+ switch (sortBy) {
+ case 'newest':
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ case 'price-asc':
+ return (a.discountPrice || a.price) - (b.discountPrice || b.price)
+ case 'price-desc':
+ return (b.discountPrice || b.price) - (a.discountPrice || a.price)
+ case 'rating-desc':
+ return b.rating - a.rating
+ default:
+ return 0
+ }
+ })
+
+ return result
+ }, [filters, sortBy])
+
+ const handlePriceChange = (value: [number, number]) => {
+ setFilters((prev) => ({
+ ...prev,
+ minPrice: value[0],
+ maxPrice: value[1],
+ }))
+ }
+
+ const handleRatingChange = (value: [number, number]) => {
+ setFilters((prev) => ({
+ ...prev,
+ minRating: value[0],
+ maxRating: value[1],
+ }))
+ }
+
+ const handleResetFilters = () => {
+ setFilters({
+ minPrice: priceRange.min,
+ maxPrice: priceRange.max,
+ minRating: 0,
+ maxRating: 5,
+ })
+ }
+
+ const FiltersContent = () => (
+
+
+
+ Filters
+
+
+
+
+
+
+
+
+
+ )
+
+ return (
+
+
+ {/* Left Sidebar - Filters (Desktop) */}
+ {!isMobile && (
+
+
+
+
+
+ )}
+
+ {/* Main Content */}
+
+ {/* Header with Sort */}
+
+
+ {isMobile && (
+ }
+ onClick={() => setMobileFiltersOpen(true)}
+ >
+ Filters
+
+ )}
+
+ All Products
+
+
+ ({filteredAndSortedProducts.length} items)
+
+
+
+
+
+
+ {/* Products Grid */}
+ {filteredAndSortedProducts.length > 0 ? (
+
+ {filteredAndSortedProducts.map((product, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+
+ No products found
+
+
+ Try adjusting your filters
+
+
+
+ )}
+
+
+
+ {/* Mobile Filters Drawer */}
+ setMobileFiltersOpen(false)}>
+
+
+
+
+
+ )
+}
+
+export default ShopPage
diff --git a/augment-store/client/src/features/products/product-list/components/SortDropdown.tsx b/augment-store/client/src/features/products/product-list/components/SortDropdown.tsx
new file mode 100644
index 000000000..ea88721ca
--- /dev/null
+++ b/augment-store/client/src/features/products/product-list/components/SortDropdown.tsx
@@ -0,0 +1,51 @@
+import { FormControl, Select, MenuItem, SelectChangeEvent, Box, Typography } from '@mui/material'
+import { Sort as SortIcon } from '@mui/icons-material'
+import type { SortBy, ProductSortOption } from '@features/products/types'
+
+interface SortDropdownProps {
+ value: SortBy
+ onChange: (value: SortBy) => void
+}
+
+const sortOptions: ProductSortOption[] = [
+ { value: 'newest', label: 'Newest First' },
+ { value: 'price-asc', label: 'Price: Low to High' },
+ { value: 'price-desc', label: 'Price: High to Low' },
+ { value: 'rating-desc', label: 'Highest Rated' },
+]
+
+const SortDropdown = ({ value, onChange }: SortDropdownProps) => {
+ const handleChange = (event: SelectChangeEvent) => {
+ onChange(event.target.value as SortBy)
+ }
+
+ return (
+
+
+
+ Sort by:
+
+
+
+
+
+ )
+}
+
+export default SortDropdown
+
diff --git a/augment-store/client/src/features/products/types/banner.ts b/augment-store/client/src/features/products/types/banner.ts
new file mode 100644
index 000000000..e84ae83e9
--- /dev/null
+++ b/augment-store/client/src/features/products/types/banner.ts
@@ -0,0 +1,13 @@
+export interface PromotionalBanner {
+ id: string
+ title: string
+ subtitle?: string
+ description?: string
+ imageUrl: string
+ ctaText?: string
+ ctaLink?: string
+ backgroundColor?: string
+ textColor?: string
+ size: 'small' | 'large'
+}
+
diff --git a/augment-store/client/src/features/products/types/index.ts b/augment-store/client/src/features/products/types/index.ts
new file mode 100644
index 000000000..6226675b4
--- /dev/null
+++ b/augment-store/client/src/features/products/types/index.ts
@@ -0,0 +1,74 @@
+export interface Review {
+ id: string
+ userId: string
+ userName: string
+ userAvatar?: string
+ rating: number
+ title: string
+ comment: string
+ createdAt: string
+ helpful: number
+ verified: boolean
+}
+
+export interface Product {
+ id: string
+ name: string
+ description: string
+ price: number
+ discountPrice?: number
+ images: string[]
+ category: Category
+ stock: number
+ rating: number
+ reviewCount: number
+ specifications?: Record
+ reviews?: Review[]
+ createdAt: string
+ updatedAt: string
+}
+
+export interface Category {
+ id: string
+ name: string
+ slug: string
+ description?: string
+ image?: string
+ parentId?: string
+}
+
+export interface ProductFilters {
+ categoryId?: string
+ minPrice?: number
+ maxPrice?: number
+ minRating?: number
+ maxRating?: number
+ inStockOnly?: boolean
+}
+
+export type SortBy = 'newest' | 'price-asc' | 'price-desc' | 'rating-desc'
+
+export interface ProductSortOption {
+ value: SortBy
+ label: string
+}
+
+export interface ProductSearchParams {
+ page?: number
+ limit?: number
+ categoryId?: string
+ minPrice?: number
+ maxPrice?: number
+ minRating?: number
+ maxRating?: number
+ sortBy?: SortBy
+ inStockOnly?: boolean
+}
+
+export interface ProductListResponse {
+ products: Product[]
+ total: number
+ page: number
+ limit: number
+ totalPages: number
+}
diff --git a/augment-store/client/src/features/user/profile/components/ProfilePage.tsx b/augment-store/client/src/features/user/profile/components/ProfilePage.tsx
new file mode 100644
index 000000000..d08ea037a
--- /dev/null
+++ b/augment-store/client/src/features/user/profile/components/ProfilePage.tsx
@@ -0,0 +1,342 @@
+import { useState, useEffect, useRef } from 'react'
+import {
+ Container,
+ Typography,
+ Paper,
+ Box,
+ Alert,
+ CircularProgress,
+ Divider,
+ Avatar,
+ TextField,
+ Button,
+ Grid,
+ MenuItem,
+} from '@mui/material'
+import { Edit, Save, Cancel } from '@mui/icons-material'
+import delay from 'lodash/delay'
+import { userService } from '@services/api/user/userService'
+import type { UserProfile } from '@features/user/types'
+import { Colors } from '@config/colors'
+import { useProfileForm } from '../hooks/useProfileForm'
+import { getChangedFields } from '../utils/profileValidation'
+
+const ProfilePage = () => {
+ const [profile, setProfile] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [isEditing, setIsEditing] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [successMessage, setSuccessMessage] = useState(null)
+
+ // Ref to store timeout ID for cleanup
+ const successTimeoutRef = useRef(null)
+
+ // Profile form with validation
+ const { form, setProfileValues, resetToProfile } = useProfileForm(profile)
+
+ // Fetch profile on mount
+ useEffect(() => {
+ fetchProfile()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (successTimeoutRef.current !== null) {
+ clearTimeout(successTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ const fetchProfile = async () => {
+ try {
+ setIsLoading(true)
+ setError(null)
+ const profileData = await userService.getProfile()
+ setProfile(profileData)
+ setProfileValues(profileData)
+ } catch (err) {
+ const errorMessage =
+ (err as { response?: { data?: { message?: string } }; message?: string }).response?.data
+ ?.message ||
+ (err as { message?: string }).message ||
+ 'Failed to load profile'
+ setError(errorMessage)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleEdit = () => {
+ setIsEditing(true)
+ setError(null)
+ setSuccessMessage(null)
+ }
+
+ const handleCancel = () => {
+ setIsEditing(false)
+ setError(null)
+ setSuccessMessage(null)
+
+ // Reset form to current profile data
+ if (profile) {
+ resetToProfile(profile)
+ }
+ }
+
+ const handleSave = form.onSubmit(async (values) => {
+ // Prevent concurrent submissions
+ if (isSaving) return
+
+ try {
+ setIsSaving(true)
+ setError(null)
+ setSuccessMessage(null)
+
+ // Clear any existing success message timeout
+ if (successTimeoutRef.current !== null) {
+ clearTimeout(successTimeoutRef.current)
+ successTimeoutRef.current = null
+ }
+
+ // Get only changed fields
+ const updateData = getChangedFields(values, profile)
+
+ // Update profile via API
+ const updatedProfile = await userService.updateProfile(updateData)
+ setProfile(updatedProfile)
+ setProfileValues(updatedProfile)
+
+ setIsEditing(false)
+ setSuccessMessage('Profile updated successfully!')
+
+ // Auto-hide success message after 3 seconds
+ successTimeoutRef.current = delay(() => setSuccessMessage(null), 3000)
+ } catch (err) {
+ const errorMessage =
+ (err as { response?: { data?: { message?: string } }; message?: string }).response?.data
+ ?.message ||
+ (err as { message?: string }).message ||
+ 'Failed to update profile'
+ setError(errorMessage)
+ } finally {
+ setIsSaving(false)
+ }
+ })
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error && !profile) {
+ return (
+
+ {error}
+
+
+ )
+ }
+
+ return (
+
+
+ My Profile
+
+
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Profile Header */}
+
+
+ {profile?.first_name?.[0]?.toUpperCase() || profile?.email?.[0]?.toUpperCase() || 'U'}
+
+
+
+ {profile?.full_name || `${profile?.first_name} ${profile?.last_name}`}
+
+
+ {profile?.email}
+
+
+ Member since{' '}
+ {profile?.date_joined ? new Date(profile.date_joined).toLocaleDateString() : 'N/A'}
+
+
+ {!isEditing && (
+ }
+ onClick={handleEdit}
+ sx={{ borderColor: Colors.primary.main, color: Colors.primary.main }}
+ >
+ Edit Profile
+
+ )}
+
+
+
+
+ {/* Profile Form */}
+
+
+
+ )
+}
+
+export default ProfilePage
diff --git a/augment-store/client/src/features/user/profile/hooks/useProfileForm.ts b/augment-store/client/src/features/user/profile/hooks/useProfileForm.ts
new file mode 100644
index 000000000..e30bda982
--- /dev/null
+++ b/augment-store/client/src/features/user/profile/hooks/useProfileForm.ts
@@ -0,0 +1,46 @@
+import { useForm } from '@mantine/form'
+import type { UpdateProfileRequest, UserProfile } from '@features/user/types'
+import { validateProfileForm } from '../utils/profileValidation'
+
+/**
+ * Custom hook for profile form management
+ */
+export const useProfileForm = (profile: UserProfile | null) => {
+ const form = useForm({
+ initialValues: {
+ username: '',
+ first_name: '',
+ last_name: '',
+ mobile: '',
+ gender: 'Other', // Backend default
+ },
+ validate: (values) => validateProfileForm(values, profile),
+ })
+
+ /**
+ * Set form values from profile data
+ */
+ const setProfileValues = (profileData: UserProfile) => {
+ form.setValues({
+ username: profileData.username || '',
+ first_name: profileData.first_name || '',
+ last_name: profileData.last_name || '',
+ mobile: profileData.mobile || '',
+ gender: profileData.gender, // Backend always returns a value
+ })
+ }
+
+ /**
+ * Reset form to profile values
+ */
+ const resetToProfile = (profileData: UserProfile) => {
+ form.reset()
+ setProfileValues(profileData)
+ }
+
+ return {
+ form,
+ setProfileValues,
+ resetToProfile,
+ }
+}
diff --git a/augment-store/client/src/features/user/profile/utils/profileValidation.ts b/augment-store/client/src/features/user/profile/utils/profileValidation.ts
new file mode 100644
index 000000000..820dddbe1
--- /dev/null
+++ b/augment-store/client/src/features/user/profile/utils/profileValidation.ts
@@ -0,0 +1,126 @@
+import { z } from 'zod'
+import type { UpdateProfileRequest, UserProfile } from '@features/user/types'
+
+/**
+ * Zod schema for profile update validation
+ */
+export const profileUpdateSchema = z.object({
+ username: z
+ .string()
+ .min(1, 'Username is required')
+ .trim()
+ .min(3, 'Username must be at least 3 characters')
+ .max(150, 'Username must be less than 150 characters'),
+ first_name: z
+ .string()
+ .min(1, 'First name is required')
+ .trim()
+ .min(2, 'First name must be at least 2 characters')
+ .max(150, 'First name must be less than 150 characters'),
+ last_name: z
+ .string()
+ .min(1, 'Last name is required')
+ .trim()
+ .min(2, 'Last name must be at least 2 characters')
+ .max(150, 'Last name must be less than 150 characters'),
+ mobile: z
+ .string()
+ .max(20, 'Mobile number must be less than 20 characters')
+ .optional()
+ .or(z.literal('')),
+ gender: z.enum(['Male', 'Female', 'Other']), // Required field, backend default is 'Other'
+})
+
+/**
+ * Infer TypeScript type from Zod schema
+ */
+export type ProfileUpdateFormValues = z.infer
+
+/**
+ * Zod resolver for Mantine form
+ * Converts Zod validation to Mantine form errors format
+ */
+export const zodResolver =
+ (schema: T) =>
+ (values: unknown): Record => {
+ const result = schema.safeParse(values)
+
+ if (!result.success) {
+ const errors: Record = {}
+ result.error.issues.forEach((issue) => {
+ const path = issue.path.join('.')
+ errors[path] = issue.message
+ })
+ return errors
+ }
+
+ return {}
+ }
+
+/**
+ * Check if any field has changed from the original profile
+ */
+export const hasProfileChanges = (
+ values: UpdateProfileRequest,
+ profile: UserProfile | null
+): boolean => {
+ if (!profile) return false
+
+ return (
+ (values.username !== undefined && values.username !== (profile.username || '')) ||
+ (values.first_name !== undefined && values.first_name !== (profile.first_name || '')) ||
+ (values.last_name !== undefined && values.last_name !== (profile.last_name || '')) ||
+ (values.mobile !== undefined && values.mobile !== (profile.mobile || '')) ||
+ (values.gender !== undefined && values.gender !== (profile.gender || ''))
+ )
+}
+
+/**
+ * Get only the changed fields from form values
+ * Uses !== undefined to allow clearing fields (e.g., setting mobile to empty string)
+ */
+export const getChangedFields = (
+ values: UpdateProfileRequest,
+ profile: UserProfile | null
+): UpdateProfileRequest => {
+ const updateData: UpdateProfileRequest = {}
+
+ if (!profile) return updateData
+
+ if (values.username !== undefined && values.username !== (profile.username || '')) {
+ updateData.username = values.username
+ }
+ if (values.first_name !== undefined && values.first_name !== (profile.first_name || '')) {
+ updateData.first_name = values.first_name
+ }
+ if (values.last_name !== undefined && values.last_name !== (profile.last_name || '')) {
+ updateData.last_name = values.last_name
+ }
+ if (values.mobile !== undefined && values.mobile !== (profile.mobile || '')) {
+ updateData.mobile = values.mobile
+ }
+ if (values.gender !== undefined && values.gender !== (profile.gender || '')) {
+ updateData.gender = values.gender
+ }
+
+ return updateData
+}
+
+/**
+ * Main validation function for profile form using Zod
+ * Combines schema validation with custom business logic (change detection)
+ */
+export const validateProfileForm = (
+ values: UpdateProfileRequest,
+ profile: UserProfile | null
+): Record => {
+ // Field-level validation using Zod resolver
+ const errors = zodResolver(profileUpdateSchema)(values)
+
+ // Form-level validation: check if any field has changed
+ if (!hasProfileChanges(values, profile)) {
+ errors.username = errors.username || 'No changes detected. Please modify at least one field.'
+ }
+
+ return errors
+}
diff --git a/augment-store/client/src/features/user/types/index.ts b/augment-store/client/src/features/user/types/index.ts
new file mode 100644
index 000000000..e3d465c45
--- /dev/null
+++ b/augment-store/client/src/features/user/types/index.ts
@@ -0,0 +1,63 @@
+import type { Product } from '@features/products/types'
+
+// User profile (matches backend API format with snake_case)
+export interface UserProfile {
+ id: string
+ email: string
+ username: string
+ first_name: string
+ last_name: string
+ full_name: string
+ mobile: string
+ gender: 'Male' | 'Female' | 'Other'
+ image: string
+ role: 'admin' | 'customer'
+ is_active: boolean
+ is_registration_completed: boolean
+ date_joined: string
+}
+
+// Update profile request (matches backend API format with snake_case)
+export interface UpdateProfileRequest {
+ username?: string
+ first_name?: string
+ last_name?: string
+ mobile?: string
+ gender?: 'Male' | 'Female' | 'Other'
+ image?: string
+}
+
+export interface Address {
+ id: string
+ type: 'shipping' | 'billing'
+ firstName: string
+ lastName: string
+ addressLine1: string
+ addressLine2?: string
+ city: string
+ state: string
+ postalCode: string
+ country: string
+ phone: string
+ isDefault: boolean
+}
+
+export interface CreateAddressRequest {
+ type: 'shipping' | 'billing'
+ firstName: string
+ lastName: string
+ addressLine1: string
+ addressLine2?: string
+ city: string
+ state: string
+ postalCode: string
+ country: string
+ phone: string
+ isDefault?: boolean
+}
+
+export interface WishlistItem {
+ id: string
+ product: Product
+ addedAt: string
+}
diff --git a/augment-store/client/src/features/user/wishlist/components/WishlistPage.tsx b/augment-store/client/src/features/user/wishlist/components/WishlistPage.tsx
new file mode 100644
index 000000000..c953ea9bf
--- /dev/null
+++ b/augment-store/client/src/features/user/wishlist/components/WishlistPage.tsx
@@ -0,0 +1,14 @@
+import { Container, Typography } from '@mui/material'
+
+const WishlistPage = () => {
+ return (
+
+
+ My Wishlist
+
+ Wishlist items will be displayed here
+
+ )
+}
+
+export default WishlistPage
diff --git a/augment-store/client/src/hooks/index.ts b/augment-store/client/src/hooks/index.ts
new file mode 100644
index 000000000..086339321
--- /dev/null
+++ b/augment-store/client/src/hooks/index.ts
@@ -0,0 +1,3 @@
+// Export all common hooks from a single entry point
+export { useLocalStorage } from './useLocalStorage'
+export { useDebounce } from './useDebounce'
diff --git a/augment-store/client/src/hooks/useDebounce.ts b/augment-store/client/src/hooks/useDebounce.ts
new file mode 100644
index 000000000..090d1a2ef
--- /dev/null
+++ b/augment-store/client/src/hooks/useDebounce.ts
@@ -0,0 +1,20 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * Custom hook for debouncing values
+ */
+export const useDebounce = (value: T, delay = 500): T => {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => {
+ clearTimeout(handler)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
diff --git a/augment-store/client/src/hooks/useLocalStorage.ts b/augment-store/client/src/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..edb70862b
--- /dev/null
+++ b/augment-store/client/src/hooks/useLocalStorage.ts
@@ -0,0 +1,40 @@
+import { useState } from 'react'
+
+/**
+ * Custom hook for managing localStorage with React state
+ */
+export const useLocalStorage = (key: string, initialValue: T) => {
+ // Get initial value from localStorage or use provided initial value
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key)
+ return item ? JSON.parse(item) : initialValue
+ } catch (error) {
+ console.error(`Error reading localStorage key "${key}":`, error)
+ return initialValue
+ }
+ })
+
+ // Update localStorage when state changes
+ const setValue = (value: T | ((val: T) => T)) => {
+ try {
+ const valueToStore = value instanceof Function ? value(storedValue) : value
+ setStoredValue(valueToStore)
+ window.localStorage.setItem(key, JSON.stringify(valueToStore))
+ } catch (error) {
+ console.error(`Error setting localStorage key "${key}":`, error)
+ }
+ }
+
+ // Remove item from localStorage
+ const removeValue = () => {
+ try {
+ window.localStorage.removeItem(key)
+ setStoredValue(initialValue)
+ } catch (error) {
+ console.error(`Error removing localStorage key "${key}":`, error)
+ }
+ }
+
+ return [storedValue, setValue, removeValue] as const
+}
diff --git a/augment-store/client/src/layouts/AuthLayout.tsx b/augment-store/client/src/layouts/AuthLayout.tsx
new file mode 100644
index 000000000..950a77ec1
--- /dev/null
+++ b/augment-store/client/src/layouts/AuthLayout.tsx
@@ -0,0 +1,22 @@
+import { Outlet } from 'react-router-dom'
+import { Box, Container } from '@mui/material'
+
+const AuthLayout = () => {
+ return (
+
+
+
+
+
+ )
+}
+
+export default AuthLayout
diff --git a/augment-store/client/src/layouts/MainLayout.tsx b/augment-store/client/src/layouts/MainLayout.tsx
new file mode 100644
index 000000000..378345ef2
--- /dev/null
+++ b/augment-store/client/src/layouts/MainLayout.tsx
@@ -0,0 +1,22 @@
+import { Outlet } from 'react-router-dom'
+import { Box } from '@mui/material'
+import Header from '@components/Header'
+import Footer from '@components/Footer'
+import Sidebar from '@components/Sidebar'
+import CartDrawer from '@features/cart/components/CartDrawer'
+
+const MainLayout = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default MainLayout
diff --git a/augment-store/client/src/main.tsx b/augment-store/client/src/main.tsx
new file mode 100644
index 000000000..4388c9c49
--- /dev/null
+++ b/augment-store/client/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.tsx'
+import './styles/index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/augment-store/client/src/routes/AppRoutes.tsx b/augment-store/client/src/routes/AppRoutes.tsx
new file mode 100644
index 000000000..5dd509140
--- /dev/null
+++ b/augment-store/client/src/routes/AppRoutes.tsx
@@ -0,0 +1,80 @@
+import { Routes, Route, Navigate } from 'react-router-dom'
+import MainLayout from '@layouts/MainLayout'
+import AuthLayout from '@layouts/AuthLayout'
+import ProtectedRoute from '@components/ProtectedRoute'
+import PublicRoute from '@components/PublicRoute'
+
+// Placeholder pages - to be implemented
+import HomePage from '@features/products/product-list/components/HomePage'
+import LoginPage from '@features/auth/login/components/LoginPage'
+import RegisterPage from '@features/auth/register/components/RegisterPage'
+import ForgotPasswordPage from '@features/auth/forgot-password/components/ForgotPasswordPage'
+import ResetPasswordPage from '@features/auth/forgot-password/components/ResetPasswordPage'
+import VerifyEmailPage from '@features/auth/verify-email/components/VerifyEmailPage'
+import ShopPage from '@features/products/product-list/components/ShopPage'
+import ProductDetailPage from '@features/products/product-detail/components/ProductDetailPage'
+import CartPage from '@features/cart/components/CartPage'
+import CheckoutPage from '@features/checkout/components/CheckoutPage'
+import OrdersPage from '@features/orders/order-list/components/OrdersPage'
+import OrderDetailPage from '@features/orders/order-detail/components/OrderDetailPage'
+import ProfilePage from '@features/user/profile/components/ProfilePage'
+import WishlistPage from '@features/user/wishlist/components/WishlistPage'
+
+// Info pages
+import AboutPage from '@features/info/about/components/AboutPage'
+import ContactPage from '@features/info/contact/components/ContactPage'
+import HelpPage from '@features/info/help/components/HelpPage'
+import ReturnsPage from '@features/info/returns/components/ReturnsPage'
+import ShippingPage from '@features/info/shipping/components/ShippingPage'
+import TermsPage from '@features/info/terms/components/TermsPage'
+import PrivacyPage from '@features/info/privacy/components/PrivacyPage'
+
+const AppRoutes = () => {
+ return (
+
+ {/* Public routes with main layout */}
+ }>
+ } />
+ } />
+ } />
+ } />
+
+ {/* Info pages */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Auth routes with auth layout - redirect to home if already logged in */}
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ {/* Protected routes with main layout - require authentication */}
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ {/* Catch all */}
+ } />
+
+ )
+}
+
+export default AppRoutes
diff --git a/augment-store/client/src/services/api/auth/authService.ts b/augment-store/client/src/services/api/auth/authService.ts
new file mode 100644
index 000000000..0c4ad8ada
--- /dev/null
+++ b/augment-store/client/src/services/api/auth/authService.ts
@@ -0,0 +1,119 @@
+import { apiClient } from '../client'
+import { API_ENDPOINTS } from '@config/api'
+import { useAuthStore } from '@store/authStore'
+import type {
+ LoginRequest,
+ LoginResponse,
+ LoginResponseAPI,
+ RegisterRequest,
+ RegisterResponse,
+ RegisterRequestAPI,
+ RegisterResponseAPI,
+ ForgotPasswordRequest,
+ ResetPasswordRequest,
+ User,
+} from '@features/auth/types'
+
+// Backend user profile response format
+interface UserProfileAPI {
+ id: string
+ email: string
+ first_name: string
+ last_name: string
+ username: string | null
+ mobile: string | null
+ gender: string | null
+ image: string | null
+ role: string
+ is_active: boolean
+ date_joined: string
+}
+
+export const authService = {
+ login: async (credentials: LoginRequest): Promise => {
+ // Step 1: Login and get tokens
+ const loginResponse = await apiClient.post(
+ API_ENDPOINTS.AUTH.LOGIN,
+ credentials
+ )
+
+ // Step 2: Fetch user profile using the access token
+ // Temporarily set the token in the store so the interceptor can use it
+ useAuthStore.getState().setTokens(loginResponse.access, loginResponse.refresh)
+
+ try {
+ const userProfile = await apiClient.get(API_ENDPOINTS.USER.PROFILE)
+
+ // Transform backend response to frontend User type
+ const user: User = {
+ id: userProfile.id,
+ email: userProfile.email,
+ firstName: userProfile.first_name,
+ lastName: userProfile.last_name,
+ role: userProfile.role === 'admin' ? 'admin' : 'customer',
+ isEmailVerified: userProfile.is_active, // Assuming is_active means email verified
+ createdAt: userProfile.date_joined,
+ updatedAt: userProfile.date_joined,
+ }
+
+ return {
+ user,
+ accessToken: loginResponse.access,
+ refreshToken: loginResponse.refresh,
+ }
+ } catch (error) {
+ // If profile fetch fails, clear the tokens
+ useAuthStore.getState().logout()
+ throw error
+ }
+ },
+
+ register: async (userData: RegisterRequest): Promise => {
+ // Transform camelCase to snake_case for Django backend
+ const requestData: RegisterRequestAPI = {
+ email: userData.email,
+ password: userData.password,
+ first_name: userData.firstName,
+ last_name: userData.lastName,
+ }
+
+ const response = await apiClient.post(
+ API_ENDPOINTS.AUTH.REGISTER,
+ requestData
+ )
+
+ // Transform snake_case response to camelCase for frontend
+ // No tokens returned - user must verify email first
+ return {
+ email: response.email,
+ firstName: response.first_name,
+ lastName: response.last_name,
+ }
+ },
+
+ logout: async (): Promise => {
+ try {
+ // Attempt to notify backend of logout (may fail if endpoint not implemented)
+ await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT)
+ } catch (error) {
+ // Ignore API errors - client-side logout should always succeed
+ console.warn('Logout API call failed, but clearing client auth state:', error)
+ } finally {
+ // Always clear auth state from Zustand store (which automatically syncs to localStorage)
+ // This ensures users can reliably log out even if the backend endpoint fails
+ useAuthStore.getState().logout()
+ }
+ },
+
+ forgotPassword: async (data: ForgotPasswordRequest): Promise => {
+ return apiClient.post(API_ENDPOINTS.AUTH.FORGOT_PASSWORD, data)
+ },
+
+ resetPassword: async (data: ResetPasswordRequest): Promise => {
+ return apiClient.post(API_ENDPOINTS.AUTH.RESET_PASSWORD, data)
+ },
+
+ verifyEmail: async (token: string): Promise => {
+ return apiClient.post(API_ENDPOINTS.AUTH.VERIFY_EMAIL, { token })
+ },
+}
diff --git a/augment-store/client/src/services/api/cart/cartService.ts b/augment-store/client/src/services/api/cart/cartService.ts
new file mode 100644
index 000000000..288c6d5c9
--- /dev/null
+++ b/augment-store/client/src/services/api/cart/cartService.ts
@@ -0,0 +1,25 @@
+import { apiClient } from '../client'
+import { API_ENDPOINTS } from '@config/api'
+import type { Cart, AddToCartRequest, UpdateCartItemRequest } from '@features/cart/types'
+
+export const cartService = {
+ getCart: async (): Promise => {
+ return apiClient.get(API_ENDPOINTS.CART.GET)
+ },
+
+ addToCart: async (data: AddToCartRequest): Promise => {
+ return apiClient.post(API_ENDPOINTS.CART.ADD, data)
+ },
+
+ updateCartItem: async (itemId: string, data: UpdateCartItemRequest): Promise => {
+ return apiClient.patch(API_ENDPOINTS.CART.UPDATE(itemId), data)
+ },
+
+ removeFromCart: async (itemId: string): Promise => {
+ return apiClient.delete(API_ENDPOINTS.CART.REMOVE(itemId))
+ },
+
+ clearCart: async (): Promise => {
+ return apiClient.delete(API_ENDPOINTS.CART.CLEAR)
+ },
+}
diff --git a/augment-store/client/src/services/api/client.ts b/augment-store/client/src/services/api/client.ts
new file mode 100644
index 000000000..0936560bf
--- /dev/null
+++ b/augment-store/client/src/services/api/client.ts
@@ -0,0 +1,111 @@
+import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
+import { API_CONFIG, API_ENDPOINTS } from '@config/api'
+import { useAuthStore } from '@store/authStore'
+
+class ApiClient {
+ private client: AxiosInstance
+
+ constructor() {
+ this.client = axios.create({
+ baseURL: API_CONFIG.BASE_URL,
+ timeout: API_CONFIG.TIMEOUT,
+ headers: API_CONFIG.HEADERS,
+ })
+
+ this.setupInterceptors()
+ }
+
+ private setupInterceptors(): void {
+ // Request interceptor
+ this.client.interceptors.request.use(
+ (config) => {
+ // Add auth token if available from Zustand store
+ const token = useAuthStore.getState().accessToken
+ if (token) {
+ // Ensure headers object exists before assigning
+ config.headers = config.headers || {}
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+ },
+ (error) => {
+ return Promise.reject(error)
+ }
+ )
+
+ // Response interceptor
+ this.client.interceptors.response.use(
+ (response) => response,
+ async (error: AxiosError) => {
+ const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }
+
+ // Handle 401 errors (unauthorized)
+ // Skip retry if this is already a retry attempt or if it's the refresh token endpoint
+ const isRefreshTokenEndpoint = originalRequest.url?.includes(
+ API_ENDPOINTS.AUTH.REFRESH_TOKEN
+ )
+
+ if (error.response?.status === 401 && !originalRequest._retry && !isRefreshTokenEndpoint) {
+ originalRequest._retry = true
+
+ try {
+ // Try to refresh token using a separate axios instance to avoid interceptor recursion
+ const refreshToken = useAuthStore.getState().refreshToken
+ if (refreshToken) {
+ // Create a new axios instance without interceptors for the refresh call
+ // Django expects {refresh: "..."} and returns {access: "...", refresh: "..."}
+ const refreshResponse = await axios.post(
+ `${API_CONFIG.BASE_URL}${API_ENDPOINTS.AUTH.REFRESH_TOKEN}`,
+ { refresh: refreshToken },
+ { headers: API_CONFIG.HEADERS }
+ )
+ const { access, refresh } = refreshResponse.data
+
+ // Update Zustand store with new tokens
+ useAuthStore.getState().setTokens(access, refresh)
+
+ // Retry original request with new token
+ originalRequest.headers = originalRequest.headers || {}
+ originalRequest.headers.Authorization = `Bearer ${access}`
+ return this.client(originalRequest)
+ }
+ } catch (refreshError) {
+ // Refresh failed, logout and redirect to login
+ useAuthStore.getState().logout()
+ window.location.href = '/login'
+ return Promise.reject(refreshError)
+ }
+ }
+
+ return Promise.reject(error)
+ }
+ )
+ }
+
+ public async get(url: string, config?: AxiosRequestConfig): Promise {
+ const response: AxiosResponse = await this.client.get(url, config)
+ return response.data
+ }
+
+ public async post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise {
+ const response: AxiosResponse = await this.client.post(url, data, config)
+ return response.data
+ }
+
+ public async put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise {
+ const response: AxiosResponse = await this.client.put(url, data, config)
+ return response.data
+ }
+
+ public async patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise {
+ const response: AxiosResponse = await this.client.patch(url, data, config)
+ return response.data
+ }
+
+ public async delete(url: string, config?: AxiosRequestConfig): Promise {
+ const response: AxiosResponse = await this.client.delete(url, config)
+ return response.data
+ }
+}
+
+export const apiClient = new ApiClient()
diff --git a/augment-store/client/src/services/api/index.ts b/augment-store/client/src/services/api/index.ts
new file mode 100644
index 000000000..6fda18bdb
--- /dev/null
+++ b/augment-store/client/src/services/api/index.ts
@@ -0,0 +1,7 @@
+// Export all API services from a single entry point
+export { authService } from './auth/authService'
+export { productService } from './products/productService'
+export { cartService } from './cart/cartService'
+export { orderService } from './orders/orderService'
+export { userService } from './user/userService'
+export { apiClient } from './client'
diff --git a/augment-store/client/src/services/api/orders/orderService.ts b/augment-store/client/src/services/api/orders/orderService.ts
new file mode 100644
index 000000000..648946f29
--- /dev/null
+++ b/augment-store/client/src/services/api/orders/orderService.ts
@@ -0,0 +1,23 @@
+import { apiClient } from '../client'
+import { API_ENDPOINTS } from '@config/api'
+import type { Order, CreateOrderRequest, OrderListResponse } from '@features/orders/types'
+
+export const orderService = {
+ getOrders: async (page = 1, limit = 10): Promise => {
+ return apiClient.get(API_ENDPOINTS.ORDERS.LIST, {
+ params: { page, limit },
+ })
+ },
+
+ getOrderById: async (id: string): Promise => {
+ return apiClient.get(API_ENDPOINTS.ORDERS.DETAIL(id))
+ },
+
+ createOrder: async (data: CreateOrderRequest): Promise => {
+ return apiClient.post(API_ENDPOINTS.ORDERS.CREATE, data)
+ },
+
+ cancelOrder: async (id: string): Promise => {
+ return apiClient.post(API_ENDPOINTS.ORDERS.CANCEL(id))
+ },
+}
diff --git a/augment-store/client/src/services/api/products/mockProductService.ts b/augment-store/client/src/services/api/products/mockProductService.ts
new file mode 100644
index 000000000..e7347b729
--- /dev/null
+++ b/augment-store/client/src/services/api/products/mockProductService.ts
@@ -0,0 +1,86 @@
+import type {
+ Product,
+ ProductListResponse,
+ ProductSearchParams,
+ Category,
+} from '@features/products/types'
+import dummyProducts from '@data/dummyProducts.json'
+
+export const mockProductService = {
+ searchProducts: async (
+ query: string,
+ params?: ProductSearchParams
+ ): Promise => {
+ // Simulate network delay
+ await new Promise((resolve) => setTimeout(resolve, 300))
+
+ // Filter products by query (search in name and description)
+ const filteredProducts = (dummyProducts as Product[]).filter(
+ (product) =>
+ product.name.toLowerCase().includes(query.toLowerCase()) ||
+ product.description.toLowerCase().includes(query.toLowerCase())
+ )
+
+ // Apply limit
+ const limit = params?.limit || 5
+ const products = filteredProducts.slice(0, limit)
+
+ return {
+ products,
+ total: filteredProducts.length,
+ page: 1,
+ limit,
+ totalPages: Math.ceil(filteredProducts.length / limit),
+ }
+ },
+
+ getProducts: async (params?: ProductSearchParams): Promise => {
+ await new Promise((resolve) => setTimeout(resolve, 300))
+
+ const limit = params?.limit || 12
+ const page = params?.page || 1
+ const startIndex = (page - 1) * limit
+ const endIndex = startIndex + limit
+
+ const products = (dummyProducts as Product[]).slice(startIndex, endIndex)
+
+ return {
+ products,
+ total: dummyProducts.length,
+ page,
+ limit,
+ totalPages: Math.ceil(dummyProducts.length / limit),
+ }
+ },
+
+ getProductById: async (id: string): Promise => {
+ await new Promise((resolve) => setTimeout(resolve, 300))
+
+ const product = (dummyProducts as Product[]).find((p) => p.id === id)
+ if (!product) {
+ throw new Error('Product not found')
+ }
+ return product
+ },
+
+ getCategories: async (): Promise => {
+ await new Promise((resolve) => setTimeout(resolve, 300))
+
+ // Extract unique categories from products
+ const categoriesMap = new Map()
+ ;(dummyProducts as Product[]).forEach((product) => {
+ if (!categoriesMap.has(product.category.id)) {
+ categoriesMap.set(product.category.id, product.category)
+ }
+ })
+
+ return Array.from(categoriesMap.values())
+ },
+
+ getFeaturedProducts: async (): Promise => {
+ await new Promise((resolve) => setTimeout(resolve, 300))
+
+ // Return products with discount prices as featured
+ return (dummyProducts as Product[]).filter((p) => p.discountPrice).slice(0, 6)
+ },
+}
diff --git a/augment-store/client/src/services/api/products/productService.ts b/augment-store/client/src/services/api/products/productService.ts
new file mode 100644
index 000000000..9f716249b
--- /dev/null
+++ b/augment-store/client/src/services/api/products/productService.ts
@@ -0,0 +1,35 @@
+import { apiClient } from '../client'
+import { API_ENDPOINTS } from '@config/api'
+import type {
+ Product,
+ ProductListResponse,
+ ProductSearchParams,
+ Category,
+} from '@features/products/types'
+
+export const productService = {
+ getProducts: async (params?: ProductSearchParams): Promise => {
+ return apiClient.get(API_ENDPOINTS.PRODUCTS.LIST, { params })
+ },
+
+ getProductById: async (id: string): Promise => {
+ return apiClient.get(API_ENDPOINTS.PRODUCTS.DETAIL(id))
+ },
+
+ searchProducts: async (
+ query: string,
+ params?: ProductSearchParams
+ ): Promise => {
+ return apiClient.get(API_ENDPOINTS.PRODUCTS.SEARCH, {
+ params: { q: query, ...params },
+ })
+ },
+
+ getCategories: async (): Promise => {
+ return apiClient.get(API_ENDPOINTS.PRODUCTS.CATEGORIES)
+ },
+
+ getFeaturedProducts: async (): Promise => {
+ return apiClient.get(API_ENDPOINTS.PRODUCTS.FEATURED)
+ },
+}
diff --git a/augment-store/client/src/services/api/user/userService.ts b/augment-store/client/src/services/api/user/userService.ts
new file mode 100644
index 000000000..1a5015fb8
--- /dev/null
+++ b/augment-store/client/src/services/api/user/userService.ts
@@ -0,0 +1,47 @@
+import { apiClient } from '../client'
+import { API_ENDPOINTS } from '@config/api'
+import type {
+ UserProfile,
+ UpdateProfileRequest,
+ Address,
+ CreateAddressRequest,
+ WishlistItem,
+} from '@features/user/types'
+
+export const userService = {
+ getProfile: async (): Promise => {
+ return apiClient.get(API_ENDPOINTS.USER.PROFILE)
+ },
+
+ updateProfile: async (data: UpdateProfileRequest): Promise => {
+ return apiClient.patch(API_ENDPOINTS.USER.UPDATE_PROFILE, data)
+ },
+
+ getAddresses: async (): Promise => {
+ return apiClient.get(API_ENDPOINTS.USER.ADDRESSES)
+ },
+
+ addAddress: async (data: CreateAddressRequest): Promise => {
+ return apiClient.post(API_ENDPOINTS.USER.ADD_ADDRESS, data)
+ },
+
+ updateAddress: async (id: string, data: CreateAddressRequest): Promise => {
+ return apiClient.patch(API_ENDPOINTS.USER.UPDATE_ADDRESS(id), data)
+ },
+
+ deleteAddress: async (id: string): Promise => {
+ return apiClient.delete(API_ENDPOINTS.USER.DELETE_ADDRESS(id))
+ },
+
+ getWishlist: async (): Promise => {
+ return apiClient.get(API_ENDPOINTS.USER.WISHLIST)
+ },
+
+ addToWishlist: async (productId: string): Promise => {
+ return apiClient.post(API_ENDPOINTS.USER.ADD_TO_WISHLIST, { productId })
+ },
+
+ removeFromWishlist: async (id: string): Promise => {
+ return apiClient.delete(API_ENDPOINTS.USER.REMOVE_FROM_WISHLIST(id))
+ },
+}
diff --git a/augment-store/client/src/store/authStore.ts b/augment-store/client/src/store/authStore.ts
new file mode 100644
index 000000000..9e845f65c
--- /dev/null
+++ b/augment-store/client/src/store/authStore.ts
@@ -0,0 +1,84 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+import type { User } from '@features/auth/types'
+
+interface AuthState {
+ user: User | null
+ accessToken: string | null
+ refreshToken: string | null
+ isAuthenticated: boolean
+ isLoading: boolean
+ error: string | null
+ hasHydrated: boolean
+
+ // Actions
+ setUser: (user: User) => void
+ setTokens: (accessToken: string, refreshToken: string) => void
+ login: (user: User, accessToken: string, refreshToken: string) => void
+ logout: () => void
+ setLoading: (isLoading: boolean) => void
+ setError: (error: string | null) => void
+ clearError: () => void
+ setHasHydrated: (hasHydrated: boolean) => void
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ isAuthenticated: false,
+ isLoading: false,
+ error: null,
+ hasHydrated: false,
+
+ setUser: (user) => set((state) => ({ user, isAuthenticated: !!user && !!state.accessToken })),
+
+ setTokens: (accessToken, refreshToken) =>
+ set((state) => ({
+ accessToken,
+ refreshToken,
+ isAuthenticated: !!accessToken && !!state.user,
+ })),
+
+ login: (user, accessToken, refreshToken) =>
+ set({
+ user,
+ accessToken,
+ refreshToken,
+ isAuthenticated: !!accessToken, // User is authenticated if access token exists
+ error: null,
+ }),
+
+ logout: () =>
+ set({
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ isAuthenticated: false,
+ error: null,
+ }),
+
+ setLoading: (isLoading) => set({ isLoading }),
+
+ setError: (error) => set({ error }),
+
+ clearError: () => set({ error: null }),
+
+ setHasHydrated: (hasHydrated) => set({ hasHydrated }),
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({
+ user: state.user,
+ accessToken: state.accessToken,
+ refreshToken: state.refreshToken,
+ isAuthenticated: state.isAuthenticated,
+ }),
+ onRehydrateStorage: () => (state) => {
+ state?.setHasHydrated(true)
+ },
+ }
+ )
+)
diff --git a/augment-store/client/src/store/cartStore.ts b/augment-store/client/src/store/cartStore.ts
new file mode 100644
index 000000000..8ff0de7db
--- /dev/null
+++ b/augment-store/client/src/store/cartStore.ts
@@ -0,0 +1,213 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+import type { Cart, CartItem } from '@features/cart/types'
+
+interface CartState {
+ cart: Cart | null
+ isLoading: boolean
+ error: string | null
+
+ // Actions
+ setCart: (cart: Cart) => void
+ addItem: (item: CartItem) => void
+ updateItem: (itemId: string, quantity: number) => void
+ removeItem: (itemId: string) => void
+ removeItems: (itemIds: string[]) => void
+ clearCart: () => void
+ setLoading: (isLoading: boolean) => void
+ setError: (error: string | null) => void
+
+ // Computed
+ getItemCount: () => number
+ getTotal: () => number
+ isInCart: (productId: string) => boolean
+ getCartItem: (productId: string) => CartItem | undefined
+}
+
+const createEmptyCart = (): Cart => ({
+ id: 'cart-' + Date.now(),
+ items: [],
+ subtotal: 0,
+ tax: 0,
+ shipping: 0,
+ total: 0,
+ itemCount: 0,
+})
+
+// Helper function to calculate cart totals
+const calculateCartTotals = (
+ items: CartItem[]
+): Pick => {
+ const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0)
+ const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
+ const tax = subtotal * 0.1 // 10% tax rate
+ const shipping = subtotal > 50 ? 0 : 5.99 // Free shipping over $50
+ const total = subtotal + tax + shipping
+
+ return {
+ subtotal,
+ tax,
+ shipping,
+ total,
+ itemCount,
+ }
+}
+
+const initialCart: Cart = createEmptyCart()
+
+export const useCartStore = create()(
+ persist(
+ (set, get) => ({
+ cart: initialCart,
+ isLoading: false,
+ error: null,
+
+ setCart: (cart) => set({ cart }),
+
+ addItem: (item) =>
+ set((state) => {
+ // Initialize cart if it's null
+ const currentCart = state.cart || createEmptyCart()
+
+ let updatedItems: CartItem[]
+
+ const existingItemIndex = currentCart.items.findIndex(
+ (i) => i.product.id === item.product.id
+ )
+
+ if (existingItemIndex >= 0) {
+ // Replace quantity for existing item (don't add to it)
+ updatedItems = [...currentCart.items]
+ const existingItem = updatedItems[existingItemIndex]
+
+ // Cap quantity at available stock
+ const finalQuantity = Math.min(item.quantity, existingItem.product.stock)
+
+ updatedItems[existingItemIndex] = {
+ ...existingItem,
+ quantity: finalQuantity,
+ subtotal: finalQuantity * existingItem.price,
+ }
+ } else {
+ // Add new item with stock validation
+ const finalQuantity = Math.min(item.quantity, item.product.stock)
+ updatedItems = [
+ ...currentCart.items,
+ {
+ ...item,
+ quantity: finalQuantity,
+ subtotal: finalQuantity * item.price,
+ },
+ ]
+ }
+
+ // Calculate totals
+ const totals = calculateCartTotals(updatedItems)
+
+ return {
+ cart: {
+ ...currentCart,
+ items: updatedItems,
+ ...totals,
+ },
+ }
+ }),
+
+ updateItem: (itemId, quantity) =>
+ set((state) => {
+ // Initialize cart if it's null
+ const currentCart = state.cart || createEmptyCart()
+
+ const updatedItems = currentCart.items.map((item) => {
+ if (item.id === itemId) {
+ // Cap quantity at available stock
+ const finalQuantity = Math.min(Math.max(1, quantity), item.product.stock)
+ return { ...item, quantity: finalQuantity, subtotal: finalQuantity * item.price }
+ }
+ return item
+ })
+
+ // Calculate totals
+ const totals = calculateCartTotals(updatedItems)
+
+ return {
+ cart: {
+ ...currentCart,
+ items: updatedItems,
+ ...totals,
+ },
+ }
+ }),
+
+ removeItem: (itemId) =>
+ set((state) => {
+ // Initialize cart if it's null
+ const currentCart = state.cart || createEmptyCart()
+
+ const updatedItems = currentCart.items.filter((item) => item.id !== itemId)
+
+ // Calculate totals
+ const totals = calculateCartTotals(updatedItems)
+
+ return {
+ cart: {
+ ...currentCart,
+ items: updatedItems,
+ ...totals,
+ },
+ }
+ }),
+
+ removeItems: (itemIds) =>
+ set((state) => {
+ // Initialize cart if it's null
+ const currentCart = state.cart || createEmptyCart()
+
+ const updatedItems = currentCart.items.filter((item) => !itemIds.includes(item.id))
+
+ // Calculate totals
+ const totals = calculateCartTotals(updatedItems)
+
+ return {
+ cart: {
+ ...currentCart,
+ items: updatedItems,
+ ...totals,
+ },
+ }
+ }),
+
+ clearCart: () => set({ cart: createEmptyCart() }),
+
+ setLoading: (isLoading) => set({ isLoading }),
+
+ setError: (error) => set({ error }),
+
+ getItemCount: () => {
+ const { cart } = get()
+ return cart?.items.reduce((total, item) => total + item.quantity, 0) || 0
+ },
+
+ getTotal: () => {
+ const { cart } = get()
+ return cart?.total || 0
+ },
+
+ isInCart: (productId) => {
+ const { cart } = get()
+ return cart?.items.some((item) => item.product.id === productId) || false
+ },
+
+ getCartItem: (productId) => {
+ const { cart } = get()
+ return cart?.items.find((item) => item.product.id === productId)
+ },
+ }),
+ {
+ name: 'cart-storage',
+ partialize: (state) => ({
+ cart: state.cart,
+ }),
+ }
+ )
+)
diff --git a/augment-store/client/src/store/index.ts b/augment-store/client/src/store/index.ts
new file mode 100644
index 000000000..2b75fd4ef
--- /dev/null
+++ b/augment-store/client/src/store/index.ts
@@ -0,0 +1,5 @@
+// Export all stores from a single entry point
+export { useAuthStore } from './authStore'
+export { useCartStore } from './cartStore'
+export { useProductStore } from './productStore'
+export { useUIStore } from './uiStore'
diff --git a/augment-store/client/src/store/productStore.ts b/augment-store/client/src/store/productStore.ts
new file mode 100644
index 000000000..9630acd4e
--- /dev/null
+++ b/augment-store/client/src/store/productStore.ts
@@ -0,0 +1,62 @@
+import { create } from 'zustand'
+import type { Product, ProductSearchParams } from '@features/products/types'
+
+interface ProductState {
+ products: Product[]
+ selectedProduct: Product | null
+ searchParams: ProductSearchParams
+ isLoading: boolean
+ error: string | null
+ total: number
+ page: number
+ totalPages: number
+
+ // Actions
+ setProducts: (products: Product[], total: number, page: number, totalPages: number) => void
+ setSelectedProduct: (product: Product | null) => void
+ setSearchParams: (params: Partial) => void
+ setLoading: (isLoading: boolean) => void
+ setError: (error: string | null) => void
+ clearProducts: () => void
+}
+
+export const useProductStore = create((set) => ({
+ products: [],
+ selectedProduct: null,
+ searchParams: {
+ page: 1,
+ limit: 12,
+ },
+ isLoading: false,
+ error: null,
+ total: 0,
+ page: 1,
+ totalPages: 0,
+
+ setProducts: (products, total, page, totalPages) =>
+ set({
+ products,
+ total,
+ page,
+ totalPages,
+ }),
+
+ setSelectedProduct: (product) => set({ selectedProduct: product }),
+
+ setSearchParams: (params) =>
+ set((state) => ({
+ searchParams: { ...state.searchParams, ...params },
+ })),
+
+ setLoading: (isLoading) => set({ isLoading }),
+
+ setError: (error) => set({ error }),
+
+ clearProducts: () =>
+ set({
+ products: [],
+ total: 0,
+ page: 1,
+ totalPages: 0,
+ }),
+}))
diff --git a/augment-store/client/src/store/uiStore.ts b/augment-store/client/src/store/uiStore.ts
new file mode 100644
index 000000000..6a1c25878
--- /dev/null
+++ b/augment-store/client/src/store/uiStore.ts
@@ -0,0 +1,57 @@
+import { create } from 'zustand'
+
+interface Notification {
+ id: string
+ type: 'success' | 'error' | 'warning' | 'info'
+ message: string
+ duration?: number
+}
+
+interface UIState {
+ isSidebarOpen: boolean
+ isCartDrawerOpen: boolean
+ notifications: Notification[]
+ isLoading: boolean
+
+ // Actions
+ toggleSidebar: () => void
+ openSidebar: () => void
+ closeSidebar: () => void
+ setSidebarOpen: (isOpen: boolean) => void
+ toggleCartDrawer: () => void
+ setCartDrawerOpen: (isOpen: boolean) => void
+ addNotification: (notification: Omit) => void
+ removeNotification: (id: string) => void
+ setGlobalLoading: (isLoading: boolean) => void
+}
+
+export const useUIStore = create((set) => ({
+ isSidebarOpen: false,
+ isCartDrawerOpen: false,
+ notifications: [],
+ isLoading: false,
+
+ toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
+
+ openSidebar: () => set({ isSidebarOpen: true }),
+
+ closeSidebar: () => set({ isSidebarOpen: false }),
+
+ setSidebarOpen: (isOpen) => set({ isSidebarOpen: isOpen }),
+
+ toggleCartDrawer: () => set((state) => ({ isCartDrawerOpen: !state.isCartDrawerOpen })),
+
+ setCartDrawerOpen: (isOpen) => set({ isCartDrawerOpen: isOpen }),
+
+ addNotification: (notification) =>
+ set((state) => ({
+ notifications: [...state.notifications, { ...notification, id: Date.now().toString() }],
+ })),
+
+ removeNotification: (id) =>
+ set((state) => ({
+ notifications: state.notifications.filter((n) => n.id !== id),
+ })),
+
+ setGlobalLoading: (isLoading) => set({ isLoading }),
+}))
diff --git a/augment-store/client/src/styles/index.css b/augment-store/client/src/styles/index.css
new file mode 100644
index 000000000..8150959cd
--- /dev/null
+++ b/augment-store/client/src/styles/index.css
@@ -0,0 +1,15 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Roboto', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/augment-store/client/src/types/common.ts b/augment-store/client/src/types/common.ts
new file mode 100644
index 000000000..d5ef60f3b
--- /dev/null
+++ b/augment-store/client/src/types/common.ts
@@ -0,0 +1,25 @@
+export interface ApiError {
+ message: string
+ statusCode: number
+ errors?: Record
+}
+
+export interface PaginationParams {
+ page: number
+ limit: number
+}
+
+export interface PaginatedResponse {
+ data: T[]
+ total: number
+ page: number
+ limit: number
+ totalPages: number
+}
+
+export type SortOrder = 'asc' | 'desc'
+
+export interface SelectOption {
+ label: string
+ value: string | number
+}
diff --git a/augment-store/client/src/utils/formatters.ts b/augment-store/client/src/utils/formatters.ts
new file mode 100644
index 000000000..1c13ad676
--- /dev/null
+++ b/augment-store/client/src/utils/formatters.ts
@@ -0,0 +1,40 @@
+/**
+ * Format a number as currency
+ */
+export const formatCurrency = (amount: number, currency = 'USD'): string => {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ }).format(amount)
+}
+
+/**
+ * Format a date string
+ */
+export const formatDate = (date: string | Date, format: 'short' | 'long' = 'short'): string => {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+
+ if (format === 'long') {
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(dateObj)
+ }
+
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(dateObj)
+}
+
+/**
+ * Truncate text to a specified length
+ */
+export const truncateText = (text: string, maxLength: number): string => {
+ if (text.length <= maxLength) return text
+ return text.slice(0, maxLength) + '...'
+}
diff --git a/augment-store/client/src/utils/index.ts b/augment-store/client/src/utils/index.ts
new file mode 100644
index 000000000..0826386fe
--- /dev/null
+++ b/augment-store/client/src/utils/index.ts
@@ -0,0 +1,3 @@
+// Export all utilities from a single entry point
+export * from './formatters'
+export * from './validators'
diff --git a/augment-store/client/src/utils/validators.ts b/augment-store/client/src/utils/validators.ts
new file mode 100644
index 000000000..8e9ea93af
--- /dev/null
+++ b/augment-store/client/src/utils/validators.ts
@@ -0,0 +1,35 @@
+/**
+ * Validate email format
+ */
+export const isValidEmail = (email: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return emailRegex.test(email)
+}
+
+/**
+ * Validate password strength
+ */
+export const isValidPassword = (password: string): boolean => {
+ // At least 8 characters, 1 uppercase, 1 lowercase, 1 number
+ const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
+ return passwordRegex.test(password)
+}
+
+/**
+ * Validate phone number
+ */
+export const isValidPhone = (phone: string): boolean => {
+ const phoneRegex = /^\+?[\d\s-()]+$/
+ return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 10
+}
+
+/**
+ * Validate postal code
+ */
+export const isValidPostalCode = (postalCode: string, country = 'US'): boolean => {
+ if (country === 'US') {
+ return /^\d{5}(-\d{4})?$/.test(postalCode)
+ }
+ // Add more country-specific validations as needed
+ return postalCode.length > 0
+}
diff --git a/augment-store/client/src/vite-env.d.ts b/augment-store/client/src/vite-env.d.ts
new file mode 100644
index 000000000..4ebadb511
--- /dev/null
+++ b/augment-store/client/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string
+ // Add more env variables as needed
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/augment-store/client/tsconfig.json b/augment-store/client/tsconfig.json
new file mode 100644
index 000000000..048280b60
--- /dev/null
+++ b/augment-store/client/tsconfig.json
@@ -0,0 +1,46 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ /* Path aliases */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "@components/*": ["src/components/*"],
+ "@features/*": ["src/features/*"],
+ "@hooks/*": ["src/hooks/*"],
+ "@utils/*": ["src/utils/*"],
+ "@services/*": ["src/services/*"],
+ "@types/*": ["src/types/*"],
+ "@constants/*": ["src/constants/*"],
+ "@assets/*": ["src/assets/*"],
+ "@styles/*": ["src/styles/*"],
+ "@layouts/*": ["src/layouts/*"],
+ "@routes/*": ["src/routes/*"],
+ "@context/*": ["src/context/*"],
+ "@config/*": ["src/config/*"],
+ "@store/*": ["src/store/*"],
+ "@data/*": ["src/data/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/augment-store/client/tsconfig.node.json b/augment-store/client/tsconfig.node.json
new file mode 100644
index 000000000..42872c59f
--- /dev/null
+++ b/augment-store/client/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/augment-store/client/vite.config.ts b/augment-store/client/vite.config.ts
new file mode 100644
index 000000000..a2a45cf11
--- /dev/null
+++ b/augment-store/client/vite.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ '@components': path.resolve(__dirname, './src/components'),
+ '@features': path.resolve(__dirname, './src/features'),
+ '@hooks': path.resolve(__dirname, './src/hooks'),
+ '@utils': path.resolve(__dirname, './src/utils'),
+ '@services': path.resolve(__dirname, './src/services'),
+ '@types': path.resolve(__dirname, './src/types'),
+ '@constants': path.resolve(__dirname, './src/constants'),
+ '@assets': path.resolve(__dirname, './src/assets'),
+ '@styles': path.resolve(__dirname, './src/styles'),
+ '@layouts': path.resolve(__dirname, './src/layouts'),
+ '@routes': path.resolve(__dirname, './src/routes'),
+ '@context': path.resolve(__dirname, './src/context'),
+ '@config': path.resolve(__dirname, './src/config'),
+ '@store': path.resolve(__dirname, './src/store'),
+ '@data': path.resolve(__dirname, './src/data'),
+ },
+ },
+ server: {
+ port: 3000,
+ open: true,
+ },
+})
diff --git a/augment-store/server/.env.example b/augment-store/server/.env.example
new file mode 100644
index 000000000..2505b4215
--- /dev/null
+++ b/augment-store/server/.env.example
@@ -0,0 +1,36 @@
+# Application Configuration
+DEBUG=True
+APP_DOMAIN=http://localhost:8000
+ALLOWED_HOSTS=*
+FRONTEND_URL=http://localhost:3000
+# True | False
+CORS_ALLOW_ALL_ORIGINS=False
+
+SECRET_KEY=add-your-secret-key-here
+ACCESS_TOKEN_EXPIRATION_TIME_IN_MINUTES=30
+REFRESH_TOKEN_EXPIRATION_TIME_IN_MINUTES=60
+
+# ACCOUNTS CONFIGURATION
+# True | False
+DISABLE_EMAIL_VERIFICATION=True
+
+# Database Configuration
+DATABASE_NAME=augment_store
+DATABASE_USER=postgres
+DATABASE_PORT=5432
+DATABASE_HOST=localhost
+DATABASE_PASSWORD=postgres
+
+# Storage Configuration
+# local | s3
+FILE_UPLOAD_STORAGE=local
+FILE_MAX_SIZE=104857600
+
+# S3 AWS Configuration
+AWS_ACCESS_KEY_ID=your-access-key-id-here
+AWS_SECRET_ACCESS_KEY=your-secret-access-key-here
+AWS_S3_REGION_NAME=your-region-name-here
+AWS_STORAGE_BUCKET_NAME=your-bucket-name-here
+AWS_DEFAULT_ACL=public-read
+AWS_PRESIGNED_EXPIRY=3600
+AWS_S3_CUSTOM_DOMAIN=your-custom-domain-here
\ No newline at end of file
diff --git a/augment-store/server/.gitignore b/augment-store/server/.gitignore
new file mode 100644
index 000000000..99da573d0
--- /dev/null
+++ b/augment-store/server/.gitignore
@@ -0,0 +1,110 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# dotenv
+.env
+
+# virtualenv
+.venv
+venv/
+ENV/
+.vscode
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
+.DS_Store
+*.sqlite3
+media/
+*.pyc
+*.db
+*.pid
+
+test_media/
\ No newline at end of file
diff --git a/augment-store/server/README.md b/augment-store/server/README.md
new file mode 100644
index 000000000..e3cb6912c
--- /dev/null
+++ b/augment-store/server/README.md
@@ -0,0 +1,421 @@
+# Augment Store Server
+
+A Django REST Framework-based backend API for the Augment Store e-commerce application.
+
+## ๐ Table of Contents
+
+- [Overview](#overview)
+- [Prerequisites](#prerequisites)
+- [Installation](#installation)
+- [Configuration](#configuration)
+- [Running the Server](#running-the-server)
+- [API Documentation](#api-documentation)
+- [Project Structure](#project-structure)
+- [Available Commands](#available-commands)
+
+## Overview
+
+This is a Django REST Framework API server that provides endpoints for the Augment Store e-commerce platform. It includes:
+
+- **Authentication**: JWT-based authentication with token refresh
+- **User Management**: Custom user model with account management
+- **API**: RESTful API with comprehensive documentation
+- **Database**: PostgreSQL for robust data management
+- **API Documentation**: Swagger/OpenAPI documentation
+
+### Tech Stack
+
+- **Framework**: Django 5.2.7
+- **API**: Django REST Framework 3.16.1
+- **Authentication**: djangorestframework-simplejwt
+- **Documentation**: drf-spectacular
+- **Database**: PostgreSQL
+- **Environment**: python-dotenv
+
+## Prerequisites
+
+Before you begin, ensure you have the following installed:
+
+- **Python 3.10+** - [Download Python](https://www.python.org/downloads/)
+- **pip** - Python package manager (comes with Python)
+- **PostgreSQL 12+** - [Download PostgreSQL](https://www.postgresql.org/download/)
+- **Git** - [Download Git](https://git-scm.com/downloads)
+
+### Verify Installation
+
+```bash
+python --version
+pip --version
+psql --version
+```
+
+## Installation
+
+### 1. Navigate to the Server Directory
+
+```bash
+cd augment-store/server
+```
+
+### 2. Create a Virtual Environment
+
+It's recommended to use a virtual environment to isolate project dependencies.
+
+**On macOS/Linux:**
+```bash
+python3 -m venv env
+source env/bin/activate
+```
+
+**On Windows:**
+```bash
+python -m venv env
+env\Scripts\activate
+```
+
+### 3. Install Dependencies
+
+```bash
+pip install -r requirements.txt
+```
+
+This will install all required packages including:
+- Django
+- Django REST Framework
+- djangorestframework-simplejwt (JWT authentication)
+- drf-spectacular (API documentation)
+- python-dotenv (environment variables)
+- And other dependencies
+
+## Configuration
+
+### 1. Set Up PostgreSQL Database
+
+Before configuring the Django application, you need to create a PostgreSQL database:
+
+**On macOS/Linux:**
+```bash
+# Start PostgreSQL service (if not already running)
+brew services start postgresql
+
+# Connect to PostgreSQL
+psql postgres
+
+# Create database and user
+CREATE DATABASE augment_store;
+CREATE USER postgres WITH PASSWORD 'postgres';
+ALTER ROLE postgres SET client_encoding TO 'utf8';
+ALTER ROLE postgres SET default_transaction_isolation TO 'read committed';
+ALTER ROLE postgres SET default_transaction_deferrable TO on;
+ALTER ROLE postgres SET default_transaction_isolation TO 'read committed';
+ALTER ROLE postgres SET timezone TO 'UTC';
+GRANT ALL PRIVILEGES ON DATABASE augment_store TO postgres;
+\q
+```
+
+**On Windows:**
+```bash
+# Open pgAdmin (installed with PostgreSQL)
+# Or use psql command line:
+psql -U postgres
+
+# Then run the same SQL commands as above
+```
+
+### 2. Create Environment File
+
+Copy the example environment file and create your own `.env` file:
+
+```bash
+cp .env.example .env
+```
+
+### 3. Configure Environment Variables
+
+Edit the `.env` file and set the following variables:
+
+```env
+# Django Settings
+SECRET_KEY=your-secret-key-here
+DEBUG=True
+
+# Token Expiration (in minutes)
+ACCESS_TOKEN_EXPIRATION_TIME_IN_MINUTES=30
+REFRESH_TOKEN_EXPIRATION_TIME_IN_MINUTES=60
+
+# Database Configuration
+DATABASE_NAME=augment_store
+DATABASE_USER=postgres
+DATABASE_PASSWORD=postgres
+DATABASE_HOST=localhost
+DATABASE_PORT=5432
+```
+
+**Important**:
+- Generate a secure `SECRET_KEY` for production. You can use Django's built-in utility:
+ ```bash
+ python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
+ ```
+- Set `DEBUG=False` in production
+- Update database credentials to match your PostgreSQL setup
+- Ensure PostgreSQL is running before running migrations
+
+### 4. Install PostgreSQL Python Driver
+
+The PostgreSQL driver is included in requirements.txt, but ensure it's installed:
+
+```bash
+pip install psycopg2
+```
+
+If you encounter issues, try:
+```bash
+pip install psycopg2-binary
+```
+
+### 5. Database Setup
+
+Run migrations to set up the database:
+
+```bash
+python manage.py migrate
+```
+
+This will apply all migrations to your PostgreSQL database.
+
+### 6. Create a Superuser (Admin Account)
+
+Create an admin account to access the Django admin panel:
+
+```bash
+python manage.py createsuperuser
+```
+
+Follow the prompts to enter:
+- Username
+- Email
+- Password
+
+## Running the Server
+
+### Start the Development Server
+
+```bash
+python manage.py runserver
+```
+
+The server will start at `http://localhost:8000`
+
+### Access the Application
+
+- **API Root**: http://localhost:8000/api/v1/
+- **Swagger Documentation**: http://localhost:8000/
+- **Django Admin**: http://localhost:8000/admin/
+
+### Stop the Server
+
+Press `Ctrl+C` in the terminal where the server is running.
+
+## API Documentation
+
+### Swagger UI
+
+The API documentation is automatically generated and available at:
+
+```
+http://localhost:8000/
+```
+
+This provides an interactive interface to:
+- View all available endpoints
+- See request/response schemas
+- Test API endpoints directly
+
+### API Endpoints
+
+The API is organized under `/api/v1/` namespace. Available endpoints include:
+
+- **Authentication**: User login, token refresh, registration
+- **Accounts**: User profile management
+- **API**: Main application endpoints
+
+## Project Structure
+
+```
+augment-store/server/
+โโโ manage.py # Django management script
+โโโ requirements.txt # Python dependencies
+โโโ .env.example # Environment variables template
+โ
+โโโ core/ # Django project settings
+โ โโโ settings.py # Project settings (PostgreSQL config)
+โ โโโ urls.py # URL routing
+โ โโโ wsgi.py # WSGI configuration
+โ โโโ asgi.py # ASGI configuration
+โ
+โโโ accounts/ # User account management app
+โ โโโ models.py # Custom User model
+โ โโโ views.py # Account views
+โ โโโ serializers.py # Data serializers
+โ โโโ migrations/ # Database migrations
+โ
+โโโ authentication/ # Authentication app
+โ โโโ views.py # Auth endpoints
+โ โโโ serializers.py # Auth serializers
+โ โโโ urls.py # Auth URLs
+โ โโโ migrations/ # Database migrations
+โ
+โโโ api/ # Main API app
+ โโโ models.py # API models
+ โโโ views.py # API views
+ โโโ serializers.py # API serializers
+ โโโ urls.py # API URLs
+ โโโ migrations/ # Database migrations
+```
+
+
+
+## Development Workflow
+
+### 1. Activate Virtual Environment
+
+Ensure you activate the virtual environment before making any changes:
+
+If use are using venv
+```bash
+source env/bin/activate
+```
+
+Else use the appropriate command for your virtual environment manager
+
+### 2. Start the Server
+
+```bash
+python manage.py runserver
+```
+
+### 3. Access Admin Panel
+
+Navigate to `http://localhost:8000/admin/` and log in with your superuser credentials.
+
+### 4. Make Changes
+
+Edit your models, views, or serializers as needed.
+
+### 5. Create Migrations (if you modified models)
+
+```bash
+python manage.py makemigrations
+python manage.py migrate
+```
+
+### 6. Test Your Changes
+
+```bash
+python manage.py test
+```
+
+## Connecting to Frontend
+
+The frontend (React application) should be configured to connect to this backend:
+
+1. **Frontend API URL**: `http://localhost:8000/api/v1/`
+2. **Update `.env` in `augment-store/client/`**:
+ ```
+ VITE_API_BASE_URL=http://localhost:8000/api/v1
+ ```
+
+## Troubleshooting
+
+### Port Already in Use
+
+If port 8000 is already in use, run the server on a different port:
+
+```bash
+python manage.py runserver 8001
+```
+
+### PostgreSQL Connection Issues
+
+If you get a PostgreSQL connection error:
+
+1. **Verify PostgreSQL is running:**
+ ```bash
+ # macOS
+ brew services list | grep postgresql
+
+ # Linux
+ sudo systemctl status postgresql
+ ```
+
+2. **Check database credentials in `.env`:**
+ - Ensure `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USER`, and `DATABASE_PASSWORD` are correct
+ - Default PostgreSQL port is `5432`
+
+3. **Verify the database exists:**
+ ```bash
+ psql -U postgres -h localhost -c "SELECT datname FROM pg_database WHERE datname='augment_store';"
+ ```
+
+4. **Recreate the database if needed:**
+ ```bash
+ psql -U postgres
+ DROP DATABASE augment_store;
+ CREATE DATABASE augment_store;
+ GRANT ALL PRIVILEGES ON DATABASE augment_store TO postgres;
+ \q
+ ```
+
+### Database Migration Issues
+
+If you encounter migration errors:
+
+```bash
+# Check migration status
+python manage.py showmigrations
+
+# Rollback migrations (if needed)
+python manage.py migrate app_name zero
+
+# Run migrations again
+python manage.py migrate
+```
+
+### Virtual Environment Issues
+
+If you have issues with the virtual environment:
+
+```bash
+# Deactivate current environment
+deactivate
+
+# Remove the env folder
+rm -rf env
+
+# Create a new virtual environment
+python3 -m venv env
+source env/bin/activate
+
+# Reinstall dependencies
+pip install -r requirements.txt
+```
+
+## Next Steps
+
+1. โ Set up the server following this guide
+2. ๐ Review the API documentation at `http://localhost:8000/`
+3. ๐ง Customize models and endpoints as needed
+4. ๐งช Write tests for your endpoints
+5. ๐ Deploy to production when ready
+
+## Support
+
+For issues or questions:
+- Check the [Django Documentation](https://docs.djangoproject.com/)
+- Review [Django REST Framework Documentation](https://www.django-rest-framework.org/)
+- Check the project's GitHub issues
+
+## License
+
+This project is part of the Augment Store e-commerce platform.
+
diff --git a/augment-store/server/accounts/__init__.py b/augment-store/server/accounts/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/accounts/admin.py b/augment-store/server/accounts/admin.py
new file mode 100644
index 000000000..8c38f3f3d
--- /dev/null
+++ b/augment-store/server/accounts/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/augment-store/server/accounts/apps.py b/augment-store/server/accounts/apps.py
new file mode 100644
index 000000000..3e3c76595
--- /dev/null
+++ b/augment-store/server/accounts/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class AccountsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'accounts'
diff --git a/augment-store/server/accounts/factory.py b/augment-store/server/accounts/factory.py
new file mode 100644
index 000000000..9f83fe8fc
--- /dev/null
+++ b/augment-store/server/accounts/factory.py
@@ -0,0 +1,24 @@
+
+from factory import Faker
+from factory.django import DjangoModelFactory
+
+
+class UserFactory(DjangoModelFactory):
+ email = Faker("email")
+ password = Faker("password")
+ first_name = Faker("first_name")
+ last_name = Faker("last_name")
+ is_active = True
+
+ # user the set_password method to set the password
+ @classmethod
+ def _create(cls, model_class, *args, **kwargs):
+ user = super()._create(model_class, *args, **kwargs)
+ user.set_password(kwargs.get("password"))
+ user.save()
+ return user
+
+ class Meta:
+ model = "accounts.User"
+ django_get_or_create = ["email"]
+
diff --git a/augment-store/server/accounts/migrations/0001_initial.py b/augment-store/server/accounts/migrations/0001_initial.py
new file mode 100644
index 000000000..73050c537
--- /dev/null
+++ b/augment-store/server/accounts/migrations/0001_initial.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.2.7 on 2025-10-10 15:14
+
+import django.utils.timezone
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('username', models.CharField(max_length=255, null=True, verbose_name='username')),
+ ('email', models.EmailField(max_length=254, unique=True, verbose_name='user email')),
+ ('mobile', models.CharField(blank=True, max_length=20, null=True, verbose_name='mobile number')),
+ ('gender', models.CharField(choices=[('Male', 'Male'), ('Female', 'Female'), ('Other', 'Other')], default='Other', max_length=10)),
+ ('image', models.ImageField(blank=True, null=True, upload_to='user_images')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/augment-store/server/accounts/migrations/0002_user_role.py b/augment-store/server/accounts/migrations/0002_user_role.py
new file mode 100644
index 000000000..b9a21872e
--- /dev/null
+++ b/augment-store/server/accounts/migrations/0002_user_role.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.7 on 2025-10-13 23:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='role',
+ field=models.CharField(choices=[('admin', 'Admin'), ('merchant', 'Merchant'), ('member', 'Member')], default='member', max_length=20),
+ ),
+ ]
diff --git a/augment-store/server/accounts/migrations/0003_user_profile_image.py b/augment-store/server/accounts/migrations/0003_user_profile_image.py
new file mode 100644
index 000000000..e8ee8d02d
--- /dev/null
+++ b/augment-store/server/accounts/migrations/0003_user_profile_image.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2.7 on 2025-10-29 16:45
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0002_user_role'),
+ ('storage', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='profile_image',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='storage.file'),
+ ),
+ ]
diff --git a/augment-store/server/accounts/migrations/__init__.py b/augment-store/server/accounts/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/accounts/models.py b/augment-store/server/accounts/models.py
new file mode 100644
index 000000000..a4885d3c6
--- /dev/null
+++ b/augment-store/server/accounts/models.py
@@ -0,0 +1,119 @@
+import uuid
+
+from django.contrib.auth.base_user import BaseUserManager
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.db.models.query import QuerySet
+from django.utils.translation import gettext as _
+from django.conf import settings
+
+
+class UserManager(BaseUserManager):
+
+ """
+ Custom user model manager where email is the unique identifiers
+ for authentication instead of usernames.
+ """
+
+ def get_queryset(self) -> QuerySet:
+ return super().get_queryset().order_by("email")
+
+ def create_user(self, email, password, **extra_fields):
+ """
+ Create and save a User with the given email and password.
+ """
+ if not email:
+ raise ValueError(_("The Email must be set"))
+ if not password:
+ raise ValueError(_("The Password must be set"))
+ email = self.normalize_email(email)
+
+ extra_fields.setdefault("is_active", True if settings.DISABLE_EMAIL_VERIFICATION else False)
+ user: "User" = self.model(email=email, **extra_fields)
+ user.set_password(password)
+ user.save()
+ return user
+
+ def create_superuser(self, email, password, **extra_fields):
+ """
+ Create and save a SuperUser with the given email and password.
+ """
+ extra_fields.setdefault("is_staff", True)
+ extra_fields.setdefault("is_superuser", True)
+ extra_fields.setdefault("is_active", True)
+ extra_fields.setdefault("username", email)
+
+ if extra_fields.get("is_staff") is not True:
+ raise ValueError(_("Superuser must have is_staff=True."))
+ if extra_fields.get("is_superuser") is not True:
+ raise ValueError(_("Superuser must have is_superuser=True."))
+ return self.create_user(email, password, **extra_fields)
+
+
+class User(AbstractUser):
+
+ class Role:
+ ADMIN = "admin"
+ MERCHANT = "merchant"
+ MEMBER = "member"
+
+ CHOICES = (
+ (ADMIN, _("Admin")),
+ (MERCHANT, _("Merchant")),
+ (MEMBER, _("Member")),
+ )
+
+ class Gender:
+ MALE = "Male"
+ FEMALE = "Female"
+ OTHER = "Other"
+
+ CHOICES = (
+ (MALE, _("Male")),
+ (FEMALE, _("Female")),
+ (OTHER, _("Other")),
+ )
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ username = models.CharField( _("username"), max_length=255, null=True)
+ email = models.EmailField(_("user email"), max_length=254, unique=True)
+ mobile = models.CharField( _("mobile number"), max_length=20, blank=True, null=True)
+ gender = models.CharField(max_length=10, choices=Gender.CHOICES, default=Gender.OTHER)
+ image = models.ImageField(
+ upload_to="user_images",
+ null=True,
+ blank=True,
+ )
+ profile_image = models.ForeignKey(
+ "storage.File", null=True, blank=True, on_delete=models.SET_NULL
+ )
+ role = models.CharField(max_length=20, choices=Role.CHOICES, default=Role.MEMBER)
+ objects: UserManager = UserManager()
+ USERNAME_FIELD = "email"
+ EMAIL_FIELD = "email"
+ REQUIRED_FIELDS = [ "username", "mobile"]
+
+ def __str__(self) -> str:
+ return self.full_name
+
+ @property
+ def is_registration_completed(self):
+ if self.first_name:
+ return True
+ return False
+
+ @property
+ def full_name(self):
+ return f"{self.first_name} {self.last_name}"
+
+ @property
+ def is_admin(self):
+ return self.role == self.Role.ADMIN
+
+ @property
+ def is_merchant(self):
+ return self.role == self.Role.MERCHANT
+
+ @property
+ def is_member(self):
+ return self.role == self.Role.MEMBER
diff --git a/augment-store/server/accounts/permissions.py b/augment-store/server/accounts/permissions.py
new file mode 100644
index 000000000..6742bbbfb
--- /dev/null
+++ b/augment-store/server/accounts/permissions.py
@@ -0,0 +1,25 @@
+
+from rest_framework.permissions import BasePermission
+from accounts.models import User
+
+class hasAdminRole(BasePermission):
+ def has_permission(self, request, view):
+ user: User = request.user
+ return user.is_authenticated and user.is_admin
+
+
+class hasMerchantRole(BasePermission):
+ def has_permission(self, request, view):
+ user: User = request.user
+ return user.is_authenticated and user.is_merchant
+
+class hasAdminOrMerchantRole(BasePermission):
+ def has_permission(self, request, view):
+ user: User = request.user
+ return user.is_authenticated and (user.is_admin or user.is_merchant)
+
+
+class hasMemberRole(BasePermission):
+ def has_permission(self, request, view):
+ user: User = request.user
+ return user.is_authenticated and user.is_member
diff --git a/augment-store/server/accounts/serializers.py b/augment-store/server/accounts/serializers.py
new file mode 100644
index 000000000..fcc9b7201
--- /dev/null
+++ b/augment-store/server/accounts/serializers.py
@@ -0,0 +1,56 @@
+from rest_framework import serializers
+from .models import User
+
+
+class UserProfileSerializer(serializers.ModelSerializer):
+ """Serializer for retrieving user profile information"""
+ full_name = serializers.CharField(read_only=True)
+ is_registration_completed = serializers.BooleanField(read_only=True)
+ profile_image = serializers.SerializerMethodField()
+
+ class Meta:
+ model = User
+ fields = [
+ "id",
+ "email",
+ "username",
+ "first_name",
+ "last_name",
+ "full_name",
+ "mobile",
+ "gender",
+ "image",
+ "profile_image",
+ "role",
+ "is_active",
+ "is_registration_completed",
+ "date_joined",
+ ]
+ read_only_fields = ["id", "email", "role", "is_active", "date_joined"]
+
+ def get_profile_image(self, obj: User):
+ if obj.profile_image:
+ return obj.profile_image.file.url
+ return None
+
+
+class UpdateUserProfileSerializer(serializers.ModelSerializer):
+ """Serializer for updating user profile information"""
+
+ class Meta:
+ model = User
+ fields = [
+ "username",
+ "first_name",
+ "last_name",
+ "mobile",
+ "gender",
+ "image",
+ ]
+
+ def validate_mobile(self, value):
+ """Validate mobile number format"""
+ if value and len(value) > 20:
+ raise serializers.ValidationError("Mobile number is too long")
+ return value
+
diff --git a/augment-store/server/accounts/tests.py b/augment-store/server/accounts/tests.py
new file mode 100644
index 000000000..8197211bf
--- /dev/null
+++ b/augment-store/server/accounts/tests.py
@@ -0,0 +1,171 @@
+from core.tests import BaseAPITestCase
+from accounts.factory import UserFactory
+from rest_framework import status
+from django.urls import reverse
+
+
+class UserProfileTests(BaseAPITestCase):
+
+ def test_get_user_profile_authenticated(self):
+ # GIVEN an authenticated user exists
+ user = UserFactory(
+ email="testuser@example.com",
+ first_name="John",
+ last_name="Doe",
+ username="johndoe",
+ mobile="1234567890",
+ gender="Male",
+ is_active=True
+ )
+
+ # WHEN we make a GET request to retrieve the user profile
+ self.authenticated_client.force_authenticate(user=user)
+ url = reverse("v1:user_profile")
+ response = self.authenticated_client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the user profile data
+ self.assertEqual(response.data["email"], "testuser@example.com")
+ self.assertEqual(response.data["first_name"], "John")
+ self.assertEqual(response.data["last_name"], "Doe")
+ self.assertEqual(response.data["username"], "johndoe")
+ self.assertEqual(response.data["mobile"], "1234567890")
+ self.assertEqual(response.data["gender"], "Male")
+ self.assertEqual(response.data["full_name"], "John Doe")
+ self.assertIn("id", response.data)
+ self.assertIn("date_joined", response.data)
+
+ def test_get_user_profile_unauthenticated(self):
+ # GIVEN an unauthenticated user
+ # WHEN we make a GET request to retrieve the user profile
+ url = reverse("v1:user_profile")
+ response = self.client.get(url)
+
+ # THEN we should get a 401 Unauthorized response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_update_user_profile_authenticated(self):
+ # GIVEN an authenticated user exists
+ user = UserFactory(
+ email="testuser@example.com",
+ first_name="John",
+ last_name="Doe",
+ username="johndoe",
+ is_active=True
+ )
+
+ # WHEN we make a PATCH request to update the user profile
+ self.authenticated_client.force_authenticate(user=user)
+ url = reverse("v1:user_profile")
+ payload = {
+ "first_name": "Jane",
+ "last_name": "Smith",
+ "username": "janesmith",
+ "mobile": "9876543210",
+ "gender": "Female"
+ }
+ response = self.authenticated_client.patch(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the user profile should be updated
+ user.refresh_from_db()
+ self.assertEqual(user.first_name, "Jane")
+ self.assertEqual(user.last_name, "Smith")
+ self.assertEqual(user.username, "janesmith")
+ self.assertEqual(user.mobile, "9876543210")
+ self.assertEqual(user.gender, "Female")
+
+ def test_update_user_profile_partial(self):
+ # GIVEN an authenticated user exists
+ user = UserFactory(
+ email="testuser@example.com",
+ first_name="John",
+ last_name="Doe",
+ username="johndoe",
+ is_active=True
+ )
+
+ # WHEN we make a PATCH request to update only some fields
+ self.authenticated_client.force_authenticate(user=user)
+ url = reverse("v1:user_profile")
+ payload = {
+ "first_name": "Jane"
+ }
+ response = self.authenticated_client.patch(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND only the specified field should be updated
+ user.refresh_from_db()
+ self.assertEqual(user.first_name, "Jane")
+ self.assertEqual(user.last_name, "Doe") # Should remain unchanged
+ self.assertEqual(user.username, "johndoe") # Should remain unchanged
+
+ def test_update_user_profile_unauthenticated(self):
+ # GIVEN an unauthenticated user
+ # WHEN we make a PATCH request to update the user profile
+ url = reverse("v1:user_profile")
+ payload = {
+ "first_name": "Jane"
+ }
+ response = self.client.patch(url, payload)
+
+ # THEN we should get a 401 Unauthorized response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_update_user_profile_readonly_fields(self):
+ # GIVEN an authenticated user exists
+ user = UserFactory(
+ email="testuser@example.com",
+ first_name="John",
+ last_name="Doe",
+ is_active=True
+ )
+ original_email = user.email
+ original_role = user.role
+
+ # WHEN we try to update read-only fields like email and role
+ self.authenticated_client.force_authenticate(user=user)
+ url = reverse("v1:user_profile")
+ payload = {
+ "email": "newemail@example.com",
+ "role": "admin",
+ "first_name": "Jane"
+ }
+ response = self.authenticated_client.patch(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the read-only fields should not be updated
+ user.refresh_from_db()
+ self.assertEqual(user.email, original_email) # Should remain unchanged
+ self.assertEqual(user.role, original_role) # Should remain unchanged
+ self.assertEqual(user.first_name, "Jane") # Should be updated
+
+ def test_update_user_profile_invalid_mobile(self):
+ # GIVEN an authenticated user exists
+ user = UserFactory(
+ email="testuser@example.com",
+ first_name="John",
+ last_name="Doe",
+ is_active=True
+ )
+
+ # WHEN we try to update with an invalid mobile number (too long)
+ self.authenticated_client.force_authenticate(user=user)
+ url = reverse("v1:user_profile")
+ payload = {
+ "mobile": "1" * 25 # More than 20 characters
+ }
+ response = self.authenticated_client.patch(url, payload)
+
+ # THEN we should get a 400 Bad Request response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertIn("mobile", response.data)
+ # self.assertF
diff --git a/augment-store/server/accounts/urls.py b/augment-store/server/accounts/urls.py
new file mode 100644
index 000000000..e23a661ce
--- /dev/null
+++ b/augment-store/server/accounts/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+from .views import UserProfileView
+
+urlpatterns = [
+ path('profile/', UserProfileView.as_view(), name='user_profile'),
+]
+
diff --git a/augment-store/server/accounts/views.py b/augment-store/server/accounts/views.py
new file mode 100644
index 000000000..df45dbe98
--- /dev/null
+++ b/augment-store/server/accounts/views.py
@@ -0,0 +1,25 @@
+from rest_framework.generics import RetrieveUpdateAPIView
+from rest_framework.permissions import IsAuthenticated
+from .models import User
+from .serializers import UserProfileSerializer, UpdateUserProfileSerializer
+
+
+class UserProfileView(RetrieveUpdateAPIView):
+ """
+ View for retrieving and updating the authenticated user's profile.
+
+ GET: Retrieve the current user's profile information
+ PATCH/PUT: Update the current user's profile information
+ """
+ permission_classes = [IsAuthenticated]
+ serializer_class = UserProfileSerializer
+
+ def get_object(self):
+ """Return the current authenticated user"""
+ return self.request.user
+
+ def get_serializer_class(self):
+ """Use different serializers for read and write operations"""
+ if self.request.method in ['PATCH', 'PUT']:
+ return UpdateUserProfileSerializer
+ return UserProfileSerializer
\ No newline at end of file
diff --git a/augment-store/server/api/__init__.py b/augment-store/server/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/api/admin.py b/augment-store/server/api/admin.py
new file mode 100644
index 000000000..8c38f3f3d
--- /dev/null
+++ b/augment-store/server/api/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/augment-store/server/api/apps.py b/augment-store/server/api/apps.py
new file mode 100644
index 000000000..66656fd29
--- /dev/null
+++ b/augment-store/server/api/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'api'
diff --git a/augment-store/server/api/migrations/__init__.py b/augment-store/server/api/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/api/models.py b/augment-store/server/api/models.py
new file mode 100644
index 000000000..71a836239
--- /dev/null
+++ b/augment-store/server/api/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/augment-store/server/api/tests.py b/augment-store/server/api/tests.py
new file mode 100644
index 000000000..7ce503c2d
--- /dev/null
+++ b/augment-store/server/api/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/augment-store/server/api/urls.py b/augment-store/server/api/urls.py
new file mode 100644
index 000000000..1916e2036
--- /dev/null
+++ b/augment-store/server/api/urls.py
@@ -0,0 +1,26 @@
+
+from django.urls import path, include
+from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
+from rest_framework.response import Response
+from rest_framework.decorators import api_view
+
+
+app_name = 'v1'
+
+@api_view(['GET'])
+def health_check(request):
+ return Response({'status': 'ok'})
+
+
+urlpatterns = [
+ path('schema/', SpectacularAPIView.as_view(), name='schema'),
+ path('schema/redoc/', SpectacularRedocView.as_view(url_name='v1:schema'), name='redoc'),
+ path('health-check/', health_check, name='health_check'),
+ path('auth/', include('authentication.urls')),
+ path('accounts/', include('accounts.urls')),
+ path('products/', include('products.urls_products')),
+ path('products/brands/', include('products.urls_brands')),
+ path('products/categories/', include('products.urls_categories')),
+ path('storage/', include('storage.urls', namespace='storage')),
+ path('carts/', include('carts.urls')),
+]
diff --git a/augment-store/server/api/views.py b/augment-store/server/api/views.py
new file mode 100644
index 000000000..91ea44a21
--- /dev/null
+++ b/augment-store/server/api/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/augment-store/server/authentication/__init__.py b/augment-store/server/authentication/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/authentication/admin.py b/augment-store/server/authentication/admin.py
new file mode 100644
index 000000000..8c38f3f3d
--- /dev/null
+++ b/augment-store/server/authentication/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/augment-store/server/authentication/apps.py b/augment-store/server/authentication/apps.py
new file mode 100644
index 000000000..8bab8df09
--- /dev/null
+++ b/augment-store/server/authentication/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class AuthenticationConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'authentication'
diff --git a/augment-store/server/authentication/migrations/__init__.py b/augment-store/server/authentication/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/authentication/models.py b/augment-store/server/authentication/models.py
new file mode 100644
index 000000000..71a836239
--- /dev/null
+++ b/augment-store/server/authentication/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/augment-store/server/authentication/serializers.py b/augment-store/server/authentication/serializers.py
new file mode 100644
index 000000000..ddcba3144
--- /dev/null
+++ b/augment-store/server/authentication/serializers.py
@@ -0,0 +1,81 @@
+
+
+from rest_framework import serializers
+from accounts.models import User
+from rest_framework_simplejwt.tokens import RefreshToken
+from rest_framework.exceptions import NotAuthenticated, AuthenticationFailed
+
+
+
+class RegisterSerializer(serializers.ModelSerializer):
+ password = serializers.CharField(write_only=True)
+ class Meta:
+ model = User
+ fields = ["email", "password", "first_name", "last_name"]
+
+ def create(self, validated_data):
+ user = User.objects.create_user(
+ email=validated_data["email"],
+ password=validated_data["password"],
+ first_name=validated_data["first_name"],
+ last_name=validated_data["last_name"],
+ )
+ return user
+
+class LoginSerializer(serializers.Serializer):
+ email = serializers.EmailField(write_only=True, required=True)
+ password = serializers.CharField(write_only=True, required=True)
+ refresh = serializers.CharField(read_only=True)
+ access = serializers.CharField(read_only=True)
+
+
+ def validate(self, attrs):
+ email = attrs.get("email")
+ password = attrs.get("password")
+
+ user = User.objects.filter(email=email).first()
+
+ if not user:
+ raise NotAuthenticated("Invalid credentials")
+
+ if not user.is_active:
+ raise AuthenticationFailed("User is not active")
+
+ if not user.check_password(password):
+ raise NotAuthenticated("Invalid credentials")
+
+ return attrs
+
+ def create(self, validated_data):
+ # I have validated the user in the validate method, so it is safe to use get()
+ user = User.objects.get(email=validated_data.get("email"))
+ refresh = RefreshToken.for_user(user)
+ return {
+ "refresh": str(refresh),
+ "access": str(refresh.access_token),
+ }
+
+
+class ForgotPasswordSerializer(serializers.Serializer):
+ email = serializers.EmailField()
+
+
+class RefreshTokenSerializer(serializers.Serializer):
+ refresh = serializers.CharField(required=True)
+ access = serializers.CharField(read_only=True)
+
+ def validate(self, attrs):
+ refresh = attrs.get("refresh")
+ try:
+ RefreshToken(refresh)
+ except:
+ raise NotAuthenticated("Invalid refresh token")
+ return attrs
+
+
+ def create(self, validated_data):
+ refresh = RefreshToken(validated_data.get("refresh"))
+ return {
+ "access": str(refresh.access_token),
+ "refresh": str(refresh),
+ }
diff --git a/augment-store/server/authentication/tests.py b/augment-store/server/authentication/tests.py
new file mode 100644
index 000000000..9d96ed4f2
--- /dev/null
+++ b/augment-store/server/authentication/tests.py
@@ -0,0 +1,193 @@
+from core.tests import BaseAPITestCase
+from accounts.factory import UserFactory
+from rest_framework import status
+from django.urls import reverse
+from django.test import override_settings
+from accounts.models import User
+
+
+class AuthenticationTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+
+ def test_register(self):
+ # GIVEN a user does not exist
+ # WHEN we make a post request to /auth/register/ with valid data
+ url = reverse("v1:register")
+ payload = {
+ "email": "test@example.com",
+ "password": "testpassword",
+ "first_name": "Test",
+ "last_name": "User",
+ }
+ response = self.client.post(url, payload)
+ self.assertEqual(response.status_code, 201)
+
+ @override_settings(DISABLE_EMAIL_VERIFICATION=True)
+ def test_create_user_when_verification_disabled(self):
+ # GIVEN verification is disabled
+ # WHEN we create a user
+ url = reverse("v1:register")
+ payload = {
+ "email": "test@example.com",
+ "password": "testpassword",
+ "first_name": "Test",
+ "last_name": "User",
+ }
+ response = self.client.post(url, payload)
+
+ user = User.objects.get(email="test@example.com")
+ # THEN the user should be active
+ self.assertTrue(user.is_active)
+
+ @override_settings(DISABLE_EMAIL_VERIFICATION=False)
+ def test_create_user_when_verification_enabled(self):
+ # GIVEN verification is enabled
+ # WHEN we create a user
+ url = reverse("v1:register")
+ payload = {
+ "email": "test@example.com",
+ "password": "testpassword",
+ "first_name": "Test",
+ "last_name": "User",
+ }
+ response = self.client.post(url, payload)
+ user = User.objects.get(email="test@example.com")
+
+ # THEN the user should not be active
+ self.assertFalse(user.is_active)
+
+
+ def test_active_user_login(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ UserFactory(email="user@demo.com", password="asdf1234", is_active=True)
+
+ # WHEN we make a post request to login with the user credentials
+ url = reverse("v1:login")
+ payload = {
+ "email": "user@demo.com",
+ "password": "asdf1234",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the response should contain access and refresh tokens
+ self.assertIn("access", response.data)
+ self.assertIn("refresh", response.data)
+
+ def test_inactive_user_login(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ UserFactory(email="user@demo.com", password="asdf1234", is_active=False)
+
+ # WHEN we make a post request to login with the user credentials
+ url = reverse("v1:login")
+
+ payload = {
+ "email": "user@demo.com",
+ "password": "asdf1234",
+ }
+ response = self.client.post(url, payload)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ # AND the response should contain error message
+ self.assertEqual(response.data["detail"], "User is not active")
+ # AND the response should not contain access and refresh tokens
+ self.assertNotIn("access", response.data)
+ self.assertNotIn("refresh", response.data)
+
+ def test_invalid_user_login(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ UserFactory(email="user@demo.com", password="asdf1234", is_active=True)
+
+ # WHEN we make a post request to login with the user credentials
+ url = reverse("v1:login")
+
+ payload = {
+ "email": "user@demo.com",
+ "password": "wrongpassword",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ # AND the response should contain error message
+ self.assertEqual(response.data["detail"], "Invalid credentials")
+
+ # AND the response should not contain access and refresh tokens
+ self.assertNotIn("access", response.data)
+ self.assertNotIn("refresh", response.data)
+
+ def test_refresh_token(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ user = UserFactory(email="user@demo.com", password="asdf1234", is_active=True)
+ # AND the user has a refresh token
+ from rest_framework_simplejwt.tokens import RefreshToken
+ refresh = RefreshToken.for_user(user)
+
+ # WHEN we make a post request to refresh token with the refresh token
+ url = reverse("v1:refresh_token")
+ payload = {
+ "refresh": str(refresh),
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the response should contain access and refresh tokens
+ self.assertIn("access", response.data)
+ self.assertIn("refresh", response.data)
+
+ def test_invalid_refresh_token(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ UserFactory(email="user@demo.com", password="asdf1234", is_active=True)
+
+ # WHEN we make a post request to refresh token with the refresh token
+ url = reverse("v1:refresh_token")
+ payload = {
+ "refresh": "invalidrefresh",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ # AND the response should contain error message
+ self.assertEqual(response.data["detail"], "Invalid refresh token")
+
+ def test_forgot_password(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ UserFactory(email="user@demo.com", password="asdf1234", is_active=True)
+ # WHEN we make a post request to forgot password with the user email
+ url = reverse("v1:forgot_password")
+ payload = {
+ "email": "user@demo.com",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain success message
+ self.assertEqual(response.data["message"], "Password reset email sent")
+
+ def test_logout(self):
+ # GIVEN a user with email:user@demo.com and passowrd:asdf1234 exist
+ user = UserFactory(email="user@demo.com", password="asdf1234", is_active=True)
+ # AND the user is logged in
+ self.client.force_authenticate(user=user)
+
+ # WHEN we make a post request to logout
+ url = reverse("v1:logout")
+ response = self.client.post(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain success message
+ self.assertEqual(response.data["message"], "Logged out")
diff --git a/augment-store/server/authentication/urls.py b/augment-store/server/authentication/urls.py
new file mode 100644
index 000000000..9c300a602
--- /dev/null
+++ b/augment-store/server/authentication/urls.py
@@ -0,0 +1,11 @@
+
+from django.urls import path
+from .views import RegisterView, LoginView, RefreshTokenView, ResetPasswordView, LogoutView
+
+urlpatterns = [
+ path('register/', RegisterView.as_view(), name='register'),
+ path('login/', LoginView.as_view(), name='login'),
+ path('logout/', LogoutView.as_view(), name='logout'),
+ path('refresh-token/', RefreshTokenView.as_view(), name='refresh_token'),
+ path('forgot-password/', ResetPasswordView.as_view(), name='forgot_password'),
+]
diff --git a/augment-store/server/authentication/views.py b/augment-store/server/authentication/views.py
new file mode 100644
index 000000000..c47a77af7
--- /dev/null
+++ b/augment-store/server/authentication/views.py
@@ -0,0 +1,44 @@
+from rest_framework.response import Response
+from rest_framework.generics import CreateAPIView
+from rest_framework.views import APIView
+from rest_framework.permissions import IsAuthenticated
+
+from .serializers import ForgotPasswordSerializer, LoginSerializer, RefreshTokenSerializer, RegisterSerializer
+
+
+class RegisterView(CreateAPIView):
+ serializer_class = RegisterSerializer
+ permission_classes = []
+
+
+class LoginView(CreateAPIView):
+ permission_classes = []
+
+ def get_serializer_class(self):
+ return LoginSerializer
+
+class LogoutView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request, *args, **kwargs):
+ return Response({
+ "message": "Logged out"
+ })
+
+
+class RefreshTokenView(CreateAPIView):
+ serializer_class = RefreshTokenSerializer
+ permission_classes = []
+
+
+class ResetPasswordView(CreateAPIView):
+ serializer_class = ForgotPasswordSerializer
+ permission_classes = []
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ return Response({
+ "message": "Password reset email sent"
+ })
+
diff --git a/augment-store/server/carts/__init__.py b/augment-store/server/carts/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/carts/admin.py b/augment-store/server/carts/admin.py
new file mode 100644
index 000000000..8c38f3f3d
--- /dev/null
+++ b/augment-store/server/carts/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/augment-store/server/carts/apps.py b/augment-store/server/carts/apps.py
new file mode 100644
index 000000000..a1719f829
--- /dev/null
+++ b/augment-store/server/carts/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class CartsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'carts'
diff --git a/augment-store/server/carts/factory.py b/augment-store/server/carts/factory.py
new file mode 100644
index 000000000..caaec3cfa
--- /dev/null
+++ b/augment-store/server/carts/factory.py
@@ -0,0 +1,30 @@
+from factory import Faker, SubFactory, post_generation
+from factory.django import DjangoModelFactory
+from accounts.factory import UserFactory
+from products.factory import ProductFactory
+
+
+class CartItemFactory(DjangoModelFactory):
+ product = SubFactory(ProductFactory)
+ quantity = Faker("random_int", min=1, max=10)
+ created_by = SubFactory(UserFactory)
+
+ class Meta:
+ model = "carts.CartItem"
+
+
+class CartFactory(DjangoModelFactory):
+ user = SubFactory(UserFactory)
+
+ class Meta:
+ model = "carts.Cart"
+
+ @post_generation
+ def items(self, create, extracted, **kwargs):
+ if not create:
+ return
+
+ if extracted:
+ # If a list of cart items was passed, use it
+ for item in extracted:
+ self.items.add(item)
diff --git a/augment-store/server/carts/migrations/0001_initial.py b/augment-store/server/carts/migrations/0001_initial.py
new file mode 100644
index 000000000..6f559d2cf
--- /dev/null
+++ b/augment-store/server/carts/migrations/0001_initial.py
@@ -0,0 +1,48 @@
+# Generated by Django 5.2.7 on 2025-10-24 01:42
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('products', '0003_alter_productbrand_image_alter_productcategory_image'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CartItem',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('quantity', models.IntegerField(default=1)),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to=settings.AUTH_USER_MODEL)),
+ ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cart_items', to='products.product')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='Cart',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
+ ('items', models.ManyToManyField(related_name='carts', to='carts.cartitem')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/augment-store/server/carts/migrations/__init__.py b/augment-store/server/carts/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/carts/models.py b/augment-store/server/carts/models.py
new file mode 100644
index 000000000..799aca489
--- /dev/null
+++ b/augment-store/server/carts/models.py
@@ -0,0 +1,52 @@
+from django.db import models
+from core.models import BaseModel
+from accounts.models import User
+from products.models import Product
+from django.db.models import F
+
+
+class CartItem(BaseModel):
+ product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, related_name='cart_items')
+ quantity = models.IntegerField(default=1)
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='cart_items')
+
+
+class CartManager(models.Manager):
+ def get_queryset(self):
+ return super().get_queryset().order_by('-created_at')
+
+ def get_user_cart(self, user)-> "Cart":
+ cart, _ = self.get_queryset().get_or_create(user=user)
+ return cart
+
+ def get_user_cart_items(self, user):
+ cart = self.get_user_cart(user)
+ return cart.items.all()
+
+ def add_to_cart(self, user: User, product: Product, quantity=1):
+ user_cart = self.get_user_cart(user)
+
+ # if item exists and is already in cart, update quantity
+ is_in_cart = user_cart.items.filter(product=product).exists()
+ if is_in_cart:
+ # get cart item and update quantity. we can use F() to avoid race conditions
+ cart_item = user_cart.items.get(product=product)
+ cart_item.quantity = F('quantity') + quantity
+ cart_item.save()
+ return user_cart
+
+ cart_item = CartItem.objects.create(product=product, quantity=quantity, created_by=user)
+ user_cart.items.add(cart_item)
+ user_cart.save()
+ return user_cart
+
+ def contains_cart_item(self, user: User, cart_item_id: str):
+ user_cart = self.get_user_cart(user)
+ return user_cart.items.filter(id=cart_item_id).exists()
+
+
+class Cart(BaseModel):
+ items = models.ManyToManyField(CartItem, related_name='carts')
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='cart')
+ objects: CartManager = CartManager()
+
diff --git a/augment-store/server/carts/serializers.py b/augment-store/server/carts/serializers.py
new file mode 100644
index 000000000..d9b4cd8a5
--- /dev/null
+++ b/augment-store/server/carts/serializers.py
@@ -0,0 +1,79 @@
+
+from rest_framework import serializers
+from .models import Cart, CartItem
+from products.models import Product
+from products.serializers import ProductListSerializer
+
+
+class AddToCartSerializer(serializers.Serializer):
+ product_id = serializers.UUIDField(write_only=True)
+ quantity = serializers.IntegerField(min_value=1, write_only=True)
+
+ def validate(self, attrs):
+ product_id = attrs.get("product_id")
+ quantity = attrs.get("quantity")
+
+ try:
+ product: Product = Product.objects.get(id=product_id)
+ except Product.DoesNotExist:
+ raise serializers.ValidationError("Product does not exist")
+
+ if not product.check_stock(quantity):
+ raise serializers.ValidationError("Quantity exceeds stock")
+
+ return attrs
+
+
+ def create(self, validated_data):
+
+ user = self.context.get("request").user
+ user_cart = Cart.objects.get_user_cart(user)
+ product_id = validated_data.get("product_id")
+ quantity = validated_data.get("quantity")
+
+ product = Product.objects.get(id=product_id)
+ user_cart = Cart.objects.add_to_cart(user, product, quantity)
+ return user_cart
+
+class UpdateCartItemSerializer(serializers.ModelSerializer):
+ quantity = serializers.IntegerField(min_value=1)
+ operation = serializers.ChoiceField(choices=["add", "subtract", "set"], default="set")
+
+ class Meta:
+ model = CartItem
+ fields = ["quantity", "operation"]
+
+ def validate(self, attrs):
+ cart_item = self.instance
+ quantity = attrs.get("quantity")
+ if not cart_item.product.check_stock(quantity):
+ raise serializers.ValidationError("Quantity exceeds stock")
+
+ return attrs
+
+ def update(self, instance, validated_data):
+ quantity = validated_data.get("quantity")
+ operation = validated_data.get("operation")
+ if operation == "add":
+ instance.quantity += quantity
+ elif operation == "subtract":
+ instance.quantity -= quantity
+ else:
+ instance.quantity = quantity
+ instance.save()
+ return instance
+class CartItemListSerializer(serializers.ModelSerializer):
+ product = ProductListSerializer()
+
+ class Meta:
+ model = CartItem
+ fields = "__all__"
+
+
+class CartDetailSerializer(serializers.ModelSerializer):
+ items = CartItemListSerializer(many=True)
+
+ class Meta:
+ model = Cart
+ fields = "__all__"
+
diff --git a/augment-store/server/carts/tests.py b/augment-store/server/carts/tests.py
new file mode 100644
index 000000000..7f65682fe
--- /dev/null
+++ b/augment-store/server/carts/tests.py
@@ -0,0 +1,202 @@
+from core.tests import BaseAPITestCase
+from accounts.factory import UserFactory
+from accounts.models import User
+from rest_framework import status
+from django.urls import reverse
+from products.factory import ProductFactory
+from carts.models import Cart
+from carts.factory import CartItemFactory, CartFactory
+
+
+class CartDetailViewTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+ # Create a member user for authenticated tests
+ self.member_user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+ self.member_client = self.authenticated_client
+ self.member_client.force_authenticate(user=self.member_user)
+
+ # Create test products
+ self.product1 = ProductFactory(quantity=100)
+ self.product2 = ProductFactory(quantity=50)
+
+ def test_get_cart_detail_authenticated(self):
+ # GIVEN an authenticated user exists
+ # AND the user has items in their cart
+ cart = Cart.objects.get_user_cart(self.member_user)
+ cart_item1 = CartItemFactory(
+ product=self.product1,
+ quantity=2,
+ created_by=self.member_user
+ )
+ cart_item2 = CartItemFactory(
+ product=self.product2,
+ quantity=1,
+ created_by=self.member_user
+ )
+ cart.items.add(cart_item1, cart_item2)
+ cart.save()
+
+ # WHEN we make a GET request to retrieve the cart
+ url = reverse("v1:carts:cart_detail")
+ response = self.member_client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the cart details
+ self.assertIn("items", response.data)
+ self.assertEqual(len(response.data["items"]), 2)
+ self.assertEqual(response.data["user"], self.member_user.id)
+
+ def test_get_cart_detail_empty_cart(self):
+ # GIVEN an authenticated user exists
+ # AND the user has an empty cart
+
+ # WHEN we make a GET request to retrieve the cart
+ url = reverse("v1:carts:cart_detail")
+ response = self.member_client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain an empty items list
+ self.assertIn("items", response.data)
+ self.assertEqual(len(response.data["items"]), 0)
+
+ def test_get_cart_detail_unauthenticated(self):
+ # GIVEN a user is not authenticated
+
+ # WHEN we make a GET request to retrieve the cart
+ url = reverse("v1:carts:cart_detail")
+ response = self.client.get(url)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+
+class AddToCartViewTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+ # Create a member user for authenticated tests
+ self.member_user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+ self.member_client = self.authenticated_client
+ self.member_client.force_authenticate(user=self.member_user)
+
+ # Create test products
+ self.product1 = ProductFactory(quantity=100)
+ self.product2 = ProductFactory(quantity=50)
+ self.product_low_stock = ProductFactory(quantity=5)
+
+ def test_add_to_cart_success(self):
+ # GIVEN an authenticated user exists
+ # AND a product with sufficient stock exists
+
+ # WHEN we make a POST request to add the product to cart
+ url = reverse("v1:carts:add_to_cart")
+ payload = {
+ "product_id": str(self.product1.id),
+ "quantity": 2,
+ }
+ response = self.member_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the cart should contain the product
+ cart = Cart.objects.get_user_cart(self.member_user)
+ self.assertEqual(cart.items.count(), 1)
+ cart_item = cart.items.first()
+ self.assertEqual(cart_item.product.id, self.product1.id)
+ self.assertEqual(cart_item.quantity, 2)
+
+ def test_add_to_cart_existing_product_updates_quantity(self):
+ # GIVEN an authenticated user exists
+ # AND the user already has the product in their cart
+ cart = Cart.objects.get_user_cart(self.member_user)
+ cart_item = CartItemFactory(
+ product=self.product1,
+ quantity=2,
+ created_by=self.member_user
+ )
+ cart.items.add(cart_item)
+ cart.save()
+
+ # WHEN we make a POST request to add the same product again
+ url = reverse("v1:carts:add_to_cart")
+ payload = {
+ "product_id": str(self.product1.id),
+ "quantity": 3,
+ }
+ response = self.member_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the cart should still have only one item
+ cart.refresh_from_db()
+ self.assertEqual(cart.items.count(), 1)
+
+ # AND the quantity should be updated (2 + 3 = 5)
+ cart_item.refresh_from_db()
+ self.assertEqual(cart_item.quantity, 5)
+
+ def test_add_to_cart_invalid_product_id(self):
+ # GIVEN an authenticated user exists
+ # AND an invalid product ID
+
+ # WHEN we make a POST request with an invalid product ID
+ url = reverse("v1:carts:add_to_cart")
+ payload = {
+ "product_id": "00000000-0000-0000-0000-000000000000",
+ "quantity": 1,
+ }
+ response = self.member_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ # AND the response should contain an error message
+ self.assertIn("Product does not exist", str(response.data))
+
+ def test_update_cart_item_success(self):
+ # GIVEN an authenticated user exists
+ user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+ client = self.authenticated_client
+ client.force_authenticate(user=user)
+ # AND the user has an item in their cart
+ cart = Cart.objects.get_user_cart(user)
+ cart_item = CartItemFactory(
+ product=self.product1,
+ quantity=2,
+ created_by=user
+ )
+ cart.items.add(cart_item)
+
+ # WHEN we make a PATCH request to update the cart item
+ url = reverse("v1:carts:update_cart_item", kwargs={"pk": str(cart_item.id)})
+ payload = {
+ "operation": "set",
+ "quantity": 3,
+ }
+ response = client.patch(url, payload)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ cart_item.refresh_from_db()
+ self.assertEqual(cart_item.quantity, 3)
diff --git a/augment-store/server/carts/urls.py b/augment-store/server/carts/urls.py
new file mode 100644
index 000000000..4755a20af
--- /dev/null
+++ b/augment-store/server/carts/urls.py
@@ -0,0 +1,10 @@
+
+from django.urls import path
+from .views import AddToCartView, UpdateCartItemView, CartDetailView
+
+app_name = "carts"
+urlpatterns = [
+ path('add-item/', AddToCartView.as_view(), name='add_to_cart'),
+ path('items//', UpdateCartItemView.as_view(), name='update_cart_item'),
+ path('', CartDetailView.as_view(), name='cart_detail'),
+]
diff --git a/augment-store/server/carts/views.py b/augment-store/server/carts/views.py
new file mode 100644
index 000000000..3fea94907
--- /dev/null
+++ b/augment-store/server/carts/views.py
@@ -0,0 +1,30 @@
+from rest_framework.generics import CreateAPIView, ListAPIView, RetrieveUpdateDestroyAPIView, RetrieveAPIView
+from rest_framework.permissions import IsAuthenticated
+from .models import Cart, CartItem
+
+from .serializers import AddToCartSerializer, UpdateCartItemSerializer, CartDetailSerializer
+
+
+class BaseCartView:
+ permission_classes = [IsAuthenticated]
+
+
+class CartDetailView(BaseCartView, RetrieveAPIView):
+ serializer_class = CartDetailSerializer
+
+ def get_object(self):
+ return Cart.objects.get_user_cart(self.request.user)
+
+class BaseCartItemView:
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ user_cart = Cart.objects.get_user_cart(self.request.user)
+ return user_cart.items.all()
+
+class AddToCartView(BaseCartItemView, CreateAPIView):
+ serializer_class = AddToCartSerializer
+
+class UpdateCartItemView(BaseCartItemView, RetrieveUpdateDestroyAPIView):
+ serializer_class = UpdateCartItemSerializer
+
diff --git a/augment-store/server/core/__init__.py b/augment-store/server/core/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/core/asgi.py b/augment-store/server/core/asgi.py
new file mode 100644
index 000000000..12e5e08da
--- /dev/null
+++ b/augment-store/server/core/asgi.py
@@ -0,0 +1,32 @@
+"""
+ASGI (Asynchronous Server Gateway Interface) configuration for the core
+Django project.
+
+This module configures the ASGI application server interface, which
+enables Django to handle asynchronous requests, WebSockets, and
+long-lived connections. ASGI is the spiritual successor to WSGI and
+provides a standard interface between async-capable Python web servers,
+frameworks, and applications.
+
+The module exposes the ASGI callable as a module-level variable named
+``application``, which is used by ASGI servers (such as Daphne, Uvicorn,
+or Hypercorn) to serve the Django application.
+
+Usage:
+ Deploy with an ASGI server like Uvicorn:
+ $ uvicorn core.asgi:application --host 0.0.0.0 --port 8000
+
+ Or with Daphne:
+ $ daphne -b 0.0.0.0 -p 8000 core.asgi:application
+
+For more information on ASGI deployment, see:
+https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+
+application = get_asgi_application()
diff --git a/augment-store/server/core/models.py b/augment-store/server/core/models.py
new file mode 100644
index 000000000..680a31818
--- /dev/null
+++ b/augment-store/server/core/models.py
@@ -0,0 +1,12 @@
+from django.db import models
+import uuid
+
+
+class BaseModel(models.Model):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+ is_deleted = models.BooleanField(default=False)
+
+ class Meta:
+ abstract = True
diff --git a/augment-store/server/core/settings.py b/augment-store/server/core/settings.py
new file mode 100644
index 000000000..c50ca38c0
--- /dev/null
+++ b/augment-store/server/core/settings.py
@@ -0,0 +1,258 @@
+from pathlib import Path
+from dotenv import dotenv_values
+import os
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+config = {
+ **os.environ, # load evironment system variables
+ **dotenv_values(".env"), # override loaded environment variables with .env file
+}
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = config.get('SECRET_KEY')
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = config.get('DEBUG', False) == 'True'
+
+APP_DOMAIN = config.get('APP_DOMAIN', 'http://localhost:8000')
+ALLOWED_HOSTS = [
+ config.get('ALLOWED_HOSTS', '*')
+]
+
+# CORS settings - Allow all localhost origins
+CORS_ALLOW_ALL_ORIGINS = config.get('CORS_ALLOW_ALL_ORIGINS', False) == 'True'
+CORS_ALLOWED_ORIGINS = [config.get('FRONTEND_URL', 'http://localhost:3000')]
+
+# Allow all localhost origins using regex pattern
+CORS_ALLOWED_ORIGIN_REGEXES = [
+ r"^http://localhost:\d+$",
+ r"^http://127\.0\.0\.1:\d+$",
+]
+
+# CORS headers configuration
+CORS_ALLOW_CREDENTIALS = True
+CORS_ALLOW_HEADERS = [
+ 'accept',
+ 'accept-encoding',
+ 'authorization',
+ 'content-type',
+ 'dnt',
+ 'origin',
+ 'user-agent',
+ 'x-csrftoken',
+ 'x-requested-with',
+]
+CORS_ALLOW_METHODS = [
+ 'DELETE',
+ 'GET',
+ 'OPTIONS',
+ 'PATCH',
+ 'POST',
+ 'PUT',
+]
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ "corsheaders",
+ 'rest_framework',
+ 'drf_spectacular',
+ 'rest_framework_simplejwt',
+ 'api',
+ 'accounts',
+ 'authentication',
+ 'mptt',
+ 'products',
+ 'storage',
+ 'carts',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ "corsheaders.middleware.CorsMiddleware",
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'core.urls'
+AUTH_USER_MODEL = "accounts.User"
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'core.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql_psycopg2",
+ "NAME": config.get("DATABASE_NAME"),
+ "USER": config.get("DATABASE_USER"),
+ "PASSWORD": config.get("DATABASE_PASSWORD"),
+ "HOST": config.get("DATABASE_HOST"),
+ "PORT": config.get("DATABASE_PORT"),
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': (
+ 'django.contrib.auth.password_validation.'
+ 'UserAttributeSimilarityValidator'
+ ),
+ },
+ {
+ 'NAME': (
+ 'django.contrib.auth.password_validation.'
+ 'MinimumLengthValidator'
+ ),
+ },
+ {
+ 'NAME': (
+ 'django.contrib.auth.password_validation.'
+ 'CommonPasswordValidator'
+ ),
+ },
+ {
+ 'NAME': (
+ 'django.contrib.auth.password_validation.'
+ 'NumericPasswordValidator'
+ ),
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/4.2/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/4.2/howto/static-files/
+
+STATIC_URL = 'static/'
+
+FILE_MAX_SIZE = int(config.get("FILE_MAX_SIZE", 1024))
+FILE_UPLOAD_STORAGE = config.get("FILE_UPLOAD_STORAGE", "local") # local | s3
+
+IS_USING_LOCAL_STORAGE = FILE_UPLOAD_STORAGE == "local"
+
+
+
+if FILE_UPLOAD_STORAGE == "local":
+ MEDIA_ROOT_NAME = "media"
+ MEDIA_ROOT = os.path.join(BASE_DIR, MEDIA_ROOT_NAME)
+ MEDIA_URL = f"/{MEDIA_ROOT_NAME}/"
+ DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"
+ PUBLIC_MEDIA_LOCATION = "media/public/"
+ PRIVATE_MEDIA_LOCATION = "media/private/"
+ STATIC_LOCATION = "static/"
+
+ AWS_ACCESS_KEY_ID = config.get("AWS_ACCESS_KEY_ID")
+ AWS_SECRET_ACCESS_KEY = config.get("AWS_SECRET_ACCESS_KEY")
+ AWS_STORAGE_BUCKET_NAME = config.get("AWS_STORAGE_BUCKET_NAME")
+ AWS_S3_REGION_NAME = config.get("AWS_S3_REGION_NAME")
+ AWS_S3_CUSTOM_DOMAIN = config.get("AWS_S3_CUSTOM_DOMAIN")
+
+
+else:
+ PUBLIC_MEDIA_LOCATION = "media/public/"
+ PRIVATE_MEDIA_LOCATION = "media/private/"
+ STATIC_LOCATION = "static/"
+
+ AWS_ACCESS_KEY_ID = config.get("AWS_ACCESS_KEY_ID")
+ AWS_SECRET_ACCESS_KEY = config.get("AWS_SECRET_ACCESS_KEY")
+ AWS_STORAGE_BUCKET_NAME = config.get("AWS_STORAGE_BUCKET_NAME")
+ AWS_S3_REGION_NAME = config.get("AWS_S3_REGION_NAME")
+ AWS_S3_CUSTOM_DOMAIN = config.get("AWS_S3_CUSTOM_DOMAIN")
+ AWS_S3_FILE_OVERWRITE = False
+ AWS_DEFAULT_ACL = "public-read"
+ AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
+ AWS_PRESIGNED_EXPIRY = int(config.get("AWS_PRESIGNED_EXPIRY", 10))
+ FILE_MAX_SIZE = int(config.get("FILE_MAX_SIZE", 1024))
+
+
+ STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}"
+ STATICFILES_STORAGE = "storage.storage_backends.StaticStorage"
+
+ MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MEDIA_LOCATION}"
+ DEFAULT_FILE_STORAGE = "storage.storage_backends.PublicMediaStorage"
+ PRIVATE_FILE_STORAGE = "storage.storage_backends.PrivateMediaStorage"
+
+ FILE_UPLOAD_STORAGE = config.get("FILE_UPLOAD_STORAGE", "s3")
+ AWS_PRESIGNED_EXPIRY = int(config.get("AWS_PRESIGNED_EXPIRY", 10))
+ FILE_MAX_SIZE = int(config.get("FILE_MAX_SIZE", 1024))
+
+
+# Accounts settings
+DISABLE_EMAIL_VERIFICATION = config.get("DISABLE_EMAIL_VERIFICATION", False) == "True"
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+
+REST_FRAMEWORK = {
+ # YOUR SETTINGS
+ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
+ 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
+ ),
+ "NON_FIELD_ERRORS_KEY": "details",
+ 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
+ 'PAGE_SIZE': 100
+}
+
+SPECTACULAR_SETTINGS = {
+ 'TITLE': 'Augment Store API',
+ 'DESCRIPTION': 'An E-Commerce API for Augment Store',
+ 'VERSION': '1.0.0',
+ 'SERVE_INCLUDE_SCHEMA': False,
+ 'SCHEMA_PATH_PREFIX': r'/api/v[0-9]',
+}
\ No newline at end of file
diff --git a/augment-store/server/core/tests.py b/augment-store/server/core/tests.py
new file mode 100644
index 000000000..57cdf0b78
--- /dev/null
+++ b/augment-store/server/core/tests.py
@@ -0,0 +1,18 @@
+
+from rest_framework.test import APITestCase
+from rest_framework.test import APIClient
+from accounts.factory import UserFactory
+
+
+class BaseAPITestCase(APITestCase):
+ # this class setup basic client
+ client:APIClient = None
+ authenticated_client:APIClient = None
+ user = None
+
+ def setUp(self):
+ super().setUp()
+ self.user = UserFactory()
+ self.client = APIClient()
+ self.authenticated_client = APIClient()
+ self.authenticated_client.force_authenticate(user=self.user)
diff --git a/augment-store/server/core/urls.py b/augment-store/server/core/urls.py
new file mode 100644
index 000000000..e67ce9794
--- /dev/null
+++ b/augment-store/server/core/urls.py
@@ -0,0 +1,13 @@
+
+from django.contrib import admin
+from django.urls import path
+from drf_spectacular.views import SpectacularSwaggerView
+from django.urls import include
+
+
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path('', SpectacularSwaggerView.as_view(url_name='v1:schema'), name='swagger-ui'),
+ path('api/v1/', include('api.urls', namespace='v1')),
+]
diff --git a/augment-store/server/core/wsgi.py b/augment-store/server/core/wsgi.py
new file mode 100644
index 000000000..f44964d5d
--- /dev/null
+++ b/augment-store/server/core/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for core project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+
+application = get_wsgi_application()
diff --git a/augment-store/server/manage.py b/augment-store/server/manage.py
new file mode 100755
index 000000000..f2a662cfd
--- /dev/null
+++ b/augment-store/server/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/augment-store/server/products/__init__.py b/augment-store/server/products/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/products/admin.py b/augment-store/server/products/admin.py
new file mode 100644
index 000000000..8c38f3f3d
--- /dev/null
+++ b/augment-store/server/products/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/augment-store/server/products/apps.py b/augment-store/server/products/apps.py
new file mode 100644
index 000000000..145a2ac9e
--- /dev/null
+++ b/augment-store/server/products/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ProductsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'products'
diff --git a/augment-store/server/products/factory.py b/augment-store/server/products/factory.py
new file mode 100644
index 000000000..127827083
--- /dev/null
+++ b/augment-store/server/products/factory.py
@@ -0,0 +1,60 @@
+
+from factory import Faker, SubFactory, LazyAttribute, post_generation
+from factory.django import DjangoModelFactory
+from accounts.factory import UserFactory
+from storage.factory import FileFactory
+from .models import Product, ProductBrand, ProductCategory
+
+
+
+class ProductBrandFactory(DjangoModelFactory):
+ name = Faker("company")
+ description = Faker("text", max_nb_chars=200)
+ created_by = SubFactory(UserFactory)
+ image = SubFactory(FileFactory)
+
+ class Meta:
+ model = ProductBrand
+ django_get_or_create = ["name"]
+
+
+class ProductCategoryFactory(DjangoModelFactory):
+ name = Faker("word")
+ slug = LazyAttribute(lambda obj: obj.name.lower().replace(" ", "-"))
+ description = Faker("text", max_nb_chars=200)
+ created_by = SubFactory(UserFactory)
+ parent = None
+ image = SubFactory(FileFactory)
+
+ class Meta:
+ model = ProductCategory
+ django_get_or_create = ["name"]
+
+
+class ProductFactory(DjangoModelFactory):
+ name = Faker("catch_phrase")
+ description = Faker("text", max_nb_chars=500)
+ price = Faker("pydecimal", left_digits=4, right_digits=2, positive=True)
+ brand = SubFactory(ProductBrandFactory)
+ category = SubFactory(ProductCategoryFactory)
+ created_by = SubFactory(UserFactory)
+ quantity = Faker("random_int", min=0, max=1000)
+ rating = Faker("pydecimal", left_digits=1, right_digits=2, positive=True)
+
+ class Meta:
+ model = Product
+
+ @post_generation
+ def images(self, create, extracted, **kwargs):
+ if not create:
+ return
+
+ if extracted:
+ # If a list of images was passed, use it
+ for image in extracted:
+ self.images.add(image)
+ else:
+ # Otherwise, create 3 default images
+ for _ in range(3):
+ self.images.add(FileFactory())
+
diff --git a/augment-store/server/products/filters.py b/augment-store/server/products/filters.py
new file mode 100644
index 000000000..1e1b1983c
--- /dev/null
+++ b/augment-store/server/products/filters.py
@@ -0,0 +1,14 @@
+from django_filters import rest_framework as filters
+
+from .models import Product
+
+class ProductFilter(filters.FilterSet):
+
+ rating = filters.RangeFilter()
+ price = filters.RangeFilter()
+ category = filters.CharFilter(field_name="category__slug")
+ brand = filters.CharFilter(field_name="brand__name")
+ quantity = filters.RangeFilter()
+ class Meta:
+ model = Product
+ fields = ["category", "brand", "rating", "price", "quantity" ]
diff --git a/augment-store/server/products/migrations/0001_initial.py b/augment-store/server/products/migrations/0001_initial.py
new file mode 100644
index 000000000..15eb200fd
--- /dev/null
+++ b/augment-store/server/products/migrations/0001_initial.py
@@ -0,0 +1,75 @@
+# Generated by Django 5.2.7 on 2025-10-15 14:57
+
+import django.db.models.deletion
+import mptt.fields
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ProductBrand',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('name', models.CharField(max_length=255, unique=True)),
+ ('description', models.TextField(blank=True, null=True)),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_brands', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='ProductCategory',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('name', models.CharField(max_length=255, unique=True)),
+ ('slug', models.SlugField(max_length=255, unique=True)),
+ ('description', models.TextField(blank=True, null=True)),
+ ('lft', models.PositiveIntegerField(editable=False)),
+ ('rght', models.PositiveIntegerField(editable=False)),
+ ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
+ ('level', models.PositiveIntegerField(editable=False)),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_categories', to=settings.AUTH_USER_MODEL)),
+ ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='products.productcategory')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='Product',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('name', models.CharField(max_length=255)),
+ ('description', models.TextField()),
+ ('price', models.DecimalField(decimal_places=2, max_digits=10)),
+ ('quantity', models.IntegerField(default=0)),
+ ('rating', models.DecimalField(decimal_places=2, default=0, max_digits=3)),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)),
+ ('brand', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='products.productbrand')),
+ ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='products.productcategory')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/augment-store/server/products/migrations/0002_product_images_productbrand_image_and_more.py b/augment-store/server/products/migrations/0002_product_images_productbrand_image_and_more.py
new file mode 100644
index 000000000..0251d1619
--- /dev/null
+++ b/augment-store/server/products/migrations/0002_product_images_productbrand_image_and_more.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.7 on 2025-10-19 21:33
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0001_initial'),
+ ('storage', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='product',
+ name='images',
+ field=models.ManyToManyField(blank=True, related_name='products', to='storage.file'),
+ ),
+ migrations.AddField(
+ model_name='productbrand',
+ name='image',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='storage.file'),
+ ),
+ migrations.AddField(
+ model_name='productcategory',
+ name='image',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='storage.file'),
+ ),
+ ]
diff --git a/augment-store/server/products/migrations/0003_alter_productbrand_image_alter_productcategory_image.py b/augment-store/server/products/migrations/0003_alter_productbrand_image_alter_productcategory_image.py
new file mode 100644
index 000000000..9dca34a3c
--- /dev/null
+++ b/augment-store/server/products/migrations/0003_alter_productbrand_image_alter_productcategory_image.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.7 on 2025-10-20 16:39
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0002_product_images_productbrand_image_and_more'),
+ ('storage', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='productbrand',
+ name='image',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='storage.file'),
+ ),
+ migrations.AlterField(
+ model_name='productcategory',
+ name='image',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='storage.file'),
+ ),
+ ]
diff --git a/augment-store/server/products/migrations/__init__.py b/augment-store/server/products/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/products/models.py b/augment-store/server/products/models.py
new file mode 100644
index 000000000..f5ef382eb
--- /dev/null
+++ b/augment-store/server/products/models.py
@@ -0,0 +1,56 @@
+from django.db import models
+from core.models import BaseModel
+from accounts.models import User
+from mptt.models import MPTTModel, TreeForeignKey
+from storage.models import File
+
+
+class ProductBrand(BaseModel):
+ name = models.CharField(max_length=255, unique=True)
+ description = models.TextField(null=True, blank=True)
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='product_brands')
+ image = models.ForeignKey(File, on_delete=models.SET_NULL, null=True, blank=True)
+
+ def __str__(self):
+ return self.name
+
+class ProductCategory(MPTTModel, BaseModel):
+ name = models.CharField(max_length=255, unique=True)
+ slug = models.SlugField(max_length=255, unique=True)
+ description = models.TextField(null=True, blank=True)
+ parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='product_categories')
+ image = models.ForeignKey(File, on_delete=models.SET_NULL, null=True, blank=True)
+ class MPTTMeta:
+ order_insertion_by = ['name']
+
+ def __str__(self):
+ return self.name
+
+
+class ProductManager(models.Manager):
+ def get_queryset(self):
+ return super().get_queryset().order_by('-created_at')
+
+ def get_user_products(self, user):
+ return self.get_queryset().filter(created_by=user)
+
+class Product(BaseModel):
+ name = models.CharField(max_length=255)
+ description = models.TextField()
+ price = models.DecimalField(max_digits=10, decimal_places=2)
+ brand = models.ForeignKey(ProductBrand, on_delete=models.CASCADE, related_name='products')
+ category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='products')
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='products')
+ quantity = models.IntegerField(default=0)
+ rating = models.DecimalField(max_digits=3, decimal_places=2, default=0)
+ images = models.ManyToManyField(File, related_name='products', blank=True)
+ objects:ProductManager = ProductManager()
+
+
+ def check_stock(self, quantity):
+ return self.quantity >= quantity
+
+
+
+
diff --git a/augment-store/server/products/serializers.py b/augment-store/server/products/serializers.py
new file mode 100644
index 000000000..916864dd2
--- /dev/null
+++ b/augment-store/server/products/serializers.py
@@ -0,0 +1,88 @@
+from rest_framework import serializers
+from .models import ProductBrand, ProductCategory, Product
+
+
+# Product Brand Serializers
+
+class CreateProductBrandSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ProductBrand
+ fields = ["name", "description" , "image"]
+
+ def validate(self, attrs):
+ request = self.context.get("request")
+ attrs["created_by"] = request.user
+ return attrs
+
+
+class ProductBrandListSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ProductBrand
+ fields = ["id", "name", "description"]
+
+
+class ProductBrandDetailSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ProductBrand
+ fields = "__all__"
+
+
+# Product Category Serializers
+
+class CreateProductCategorySerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ProductCategory
+ fields = ["name", "slug", "description", "parent", "image"]
+
+ def validate(self, attrs):
+ parent = attrs.get("parent")
+ if parent and parent.is_child_node():
+ raise serializers.ValidationError("Parent category cannot be a child category")
+
+ request = self.context.get("request")
+ attrs["created_by"] = request.user
+ return attrs
+
+
+class ProductCategoryListSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ProductCategory
+ fields = ["id", "name", "description", "parent", "image"]
+
+
+class ProductCategoryDetailSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ProductCategory
+ fields = "__all__"
+
+
+# Product Serializers
+
+class CreateProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Product
+ fields = ["name", "description", "price", "brand", "category", "quantity", "images"]
+
+ def validate(self, attrs):
+ request = self.context.get("request")
+ attrs["created_by"] = request.user
+ return attrs
+
+
+class ProductListSerializer(serializers.ModelSerializer):
+ brand = ProductBrandListSerializer(read_only=True)
+ category = ProductCategoryListSerializer(read_only=True)
+ images = serializers.SerializerMethodField()
+
+ def get_images(self, obj: Product):
+ return [image.file.url for image in obj.images.all()]
+
+ class Meta:
+ model = Product
+ fields = ["id", "name", "description", "price", "brand", "category", "quantity", "rating", "images"]
+
+
+class ProductDetailSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Product
+ fields = "__all__"
diff --git a/augment-store/server/products/tests.py b/augment-store/server/products/tests.py
new file mode 100644
index 000000000..d23d41348
--- /dev/null
+++ b/augment-store/server/products/tests.py
@@ -0,0 +1,1236 @@
+from core.tests import BaseAPITestCase
+from accounts.factory import UserFactory
+from accounts.models import User
+from rest_framework import status
+from django.urls import reverse
+from products.models import Product, ProductBrand, ProductCategory
+from products.factory import ProductBrandFactory, ProductCategoryFactory, ProductFactory
+from decimal import Decimal
+from storage.factory import FileFactory
+
+
+class ProductBrandTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+ # Create a merchant user for authenticated tests
+ self.merchant_user = UserFactory(
+ email="merchant@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MERCHANT
+ )
+ self.merchant_client = self.authenticated_client
+ self.merchant_client.force_authenticate(user=self.merchant_user)
+
+ # Create a member user for permission tests
+ self.member_user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+
+ def test_list_brands_unauthenticated(self):
+ # GIVEN some brands exist in the database
+ ProductBrandFactory(
+ name="Test Brand 1",
+ description="Test Description 1",
+ created_by=self.merchant_user
+ )
+ ProductBrandFactory(
+ name="Test Brand 2",
+ description="Test Description 2",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a get request to list brands without authentication
+ url = reverse("v1:product_brand_list")
+ response = self.client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the brands
+ self.assertEqual(len(response.data.get("results", [])), 2)
+
+ def test_create_brand_success(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request to create a brand with valid data
+ url = reverse("v1:create_product_brand")
+ payload = {
+ "name": "New Brand",
+ "description": "New Brand Description",
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND a ProductBrand object should be created in the database
+ self.assertTrue(ProductBrand.objects.filter(name="New Brand").exists())
+
+ # AND the brand should be created by the merchant user
+ brand = ProductBrand.objects.get(name="New Brand")
+ self.assertEqual(brand.created_by, self.merchant_user)
+
+ def test_create_brand_unauthenticated(self):
+ # GIVEN a user is not authenticated
+ # WHEN we make a post request to create a brand
+ url = reverse("v1:create_product_brand")
+ payload = {
+ "name": "New Brand",
+ "description": "New Brand Description",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_create_brand_member_role_forbidden(self):
+ # GIVEN a member user is authenticated (not merchant or admin)
+ member_client = self.authenticated_client
+ member_client.force_authenticate(user=self.member_user)
+
+ # WHEN we make a post request to create a brand
+ url = reverse("v1:create_product_brand")
+ payload = {
+ "name": "New Brand",
+ "description": "New Brand Description",
+ }
+ response = member_client.post(url, payload)
+
+ # THEN we should get a 403 response
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_create_brand_duplicate_name(self):
+ # GIVEN a brand with the same name already exists
+ ProductBrandFactory(
+ name="Existing Brand",
+ description="Existing Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to create a brand with the same name
+ url = reverse("v1:create_product_brand")
+ payload = {
+ "name": "Existing Brand",
+ "description": "New Description",
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_retrieve_brand_detail(self):
+ # GIVEN a brand exists in the database
+ brand = ProductBrandFactory(
+ name="Test Brand",
+ description="Test Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a get request to retrieve the brand detail
+ url = reverse("v1:product_brand_detail", kwargs={"pk": str(brand.id)})
+ response = self.merchant_client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the brand details
+ self.assertEqual(response.data["name"], "Test Brand")
+ self.assertEqual(response.data["description"], "Test Description")
+
+ def test_update_brand_success(self):
+ # GIVEN a brand exists in the database
+ brand = ProductBrandFactory(
+ name="Test Brand",
+ description="Test Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a patch request to update the brand
+ url = reverse("v1:product_brand_detail", kwargs={"pk": str(brand.id)})
+ payload = {
+ "description": "Updated Description",
+ }
+ response = self.merchant_client.patch(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the brand should be updated in the database
+ brand.refresh_from_db()
+ self.assertEqual(brand.description, "Updated Description")
+
+ def test_delete_brand_success(self):
+ # GIVEN a brand exists in the database
+ brand = ProductBrandFactory(
+ name="Test Brand",
+ description="Test Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a delete request to delete the brand
+ url = reverse("v1:product_brand_detail", kwargs={"pk": str(brand.id)})
+ response = self.merchant_client.delete(url)
+
+ # THEN we should get a 204 response
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+ # AND the brand should be deleted from the database
+ self.assertFalse(ProductBrand.objects.filter(id=brand.id).exists())
+
+
+class ProductCategoryTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+ # Create a merchant user for authenticated tests
+ self.merchant_user = UserFactory(
+ email="merchant@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MERCHANT
+ )
+ self.merchant_client = self.authenticated_client
+ self.merchant_client.force_authenticate(user=self.merchant_user)
+
+ # Create a member user for permission tests
+ self.member_user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+
+ def test_list_categories_unauthenticated(self):
+ # GIVEN some categories exist in the database
+ ProductCategoryFactory(
+ name="Test Category 1",
+ slug="test-category-1",
+ description="Test Description 1",
+ created_by=self.merchant_user
+ )
+ ProductCategoryFactory(
+ name="Test Category 2",
+ slug="test-category-2",
+ description="Test Description 2",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a get request to list categories without authentication
+ url = reverse("v1:product_category_list")
+ response = self.client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the categories
+ self.assertEqual(len(response.data.get("results", [])), 2)
+
+ def test_create_category_success(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request to create a category with valid data
+ url = reverse("v1:create_product_category")
+ payload = {
+ "name": "New Category",
+ "slug": "new-category",
+ "description": "New Category Description",
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND a ProductCategory object should be created in the database
+ self.assertTrue(ProductCategory.objects.filter(name="New Category").exists())
+
+ # AND the category should be created by the merchant user
+ category = ProductCategory.objects.get(name="New Category")
+ self.assertEqual(category.created_by, self.merchant_user)
+
+ def test_create_category_with_parent(self):
+ # GIVEN a parent category exists
+ parent_category = ProductCategoryFactory(
+ name="Parent Category",
+ slug="parent-category",
+ description="Parent Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to create a child category
+ url = reverse("v1:create_product_category")
+ payload = {
+ "name": "Child Category",
+ "slug": "child-category",
+ "description": "Child Category Description",
+ "parent": str(parent_category.id),
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the child category should have the correct parent
+ child_category = ProductCategory.objects.get(name="Child Category")
+ self.assertEqual(child_category.parent, parent_category)
+
+ def test_create_category_unauthenticated(self):
+ # GIVEN a user is not authenticated
+ # WHEN we make a post request to create a category
+ url = reverse("v1:create_product_category")
+ payload = {
+ "name": "New Category",
+ "slug": "new-category",
+ "description": "New Category Description",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_create_category_member_role_forbidden(self):
+ # GIVEN a member user is authenticated (not merchant or admin)
+ member_client = self.authenticated_client
+ member_client.force_authenticate(user=self.member_user)
+
+ # WHEN we make a post request to create a category
+ url = reverse("v1:create_product_category")
+ payload = {
+ "name": "New Category",
+ "slug": "new-category",
+ "description": "New Category Description",
+ }
+ response = member_client.post(url, payload)
+
+ # THEN we should get a 403 response
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_create_category_duplicate_name(self):
+ # GIVEN a category with the same name already exists
+ ProductCategoryFactory(
+ name="Existing Category",
+ slug="existing-category",
+ description="Existing Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to create a category with the same name
+ url = reverse("v1:create_product_category")
+ payload = {
+ "name": "Existing Category",
+ "slug": "existing-category-2",
+ "description": "New Description",
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_retrieve_category_detail(self):
+ # GIVEN a category exists in the database
+ category = ProductCategoryFactory(
+ name="Test Category",
+ slug="test-category",
+ description="Test Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a get request to retrieve the category detail
+ url = reverse("v1:product_category_detail", kwargs={"pk": str(category.id)})
+ response = self.merchant_client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the category details
+ self.assertEqual(response.data["name"], "Test Category")
+ self.assertEqual(response.data["slug"], "test-category")
+ self.assertEqual(response.data["description"], "Test Description")
+
+ def test_update_category_success(self):
+ # GIVEN a category exists in the database
+ category = ProductCategoryFactory(
+ name="Test Category",
+ slug="test-category",
+ description="Test Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a patch request to update the category
+ url = reverse("v1:product_category_detail", kwargs={"pk": str(category.id)})
+ payload = {
+ "description": "Updated Description",
+ }
+ response = self.merchant_client.patch(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the category should be updated in the database
+ category.refresh_from_db()
+ self.assertEqual(category.description, "Updated Description")
+
+ def test_delete_category_success(self):
+ # GIVEN a category exists in the database
+ category = ProductCategoryFactory(
+ name="Test Category",
+ slug="test-category",
+ description="Test Description",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a delete request to delete the category
+ url = reverse("v1:product_category_detail", kwargs={"pk": str(category.id)})
+ response = self.merchant_client.delete(url)
+
+ # THEN we should get a 204 response
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+ # AND the category should be deleted from the database
+ self.assertFalse(ProductCategory.objects.filter(id=category.id).exists())
+
+
+class ProductTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+ # Create a merchant user for authenticated tests
+ self.merchant_user = UserFactory(
+ email="merchant@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MERCHANT
+ )
+ self.merchant_client = self.authenticated_client
+ self.merchant_client.force_authenticate(user=self.merchant_user)
+
+ # Create another merchant user for testing permissions
+ self.merchant_user_2 = UserFactory(
+ email="merchant2@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MERCHANT
+ )
+
+ # Create a member user for permission tests
+ self.member_user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+
+ # Create an admin user for permission tests
+ self.admin_user = UserFactory(
+ email="admin@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.ADMIN
+ )
+
+ # Create test brand and category
+ self.brand = ProductBrandFactory(
+ name="Test Brand",
+ description="Test Brand Description",
+ created_by=self.merchant_user
+ )
+
+ self.category = ProductCategoryFactory(
+ name="Test Category",
+ slug="test-category",
+ description="Test Category Description",
+ created_by=self.merchant_user
+ )
+
+ def test_list_products_unauthenticated(self):
+ # GIVEN some products exist in the database
+ ProductFactory(
+ name="Test Product 1",
+ description="Test Description 1",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Test Product 2",
+ description="Test Description 2",
+ price=149.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=5,
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a get request to list products without authentication
+ url = reverse("v1:product_list")
+ response = self.client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ result = response.data.get("results", [])
+ # AND the response should contain the products
+ self.assertEqual(len(result), 2)
+
+ def test_product_list_filter_by_price_range(self):
+ # GIVEN products with different prices exist in the database
+ ProductFactory(
+ name="Cheap Product",
+ price=Decimal("50.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Mid Product",
+ price=Decimal("150.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Expensive Product",
+ price=Decimal("300.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products by price range (100-200)
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"price_min": "100", "price_max": "200"})
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ result = response.data.get("results", [])
+ # AND only products within the price range should be returned
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "Mid Product")
+
+ def test_product_list_filter_by_rating_range(self):
+ # GIVEN products with different ratings exist in the database
+ ProductFactory(
+ name="Low Rated Product",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("2.5"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Mid Rated Product",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("3.5"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="High Rated Product",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("4.8"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products by rating range (3.0-4.0)
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"rating_min": "3.0", "rating_max": "4.0"})
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ result = response.data.get("results", [])
+ # AND only products within the rating range should be returned
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "Mid Rated Product")
+
+ def test_product_list_filter_by_category_slug(self):
+ # GIVEN products with different categories exist in the database
+ category_electronics = ProductCategoryFactory(
+ name="Electronics",
+ slug="electronics",
+ created_by=self.merchant_user
+ )
+ category_clothing = ProductCategoryFactory(
+ name="Clothing",
+ slug="clothing",
+ created_by=self.merchant_user
+ )
+
+ ProductFactory(
+ name="Laptop",
+ price=Decimal("1000.00"),
+ brand=self.brand,
+ category=category_electronics,
+ quantity=10,
+ rating=Decimal("4.5"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="T-Shirt",
+ price=Decimal("25.00"),
+ brand=self.brand,
+ category=category_clothing,
+ quantity=50,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Smartphone",
+ price=Decimal("800.00"),
+ brand=self.brand,
+ category=category_electronics,
+ quantity=15,
+ rating=Decimal("4.7"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products by category slug "electronics"
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"category": "electronics"})
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND only products in the electronics category should be returned
+ result = response.data.get("results", [])
+ self.assertEqual(len(result), 2)
+ product_names = [product["name"] for product in result]
+ self.assertIn("Laptop", product_names)
+ self.assertIn("Smartphone", product_names)
+ self.assertNotIn("T-Shirt", product_names)
+
+ def test_product_list_filter_by_brand_name(self):
+ # GIVEN products with different brands exist in the database
+ brand_apple = ProductBrandFactory(
+ name="Apple",
+ created_by=self.merchant_user
+ )
+ brand_samsung = ProductBrandFactory(
+ name="Samsung",
+ created_by=self.merchant_user
+ )
+
+ ProductFactory(
+ name="iPhone",
+ price=Decimal("999.00"),
+ brand=brand_apple,
+ category=self.category,
+ quantity=20,
+ rating=Decimal("4.8"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Galaxy Phone",
+ price=Decimal("899.00"),
+ brand=brand_samsung,
+ category=self.category,
+ quantity=25,
+ rating=Decimal("4.6"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="MacBook",
+ price=Decimal("1999.00"),
+ brand=brand_apple,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("4.9"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products by brand name "Apple"
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"brand": "Apple"})
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ result = response.data.get("results", [])
+ # AND only products from Apple brand should be returned
+ self.assertEqual(len(result), 2)
+ product_names = [product["name"] for product in result]
+ self.assertIn("iPhone", product_names)
+ self.assertIn("MacBook", product_names)
+ self.assertNotIn("Galaxy Phone", product_names)
+
+ def test_product_list_filter_by_quantity_range(self):
+ # GIVEN products with different quantities exist in the database
+ ProductFactory(
+ name="Low Stock Product",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=5,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Medium Stock Product",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=50,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="High Stock Product",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=200,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products by quantity range (20-100)
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"quantity_min": "20", "quantity_max": "100"})
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND only products within the quantity range should be returned
+ result = response.data.get("results", [])
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "Medium Stock Product")
+
+ def test_product_list_filter_multiple_filters_combined(self):
+ # GIVEN products with various attributes exist in the database
+ brand_nike = ProductBrandFactory(
+ name="Nike",
+ created_by=self.merchant_user
+ )
+ brand_adidas = ProductBrandFactory(
+ name="Adidas",
+ created_by=self.merchant_user
+ )
+ category_shoes = ProductCategoryFactory(
+ name="Shoes",
+ slug="shoes",
+ created_by=self.merchant_user
+ )
+
+ ProductFactory(
+ name="Nike Running Shoes",
+ price=Decimal("120.00"),
+ brand=brand_nike,
+ category=category_shoes,
+ quantity=30,
+ rating=Decimal("4.5"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Nike Basketball Shoes",
+ price=Decimal("180.00"),
+ brand=brand_nike,
+ category=category_shoes,
+ quantity=20,
+ rating=Decimal("4.8"),
+ created_by=self.merchant_user
+ )
+ ProductFactory(
+ name="Adidas Running Shoes",
+ price=Decimal("110.00"),
+ brand=brand_adidas,
+ category=category_shoes,
+ quantity=40,
+ rating=Decimal("4.3"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products by multiple criteria: brand=Nike, price_min=100, price_max=150, rating_min=4.0
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {
+ "brand": "Nike",
+ "price_min": "100",
+ "price_max": "150",
+ "rating_min": "4.0"
+ })
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND only products matching all criteria should be returned
+ result = response.data.get("results", [])
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "Nike Running Shoes")
+
+ def test_product_list_filter_no_results(self):
+ # GIVEN products exist in the database
+ ProductFactory(
+ name="Product 1",
+ price=Decimal("100.00"),
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ rating=Decimal("4.0"),
+ created_by=self.merchant_user
+ )
+
+ # WHEN we filter products with criteria that match no products
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"price_min": "1000", "price_max": "2000"})
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND an empty list should be returned
+ self.assertEqual(len(response.data.get("results", [])), 0)
+
+ def test_create_product_success(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request to create a product with valid data
+ url = reverse("v1:create_product")
+ payload = {
+ "name": "New Product",
+ "description": "New Product Description",
+ "price": "199.99",
+ "brand": str(self.brand.id),
+ "category": str(self.category.id),
+ "quantity": 20,
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND a Product object should be created in the database
+ self.assertTrue(Product.objects.filter(name="New Product").exists())
+
+ # AND the product should be created by the merchant user
+ product = Product.objects.get(name="New Product")
+ self.assertEqual(product.created_by, self.merchant_user)
+ self.assertEqual(product.price, Decimal("199.99"))
+
+ def test_create_product_success_with_images(self):
+ # GIVEN a merchant user is authenticated
+ # AND some images exist in the database
+ image1 = FileFactory(created_by=self.merchant_user)
+ image2 = FileFactory(created_by=self.merchant_user)
+ image3 = FileFactory(created_by=self.merchant_user)
+
+ # WHEN we make a post request to create a product with images
+ url = reverse("v1:create_product")
+ payload = {
+ "name": "New Product with Images",
+ "description": "New Product Description",
+ "price": "299.99",
+ "brand": str(self.brand.id),
+ "category": str(self.category.id),
+ "quantity": 15,
+ "images": [str(image1.id), str(image2.id), str(image3.id)],
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND a Product object should be created in the database
+ self.assertTrue(Product.objects.filter(name="New Product with Images").exists())
+
+ # AND the product should have the correct images associated
+ product = Product.objects.get(name="New Product with Images")
+ self.assertEqual(product.images.count(), 3)
+ self.assertIn(image1, product.images.all())
+ self.assertIn(image2, product.images.all())
+ self.assertIn(image3, product.images.all())
+
+ def test_create_product_unauthenticated(self):
+ # GIVEN a user is not authenticated
+ # WHEN we make a post request to create a product
+ url = reverse("v1:create_product")
+ payload = {
+ "name": "New Product",
+ "description": "New Product Description",
+ "price": "199.99",
+ "brand": str(self.brand.id),
+ "category": str(self.category.id),
+ "quantity": 20,
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_create_product_member_role_forbidden(self):
+ # GIVEN a member user is authenticated (not merchant or admin)
+ member_client = self.authenticated_client
+ member_client.force_authenticate(user=self.member_user)
+
+ # WHEN we make a post request to create a product
+ url = reverse("v1:create_product")
+ payload = {
+ "name": "New Product",
+ "description": "New Product Description",
+ "price": "199.99",
+ "brand": str(self.brand.id),
+ "category": str(self.category.id),
+ "quantity": 20,
+ }
+ response = member_client.post(url, payload)
+
+ # THEN we should get a 403 response
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_retrieve_product_detail(self):
+ # GIVEN a product exists in the database
+ product = ProductFactory(
+ name="Test Product",
+ description="Test Description",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a get request to retrieve the product detail
+ url = reverse("v1:product_update_delete", kwargs={"pk": str(product.id)})
+ response = self.merchant_client.get(url)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the response should contain the product details
+ self.assertEqual(response.data["name"], "Test Product")
+ self.assertEqual(response.data["description"], "Test Description")
+ self.assertEqual(float(response.data["price"]), 99.99)
+
+ def test_update_product_success(self):
+ # GIVEN a product exists in the database created by the merchant user
+ product = ProductFactory(
+ name="Test Product",
+ description="Test Description",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a patch request to update the product
+ url = reverse("v1:product_update_delete", kwargs={"pk": str(product.id)})
+ payload = {
+ "description": "Updated Description",
+ "price": "149.99",
+ }
+ response = self.merchant_client.patch(url, payload)
+
+ # THEN we should get a 200 response
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the product should be updated in the database
+ product.refresh_from_db()
+ self.assertEqual(product.description, "Updated Description")
+ self.assertEqual(product.price, Decimal("149.99"))
+
+ def test_update_product_by_different_merchant_forbidden(self):
+ # GIVEN a product exists created by merchant_user
+ product = ProductFactory(
+ name="Test Product",
+ description="Test Description",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+
+ # WHEN merchant_user_2 tries to update the product
+ merchant_client_2 = self.authenticated_client
+ merchant_client_2.force_authenticate(user=self.merchant_user_2)
+
+ url = reverse("v1:product_update_delete", kwargs={"pk": str(product.id)})
+ payload = {
+ "description": "Updated Description",
+ }
+ response = merchant_client_2.patch(url, payload)
+
+ # THEN we should get a 404 response (product not in their queryset)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_update_product_by_admin_success(self):
+ # GIVEN a product exists created by merchant_user
+ product = ProductFactory(
+ name="Test Product",
+ description="Test Description",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+
+ # WHEN admin user tries to update the product
+ admin_client = self.authenticated_client
+ admin_client.force_authenticate(user=self.admin_user)
+
+ url = reverse("v1:product_update_delete", kwargs={"pk": str(product.id)})
+ payload = {
+ "description": "Updated by Admin",
+ }
+ response = admin_client.patch(url, payload)
+
+ # THEN we should get a 200 response (admin can update any product)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # AND the product should be updated
+ product.refresh_from_db()
+ self.assertEqual(product.description, "Updated by Admin")
+
+ def test_delete_product_success(self):
+ # GIVEN a product exists in the database created by the merchant user
+ product = ProductFactory(
+ name="Test Product",
+ description="Test Description",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a delete request to delete the product
+ url = reverse("v1:product_update_delete", kwargs={"pk": str(product.id)})
+ response = self.merchant_client.delete(url)
+
+ # THEN we should get a 204 response
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+ # AND the product should be deleted from the database
+ self.assertFalse(Product.objects.filter(id=product.id).exists())
+
+ def test_delete_product_by_different_merchant_forbidden(self):
+ # GIVEN a product exists created by merchant_user
+ product = ProductFactory(
+ name="Test Product",
+ description="Test Description",
+ price=99.99,
+ brand=self.brand,
+ category=self.category,
+ quantity=10,
+ created_by=self.merchant_user
+ )
+
+ # WHEN merchant_user_2 tries to delete the product
+ merchant_client_2 = self.authenticated_client
+ merchant_client_2.force_authenticate(user=self.merchant_user_2)
+
+ url = reverse("v1:product_update_delete", kwargs={"pk": str(product.id)})
+ response = merchant_client_2.delete(url)
+
+ # THEN we should get a 404 response (product not in their queryset)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ # AND the product should still exist in the database
+ self.assertTrue(Product.objects.filter(id=product.id).exists())
+
+ def test_create_product_with_invalid_brand(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request with an invalid brand ID
+ url = reverse("v1:create_product")
+ payload = {
+ "name": "New Product",
+ "description": "New Product Description",
+ "price": "199.99",
+ "brand": "99999999-9999-9999-9999-999999999999",
+ "category": str(self.category.id),
+ "quantity": 20,
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_create_product_with_invalid_category(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request with an invalid category ID
+ url = reverse("v1:create_product")
+ payload = {
+ "name": "New Product",
+ "description": "New Product Description",
+ "price": "199.99",
+ "brand": str(self.brand.id),
+ "category": "99999999-9999-9999-9999-999999999999",
+ "quantity": 20,
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def _create_products_and_test_ordering(self, products_data, ordering_param, expected_order):
+ """
+ Helper method to create products and test ordering.
+
+ Args:
+ products_data: List of dicts with product attributes
+ ordering_param: The ordering parameter to use in the request
+ expected_order: List of tuples (product_name, field_name, expected_value) or (product_name, nested_field_path, expected_value)
+ """
+ # Create products
+ for product_data in products_data:
+ ProductFactory(**product_data, created_by=self.merchant_user)
+
+ # Make request with ordering
+ url = reverse("v1:product_list")
+ response = self.client.get(url, {"ordering": ordering_param})
+
+ # Assert response status
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ result = response.data.get("results", [])
+ self.assertEqual(len(result), len(products_data))
+
+ # Assert ordering
+ for idx, (expected_name, field_path, expected_value) in enumerate(expected_order):
+ self.assertEqual(result[idx]["name"], expected_name)
+
+ # Handle nested fields (e.g., "brand.name" or "category.name")
+ if "." in field_path:
+ parts = field_path.split(".")
+ value = result[idx]
+ for part in parts:
+ value = value[part]
+ self.assertEqual(value, expected_value)
+ else:
+ # Handle direct fields
+ actual_value = result[idx][field_path]
+ if isinstance(expected_value, float):
+ self.assertEqual(float(actual_value), expected_value)
+ else:
+ self.assertEqual(actual_value, expected_value)
+
+ def test_product_list_ordering_by_price_ascending(self):
+ # GIVEN multiple products with different prices exist
+ products_data = [
+ {"name": "Expensive Product", "price": 299.99, "brand": self.brand, "category": self.category, "quantity": 10, "rating": 4.5},
+ {"name": "Cheap Product", "price": 49.99, "brand": self.brand, "category": self.category, "quantity": 20, "rating": 3.5},
+ {"name": "Mid-range Product", "price": 149.99, "brand": self.brand, "category": self.category, "quantity": 15, "rating": 4.0},
+ ]
+
+ expected_order = [
+ ("Cheap Product", "price", 49.99),
+ ("Mid-range Product", "price", 149.99),
+ ("Expensive Product", "price", 299.99),
+ ]
+
+ # WHEN we request the product list ordered by price ascending
+ # THEN the products should be ordered by price ascending
+ self._create_products_and_test_ordering(products_data, "price", expected_order)
+
+ def test_product_list_ordering_by_price_descending(self):
+ # GIVEN multiple products with different prices exist
+ products_data = [
+ {"name": "Expensive Product", "price": 299.99, "brand": self.brand, "category": self.category, "quantity": 10, "rating": 4.5},
+ {"name": "Cheap Product", "price": 49.99, "brand": self.brand, "category": self.category, "quantity": 20, "rating": 3.5},
+ {"name": "Mid-range Product", "price": 149.99, "brand": self.brand, "category": self.category, "quantity": 15, "rating": 4.0},
+ ]
+
+ expected_order = [
+ ("Expensive Product", "price", 299.99),
+ ("Mid-range Product", "price", 149.99),
+ ("Cheap Product", "price", 49.99),
+ ]
+
+ # WHEN we request the product list ordered by price descending
+ # THEN the products should be ordered by price descending
+ self._create_products_and_test_ordering(products_data, "-price", expected_order)
+
+ def test_product_list_ordering_by_rating_descending(self):
+ # GIVEN multiple products with different ratings exist
+ products_data = [
+ {"name": "Highly Rated Product", "price": 199.99, "brand": self.brand, "category": self.category, "quantity": 10, "rating": 4.8},
+ {"name": "Low Rated Product", "price": 99.99, "brand": self.brand, "category": self.category, "quantity": 20, "rating": 2.5},
+ {"name": "Average Rated Product", "price": 149.99, "brand": self.brand, "category": self.category, "quantity": 15, "rating": 3.7},
+ ]
+
+ expected_order = [
+ ("Highly Rated Product", "rating", 4.8),
+ ("Average Rated Product", "rating", 3.7),
+ ("Low Rated Product", "rating", 2.5),
+ ]
+
+ # WHEN we request the product list ordered by rating descending
+ # THEN the products should be ordered by rating descending
+ self._create_products_and_test_ordering(products_data, "-rating", expected_order)
+
+ def test_product_list_ordering_by_quantity_ascending(self):
+ # GIVEN multiple products with different quantities exist
+ products_data = [
+ {"name": "High Stock Product", "price": 199.99, "brand": self.brand, "category": self.category, "quantity": 100, "rating": 4.0},
+ {"name": "Low Stock Product", "price": 99.99, "brand": self.brand, "category": self.category, "quantity": 5, "rating": 4.0},
+ {"name": "Medium Stock Product", "price": 149.99, "brand": self.brand, "category": self.category, "quantity": 50, "rating": 4.0},
+ ]
+
+ expected_order = [
+ ("Low Stock Product", "quantity", 5),
+ ("Medium Stock Product", "quantity", 50),
+ ("High Stock Product", "quantity", 100),
+ ]
+
+ # WHEN we request the product list ordered by quantity ascending
+ # THEN the products should be ordered by quantity ascending
+ self._create_products_and_test_ordering(products_data, "quantity", expected_order)
+
+ def test_product_list_ordering_by_brand_name(self):
+ # GIVEN multiple products with different brands exist
+ brand_a = ProductBrandFactory(name="Alpha Brand", description="First brand", created_by=self.merchant_user)
+ brand_z = ProductBrandFactory(name="Zeta Brand", description="Last brand", created_by=self.merchant_user)
+ brand_m = ProductBrandFactory(name="Mega Brand", description="Middle brand", created_by=self.merchant_user)
+
+ products_data = [
+ {"name": "Product from Zeta", "price": 199.99, "brand": brand_z, "category": self.category, "quantity": 10, "rating": 4.0},
+ {"name": "Product from Alpha", "price": 99.99, "brand": brand_a, "category": self.category, "quantity": 20, "rating": 4.0},
+ {"name": "Product from Mega", "price": 149.99, "brand": brand_m, "category": self.category, "quantity": 15, "rating": 4.0},
+ ]
+
+ expected_order = [
+ ("Product from Alpha", "brand.name", "Alpha Brand"),
+ ("Product from Mega", "brand.name", "Mega Brand"),
+ ("Product from Zeta", "brand.name", "Zeta Brand"),
+ ]
+
+ # WHEN we request the product list ordered by brand name ascending
+ # THEN the products should be ordered by brand name ascending
+ self._create_products_and_test_ordering(products_data, "brand__name", expected_order)
+
+ def test_product_list_ordering_by_category_name(self):
+ # GIVEN multiple products with different categories exist
+ category_a = ProductCategoryFactory(name="Accessories", slug="accessories", description="Accessories category", created_by=self.merchant_user)
+ category_e = ProductCategoryFactory(name="Electronics", slug="electronics", description="Electronics category", created_by=self.merchant_user)
+ category_c = ProductCategoryFactory(name="Clothing", slug="clothing", description="Clothing category", created_by=self.merchant_user)
+
+ products_data = [
+ {"name": "Electronic Product", "price": 199.99, "brand": self.brand, "category": category_e, "quantity": 10, "rating": 4.0},
+ {"name": "Accessory Product", "price": 99.99, "brand": self.brand, "category": category_a, "quantity": 20, "rating": 4.0},
+ {"name": "Clothing Product", "price": 149.99, "brand": self.brand, "category": category_c, "quantity": 15, "rating": 4.0},
+ ]
+
+ expected_order = [
+ ("Accessory Product", "category.name", "Accessories"),
+ ("Clothing Product", "category.name", "Clothing"),
+ ("Electronic Product", "category.name", "Electronics"),
+ ]
+
+ # WHEN we request the product list ordered by category name ascending
+ # THEN the products should be ordered by category name ascending
+ self._create_products_and_test_ordering(products_data, "category__name", expected_order)
diff --git a/augment-store/server/products/urls_brands.py b/augment-store/server/products/urls_brands.py
new file mode 100644
index 000000000..8a5e4b3d4
--- /dev/null
+++ b/augment-store/server/products/urls_brands.py
@@ -0,0 +1,8 @@
+from django.urls import path
+from .views import ProductBrandListView, CreateProductBrandView, ProductBrandDetailView
+
+urlpatterns = [
+ path('', ProductBrandListView.as_view(), name='product_brand_list'),
+ path('create/', CreateProductBrandView.as_view(), name='create_product_brand'),
+ path('/', ProductBrandDetailView.as_view(), name='product_brand_detail'),
+]
diff --git a/augment-store/server/products/urls_categories.py b/augment-store/server/products/urls_categories.py
new file mode 100644
index 000000000..c079aa846
--- /dev/null
+++ b/augment-store/server/products/urls_categories.py
@@ -0,0 +1,8 @@
+from django.urls import path
+from .views import ProductCategoryListView, CreateProductCategoryView, ProductCategoryDetailView
+
+urlpatterns = [
+ path('', ProductCategoryListView.as_view(), name='product_category_list'),
+ path('create/', CreateProductCategoryView.as_view(), name='create_product_category'),
+ path('/', ProductCategoryDetailView.as_view(), name='product_category_detail'),
+]
diff --git a/augment-store/server/products/urls_products.py b/augment-store/server/products/urls_products.py
new file mode 100644
index 000000000..b80b12a5f
--- /dev/null
+++ b/augment-store/server/products/urls_products.py
@@ -0,0 +1,9 @@
+from django.urls import path
+from .views import ProductListView, CreateProductView, ProductUpdateDeleteView
+
+urlpatterns = [
+ path('', ProductListView.as_view(), name='product_list'),
+ path('create/', CreateProductView.as_view(), name='create_product'),
+ path('/', ProductUpdateDeleteView.as_view(), name='product_update_delete'),
+
+]
diff --git a/augment-store/server/products/views.py b/augment-store/server/products/views.py
new file mode 100644
index 000000000..bcf0e65de
--- /dev/null
+++ b/augment-store/server/products/views.py
@@ -0,0 +1,102 @@
+import typing
+
+from rest_framework.generics import CreateAPIView, ListAPIView, RetrieveUpdateDestroyAPIView
+from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAuthenticated
+
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework import filters
+
+from accounts.permissions import hasAdminOrMerchantRole
+from .models import Product, ProductBrand, ProductCategory
+from .serializers import CreateProductBrandSerializer, CreateProductCategorySerializer, CreateProductSerializer, ProductBrandDetailSerializer, ProductBrandListSerializer, ProductCategoryDetailSerializer, ProductCategoryListSerializer, ProductListSerializer, ProductDetailSerializer
+from .filters import ProductFilter
+
+
+
+if typing.TYPE_CHECKING:
+ from accounts.models import User
+
+
+# Brand views
+
+class BaseBrandView:
+ permission_classes = [IsAuthenticatedOrReadOnly]
+ serializer_class = ProductBrandListSerializer
+
+ def get_queryset(self):
+ return ProductBrand.objects.all().order_by('name')
+
+class ProductBrandListView(BaseBrandView, ListAPIView):
+ pass
+
+class CreateProductBrandView(BaseBrandView, CreateAPIView):
+ serializer_class = CreateProductBrandSerializer
+ permission_classes = [IsAuthenticated, hasAdminOrMerchantRole]
+
+class ProductBrandDetailView(BaseBrandView, RetrieveUpdateDestroyAPIView):
+ serializer_class = ProductBrandDetailSerializer
+ permission_classes = [IsAuthenticated, hasAdminOrMerchantRole]
+
+
+# Category views
+
+class BaseCategoryView:
+ permission_classes = [IsAuthenticatedOrReadOnly]
+ serializer_class = ProductCategoryListSerializer
+
+ def get_queryset(self):
+ return ProductCategory.objects.all().order_by('name')
+
+class ProductCategoryListView(BaseCategoryView, ListAPIView):
+ pass
+
+
+class CreateProductCategoryView(BaseCategoryView, CreateAPIView):
+ serializer_class = CreateProductCategorySerializer
+ permission_classes = [IsAuthenticated, hasAdminOrMerchantRole]
+
+class ProductCategoryDetailView(BaseCategoryView, RetrieveUpdateDestroyAPIView):
+ serializer_class = ProductCategoryDetailSerializer
+ permission_classes = [IsAuthenticated, hasAdminOrMerchantRole]
+
+
+# Product views
+
+class BaseProductView:
+ permission_classes = [IsAuthenticatedOrReadOnly]
+ serializer_class = ProductListSerializer
+
+ def get_queryset(self):
+ return Product.objects.all().order_by('-created_at')
+
+class ProductListView(BaseProductView, ListAPIView):
+ filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
+ filterset_class = ProductFilter
+
+ ordering_fields = ["created_at", "price", "rating", "quantity", "category", "category__name", "brand", "brand__name"]
+ search_fields = ["name", "description", "brand__name", "category__name"]
+
+
+class CreateProductView(BaseProductView, CreateAPIView):
+ serializer_class = CreateProductSerializer
+ permission_classes = [IsAuthenticated, hasAdminOrMerchantRole]
+
+class ProductUpdateDeleteView(BaseProductView, RetrieveUpdateDestroyAPIView):
+ serializer_class = ProductDetailSerializer
+ permission_classes = [IsAuthenticated, hasAdminOrMerchantRole]
+
+ def get_queryset(self):
+ user: "User" = self.request.user
+ if user.is_admin:
+ return Product.objects.all()
+
+ return Product.objects.get_user_products(user)
+
+
+
+
+
+
+
+
+
diff --git a/augment-store/server/requirements.txt b/augment-store/server/requirements.txt
new file mode 100644
index 000000000..529600bd0
--- /dev/null
+++ b/augment-store/server/requirements.txt
@@ -0,0 +1,16 @@
+asgiref==3.10.0
+Django==5.2.7
+sqlparse==0.5.3
+typing_extensions==4.15.0
+python-dotenv==1.1.1
+djangorestframework==3.16.1
+django-filter==25.2
+drf-spectacular==0.28.0
+factory_boy==3.3.3
+pillow==11.3.0
+psycopg2==2.9.11
+djangorestframework_simplejwt==5.5.1
+django-mptt==0.18.0
+boto3==1.40.53
+django-storages==1.14.6
+django-cors-headers==4.9.0
\ No newline at end of file
diff --git a/augment-store/server/storage/__init__.py b/augment-store/server/storage/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/storage/admin.py b/augment-store/server/storage/admin.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/storage/apps.py b/augment-store/server/storage/apps.py
new file mode 100644
index 000000000..c5254ba57
--- /dev/null
+++ b/augment-store/server/storage/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class StorageConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "storage"
diff --git a/augment-store/server/storage/enums.py b/augment-store/server/storage/enums.py
new file mode 100644
index 000000000..eea8cab04
--- /dev/null
+++ b/augment-store/server/storage/enums.py
@@ -0,0 +1,11 @@
+from enum import Enum
+
+
+class FileUploadStrategy(Enum):
+ STANDARD = "standard"
+ DIRECT = "direct"
+
+
+class FileUploadStorage:
+ LOCAL = "local"
+ S3 = "s3"
diff --git a/augment-store/server/storage/factory.py b/augment-store/server/storage/factory.py
new file mode 100644
index 000000000..36bcfe095
--- /dev/null
+++ b/augment-store/server/storage/factory.py
@@ -0,0 +1,16 @@
+from factory import Faker, SubFactory, LazyAttribute
+from factory.django import DjangoModelFactory, FileField
+from accounts.factory import UserFactory
+from storage.utils import file_generate_name
+
+
+class FileFactory(DjangoModelFactory):
+ original_file_name = Faker("file_name", extension="jpg")
+ file_name = LazyAttribute(lambda obj: file_generate_name(obj.original_file_name))
+ file_type = "image/jpeg"
+ created_by = SubFactory(UserFactory)
+ file = FileField(filename="test_image.jpg")
+
+ class Meta:
+ model = "storage.File"
+
diff --git a/augment-store/server/storage/migrations/0001_initial.py b/augment-store/server/storage/migrations/0001_initial.py
new file mode 100644
index 000000000..876d9f3cd
--- /dev/null
+++ b/augment-store/server/storage/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.2.7 on 2025-10-17 02:26
+
+import django.db.models.deletion
+import storage.utils
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='File',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('file', models.FileField(blank=True, null=True, upload_to=storage.utils.file_generate_upload_path)),
+ ('thumbnail', models.FileField(blank=True, null=True, upload_to=storage.utils.file_generate_upload_path)),
+ ('original_file_name', models.TextField()),
+ ('file_name', models.CharField(max_length=255, unique=True)),
+ ('file_type', models.CharField(max_length=255)),
+ ('upload_finished_at', models.DateTimeField(blank=True, null=True)),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/augment-store/server/storage/migrations/__init__.py b/augment-store/server/storage/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/augment-store/server/storage/models.py b/augment-store/server/storage/models.py
new file mode 100644
index 000000000..d20f1e882
--- /dev/null
+++ b/augment-store/server/storage/models.py
@@ -0,0 +1,36 @@
+from accounts.models import User
+from core.models import BaseModel
+from django.db import models
+from .utils import file_generate_upload_path
+
+
+
+
+class File(BaseModel):
+ file = models.FileField(
+ upload_to=file_generate_upload_path,
+ blank=True,
+ null=True,
+ )
+
+ thumbnail = models.FileField(
+ upload_to=file_generate_upload_path,
+ blank=True,
+ null=True,
+ )
+
+ original_file_name = models.TextField()
+
+ file_name = models.CharField(max_length=255, unique=True)
+ file_type = models.CharField(max_length=255)
+
+ created_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
+ upload_finished_at = models.DateTimeField(blank=True, null=True)
+
+
+
+
+
+
+
+
diff --git a/augment-store/server/storage/serializers.py b/augment-store/server/storage/serializers.py
new file mode 100644
index 000000000..34ccf2113
--- /dev/null
+++ b/augment-store/server/storage/serializers.py
@@ -0,0 +1,96 @@
+from typing import List
+
+from django.conf import settings
+from django.shortcuts import get_object_or_404
+from rest_framework import serializers
+from storage.services import FileDirectUploadService, StorageValidatedData
+from .utils import create_presigned_url
+
+from .models import File
+from .enums import FileUploadStorage
+
+
+
+class FileSerializer(serializers.ModelSerializer):
+ file = serializers.SerializerMethodField()
+
+
+ class Meta:
+ model = File
+ exclude = ("is_deleted",)
+ read_only_fields = (
+ "id",
+ "upload_finished_at",
+ "created_by",
+ )
+
+
+ def get_file(self, obj: File):
+ if not obj.file: return None
+
+ if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.LOCAL: return obj.file.url
+
+ return create_presigned_url(obj.file.name)
+
+
+class StartDirectFileUploadSerializer( serializers.Serializer):
+
+ original_file_name = serializers.CharField(write_only=True)
+ file_type = serializers.CharField(write_only=True)
+ file = serializers.SerializerMethodField()
+ presigned_data = serializers.SerializerMethodField()
+
+
+ def create(self, validated_data: StorageValidatedData):
+
+ user = self.context["request"].user
+ validated_data["user"] = user
+ service = FileDirectUploadService(user)
+ data = service.start(validated_data)
+
+ return data
+
+ def get_file(self, obj):
+ file = obj.get("file")
+ if not file: return None
+ return FileSerializer(file).data
+
+ def get_presigned_data(self, obj):
+ return obj.get("presigned_data")
+
+class DirectLocalFileUploadSerializer(serializers.Serializer):
+ file = serializers.FileField(write_only=True)
+ file_id = serializers.CharField(write_only=True)
+
+
+ def create(self, validated_data):
+ user = self.context["request"].user
+ file_id = validated_data["file_id"]
+ file_obj = validated_data["file"]
+
+ file = get_object_or_404(File, id=file_id)
+
+ service = FileDirectUploadService(user)
+ file = service.upload_local(file=file, file_obj=file_obj)
+ return {"file": file, "file_id": file_id,}
+
+class FinishFileUploadSerializer(serializers.Serializer):
+ file_id = serializers.CharField(write_only=True)
+ file = serializers.SerializerMethodField()
+
+ def create(self, validated_data):
+ user = self.context["request"].user
+ file_id = validated_data["file_id"]
+
+ file = get_object_or_404(File, id=file_id)
+
+ service = FileDirectUploadService(user)
+ file = service.finish(file=file)
+ return {"file": file, "file_id": file_id,}
+
+ def get_file(self, obj: File):
+ file = obj.get("file")
+ if not file: return
+ return FileSerializer(file).data
+
+
diff --git a/augment-store/server/storage/services.py b/augment-store/server/storage/services.py
new file mode 100644
index 000000000..20daecde0
--- /dev/null
+++ b/augment-store/server/storage/services.py
@@ -0,0 +1,186 @@
+import mimetypes
+from typing import Any
+from typing import Dict
+from typing import List
+from typing import Tuple
+
+from accounts.models import User
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.db import transaction
+from django.utils import timezone
+from typing_extensions import TypedDict
+from .utils import s3_generate_presigned_post
+
+from .enums import FileUploadStorage
+from .models import File
+from .utils import bytes_to_mib
+from .utils import file_generate_local_upload_url
+from .utils import file_generate_name
+from .utils import file_generate_upload_path
+
+
+def _validate_file_size(file_obj):
+
+ max_size = settings.FILE_MAX_SIZE
+
+ if file_obj.size > max_size:
+ raise ValidationError(
+ f"File is too large. It should not exceed {bytes_to_mib(max_size)} MiB"
+ )
+
+
+class FileStandardUploadService:
+ """
+ This also serves as an example of a service class,
+ which encapsulates 2 different behaviors (create & update) under a namespace.
+
+ Meaning, we use the class here for:
+
+ 1. The namespace
+ 2. The ability to reuse `_infer_file_name_and_type` (which can also be an util)
+ """
+
+ def __init__(self, user: User, file_obj):
+ self.user = user
+ self.file_obj = file_obj
+
+ def _infer_file_name_and_type(
+ self, file_name: str = "", file_type: str = ""
+ ) -> Tuple[str, str]:
+ if not file_name:
+ file_name = self.file_obj.name
+
+ if not file_type:
+ guessed_file_type, encoding = mimetypes.guess_type(file_name)
+
+ if guessed_file_type is None:
+ file_type = ""
+ else:
+ file_type = guessed_file_type
+
+ return file_name, file_type
+
+ @transaction.atomic
+ def create(self, file_name: str = "", file_type: str = "") -> File:
+ _validate_file_size(self.file_obj)
+
+ file_name, file_type = self._infer_file_name_and_type(
+ file_name, file_type
+ )
+
+ obj = File(
+ file=self.file_obj,
+ original_file_name=file_name,
+ file_name=file_generate_name(file_name),
+ file_type=file_type,
+ created_by=self.user,
+ upload_finished_at=timezone.now(),
+ )
+
+ obj.full_clean()
+ obj.save()
+
+ return obj
+
+ @transaction.atomic
+ def update(
+ self, file: File, file_name: str = "", file_type: str = ""
+ ) -> File:
+ _validate_file_size(self.file_obj)
+
+ file_name, file_type = self._infer_file_name_and_type(
+ file_name, file_type
+ )
+
+ file.file = self.file_obj
+ file.original_file_name = file_name
+ file.file_name = file_generate_name(file_name)
+ file.file_type = file_type
+ file.uploaded_by = self.user
+ file.upload_finished_at = timezone.now()
+
+ file.full_clean()
+ file.save()
+
+ return file
+
+
+class StartFileUploadData(TypedDict):
+ file: File
+ presigned_data: Dict[str, Any]
+
+
+class StorageValidatedData(TypedDict):
+ file_name: str
+ file_type: str
+ upload_finished_at: str
+ created_by: User
+
+
+class FileDirectUploadService:
+
+
+ def __init__(self, user: User):
+ self.user = user
+
+ @transaction.atomic
+ def start(self, data: StorageValidatedData) -> StartFileUploadData:
+ original_file_name = data.get("original_file_name")
+ file = File(
+ original_file_name=original_file_name,
+ file_name=file_generate_name(original_file_name),
+ file_type=data.get("file_type"),
+ created_by=data["user"],
+ file=None,
+ )
+
+ file.full_clean()
+ file.save()
+
+ upload_path = file_generate_upload_path(file, file.file_name)
+
+ """
+ We are doing this in order to have an associated file for the field.
+ """
+ file.file = file.file.field.attr_class(
+ file, file.file.field, upload_path
+ )
+ file.save()
+
+ presigned_data: Dict[str, Any] = {}
+
+ if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3:
+ presigned_data = s3_generate_presigned_post(
+ file_path=upload_path, file_type=file.file_type
+ )
+
+ else:
+ presigned_data = {
+ "url": file_generate_local_upload_url(file_id=str(file.id)),
+ 'presigned_data': {}
+ }
+
+ return {
+ "file": file,
+ "presigned_data": presigned_data,
+ }
+
+ @transaction.atomic
+ def finish(self, *, file: File) -> File:
+
+ file.upload_finished_at = timezone.now()
+ file.full_clean()
+ file.save()
+
+ return file
+
+ @transaction.atomic
+ def upload_local(self, *, file: File, file_obj) -> File:
+ _validate_file_size(file_obj)
+
+ file.file = file_obj
+ file.full_clean()
+ file.save()
+
+ return file
diff --git a/augment-store/server/storage/storage_backends.py b/augment-store/server/storage/storage_backends.py
new file mode 100644
index 000000000..70e33d170
--- /dev/null
+++ b/augment-store/server/storage/storage_backends.py
@@ -0,0 +1,26 @@
+from django.conf import settings
+from storages.backends.s3boto3 import S3Boto3Storage
+
+
+class StaticStorage(S3Boto3Storage):
+ location = settings.STATIC_LOCATION
+
+
+class PublicMediaStorage(S3Boto3Storage):
+ location = settings.PUBLIC_MEDIA_LOCATION
+ file_overwrite = False
+
+
+class PrivateMediaStorage(S3Boto3Storage):
+ location = settings.PRIVATE_MEDIA_LOCATION
+ default_acl = "private"
+ file_overwrite = False
+ custom_domain = False
+
+
+def get_public_storage_class():
+ return PublicMediaStorage()
+
+
+def get_private_storage_class():
+ return PrivateMediaStorage()
diff --git a/augment-store/server/storage/tests.py b/augment-store/server/storage/tests.py
new file mode 100644
index 000000000..b50135be6
--- /dev/null
+++ b/augment-store/server/storage/tests.py
@@ -0,0 +1,307 @@
+from core.tests import BaseAPITestCase
+from accounts.factory import UserFactory
+from accounts.models import User
+from rest_framework import status
+from django.urls import reverse
+from django.core.files.uploadedfile import SimpleUploadedFile
+from storage.models import File
+from django.test import override_settings
+from django.conf import settings
+import os
+
+
+@override_settings(
+ FILE_UPLOAD_STORAGE='local',
+ MEDIA_ROOT=os.path.join(settings.BASE_DIR, 'test_media'),
+ DEFAULT_FILE_STORAGE='django.core.files.storage.FileSystemStorage',
+ APP_DOMAIN='http://testserver'
+)
+class StorageTests(BaseAPITestCase):
+
+ def setUp(self):
+ super().setUp()
+ # Create a merchant user for authenticated tests
+ self.merchant_user = UserFactory(
+ email="merchant@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MERCHANT
+ )
+ self.merchant_client = self.authenticated_client
+ self.merchant_client.force_authenticate(user=self.merchant_user)
+
+ # Create a member user for permission tests
+ self.member_user = UserFactory(
+ email="member@demo.com",
+ password="testpass123",
+ is_active=True,
+ role=User.Role.MEMBER
+ )
+
+ def test_start_direct_upload_success(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request to start direct upload with valid data
+ url = reverse("v1:storage:start_direct_upload")
+ payload = {
+ "original_file_name": "test_image.jpg",
+ "file_type": "image/jpeg",
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND a File object should be created in the database
+ self.assertTrue(File.objects.filter(original_file_name="test_image.jpg").exists())
+
+ def test_start_direct_upload_unauthenticated(self):
+ # GIVEN a user is not authenticated
+ # WHEN we make a post request to start direct upload
+ url = reverse("v1:storage:start_direct_upload")
+ payload = {
+ "original_file_name": "test_image.jpg",
+ "file_type": "image/jpeg",
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_start_direct_upload_member_role_success(self):
+ # GIVEN a member user is authenticated
+ member_client = self.authenticated_client
+ member_client.force_authenticate(user=self.member_user)
+
+ # WHEN we make a post request to start direct upload
+ url = reverse("v1:storage:start_direct_upload")
+ payload = {
+ "original_file_name": "test_image.jpg",
+ "file_type": "image/jpeg",
+ }
+ response = member_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND a File object should be created in the database
+ self.assertTrue(File.objects.filter(original_file_name="test_image.jpg").exists())
+
+ def test_start_direct_upload_missing_fields(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request with missing required fields
+ url = reverse("v1:storage:start_direct_upload")
+ payload = {
+ "original_file_name": "test_image.jpg",
+ # missing file_type
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_direct_local_upload_success(self):
+ # GIVEN a merchant user is authenticated
+ # AND a file record exists in the database
+ file_record = File.objects.create(
+ original_file_name="test_upload.jpg",
+ file_name="test_upload_123.jpg",
+ file_type="image/jpeg",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to upload the actual file
+ url = reverse("v1:storage:direct_local_upload", kwargs={"file_id": str(file_record.id)})
+ test_file = SimpleUploadedFile(
+ "test_upload.jpg",
+ b"file_content",
+ content_type="image/jpeg"
+ )
+ payload = {
+ "file": test_file,
+ "file_id": str(file_record.id),
+ }
+ response = self.merchant_client.post(url, payload, format='multipart')
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the file record should be updated with the file
+ file_record.refresh_from_db()
+ self.assertIsNotNone(file_record.file)
+
+ def test_direct_local_upload_unauthenticated(self):
+ # GIVEN a user is not authenticated
+ # AND a file record exists
+ file_record = File.objects.create(
+ original_file_name="test_upload.jpg",
+ file_name="test_upload_123.jpg",
+ file_type="image/jpeg",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to upload the actual file
+ url = reverse("v1:storage:direct_local_upload", kwargs={"file_id": str(file_record.id)})
+ test_file = SimpleUploadedFile(
+ "test_upload.jpg",
+ b"file_content",
+ content_type="image/jpeg"
+ )
+ payload = {
+ "file": test_file,
+ "file_id": str(file_record.id),
+ }
+ response = self.client.post(url, payload, format='multipart')
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_direct_local_upload_member_role_success(self):
+ # GIVEN a member user is authenticated
+ member_client = self.authenticated_client
+ member_client.force_authenticate(user=self.member_user)
+
+ # AND a file record exists
+ file_record = File.objects.create(
+ original_file_name="test_upload.jpg",
+ file_name="test_upload_123.jpg",
+ file_type="image/jpeg",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to upload the actual file
+ url = reverse("v1:storage:direct_local_upload", kwargs={"file_id": str(file_record.id)})
+ test_file = SimpleUploadedFile(
+ "test_upload.jpg",
+ b"file_content",
+ content_type="image/jpeg"
+ )
+ payload = {
+ "file": test_file,
+ "file_id": str(file_record.id),
+ }
+ response = member_client.post(url, payload, format='multipart')
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the file record should be updated with the file
+ file_record.refresh_from_db()
+ self.assertIsNotNone(file_record.file)
+
+ def test_direct_local_upload_file_not_found(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request with a non-existent file_id
+ url = reverse("v1:storage:direct_local_upload", kwargs={"file_id": "99999999-9999-9999-9999-999999999999"})
+ test_file = SimpleUploadedFile(
+ "test_upload.jpg",
+ b"file_content",
+ content_type="image/jpeg"
+ )
+ payload = {
+ "file": test_file,
+ "file_id": "99999999-9999-9999-9999-999999999999",
+ }
+ response = self.merchant_client.post(url, payload, format='multipart')
+
+ # THEN we should get a 404 response
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_finish_direct_upload_success(self):
+ # GIVEN a merchant user is authenticated
+ # AND a file record exists with an uploaded file
+ file_record = File.objects.create(
+ original_file_name="test_finish.jpg",
+ file_name="test_finish_123.jpg",
+ file_type="image/jpeg",
+ created_by=self.merchant_user,
+ file=SimpleUploadedFile("test_finish.jpg", b"file_content")
+ )
+
+ # WHEN we make a post request to finish the upload
+ url = reverse("v1:storage:finish_direct_upload")
+ payload = {
+ "file_id": str(file_record.id),
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the response should contain file data
+ self.assertIn("file", response.data)
+
+ # AND the file record should have upload_finished_at set
+ file_record.refresh_from_db()
+ self.assertIsNotNone(file_record.upload_finished_at)
+
+ def test_finish_direct_upload_unauthenticated(self):
+ # GIVEN a user is not authenticated
+ # AND a file record exists
+ file_record = File.objects.create(
+ original_file_name="test_finish.jpg",
+ file_name="test_finish_123.jpg",
+ file_type="image/jpeg",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to finish the upload
+ url = reverse("v1:storage:finish_direct_upload")
+ payload = {
+ "file_id": str(file_record.id),
+ }
+ response = self.client.post(url, payload)
+
+ # THEN we should get a 401 response
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ def test_finish_direct_upload_member_role_success(self):
+ # GIVEN a member user is authenticated
+ member_client = self.authenticated_client
+ member_client.force_authenticate(user=self.member_user)
+
+ # AND a file record exists
+ file_record = File.objects.create(
+ original_file_name="test_finish.jpg",
+ file_name="test_finish_123.jpg",
+ file_type="image/jpeg",
+ created_by=self.merchant_user
+ )
+
+ # WHEN we make a post request to finish the upload
+ url = reverse("v1:storage:finish_direct_upload")
+ payload = {
+ "file_id": str(file_record.id),
+ }
+ response = member_client.post(url, payload)
+
+ # THEN we should get a 201 response
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # AND the response should contain file data
+ self.assertIn("file", response.data)
+
+ # AND the file record should have upload_finished_at set
+ file_record.refresh_from_db()
+ self.assertIsNotNone(file_record.upload_finished_at)
+
+ def test_finish_direct_upload_file_not_found(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request with a non-existent file_id
+ url = reverse("v1:storage:finish_direct_upload")
+ payload = {
+ "file_id": "99999999-9999-9999-9999-999999999999",
+ }
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 404 response
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_finish_direct_upload_missing_file_id(self):
+ # GIVEN a merchant user is authenticated
+ # WHEN we make a post request without file_id
+ url = reverse("v1:storage:finish_direct_upload")
+ payload = {}
+ response = self.merchant_client.post(url, payload)
+
+ # THEN we should get a 400 response
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/augment-store/server/storage/urls.py b/augment-store/server/storage/urls.py
new file mode 100644
index 000000000..a103ffa4f
--- /dev/null
+++ b/augment-store/server/storage/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from .views import StartDirectFileUpload, DirectLocalFileUpload, FinishDirectFileUploadFinish
+
+
+app_name = "storage"
+urlpatterns = [
+ path('direct/', StartDirectFileUpload.as_view(), name='start_direct_upload'),
+ path('direct/local//', DirectLocalFileUpload.as_view(), name='direct_local_upload'),
+ path('direct/finish/', FinishDirectFileUploadFinish.as_view(), name='finish_direct_upload'),
+]
diff --git a/augment-store/server/storage/utils.py b/augment-store/server/storage/utils.py
new file mode 100644
index 000000000..c344c225a
--- /dev/null
+++ b/augment-store/server/storage/utils.py
@@ -0,0 +1,187 @@
+import logging
+from dataclasses import dataclass
+from typing import Any, Dict
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+
+import boto3
+from botocore.client import Config
+from botocore.exceptions import ClientError
+
+import pathlib
+from uuid import uuid4
+
+from django.conf import settings
+from django.urls import reverse
+
+
+
+
+
+
+def assert_settings(required_settings, error_message_prefix=""):
+ """
+ Checks if each item from `required_settings` is present in Django settings
+ """
+ not_present = []
+ values = {}
+
+ for required_setting in required_settings:
+ if not hasattr(settings, required_setting):
+ not_present.append(required_setting)
+ continue
+
+ values[required_setting] = getattr(settings, required_setting)
+
+ if not_present:
+ if not error_message_prefix:
+ error_message_prefix = "Required settings not found."
+
+ stringified_not_present = ", ".join(not_present)
+
+ raise ImproperlyConfigured(
+ f"{error_message_prefix} Could not find: {stringified_not_present}"
+ )
+
+ return values
+
+
+
+@dataclass()
+class S3Credentials:
+ access_key_id: str
+ secret_access_key: str
+ region_name: str
+ bucket_name: str
+ default_acl: str
+ presigned_expiry: int
+ max_size: int
+
+
+def s3_get_credentials() -> S3Credentials:
+ required_config = assert_settings(
+ [
+ "AWS_ACCESS_KEY_ID",
+ "AWS_SECRET_ACCESS_KEY",
+ "AWS_S3_REGION_NAME",
+ "AWS_STORAGE_BUCKET_NAME",
+ "AWS_DEFAULT_ACL",
+ "AWS_PRESIGNED_EXPIRY",
+ "FILE_MAX_SIZE",
+ ],
+ "S3 credentials not found.",
+ )
+
+ return S3Credentials(
+ access_key_id=required_config["AWS_ACCESS_KEY_ID"],
+ secret_access_key=required_config["AWS_SECRET_ACCESS_KEY"],
+ region_name=required_config["AWS_S3_REGION_NAME"],
+ bucket_name=required_config["AWS_STORAGE_BUCKET_NAME"],
+ default_acl=required_config["AWS_DEFAULT_ACL"],
+ presigned_expiry=required_config["AWS_PRESIGNED_EXPIRY"],
+ max_size=required_config["FILE_MAX_SIZE"],
+ )
+
+
+def s3_get_client():
+ credentials = s3_get_credentials()
+
+ return boto3.client(
+ service_name="s3",
+ aws_access_key_id=credentials.access_key_id,
+ aws_secret_access_key=credentials.secret_access_key,
+ region_name=credentials.region_name,
+ )
+
+
+def s3_generate_presigned_post(
+ *, file_path: str, file_type: str
+) -> Dict[str, Any]:
+ credentials = s3_get_credentials()
+ s3_client = s3_get_client()
+
+ acl = credentials.default_acl
+ expires_in = credentials.presigned_expiry
+
+ presigned_data = s3_client.generate_presigned_post(
+ credentials.bucket_name,
+ file_path,
+ Fields={"acl": acl, "Content-Type": file_type},
+ Conditions=[
+ {"acl": acl},
+ {"Content-Type": file_type},
+ # As an example, allow file size up to 10 MiB
+ # More on conditions, here:
+ # https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html
+ ["content-length-range", 1, credentials.max_size],
+ ],
+ ExpiresIn=expires_in,
+ )
+
+ return presigned_data
+
+
+def create_presigned_url(object_name, bucket_name=None):
+ """Generate a presigned URL to share an S3 object
+
+ :param bucket_name: string
+ :param object_name: string
+ :param expiration: Time in seconds for the presigned URL to remain valid
+ :return: Presigned URL as string. If error, returns None.
+ """
+
+ credentials = s3_get_credentials()
+
+ s3_client = boto3.client(
+ config=Config(
+ s3={"addressing_style": "path"}, signature_version="s3v4"
+ ),
+ service_name="s3",
+ aws_access_key_id=credentials.access_key_id,
+ aws_secret_access_key=credentials.secret_access_key,
+ region_name=credentials.region_name,
+ )
+
+ credentials = s3_get_credentials()
+
+ # Generate a presigned URL for the S3 object
+ params = {"Bucket": credentials.bucket_name, "Key": object_name}
+ try:
+ response = s3_client.generate_presigned_url(
+ "get_object",
+ Params=params,
+ ExpiresIn=credentials.presigned_expiry,
+ )
+ except ClientError as e:
+ logging.error(e)
+ return None
+ return response
+
+
+
+
+
+
+
+def file_generate_name(original_file_name):
+ extension = pathlib.Path(original_file_name).suffix
+
+ return f"{uuid4().hex}{extension}"
+
+
+def file_generate_upload_path(instance, filename):
+ return f"augment-store/{instance.file_name}"
+
+
+def file_generate_local_upload_url(*, file_id: str):
+ url = reverse("v1:storage:direct_local_upload", kwargs={"file_id": file_id})
+ app_domain: str = settings.APP_DOMAIN # type: ignore
+ return f"{app_domain}{url}"
+
+
+def bytes_to_mib(value: int) -> float:
+ # 1 bytes = 9.5367431640625E-7 mebibytes
+ return value * 9.5367431640625e-7
+
+
diff --git a/augment-store/server/storage/views.py b/augment-store/server/storage/views.py
new file mode 100644
index 000000000..ec8930576
--- /dev/null
+++ b/augment-store/server/storage/views.py
@@ -0,0 +1,26 @@
+
+
+
+from rest_framework.generics import CreateAPIView
+
+from .serializers import StartDirectFileUploadSerializer, DirectLocalFileUploadSerializer
+from .serializers import FinishFileUploadSerializer
+
+from rest_framework.permissions import IsAuthenticated
+
+
+class StartDirectFileUpload(CreateAPIView):
+ serializer_class = StartDirectFileUploadSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class DirectLocalFileUpload(CreateAPIView):
+ serializer_class = DirectLocalFileUploadSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class FinishDirectFileUploadFinish(CreateAPIView):
+ serializer_class = FinishFileUploadSerializer
+ permission_classes = [IsAuthenticated]
+
+