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