Add product applicability controls to promotion forms
Introduces product selection and exclusion controls to both new and edit promotion forms, allowing promotions to target all, specific, or all-but-specific products. Adds a reusable ProductSelector component, updates promotion types to support new fields, and adjusts cookie max-age for authentication. Also adds two new business quotes.
This commit is contained in:
@@ -22,6 +22,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Promotion, PromotionFormData } from '@/lib/types/promotion';
|
||||
import { fetchClient } from '@/lib/api';
|
||||
import ProductSelector from './ProductSelector';
|
||||
|
||||
// Form schema validation with Zod (same as NewPromotionForm)
|
||||
const formSchema = z.object({
|
||||
@@ -47,6 +48,9 @@ const formSchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().nullable().optional(),
|
||||
description: z.string().max(200, 'Description cannot exceed 200 characters').optional(),
|
||||
blacklistedProducts: z.array(z.string()).default([]),
|
||||
applicableProducts: z.enum(['all', 'specific', 'exclude_specific']).default('all'),
|
||||
specificProducts: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
interface EditPromotionFormProps {
|
||||
@@ -78,6 +82,9 @@ export default function EditPromotionForm({ promotion, onSuccess, onCancel }: Ed
|
||||
description: promotion.description,
|
||||
startDate: formatDateForInput(promotion.startDate),
|
||||
endDate: formatDateForInput(promotion.endDate),
|
||||
blacklistedProducts: promotion.blacklistedProducts || [],
|
||||
applicableProducts: promotion.applicableProducts || 'all',
|
||||
specificProducts: promotion.specificProducts || [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -308,6 +315,110 @@ export default function EditPromotionForm({ promotion, onSuccess, onCancel }: Ed
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Product Applicability Section */}
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold">Product Applicability</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicableProducts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">Apply Promotion To</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="edit-all-products" />
|
||||
<label htmlFor="edit-all-products" className="cursor-pointer">All products</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="specific" id="edit-specific-products" />
|
||||
<label htmlFor="edit-specific-products" className="cursor-pointer">Only specific products</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="exclude_specific" id="edit-exclude-products" />
|
||||
<label htmlFor="edit-exclude-products" className="cursor-pointer">All products except specific ones</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show blacklist selector for "all" and "exclude_specific" modes */}
|
||||
{(form.watch('applicableProducts') === 'all' || form.watch('applicableProducts') === 'exclude_specific') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="blacklistedProducts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{form.watch('applicableProducts') === 'all'
|
||||
? 'Exclude Products (Blacklist)'
|
||||
: 'Products to Exclude'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ProductSelector
|
||||
selectedProductIds={field.value}
|
||||
onSelectionChange={field.onChange}
|
||||
placeholder={
|
||||
form.watch('applicableProducts') === 'all'
|
||||
? "Select products to exclude from this promotion..."
|
||||
: "Select additional products to exclude..."
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{form.watch('applicableProducts') === 'all'
|
||||
? 'Select products that should not be eligible for this promotion'
|
||||
: 'Select products to exclude in addition to those selected above'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show specific products selector for "specific" and "exclude_specific" modes */}
|
||||
{(form.watch('applicableProducts') === 'specific' || form.watch('applicableProducts') === 'exclude_specific') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="specificProducts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{form.watch('applicableProducts') === 'specific'
|
||||
? 'Select Specific Products'
|
||||
: 'Products to Exclude'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ProductSelector
|
||||
selectedProductIds={field.value}
|
||||
onSelectionChange={field.onChange}
|
||||
placeholder={
|
||||
form.watch('applicableProducts') === 'specific'
|
||||
? "Select products eligible for this promotion..."
|
||||
: "Select products to exclude..."
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{form.watch('applicableProducts') === 'specific'
|
||||
? 'Only selected products will be eligible for this promotion'
|
||||
: 'Selected products will be excluded from this promotion'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { toast } from '@/components/ui/use-toast';
|
||||
import { PromotionFormData } from '@/lib/types/promotion';
|
||||
import { fetchClient } from '@/lib/api';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import ProductSelector from './ProductSelector';
|
||||
|
||||
// Form schema validation with Zod
|
||||
const formSchema = z.object({
|
||||
@@ -48,6 +49,9 @@ const formSchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().nullable().optional(),
|
||||
description: z.string().max(200, 'Description cannot exceed 200 characters').optional(),
|
||||
blacklistedProducts: z.array(z.string()).default([]),
|
||||
applicableProducts: z.enum(['all', 'specific', 'exclude_specific']).default('all'),
|
||||
specificProducts: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
interface NewPromotionFormProps {
|
||||
@@ -72,6 +76,9 @@ export default function NewPromotionForm({ onSuccess, onCancel }: NewPromotionFo
|
||||
description: '',
|
||||
startDate: new Date().toISOString().split('T')[0], // Today
|
||||
endDate: null,
|
||||
blacklistedProducts: [],
|
||||
applicableProducts: 'all',
|
||||
specificProducts: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -303,6 +310,110 @@ export default function NewPromotionForm({ onSuccess, onCancel }: NewPromotionFo
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Product Applicability Section */}
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold">Product Applicability</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicableProducts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">Apply Promotion To</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="all-products" />
|
||||
<label htmlFor="all-products" className="cursor-pointer">All products</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="specific" id="specific-products" />
|
||||
<label htmlFor="specific-products" className="cursor-pointer">Only specific products</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="exclude_specific" id="exclude-products" />
|
||||
<label htmlFor="exclude-products" className="cursor-pointer">All products except specific ones</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show blacklist selector for "all" and "exclude_specific" modes */}
|
||||
{(form.watch('applicableProducts') === 'all' || form.watch('applicableProducts') === 'exclude_specific') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="blacklistedProducts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{form.watch('applicableProducts') === 'all'
|
||||
? 'Exclude Products (Blacklist)'
|
||||
: 'Products to Exclude'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ProductSelector
|
||||
selectedProductIds={field.value}
|
||||
onSelectionChange={field.onChange}
|
||||
placeholder={
|
||||
form.watch('applicableProducts') === 'all'
|
||||
? "Select products to exclude from this promotion..."
|
||||
: "Select additional products to exclude..."
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{form.watch('applicableProducts') === 'all'
|
||||
? 'Select products that should not be eligible for this promotion'
|
||||
: 'Select products to exclude in addition to those selected above'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show specific products selector for "specific" and "exclude_specific" modes */}
|
||||
{(form.watch('applicableProducts') === 'specific' || form.watch('applicableProducts') === 'exclude_specific') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="specificProducts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{form.watch('applicableProducts') === 'specific'
|
||||
? 'Select Specific Products'
|
||||
: 'Products to Exclude'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ProductSelector
|
||||
selectedProductIds={field.value}
|
||||
onSelectionChange={field.onChange}
|
||||
placeholder={
|
||||
form.watch('applicableProducts') === 'specific'
|
||||
? "Select products eligible for this promotion..."
|
||||
: "Select products to exclude..."
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{form.watch('applicableProducts') === 'specific'
|
||||
? 'Only selected products will be eligible for this promotion'
|
||||
: 'Selected products will be excluded from this promotion'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
|
||||
243
components/dashboard/promotions/ProductSelector.tsx
Normal file
243
components/dashboard/promotions/ProductSelector.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Check, ChevronDown, X, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '@/components/ui/command';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Product } from '@/lib/types/promotion';
|
||||
import { fetchClient } from '@/lib/api';
|
||||
|
||||
interface ProductSelectorProps {
|
||||
selectedProductIds: string[];
|
||||
onSelectionChange: (productIds: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export default function ProductSelector({
|
||||
selectedProductIds,
|
||||
onSelectionChange,
|
||||
placeholder = "Select products...",
|
||||
maxHeight = "200px"
|
||||
}: ProductSelectorProps) {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Handle escape key to close dropdown
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch products when component mounts
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchClient('/promotions/products/all');
|
||||
setProducts(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
// Filter products based on search term
|
||||
const filteredProducts = products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Get selected products for display
|
||||
const selectedProducts = products.filter(product =>
|
||||
selectedProductIds.includes(product._id)
|
||||
);
|
||||
|
||||
const handleProductToggle = (productId: string) => {
|
||||
const updatedSelection = selectedProductIds.includes(productId)
|
||||
? selectedProductIds.filter(id => id !== productId)
|
||||
: [...selectedProductIds, productId];
|
||||
|
||||
onSelectionChange(updatedSelection);
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (productId: string) => {
|
||||
onSelectionChange(selectedProductIds.filter(id => id !== productId));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
className="w-full justify-between h-auto min-h-[40px] p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{selectedProducts.length === 0 ? (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
{selectedProducts.length} product{selectedProducts.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
{/* Dropdown using absolute positioning within relative container */}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-64 overflow-hidden"
|
||||
style={{ minWidth: '300px' }}
|
||||
>
|
||||
{/* Search Header */}
|
||||
<div className="flex items-center gap-2 p-3 border-b bg-muted/30">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search products..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="border-0 bg-transparent p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Products List */}
|
||||
<ScrollArea className="max-h-48">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Loading products...
|
||||
</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{searchTerm ? 'No products found matching your search.' : 'No products found.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredProducts.map((product) => (
|
||||
<div
|
||||
key={product._id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleProductToggle(product._id);
|
||||
}}
|
||||
className="flex items-center gap-3 p-2 hover:bg-accent rounded-sm cursor-pointer transition-colors"
|
||||
>
|
||||
<div className={`h-4 w-4 border rounded-sm flex items-center justify-center ${
|
||||
selectedProductIds.includes(product._id)
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
}`}>
|
||||
{selectedProductIds.includes(product._id) && (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{product.name}</div>
|
||||
{product.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{product.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!product.enabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backdrop to close dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected products display */}
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Selected Products:</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearAll();
|
||||
}}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedProducts.map((product) => (
|
||||
<Badge
|
||||
key={product._id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 pr-1"
|
||||
>
|
||||
<span className="text-xs">{product.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemoveProduct(product._id);
|
||||
}}
|
||||
className="ml-1 hover:bg-secondary-foreground/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user