feat: Add product images support with WP Media Library integration
- Add WP Media Library integration for product and variation images - Support images array (URLs) conversion to attachment IDs - Add images array to API responses (Admin & Customer SPA) - Implement drag-and-drop sortable images in Admin product form - Add image gallery thumbnails in Customer SPA product page - Initialize WooCommerce session for guest cart operations - Fix product variations and attributes display in Customer SPA - Add variation image field in Admin SPA Changes: - includes/Api/ProductsController.php: Handle images array, add to responses - includes/Frontend/ShopController.php: Add images array for customer SPA - includes/Frontend/CartController.php: Initialize WC session for guests - admin-spa/src/lib/wp-media.ts: Add openWPMediaGallery function - admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx: WP Media + sortable images - admin-spa/src/routes/Products/partials/tabs/VariationsTab.tsx: Add variation image field - customer-spa/src/pages/Product/index.tsx: Add gallery thumbnails display
This commit is contained in:
@@ -1,10 +1,209 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Cart() {
|
||||
const navigate = useNavigate();
|
||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
|
||||
// Calculate total from items
|
||||
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
|
||||
const handleUpdateQuantity = (key: string, newQuantity: number) => {
|
||||
if (newQuantity < 1) {
|
||||
handleRemoveItem(key);
|
||||
return;
|
||||
}
|
||||
updateQuantity(key, newQuantity);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (key: string) => {
|
||||
removeItem(key);
|
||||
toast.success('Item removed from cart');
|
||||
};
|
||||
|
||||
const handleClearCart = () => {
|
||||
clearCart();
|
||||
setShowClearDialog(false);
|
||||
toast.success('Cart cleared');
|
||||
};
|
||||
|
||||
if (cart.items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center py-16">
|
||||
<ShoppingBag className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">Your cart is empty</h2>
|
||||
<p className="text-gray-600 mb-6">Add some products to get started!</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Shopping Cart</h1>
|
||||
<p className="text-muted-foreground">Cart coming soon...</p>
|
||||
</div>
|
||||
<Container>
|
||||
<div className="py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold">Shopping Cart</h1>
|
||||
<Button variant="outline" onClick={() => setShowClearDialog(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Cart Items */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{cart.items.map((item: CartItem) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex gap-4 p-4 border rounded-lg bg-white"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="block w-full !h-full object-cover object-center"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg mb-1 truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-2">
|
||||
{formatPrice(item.price)}
|
||||
</p>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
handleUpdateQuantity(item.key, parseInt(e.target.value) || 1)
|
||||
}
|
||||
className="w-16 text-center border rounded py-1"
|
||||
min="1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Total & Remove */}
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.key)}
|
||||
className="text-red-600 hover:text-red-700 p-2"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
<p className="font-bold text-lg">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cart Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="border rounded-lg p-6 bg-white sticky top-4">
|
||||
<h2 className="text-xl font-bold mb-4">Cart Summary</h2>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Shipping</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</div>
|
||||
<div className="border-t pt-3 flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/checkout')}
|
||||
size="lg"
|
||||
className="w-full mb-3"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/shop')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Cart Confirmation Dialog */}
|
||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear Cart?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove all items from your cart? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowClearDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleClearCart}>
|
||||
Clear Cart
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,367 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ArrowLeft, ShoppingBag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Checkout() {
|
||||
const navigate = useNavigate();
|
||||
const { cart } = useCartStore();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const shipping = 0; // TODO: Calculate shipping
|
||||
const tax = 0; // TODO: Calculate tax
|
||||
const total = subtotal + shipping + tax;
|
||||
|
||||
// Form state
|
||||
const [billingData, setBillingData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
});
|
||||
|
||||
const [shippingData, setShippingData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
});
|
||||
|
||||
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
||||
const [orderNotes, setOrderNotes] = useState('');
|
||||
|
||||
const handlePlaceOrder = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement order placement API call
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
|
||||
|
||||
toast.success('Order placed successfully!');
|
||||
navigate('/order-received/123'); // TODO: Use actual order ID
|
||||
} catch (error) {
|
||||
toast.error('Failed to place order');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty cart redirect
|
||||
if (cart.items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center py-16">
|
||||
<ShoppingBag className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">Your cart is empty</h2>
|
||||
<p className="text-gray-600 mb-6">Add some products before checking out!</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Checkout</h1>
|
||||
<p className="text-muted-foreground">Checkout coming soon...</p>
|
||||
</div>
|
||||
<Container>
|
||||
<div className="py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" onClick={() => navigate('/cart')} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Cart
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Checkout</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePlaceOrder}>
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Billing & Shipping Forms */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Billing Details */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ship to Different Address */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shipToDifferentAddress}
|
||||
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">Ship to a different address?</span>
|
||||
</label>
|
||||
|
||||
{shipToDifferentAddress && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.city}
|
||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.postcode}
|
||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.country}
|
||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Notes */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Order Notes (Optional)</h2>
|
||||
<textarea
|
||||
value={orderNotes}
|
||||
onChange={(e) => setOrderNotes(e.target.value)}
|
||||
placeholder="Notes about your order, e.g. special notes for delivery."
|
||||
className="w-full border rounded-lg px-4 py-2 h-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white border rounded-lg p-6 sticky top-4">
|
||||
<h2 className="text-xl font-bold mb-4">Your Order</h2>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="space-y-3 mb-4 pb-4 border-b">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.key} className="flex justify-between text-sm">
|
||||
<span>
|
||||
{item.name} × {item.quantity}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Shipping</span>
|
||||
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
|
||||
</div>
|
||||
{tax > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Tax</span>
|
||||
<span>{formatPrice(tax)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-medium mb-3">Payment Method</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input type="radio" name="payment" value="cod" defaultChecked className="w-4 h-4" />
|
||||
<span>Cash on Delivery</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input type="radio" name="payment" value="bank" className="w-4 h-4" />
|
||||
<span>Bank Transfer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Place Order Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Place Order'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,176 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||
|
||||
export default function Shop() {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useTheme();
|
||||
const { layout } = useLayout();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const { addItem } = useCartStore();
|
||||
|
||||
// Fetch products
|
||||
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
||||
queryKey: ['products', page, search, category],
|
||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
||||
page,
|
||||
per_page: 12,
|
||||
search,
|
||||
category,
|
||||
}),
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = useQuery<ProductCategory[]>({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.categories),
|
||||
});
|
||||
|
||||
const handleAddToCart = async (product: any) => {
|
||||
try {
|
||||
const response = await apiClient.post(apiClient.endpoints.cart.add, {
|
||||
product_id: product.id,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
// Add to local cart store
|
||||
addItem({
|
||||
key: `${product.id}`,
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
price: parseFloat(product.price),
|
||||
quantity: 1,
|
||||
image: product.image,
|
||||
});
|
||||
|
||||
toast.success(`${product.name} added to cart!`, {
|
||||
action: {
|
||||
label: 'View Cart',
|
||||
onClick: () => navigate('/cart'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('Failed to add to cart');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Shop</h1>
|
||||
<p className="text-muted-foreground">Product listing coming soon...</p>
|
||||
</div>
|
||||
<Container>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
||||
<p className="text-muted-foreground">Browse our collection of products</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name} ({cat.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
{productsLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : productsData?.products && productsData.products.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{productsData.products.map((product: any) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{productsData.total_pages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-4">
|
||||
Page {page} of {productsData.total_pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(productsData.total_pages, p + 1))}
|
||||
disabled={page === productsData.total_pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No products found</p>
|
||||
{(search || category) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setCategory('');
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user