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:
Dwindi Ramadhana
2025-11-26 16:18:43 +07:00
parent 909bddb23d
commit f397ef850f
69 changed files with 12481 additions and 156 deletions

View File

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