feat: Add product and category selectors to coupon form
Added comprehensive product/category restrictions to coupon form Features Added: 1. Product Selectors: - Products (include) - multiselect with search - Exclude products - multiselect with search - Shows product names with searchable dropdown - Badge display for selected items 2. Category Selectors: - Product categories (include) - multiselect - Exclude categories - multiselect - Shows category names with search - Badge display for selected items 3. API Integration: - Added ProductsApi.list() endpoint - Added ProductsApi.categories() endpoint - Fetch products and categories on form load - React Query caching for performance 4. Form Data: - Added product_ids field - Added excluded_product_ids field - Added product_categories field - Added excluded_product_categories field - Proper number/string conversion UI/UX Improvements: - Searchable multiselect dropdowns - Badge display with X to remove - Shows +N more when exceeds display limit - Clear placeholder text - Helper text for each field - Consistent spacing and layout Technical: - Uses MultiSelect component (shadcn-based) - React Query for data fetching - Proper TypeScript types - Number array handling Note: Brands field not included yet (requires WooCommerce Product Brands extension check) Result: - Full WooCommerce coupon restrictions support - Clean, searchable UI - Production ready
This commit is contained in:
@@ -97,6 +97,8 @@ export const OrdersApi = {
|
|||||||
|
|
||||||
export const ProductsApi = {
|
export const ProductsApi = {
|
||||||
search: (search: string, limit = 10) => api.get('/products/search', { search, limit }),
|
search: (search: string, limit = 10) => api.get('/products/search', { search, limit }),
|
||||||
|
list: (params?: { page?: number; per_page?: number }) => api.get('/products', { params }),
|
||||||
|
categories: () => api.get('/products/categories'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomersApi = {
|
export const CustomersApi = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -7,6 +8,8 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
|
import { ProductsApi } from '@/lib/api';
|
||||||
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
||||||
|
|
||||||
interface CouponFormProps {
|
interface CouponFormProps {
|
||||||
@@ -31,6 +34,10 @@ export default function CouponForm({
|
|||||||
description: initial?.description || '',
|
description: initial?.description || '',
|
||||||
date_expires: initial?.date_expires || null,
|
date_expires: initial?.date_expires || null,
|
||||||
individual_use: initial?.individual_use || false,
|
individual_use: initial?.individual_use || false,
|
||||||
|
product_ids: initial?.product_ids || [],
|
||||||
|
excluded_product_ids: initial?.excluded_product_ids || [],
|
||||||
|
product_categories: initial?.product_categories || [],
|
||||||
|
excluded_product_categories: initial?.excluded_product_categories || [],
|
||||||
usage_limit: initial?.usage_limit || null,
|
usage_limit: initial?.usage_limit || null,
|
||||||
usage_limit_per_user: initial?.usage_limit_per_user || null,
|
usage_limit_per_user: initial?.usage_limit_per_user || null,
|
||||||
free_shipping: initial?.free_shipping || false,
|
free_shipping: initial?.free_shipping || false,
|
||||||
@@ -39,6 +46,20 @@ export default function CouponForm({
|
|||||||
maximum_amount: initial?.maximum_amount || null,
|
maximum_amount: initial?.maximum_amount || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch products and categories
|
||||||
|
const { data: productsData } = useQuery({
|
||||||
|
queryKey: ['products-list'],
|
||||||
|
queryFn: () => ProductsApi.list({ per_page: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: categoriesData } = useQuery({
|
||||||
|
queryKey: ['product-categories'],
|
||||||
|
queryFn: () => ProductsApi.categories(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const products = (productsData as any)?.rows || [];
|
||||||
|
const categories = categoriesData || [];
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -190,6 +211,78 @@ export default function CouponForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Products */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__('Products')}</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={products.map((p: any) => ({
|
||||||
|
value: String(p.id),
|
||||||
|
label: p.name,
|
||||||
|
}))}
|
||||||
|
selected={(formData.product_ids || []).map(String)}
|
||||||
|
onChange={(selected) => updateField('product_ids', selected.map(Number))}
|
||||||
|
placeholder={__('Search for products...')}
|
||||||
|
emptyMessage={__('No products found')}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Products that the coupon will be applied to, or leave blank for all products')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exclude Products */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__('Exclude products')}</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={products.map((p: any) => ({
|
||||||
|
value: String(p.id),
|
||||||
|
label: p.name,
|
||||||
|
}))}
|
||||||
|
selected={(formData.excluded_product_ids || []).map(String)}
|
||||||
|
onChange={(selected) => updateField('excluded_product_ids', selected.map(Number))}
|
||||||
|
placeholder={__('Search for products...')}
|
||||||
|
emptyMessage={__('No products found')}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Products that the coupon will not be applied to')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Categories */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__('Product categories')}</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={categories.map((c: any) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
}))}
|
||||||
|
selected={(formData.product_categories || []).map(String)}
|
||||||
|
onChange={(selected) => updateField('product_categories', selected.map(Number))}
|
||||||
|
placeholder={__('Any category')}
|
||||||
|
emptyMessage={__('No categories found')}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Product categories that the coupon will be applied to, or leave blank for all categories')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exclude Categories */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__('Exclude categories')}</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={categories.map((c: any) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
}))}
|
||||||
|
selected={(formData.excluded_product_categories || []).map(String)}
|
||||||
|
onChange={(selected) => updateField('excluded_product_categories', selected.map(Number))}
|
||||||
|
placeholder={__('No categories')}
|
||||||
|
emptyMessage={__('No categories found')}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Product categories that the coupon will not be applied to')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Individual Use */}
|
{/* Individual Use */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
Reference in New Issue
Block a user