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