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

@@ -193,6 +193,8 @@ export function ProductFormTabbed({
setDownloadable={setDownloadable}
featured={featured}
setFeatured={setFeatured}
images={images}
setImages={setImages}
sku={sku}
setSku={setSku}
regularPrice={regularPrice}

View File

@@ -4,12 +4,14 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { DollarSign } from 'lucide-react';
import { DollarSign, Upload, X, Image as ImageIcon } from 'lucide-react';
import { getStoreCurrency } from '@/lib/currency';
import { RichTextEditor } from '@/components/RichTextEditor';
import { openWPMediaGallery } from '@/lib/wp-media';
type GeneralTabProps = {
name: string;
@@ -28,6 +30,9 @@ type GeneralTabProps = {
setDownloadable: (value: boolean) => void;
featured: boolean;
setFeatured: (value: boolean) => void;
// Images
images: string[];
setImages: (value: string[]) => void;
// Pricing props
sku: string;
setSku: (value: string) => void;
@@ -54,6 +59,8 @@ export function GeneralTab({
setDownloadable,
featured,
setFeatured,
images,
setImages,
sku,
setSku,
regularPrice,
@@ -167,6 +174,97 @@ export function GeneralTab({
</p>
</div>
{/* Product Images */}
<Separator />
<div>
<Label>{__('Product Images')}</Label>
<p className="text-xs text-muted-foreground mt-1 mb-3">
{__('First image will be the featured image. Drag to reorder.')}
</p>
{/* Image Upload Button */}
<div className="space-y-3">
<Button
type="button"
variant="outline"
onClick={() => {
openWPMediaGallery((files) => {
const newImages = files.map(file => file.url);
setImages([...images, ...newImages]);
});
}}
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
{__('Add Images from Media Library')}
</Button>
{/* Image Preview Grid - Sortable */}
{images.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{images.map((image, index) => (
<div
key={index}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index.toString());
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
const toIndex = index;
if (fromIndex !== toIndex) {
const newImages = [...images];
const [movedImage] = newImages.splice(fromIndex, 1);
newImages.splice(toIndex, 0, movedImage);
setImages(newImages);
}
}}
className="relative group aspect-square border rounded-lg overflow-hidden bg-gray-50 cursor-move hover:border-primary transition-colors"
>
<img
src={image}
alt={`Product ${index + 1}`}
className="w-full h-full object-cover pointer-events-none"
/>
{index === 0 && (
<div className="absolute top-2 left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded font-medium">
{__('Featured')}
</div>
)}
<button
type="button"
onClick={() => {
const newImages = images.filter((_, i) => i !== index);
setImages(newImages);
}}
className="absolute top-2 right-2 bg-red-600 text-white p-1.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700"
>
<X className="h-3 w-3" />
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
{__('Drag to reorder')}
</div>
</div>
))}
</div>
)}
{images.length === 0 && (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<ImageIcon className="mx-auto h-12 w-12 mb-2 opacity-50" />
<p className="text-sm">{__('No images uploaded yet')}</p>
</div>
)}
</div>
</div>
{/* Pricing Section */}
<Separator />
<div className="space-y-4">

View File

@@ -7,9 +7,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Plus, X, Layers } from 'lucide-react';
import { Plus, X, Layers, Image as ImageIcon } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency';
import { openWPMediaImage } from '@/lib/wp-media';
export type ProductVariant = {
id?: number;
@@ -20,6 +21,7 @@ export type ProductVariant = {
stock_quantity?: number;
manage_stock?: boolean;
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
image?: string;
};
type VariationsTabProps = {
@@ -210,6 +212,44 @@ export function VariationsTab({
</Badge>
))}
</div>
{/* Variation Image */}
<div className="mb-3">
<Label className="text-xs">{__('Variation Image (Optional)')}</Label>
<div className="flex gap-2 mt-1.5">
{variation.image ? (
<div className="relative w-16 h-16 border rounded overflow-hidden group">
<img src={variation.image} alt="Variation" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => {
const updated = [...variations];
updated[index].image = undefined;
setVariations(updated);
}}
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
openWPMediaImage((file) => {
const updated = [...variations];
updated[index].image = file.url;
setVariations(updated);
});
}}
>
<ImageIcon className="mr-2 h-3 w-3" />
{__('Add Image')}
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Input
placeholder={__('SKU')}

View File

@@ -0,0 +1,498 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { Loader2, Palette, Layout, Monitor, ShoppingCart, CheckCircle2, AlertCircle, Store, Zap, Sparkles } from 'lucide-react';
interface CustomerSPASettings {
mode: 'disabled' | 'full' | 'checkout_only';
checkoutPages?: {
checkout: boolean;
thankyou: boolean;
account: boolean;
cart: boolean;
};
layout: 'classic' | 'modern' | 'boutique' | 'launch';
colors: {
primary: string;
secondary: string;
accent: string;
};
typography: {
preset: string;
};
}
export default function CustomerSPASettings() {
const queryClient = useQueryClient();
// Fetch settings
const { data: settings, isLoading } = useQuery<CustomerSPASettings>({
queryKey: ['customer-spa-settings'],
queryFn: async () => {
const response = await fetch('/wp-json/woonoow/v1/settings/customer-spa', {
headers: {
'X-WP-Nonce': (window as any).WNW_API?.nonce || (window as any).wpApiSettings?.nonce || '',
},
credentials: 'same-origin',
});
if (!response.ok) throw new Error('Failed to fetch settings');
return response.json();
},
});
// Update settings mutation
const updateMutation = useMutation({
mutationFn: async (newSettings: Partial<CustomerSPASettings>) => {
const response = await fetch('/wp-json/woonoow/v1/settings/customer-spa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': (window as any).WNW_API?.nonce || (window as any).wpApiSettings?.nonce || '',
},
credentials: 'same-origin',
body: JSON.stringify(newSettings),
});
if (!response.ok) throw new Error('Failed to update settings');
return response.json();
},
onSuccess: (data) => {
queryClient.setQueryData(['customer-spa-settings'], data.data);
toast.success(__('Settings saved successfully'));
},
onError: (error: any) => {
toast.error(error.message || __('Failed to save settings'));
},
});
const handleModeChange = (mode: string) => {
updateMutation.mutate({ mode: mode as any });
};
const handleLayoutChange = (layout: string) => {
updateMutation.mutate({ layout: layout as any });
};
const handleCheckoutPageToggle = (page: string, checked: boolean) => {
if (!settings) return;
const currentPages = settings.checkoutPages || {
checkout: true,
thankyou: true,
account: true,
cart: false,
};
updateMutation.mutate({
checkoutPages: {
...currentPages,
[page]: checked,
},
});
};
const handleColorChange = (colorKey: string, value: string) => {
if (!settings) return;
updateMutation.mutate({
colors: {
...settings.colors,
[colorKey]: value,
},
});
};
const handleTypographyChange = (preset: string) => {
updateMutation.mutate({
typography: {
preset,
},
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!settings) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
<p className="text-muted-foreground">{__('Failed to load settings')}</p>
</div>
</div>
);
}
return (
<div className="space-y-6 max-w-4xl pb-8">
<div>
<h1 className="text-3xl font-bold">{__('Customer SPA')}</h1>
<p className="text-muted-foreground mt-2">
{__('Configure the modern React-powered storefront for your customers')}
</p>
</div>
<Separator />
{/* Mode Selection */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5" />
{__('Activation Mode')}
</CardTitle>
<CardDescription>
{__('Choose how WooNooW Customer SPA integrates with your site')}
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={settings.mode} onValueChange={handleModeChange}>
<div className="space-y-4">
{/* Disabled */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="disabled" id="mode-disabled" className="mt-1" />
<div className="flex-1">
<Label htmlFor="mode-disabled" className="font-semibold cursor-pointer">
{__('Disabled')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Use your own theme and page builder for the storefront. Only WooNooW Admin SPA will be active.')}
</p>
</div>
</div>
{/* Full SPA */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="full" id="mode-full" className="mt-1" />
<div className="flex-1">
<Label htmlFor="mode-full" className="font-semibold cursor-pointer">
{__('Full SPA')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('WooNooW takes over the entire storefront (Shop, Product, Cart, Checkout, Account pages).')}
</p>
{settings.mode === 'full' && (
<div className="mt-3 p-3 bg-primary/10 rounded-md">
<p className="text-sm font-medium text-primary">
{__('Active - Choose your layout below')}
</p>
</div>
)}
</div>
</div>
{/* Checkout Only */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="checkout_only" id="mode-checkout" className="mt-1" />
<div className="flex-1">
<Label htmlFor="mode-checkout" className="font-semibold cursor-pointer">
{__('Checkout Only')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('WooNooW only overrides checkout pages. Perfect for single product sellers with custom landing pages.')}
</p>
{settings.mode === 'checkout_only' && (
<div className="mt-3 space-y-3">
<p className="text-sm font-medium">{__('Pages to override:')}</p>
<div className="space-y-2 pl-4">
<div className="flex items-center space-x-2">
<Checkbox
id="page-checkout"
checked={settings.checkoutPages?.checkout}
onCheckedChange={(checked) => handleCheckoutPageToggle('checkout', checked as boolean)}
/>
<Label htmlFor="page-checkout" className="cursor-pointer">
{__('Checkout')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="page-thankyou"
checked={settings.checkoutPages?.thankyou}
onCheckedChange={(checked) => handleCheckoutPageToggle('thankyou', checked as boolean)}
/>
<Label htmlFor="page-thankyou" className="cursor-pointer">
{__('Thank You (Order Received)')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="page-account"
checked={settings.checkoutPages?.account}
onCheckedChange={(checked) => handleCheckoutPageToggle('account', checked as boolean)}
/>
<Label htmlFor="page-account" className="cursor-pointer">
{__('My Account')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="page-cart"
checked={settings.checkoutPages?.cart}
onCheckedChange={(checked) => handleCheckoutPageToggle('cart', checked as boolean)}
/>
<Label htmlFor="page-cart" className="cursor-pointer">
{__('Cart (Optional)')}
</Label>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</RadioGroup>
</CardContent>
</Card>
{/* Layout Selection - Only show if Full SPA is active */}
{settings.mode === 'full' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layout className="w-5 h-5" />
{__('Layout')}
</CardTitle>
<CardDescription>
{__('Choose a master layout for your storefront')}
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={settings.layout} onValueChange={handleLayoutChange}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Classic */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="classic" id="layout-classic" className="mt-1" />
<div className="flex-1">
<Label htmlFor="layout-classic" className="font-semibold cursor-pointer flex items-center gap-2">
<Store className="w-4 h-4" />
{__('Classic')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Traditional ecommerce with sidebar filters. Best for B2B and traditional retail.')}
</p>
</div>
</div>
{/* Modern */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="modern" id="layout-modern" className="mt-1" />
<div className="flex-1">
<Label htmlFor="layout-modern" className="font-semibold cursor-pointer flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{__('Modern')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Minimalist design with large product cards. Best for fashion and lifestyle brands.')}
</p>
</div>
</div>
{/* Boutique */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="boutique" id="layout-boutique" className="mt-1" />
<div className="flex-1">
<Label htmlFor="layout-boutique" className="font-semibold cursor-pointer flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{__('Boutique')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Luxury-focused with masonry grid. Best for high-end fashion and luxury goods.')}
</p>
</div>
</div>
{/* Launch */}
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="launch" id="layout-launch" className="mt-1" />
<div className="flex-1">
<Label htmlFor="layout-launch" className="font-semibold cursor-pointer flex items-center gap-2">
<Zap className="w-4 h-4" />
{__('Launch')} <span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">NEW</span>
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Single product funnel. Best for digital products, courses, and product launches.')}
</p>
<p className="text-xs text-muted-foreground mt-2 italic">
{__('Note: Landing page uses your page builder. WooNooW takes over from checkout onwards.')}
</p>
</div>
</div>
</div>
</RadioGroup>
</CardContent>
</Card>
)}
{/* Color Customization - Show if Full SPA or Checkout Only is active */}
{(settings.mode === 'full' || settings.mode === 'checkout_only') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="w-5 h-5" />
{__('Colors')}
</CardTitle>
<CardDescription>
{__('Customize your brand colors')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Primary Color */}
<div className="space-y-2">
<Label htmlFor="color-primary">{__('Primary Color')}</Label>
<div className="flex items-center gap-2">
<Input
type="color"
id="color-primary"
value={settings.colors.primary}
onChange={(e) => handleColorChange('primary', e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={settings.colors.primary}
onChange={(e) => handleColorChange('primary', e.target.value)}
className="flex-1 font-mono text-sm"
placeholder="#3B82F6"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Buttons, links, active states')}
</p>
</div>
{/* Secondary Color */}
<div className="space-y-2">
<Label htmlFor="color-secondary">{__('Secondary Color')}</Label>
<div className="flex items-center gap-2">
<Input
type="color"
id="color-secondary"
value={settings.colors.secondary}
onChange={(e) => handleColorChange('secondary', e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={settings.colors.secondary}
onChange={(e) => handleColorChange('secondary', e.target.value)}
className="flex-1 font-mono text-sm"
placeholder="#8B5CF6"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Badges, accents, secondary buttons')}
</p>
</div>
{/* Accent Color */}
<div className="space-y-2">
<Label htmlFor="color-accent">{__('Accent Color')}</Label>
<div className="flex items-center gap-2">
<Input
type="color"
id="color-accent"
value={settings.colors.accent}
onChange={(e) => handleColorChange('accent', e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={settings.colors.accent}
onChange={(e) => handleColorChange('accent', e.target.value)}
className="flex-1 font-mono text-sm"
placeholder="#10B981"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Success states, CTAs, highlights')}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Typography - Show if Full SPA is active */}
{settings.mode === 'full' && (
<Card>
<CardHeader>
<CardTitle>{__('Typography')}</CardTitle>
<CardDescription>
{__('Choose a font pairing for your storefront')}
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={settings.typography.preset} onValueChange={handleTypographyChange}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="professional" id="typo-professional" />
<Label htmlFor="typo-professional" className="cursor-pointer flex-1">
<div className="font-semibold">Professional</div>
<div className="text-sm text-muted-foreground">Inter + Lora</div>
</Label>
</div>
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="modern" id="typo-modern" />
<Label htmlFor="typo-modern" className="cursor-pointer flex-1">
<div className="font-semibold">Modern</div>
<div className="text-sm text-muted-foreground">Poppins + Roboto</div>
</Label>
</div>
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="elegant" id="typo-elegant" />
<Label htmlFor="typo-elegant" className="cursor-pointer flex-1">
<div className="font-semibold">Elegant</div>
<div className="text-sm text-muted-foreground">Playfair Display + Source Sans</div>
</Label>
</div>
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<RadioGroupItem value="tech" id="typo-tech" />
<Label htmlFor="typo-tech" className="cursor-pointer flex-1">
<div className="font-semibold">Tech</div>
<div className="text-sm text-muted-foreground">Space Grotesk + IBM Plex Mono</div>
</Label>
</div>
</div>
</RadioGroup>
</CardContent>
</Card>
)}
{/* Info Card */}
{settings.mode !== 'disabled' && (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<div>
<p className="font-medium text-primary mb-1">
{__('Customer SPA is Active')}
</p>
<p className="text-sm text-muted-foreground">
{settings.mode === 'full'
? __('Your storefront is now powered by WooNooW React SPA. Visit your shop to see the changes.')
: __('Checkout pages are now powered by WooNooW React SPA. Create your custom landing page and link the CTA to /checkout.')}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}