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,26 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEZTCCAs2gAwIBAgIQF1GMfemibsRXEX4zKsPLuTANBgkqhkiG9w0BAQsFADCB
lzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTYwNAYDVQQLDC1kd2lu
ZG93bkBvYWppc2RoYS1pby5sb2NhbCAoRHdpbmRpIFJhbWFkaGFuYSkxPTA7BgNV
BAMMNG1rY2VydCBkd2luZG93bkBvYWppc2RoYS1pby5sb2NhbCAoRHdpbmRpIFJh
bWFkaGFuYSkwHhcNMjUxMDI0MTAzMTMxWhcNMjgwMTI0MTAzMTMxWjBhMScwJQYD
VQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxNjA0BgNVBAsMLWR3
aW5kb3duQG9hamlzZGhhLWlvLmxvY2FsIChEd2luZGkgUmFtYWRoYW5hKTCCASIw
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALt22AwSay07IFZanpCHO418klWC
KWnQw4iIrGW81hFQMCHsplDlweAN4mIO7qJsP/wtpTKDg7/h1oXLDOkvdYOwgVIq
4dZZ0YUXe7UC8dJvFD4Y9/BBRTQoJGcErKYF8yq8Sc8suGfwo0C15oeb4Nsh/U9c
bCNvCHWowyF0VGY/r0rNg88xeVPZbfvlaEaGCiH4D3BO+h8h9E7qtUMTRGNEnA/0
4jNs2S7QWmjaFobYAv2PmU5LBWYjTIoCW8v/5yRU5lVyuI9YFhtqekGR3b9OJVgG
ijqIJevC28+7/EmZXBUthwJksQFyb60WCnd8LpVrLIqkEfa5M4B23ovqnPsCAwEA
AaNiMGAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1Ud
IwQYMBaAFMm7kFGBpyWbJhnY+lPOXiQ0q9c3MBgGA1UdEQQRMA+CDXdvb25vb3cu
bG9jYWwwDQYJKoZIhvcNAQELBQADggGBAHcW6Z5kGZEhNOI+ZwadClsSW+00FfSs
uwzaShUuPZpRC9Hmcvnc3+E+9dVuupzBULq9oTrDA2yVIhD9aHC7a7Vha/VDZubo
2tTp+z71T/eXXph6q40D+beI9dw2oes9gQsZ+b9sbkH/9lVyeTTz3Oc06TYNwrK3
X5CHn3pt76urHfxCMK1485goacqD+ju4yEI0UX+rnGJHPHJjpS7vZ5+FAGAG7+r3
H1UPz94ITomyYzj0ED1v54e3lcxus/4CkiVWuh/VJYxBdoptT8RDt1eP8CD3NTOM
P0jxDKbjBBCCCdGoGU7n1FFfpG882SLiW8fsaLf45kVYRTWnk2r16y6AU5pQe3xX
8L6DuPo+xPlthxxSpX6ppbuA/O/KQ1qc3iDt8VNmQxffKiBt3zTW/ba3bgf92EAm
CZyZyE7GLxQ1X+J6VMM9zDBVSM8suu5IPXEsEepeVk8xDKmoTdJs3ZIBXm538AD/
WoI8zeb6KaJ3G8wCkEIHhxxoSmWSt2ez1Q==
MIIEdTCCAt2gAwIBAgIRAKO2NWnRuWeb2C/NQ/Teuu0wDQYJKoZIhvcNAQELBQAw
gaExHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE7MDkGA1UECwwyZHdp
bmRvd25ARHdpbmRpcy1NYWMtbWluaS5sb2NhbCAoRHdpbmRpIFJhbWFkaGFuYSkx
QjBABgNVBAMMOW1rY2VydCBkd2luZG93bkBEd2luZGlzLU1hYy1taW5pLmxvY2Fs
IChEd2luZGkgUmFtYWRoYW5hKTAeFw0yNTExMjIwOTM2NTdaFw0yODAyMjIwOTM2
NTdaMGYxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE7
MDkGA1UECwwyZHdpbmRvd25ARHdpbmRpcy1NYWMtbWluaS5sb2NhbCAoRHdpbmRp
IFJhbWFkaGFuYSkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwGedS
6QfL/vMzFktKhqvIVGAvgpuNJO2r1Mf9oHlmwSryqjYn5/zp82RhgYLIW3w3sH6x
1V5AkwiHBoaSh+CZ+CHUOvDw5+noyjaGrlW1lj42VAOH3cxSrtc1scjiP2Cph/jY
qZEWZb4iq2J+GSkpbJHUbcqtbUw0XaC8OXg0aRR5ELmRQ2VNs7cqSw1xODvBuOak
6650r5YfoR8MPj0sz5a16notcUXwT627HduyA7RAs8oWKn/96ZPBo7kPVCL/JowG
tdtIka+ESMRu1qsdu1ZtcSVbove/wTNFV9akfKRymI0J2rcTWPpz4lVfvIBhQz0J
bnFqSZeDE3pLLfg1AgMBAAGjYjBgMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAK
BggrBgEFBQcDATAfBgNVHSMEGDAWgBSsL6TlzA65pzrFGTrL97kt0FlZJzAYBgNV
HREEETAPgg13b29ub293LmxvY2FsMA0GCSqGSIb3DQEBCwUAA4IBgQBkvgb0Gp50
VW2Y7wQntNivPcDWJuDjbK1waqUqpSVUkDx2R+i6UPSloNoSLkgBLz6rn4Jt4Hzu
cLP+iuZql3KC/+G9Alr6cn/UnG++jGekcO7m/sQYYen+SzdmVYNe4BSJOeJvLe1A
Km10372m5nVd5iGRnZ+n5CprWOCymkC1Hg7xiqGOuldDu/yRcyHgdQ3a0y4nK91B
TQJzt9Ux/50E12WkPeKXDmD7MSHobQmrrtosMU5aeDwmEZm3FTItLEtXqKuiu7fG
V8gOPdL69Da0ttN2XUC0WRCtLcuRfxvi90Tkjo1JHo8586V0bjZZl4JguJwCTn78
EdZRwzLUrdvgfAL/TyN/meJgBBfVnTBviUp2OMKH+0VLtk7RNHNYiEnwk7vjIQYR
lFBdVKcqDH5yx6QsmdkhExE5/AyYbVh147JXlcTTiEJpD0Nm8m4WCIwRR81HEvKN
emjbk+5vcx0ja+jj+TM2Aofv/rdOllfjsv26PJix+jJgn0cJ6F+7gKA=
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7dtgMEmstOyBW
Wp6QhzuNfJJVgilp0MOIiKxlvNYRUDAh7KZQ5cHgDeJiDu6ibD/8LaUyg4O/4daF
ywzpL3WDsIFSKuHWWdGFF3u1AvHSbxQ+GPfwQUU0KCRnBKymBfMqvEnPLLhn8KNA
teaHm+DbIf1PXGwjbwh1qMMhdFRmP69KzYPPMXlT2W375WhGhgoh+A9wTvofIfRO
6rVDE0RjRJwP9OIzbNku0Fpo2haG2AL9j5lOSwVmI0yKAlvL/+ckVOZVcriPWBYb
anpBkd2/TiVYBoo6iCXrwtvPu/xJmVwVLYcCZLEBcm+tFgp3fC6VayyKpBH2uTOA
dt6L6pz7AgMBAAECggEAZeT1Daq9QrqOmyFqaph20DLTv1Kee/uTLJVNT4dSu9pg
LzBYPkSEGuqxECeZogNAzCtrTYeahyOT3Ok/PUgkkc3QnP7d/gqYDcVz4jGVi5IA
6LfdnGN94Bmpn600wpEdWS861zcxjJ2JvtSgVzltAO76prZPuPrTGFEAryBx95jb
3p08nAVT3Skw95bz56DBnfT/egqySmKhLRvKgey2ttGkB1WEjqY8YlQch9yy6uV7
2iEUwbGY6mbAepFv+KGdOmrGZ/kLktI90PgR1g8E4KOrhk+AfBjN9XgZP2t+yO8x
Cwh/owmn5J6s0EKFFEFBQrrbiu2PaZLZ9IEQmcEwEQKBgQDdppwaOYpfXPAfRIMq
XlGjQb+3GtFuARqSuGcCl0LxMHUqcBtSI/Ua4z0hJY2kaiomgltEqadhMJR0sWum
FXhGh6uhINn9o4Oumu9CySiq1RocR+w4/b15ggDWm60zV8t5v0+jM+R5CqTQPUTv
Fd77QZnxspmJyB7M2+jXqoHCrwKBgQDYg/mQYg25+ibwR3mdvjOd5CALTQJPRJ01
wHLE5fkcgxTukChbaRBvp9yI7vK8xN7pUbsv/G2FrkBqvpLtAYglVVPJj/TLGzgi
i5QE2ORE9KJcyV193nOWE0Y4JS0cXPh1IG5DZDAU5+/zLq67LSKk6x9cO/g7hZ3A
1sC6NVJNdQKBgQCLEh6f1bqcWxPOio5B5ywR4w8HNCxzeP3TUSBQ39eAvYbGOdDq
mOURGcMhKQ7WOkZ4IxJg4pHCyVhcX3XLn2z30+g8EQC1xAK7azr0DIMXrN3VIMt2
dr6LnqYoAUWLEWr52K9/FvAjgiom/kpiOLbPrzmIDSeI66dnohNWPgVswQKBgCDi
mqslWXRf3D4ufPhKhUh796n/vlQP1djuLABf9aAxAKLjXl3T7V0oH8TklhW5ySmi
8k1th60ANGSCIYrB6s3Q0fMRXFrk/Xexv3+k+bbHeUmihAK0INYwgz/P1bQzIsGX
dWfi9bKXL8i91Gg1iMeHtrGpoiBYQQejFo6xvphpAoGAEomDPyuRIA2oYZWtaeIp
yghLR0ixbnsZz2oA1MuR4A++iwzspUww/T5cFfI4xthk7FOxy3CK7nDL96rzhHf3
EER4qOOxP+kAAs8Ozd4ERkUSuaDkrRsaUhr8CYF5AQajPQWKMEVcCK1G+WqHGNYg
GzoAyax8kSdmzv6fMPouiGI=
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwGedS6QfL/vMz
FktKhqvIVGAvgpuNJO2r1Mf9oHlmwSryqjYn5/zp82RhgYLIW3w3sH6x1V5AkwiH
BoaSh+CZ+CHUOvDw5+noyjaGrlW1lj42VAOH3cxSrtc1scjiP2Cph/jYqZEWZb4i
q2J+GSkpbJHUbcqtbUw0XaC8OXg0aRR5ELmRQ2VNs7cqSw1xODvBuOak6650r5Yf
oR8MPj0sz5a16notcUXwT627HduyA7RAs8oWKn/96ZPBo7kPVCL/JowGtdtIka+E
SMRu1qsdu1ZtcSVbove/wTNFV9akfKRymI0J2rcTWPpz4lVfvIBhQz0JbnFqSZeD
E3pLLfg1AgMBAAECggEBAKVoH0xUD3u/w8VHen7M0ct/3Tyi6+J+PjN40ERdF8q5
Q9Lcp7OCBp/kenPPhv0UWS+hus7kf/wdXxQcwAggUomsdHH4ztkorB942BBW7bB7
J4I2FX7niQRcr04C6JICP5PdYJJ5awrjk9zSp9eTYINFNBCY85dEIyDIlLJXNJ3c
SkjmJlCAvJXYZcJ1/UaitBNFxiPWd0Abpr2kEvIbN9ipLP336FzTcp+KwxInMI5p
s/vwXDkzlUr/4azE0DlXU4WiFLCOfCiL0+gX128+fugmYimig5eRSbpZDWXPl6b7
BnbKLy1ak53qm7Otz2e/K0sgSUnMXX12tY1BGgg+kL0CgYEA2z/usrjLUu8tnvvn
XU7ULmEOUsOVh8NmW4jkVgd4Aok+zRxmstA0c+ZcIEr/0g4ad/9OQnI7miGTSdaC
1e8cDmR1D7DtyxuwhNDGN73yjWjT+4gAba087J/+JPKky3MNV5fISgRi1he5Jqfp
aPZDsf4+cAmI0DQm+TnIDBaXt0cCgYEAzZ50b4KdmqURlruDbK1GxH7jeMVdzpl8
ZyLXnXJbTK8qCv2/0kYR6r3raDjAN7AFMFaFh93j6q/DTJb/x4pNYMSKTxbkZu5J
S7jUfcgRbMp2ItLjtLc5Ve/yEUa9JtaL8778Efd5oTot5EflkG0v+3ISLYDC6Uu1
wTUcClX4iqMCgYEAovB7c8UUDhmEfQ/WnSiVVbZ5j5adDR1xd3tfvnOkg7X9vy9p
P2Cuaqf7NWCniDNFBoLtZUJB+0USkiBicZ1W63dK7BNgVb7JS5tghFKc7OzIBbnI
H7pMecpZdJoDUNO7Saqahi+GSHeu+QR22bOTEbfSLS9YxurLQBLqEdnEfMcCgYAW
0ZPoYB1vcQwvpyWhpOUqn05NM9ICQIROyc4V2gAJ1ZKb36cvBbmtTGBYk5u5Ul5x
C9kLx/MoM1NAJ63BDjciGw2iU08LoTwfHCbwwog0g49ys+azQnYpdFRv2GLbcYnc
hgBhWg50dwlqwRPX4FYn2HPt+tEmpNFJ3MP83aeUcwKBgCG4FmPe+a7gRZ/uqoNx
bIyNSKQw6O/RSP3rOcqeZjVxYwBYuqaMIr8TZj5NTePR1kZsuJ0Lo02h6NOMAP0B
UtHulMHf83AXySHt8J907fhdvCotOi6E/94ziTTmU0bNsuWE2/FYe34LrYlcoVbi
QPo8USOGPS9H/OTR3tTAPdSG
-----END PRIVATE KEY-----

