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:
NotII
2025-08-07 16:05:31 +01:00
parent db1ebcb19d
commit 2c48ecd2b4
8 changed files with 584 additions and 86 deletions

View File

@@ -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"

View File

@@ -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"

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