feat: Wishlist settings cleanup + Categories/Tags/Attributes CRUD pages
Wishlist Settings Cleanup: - Removed wishlist_page setting (not needed for SPA architecture) - Marked advanced features as 'Coming Soon' with disabled flag: * Wishlist Sharing * Back in Stock Notifications * Multiple Wishlists - Added disabled prop support to SchemaField toggle component - Kept only working features: guest wishlist, show in header, max items, add to cart button Product Taxonomy CRUD Pages: Built full CRUD interfaces for all three taxonomy types: 1. Categories (/products/categories): - Table view with search - Create/Edit dialog with name, slug, description - Delete with confirmation - Product count display - Parent category support 2. Tags (/products/tags): - Table view with search - Create/Edit dialog with name, slug, description - Delete with confirmation - Product count display 3. Attributes (/products/attributes): - Table view with search - Create/Edit dialog with label, slug, type, orderby - Delete with confirmation - Type selector (Select/Text) - Sort order selector (Custom/Name/ID) All pages include: - React Query for data fetching/mutations - Toast notifications for success/error - Loading states - Empty states - Responsive tables - Dialog forms with validation Files Modified: - includes/Modules/WishlistSettings.php (removed page selector, marked advanced as coming soon) - admin-spa/src/components/forms/SchemaField.tsx (added disabled prop) - admin-spa/src/routes/Products/Categories.tsx (full CRUD) - admin-spa/src/routes/Products/Tags.tsx (full CRUD) - admin-spa/src/routes/Products/Attributes.tsx (full CRUD) - admin-spa/src/components/nav/SubmenuBar.tsx (removed debug logging) - admin-spa/dist/app.js (rebuilt) Result: ✅ Wishlist settings now clearly show what's implemented vs coming soon ✅ Categories/Tags/Attributes pages fully functional ✅ Professional CRUD interfaces matching admin design ✅ All taxonomy management now in SPA
This commit is contained in:
@@ -16,6 +16,7 @@ export interface FieldSchema {
|
||||
options?: Record<string, string>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SchemaFieldProps {
|
||||
@@ -72,6 +73,7 @@ export function SchemaField({ name, schema, value, onChange, error }: SchemaFiel
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={schema.disabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value ? 'Enabled' : 'Disabled'}
|
||||
|
||||
@@ -30,11 +30,6 @@ export default function SubmenuBar({ items = [], fullscreen = false, headerVisib
|
||||
// Only ONE submenu item should be active at a time
|
||||
const isActive = it.path === pathname;
|
||||
|
||||
// Debug logging for Dashboard Overview issue
|
||||
if (it.label === 'Overview' && pathname.includes('dashboard')) {
|
||||
console.log('Overview check:', { label: it.label, path: it.path, pathname, isActive });
|
||||
}
|
||||
|
||||
const cls = [
|
||||
'ui-ctrl inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap',
|
||||
'focus:outline-none focus:ring-0 focus:shadow-none',
|
||||
|
||||
@@ -1,11 +1,264 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Attribute {
|
||||
attribute_id: number;
|
||||
attribute_name: string;
|
||||
attribute_label: string;
|
||||
attribute_type: string;
|
||||
attribute_orderby: string;
|
||||
attribute_public: number;
|
||||
}
|
||||
|
||||
export default function ProductAttributes() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingAttribute, setEditingAttribute] = useState<Attribute | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
label: '',
|
||||
type: 'select',
|
||||
orderby: 'menu_order',
|
||||
public: 1
|
||||
});
|
||||
|
||||
const { data: attributes = [], isLoading } = useQuery<Attribute[]>({
|
||||
queryKey: ['product-attributes'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${api.root()}/products/attributes`, {
|
||||
headers: { 'X-WP-Nonce': api.nonce() },
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/products/attributes', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||
toast.success(__('Attribute created successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create attribute'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/attributes/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||
toast.success(__('Attribute updated successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update attribute'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/products/attributes/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||
toast.success(__('Attribute deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to delete attribute'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenDialog = (attribute?: Attribute) => {
|
||||
if (attribute) {
|
||||
setEditingAttribute(attribute);
|
||||
setFormData({
|
||||
name: attribute.attribute_name,
|
||||
label: attribute.attribute_label,
|
||||
type: attribute.attribute_type,
|
||||
orderby: attribute.attribute_orderby,
|
||||
public: attribute.attribute_public,
|
||||
});
|
||||
} else {
|
||||
setEditingAttribute(null);
|
||||
setFormData({ name: '', label: '', type: 'select', orderby: 'menu_order', public: 1 });
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingAttribute(null);
|
||||
setFormData({ name: '', label: '', type: 'select', orderby: 'menu_order', public: 1 });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingAttribute) {
|
||||
updateMutation.mutate({ id: editingAttribute.attribute_id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(__('Are you sure you want to delete this attribute?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAttributes = attributes.filter((attr) =>
|
||||
attr.attribute_label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
attr.attribute_name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{__('Product Attributes')}</h1>
|
||||
<Button onClick={() => handleOpenDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Attribute')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search attributes...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">{__('Loading attributes...')}</p>
|
||||
</div>
|
||||
) : filteredAttributes.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">{__('No attributes found')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium">{__('Name')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Slug')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Type')}</th>
|
||||
<th className="text-center p-4 font-medium">{__('Order By')}</th>
|
||||
<th className="text-right p-4 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAttributes.map((attribute) => (
|
||||
<tr key={attribute.attribute_id} className="border-t hover:bg-muted/30">
|
||||
<td className="p-4 font-medium">{attribute.attribute_label}</td>
|
||||
<td className="p-4 text-muted-foreground">{attribute.attribute_name}</td>
|
||||
<td className="p-4 text-sm capitalize">{attribute.attribute_type}</td>
|
||||
<td className="p-4 text-center text-sm">{attribute.attribute_orderby}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(attribute)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(attribute.attribute_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingAttribute ? __('Edit Attribute') : __('Add Attribute')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Product Attributes')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA attributes manager.')}</p>
|
||||
<Label htmlFor="label">{__('Label')}</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder={__('e.g., Color, Size')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">{__('Slug')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={__('Leave empty to auto-generate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">{__('Type')}</Label>
|
||||
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="select">{__('Select')}</SelectItem>
|
||||
<SelectItem value="text">{__('Text')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="orderby">{__('Default Sort Order')}</Label>
|
||||
<Select value={formData.orderby} onValueChange={(value) => setFormData({ ...formData, orderby: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="menu_order">{__('Custom ordering')}</SelectItem>
|
||||
<SelectItem value="name">{__('Name')}</SelectItem>
|
||||
<SelectItem value="name_num">{__('Name (numeric)')}</SelectItem>
|
||||
<SelectItem value="id">{__('Term ID')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingAttribute ? __('Update') : __('Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,239 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Category {
|
||||
term_id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
count: number;
|
||||
parent: number;
|
||||
}
|
||||
|
||||
export default function ProductCategories() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', slug: '', description: '', parent: 0 });
|
||||
|
||||
const { data: categories = [], isLoading } = useQuery<Category[]>({
|
||||
queryKey: ['product-categories'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${api.root()}/products/categories`, {
|
||||
headers: { 'X-WP-Nonce': api.nonce() },
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/products/categories', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||
toast.success(__('Category created successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create category'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/categories/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||
toast.success(__('Category updated successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update category'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/products/categories/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||
toast.success(__('Category deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to delete category'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenDialog = (category?: Category) => {
|
||||
if (category) {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description || '',
|
||||
parent: category.parent || 0,
|
||||
});
|
||||
} else {
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: '', slug: '', description: '', parent: 0 });
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: '', slug: '', description: '', parent: 0 });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingCategory) {
|
||||
updateMutation.mutate({ id: editingCategory.term_id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(__('Are you sure you want to delete this category?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCategories = categories.filter((cat) =>
|
||||
cat.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{__('Product Categories')}</h1>
|
||||
<Button onClick={() => handleOpenDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Category')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search categories...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">{__('Loading categories...')}</p>
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">{__('No categories found')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium">{__('Name')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Slug')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Description')}</th>
|
||||
<th className="text-center p-4 font-medium">{__('Count')}</th>
|
||||
<th className="text-right p-4 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCategories.map((category) => (
|
||||
<tr key={category.term_id} className="border-t hover:bg-muted/30">
|
||||
<td className="p-4 font-medium">{category.name}</td>
|
||||
<td className="p-4 text-muted-foreground">{category.slug}</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{category.description || '-'}
|
||||
</td>
|
||||
<td className="p-4 text-center">{category.count}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(category)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(category.term_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCategory ? __('Edit Category') : __('Add Category')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Product Categories')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA categories manager.')}</p>
|
||||
<Label htmlFor="name">{__('Name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">{__('Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder={__('Leave empty to auto-generate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{__('Description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingCategory ? __('Update') : __('Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,237 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Tag {
|
||||
term_id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function ProductTags() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', slug: '', description: '' });
|
||||
|
||||
const { data: tags = [], isLoading } = useQuery<Tag[]>({
|
||||
queryKey: ['product-tags'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${api.root()}/products/tags`, {
|
||||
headers: { 'X-WP-Nonce': api.nonce() },
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/products/tags', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||
toast.success(__('Tag created successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create tag'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/tags/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||
toast.success(__('Tag updated successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update tag'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/products/tags/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||
toast.success(__('Tag deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to delete tag'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenDialog = (tag?: Tag) => {
|
||||
if (tag) {
|
||||
setEditingTag(tag);
|
||||
setFormData({
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || '',
|
||||
});
|
||||
} else {
|
||||
setEditingTag(null);
|
||||
setFormData({ name: '', slug: '', description: '' });
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingTag(null);
|
||||
setFormData({ name: '', slug: '', description: '' });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingTag) {
|
||||
updateMutation.mutate({ id: editingTag.term_id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(__('Are you sure you want to delete this tag?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTags = tags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{__('Product Tags')}</h1>
|
||||
<Button onClick={() => handleOpenDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Tag')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search tags...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">{__('Loading tags...')}</p>
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">{__('No tags found')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium">{__('Name')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Slug')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Description')}</th>
|
||||
<th className="text-center p-4 font-medium">{__('Count')}</th>
|
||||
<th className="text-right p-4 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTags.map((tag) => (
|
||||
<tr key={tag.term_id} className="border-t hover:bg-muted/30">
|
||||
<td className="p-4 font-medium">{tag.name}</td>
|
||||
<td className="p-4 text-muted-foreground">{tag.slug}</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{tag.description || '-'}
|
||||
</td>
|
||||
<td className="p-4 text-center">{tag.count}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(tag)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tag.term_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingTag ? __('Edit Tag') : __('Add Tag')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Product Tags')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA tags manager.')}</p>
|
||||
<Label htmlFor="name">{__('Name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">{__('Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder={__('Leave empty to auto-generate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{__('Description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingTag ? __('Update') : __('Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,31 +29,12 @@ class WishlistSettings {
|
||||
'description' => __('Allow non-logged-in users to create wishlists (stored in browser)', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
'wishlist_page' => [
|
||||
'type' => 'select',
|
||||
'label' => __('Wishlist Page', 'woonoow'),
|
||||
'description' => __('Page to display wishlist items', 'woonoow'),
|
||||
'placeholder' => __('-- Select Page --', 'woonoow'),
|
||||
'options' => self::get_pages_options(),
|
||||
],
|
||||
'show_in_header' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Show Wishlist Icon in Header', 'woonoow'),
|
||||
'description' => __('Display wishlist icon with item count in the header', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
'enable_sharing' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable Wishlist Sharing', 'woonoow'),
|
||||
'description' => __('Allow users to share their wishlists via link', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
'enable_email_notifications' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Back in Stock Notifications', 'woonoow'),
|
||||
'description' => __('Email users when wishlist items are back in stock', 'woonoow'),
|
||||
'default' => false,
|
||||
],
|
||||
'max_items_per_wishlist' => [
|
||||
'type' => 'number',
|
||||
'label' => __('Maximum Items Per Wishlist', 'woonoow'),
|
||||
@@ -62,34 +43,37 @@ class WishlistSettings {
|
||||
'min' => 0,
|
||||
'max' => 1000,
|
||||
],
|
||||
'enable_multiple_wishlists' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable Multiple Wishlists', 'woonoow'),
|
||||
'description' => __('Allow users to create multiple named wishlists', 'woonoow'),
|
||||
'default' => false,
|
||||
],
|
||||
'show_add_to_cart_button' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Show "Add to Cart" on Wishlist Page', 'woonoow'),
|
||||
'description' => __('Display add to cart button for each wishlist item', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
// Advanced features - Coming Soon
|
||||
'enable_sharing' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable Wishlist Sharing (Coming Soon)', 'woonoow'),
|
||||
'description' => __('Allow users to share their wishlists via link - Feature not yet implemented', 'woonoow'),
|
||||
'default' => false,
|
||||
'disabled' => true,
|
||||
],
|
||||
'enable_email_notifications' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Back in Stock Notifications (Coming Soon)', 'woonoow'),
|
||||
'description' => __('Email users when wishlist items are back in stock - Feature not yet implemented', 'woonoow'),
|
||||
'default' => false,
|
||||
'disabled' => true,
|
||||
],
|
||||
'enable_multiple_wishlists' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable Multiple Wishlists (Coming Soon)', 'woonoow'),
|
||||
'description' => __('Allow users to create multiple named wishlists - Feature not yet implemented', 'woonoow'),
|
||||
'default' => false,
|
||||
'disabled' => true,
|
||||
],
|
||||
];
|
||||
|
||||
return $schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress pages as select options
|
||||
*/
|
||||
private static function get_pages_options() {
|
||||
$pages = get_pages();
|
||||
$options = [];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$options[$page->ID] = $page->post_title;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user