View File

@@ -214,6 +214,7 @@ import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
import SettingsDeveloper from '@/routes/Settings/Developer';
import SettingsCustomerSPA from '@/routes/Settings/CustomerSPA';
import MorePage from '@/routes/More';
// Addon Route Component - Dynamically loads addon components
@@ -511,6 +512,7 @@ function AppRoutes() {
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
<Route path="/settings/brand" element={<SettingsIndex />} />
<Route path="/settings/customer-spa" element={<SettingsCustomerSPA />} />
<Route path="/settings/developer" element={<SettingsDeveloper />} />
{/* Dynamic Addon Routes */}

View File

@@ -163,3 +163,52 @@ export function openWPMediaFavicon(onSelect: (file: WPMediaFile) => void): void
onSelect
);
}
/**
* Open WordPress Media Modal for Multiple Images (Product Gallery)
*/
export function openWPMediaGallery(onSelect: (files: WPMediaFile[]) => void): void {
// Check if WordPress media is available
if (typeof window.wp === 'undefined' || typeof window.wp.media === 'undefined') {
console.error('WordPress media library is not available');
alert('WordPress Media library is not loaded.');
return;
}
// Create media frame with multiple selection
const frame = window.wp.media({
title: 'Select or Upload Product Images',
button: {
text: 'Add to Gallery',
},
multiple: true,
library: {
type: 'image',
},
});
// Handle selection
frame.on('select', () => {
const selection = frame.state().get('selection') as any;
const files: WPMediaFile[] = [];
selection.map((attachment: any) => {
const data = attachment.toJSON();
files.push({
url: data.url,
id: data.id,
title: data.title || data.filename,
filename: data.filename,
alt: data.alt || '',
width: data.width,
height: data.height,
});
return attachment;
});
onSelect(files);
});
// Open modal
frame.open();
}

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