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

@@ -0,0 +1,273 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ShoppingCart, Heart } from 'lucide-react';
import { formatPrice, formatDiscount } from '@/lib/currency';
import { Button } from './ui/button';
import { useLayout } from '@/contexts/ThemeContext';
interface ProductCardProps {
product: {
id: number;
name: string;
slug: string;
price: string;
regular_price?: string;
sale_price?: string;
image?: string;
on_sale?: boolean;
stock_status?: string;
};
onAddToCart?: (product: any) => void;
}
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
const { isClassic, isModern, isBoutique, isLaunch } = useLayout();
const handleAddToCart = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onAddToCart?.(product);
};
// Calculate discount if on sale
const discount = product.on_sale && product.regular_price && product.sale_price
? formatDiscount(parseFloat(product.regular_price), parseFloat(product.sale_price))
: null;
// Classic Layout - Traditional card with border
if (isClassic) {
return (
<Link to={`/product/${product.slug}`} className="group">
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white">
{/* Image */}
<div className="relative w-full h-64 overflow-hidden bg-gray-100" style={{ fontSize: 0 }}>
{product.image ? (
<img
src={product.image}
alt={product.name}
className="block w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full !h-full flex items-center justify-center text-gray-400" style={{ fontSize: '1rem' }}>
No Image
</div>
)}
{/* Sale Badge */}
{product.on_sale && discount && (
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded">
{discount}
</div>
)}
{/* Quick Actions */}
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 bg-white rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center">
<Heart className="w-4 h-4 block" />
</button>
</div>
</div>
{/* Content */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{product.name}
</h3>
{/* Price */}
<div className="flex items-center gap-2 mb-3">
{product.on_sale && product.regular_price ? (
<>
<span className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.sale_price || product.price)}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-lg font-bold text-gray-900">
{formatPrice(product.price)}
</span>
)}
</div>
{/* Add to Cart Button */}
<Button
onClick={handleAddToCart}
className="w-full"
disabled={product.stock_status === 'outofstock'}
>
<ShoppingCart className="w-4 h-4 mr-2" />
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
</Button>
</div>
</div>
</Link>
);
}
// Modern Layout - Minimalist, clean
if (isModern) {
return (
<Link to={`/product/${product.slug}`} className="group">
<div className="overflow-hidden">
{/* Image */}
<div className="relative w-full h-64 mb-4 overflow-hidden bg-gray-50" style={{ fontSize: 0 }}>
{product.image ? (
<img
src={product.image}
alt={product.name}
className="block w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300" style={{ fontSize: '1rem' }}>
No Image
</div>
)}
{/* Sale Badge */}
{product.on_sale && discount && (
<div className="absolute top-4 left-4 bg-black text-white text-xs font-medium px-3 py-1">
{discount}
</div>
)}
{/* Hover Overlay */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
<Button
onClick={handleAddToCart}
className="opacity-0 group-hover:opacity-100 transition-opacity"
disabled={product.stock_status === 'outofstock'}
>
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
</Button>
</div>
</div>
{/* Content */}
<div className="text-center">
<h3 className="font-medium text-gray-900 mb-2 group-hover:text-primary transition-colors">
{product.name}
</h3>
{/* Price */}
<div className="flex items-center justify-center gap-2">
{product.on_sale && product.regular_price ? (
<>
<span className="font-semibold" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.sale_price || product.price)}
</span>
<span className="text-sm text-gray-400 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="font-semibold text-gray-900">
{formatPrice(product.price)}
</span>
)}
</div>
</div>
</div>
</Link>
);
}
// Boutique Layout - Luxury, elegant
if (isBoutique) {
return (
<Link to={`/product/${product.slug}`} className="group">
<div className="overflow-hidden">
{/* Image */}
<div className="relative w-full h-80 mb-6 overflow-hidden bg-gray-50" style={{ fontSize: 0 }}>
{product.image ? (
<img
src={product.image}
alt={product.name}
className="block w-full h-full object-cover object-center group-hover:scale-110 transition-transform duration-700"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300 font-serif" style={{ fontSize: '1rem' }}>
No Image
</div>
)}
{/* Sale Badge */}
{product.on_sale && discount && (
<div className="absolute top-6 right-6 bg-white text-black text-xs font-medium px-4 py-2 tracking-wider">
{discount}
</div>
)}
</div>
{/* Content */}
<div className="text-center font-serif">
<h3 className="text-lg font-medium text-gray-900 mb-3 tracking-wide group-hover:text-primary transition-colors">
{product.name}
</h3>
{/* Price */}
<div className="flex items-center justify-center gap-3 mb-4">
{product.on_sale && product.regular_price ? (
<>
<span className="text-xl font-medium" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.sale_price || product.price)}
</span>
<span className="text-gray-400 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-xl font-medium text-gray-900">
{formatPrice(product.price)}
</span>
)}
</div>
{/* Add to Cart Button */}
<Button
onClick={handleAddToCart}
variant="outline"
className="w-full font-serif tracking-wider"
disabled={product.stock_status === 'outofstock'}
>
{product.stock_status === 'outofstock' ? 'OUT OF STOCK' : 'ADD TO CART'}
</Button>
</div>
</div>
</Link>
);
}
// Launch Layout - Funnel optimized (shouldn't show product grid, but just in case)
return (
<Link to={`/product/${product.slug}`} className="group">
<div className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-white">
<div className="relative w-full h-64 overflow-hidden bg-gray-100" style={{ fontSize: 0 }}>
{product.image ? (
<img
src={product.image}
alt={product.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" style={{ fontSize: '1rem' }}>
No Image
</div>
)}
</div>
<div className="p-4 text-center">
<h3 className="font-semibold text-gray-900 mb-2">{product.name}</h3>
<div className="text-xl font-bold mb-3" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.price)}
</div>
<Button onClick={handleAddToCart} className="w-full" size="lg">
Buy Now
</Button>
</div>
</div>
</Link>
);
}