From 0f47c08b7a851dc69b6386e0727e88d4de79eb6a Mon Sep 17 00:00:00 2001
From: dwindown
Date: Thu, 20 Nov 2025 15:26:39 +0700
Subject: [PATCH] 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
---
admin-spa/src/lib/api.ts | 2 +
admin-spa/src/routes/Coupons/CouponForm.tsx | 93 +++++++++++++++++++++
2 files changed, 95 insertions(+)
diff --git a/admin-spa/src/lib/api.ts b/admin-spa/src/lib/api.ts
index 67fcac0..9f5dace 100644
--- a/admin-spa/src/lib/api.ts
+++ b/admin-spa/src/lib/api.ts
@@ -97,6 +97,8 @@ export const OrdersApi = {
export const ProductsApi = {
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 = {
diff --git a/admin-spa/src/routes/Coupons/CouponForm.tsx b/admin-spa/src/routes/Coupons/CouponForm.tsx
index ba7ada6..1217bb6 100644
--- a/admin-spa/src/routes/Coupons/CouponForm.tsx
+++ b/admin-spa/src/routes/Coupons/CouponForm.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
import { __ } from '@/lib/i18n';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { Checkbox } from '@/components/ui/checkbox';
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';
interface CouponFormProps {
@@ -31,6 +34,10 @@ export default function CouponForm({
description: initial?.description || '',
date_expires: initial?.date_expires || null,
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_per_user: initial?.usage_limit_per_user || null,
free_shipping: initial?.free_shipping || false,
@@ -39,6 +46,20 @@ export default function CouponForm({
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 handleSubmit = async (e: React.FormEvent) => {
@@ -190,6 +211,78 @@ export default function CouponForm({
+ {/* Products */}
+
+
+
({
+ 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')}
+ />
+
+ {__('Products that the coupon will be applied to, or leave blank for all products')}
+
+
+
+ {/* Exclude Products */}
+
+
+
({
+ 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')}
+ />
+
+ {__('Products that the coupon will not be applied to')}
+
+
+
+ {/* Product Categories */}
+
+
+
({
+ 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')}
+ />
+
+ {__('Product categories that the coupon will be applied to, or leave blank for all categories')}
+
+
+
+ {/* Exclude Categories */}
+
+
+
({
+ 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')}
+ />
+
+ {__('Product categories that the coupon will not be applied to')}
+
+
+
{/* Individual Use */}