Files
ember-market-frontend/components/dashboard/promotions/EditPromotionForm.tsx
NotII 2c48ecd2b4 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.
2025-08-07 16:05:31 +01:00

472 lines
17 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Save, X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from '@/components/ui/switch';
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({
code: z.string()
.min(3, 'Code must be at least 3 characters')
.max(20, 'Code cannot exceed 20 characters')
.regex(/^[A-Za-z0-9]+$/, 'Only letters and numbers are allowed'),
discountType: z.enum(['percentage', 'fixed']),
discountValue: z.coerce.number()
.positive('Discount value must be positive')
.refine(val => val <= 100, {
message: 'Percentage discount cannot exceed 100%',
path: ['discountValue'],
// Only validate this rule if discount type is percentage
params: { type: 'percentage' },
}),
minOrderAmount: z.coerce.number()
.min(0, 'Minimum order amount cannot be negative'),
maxUsage: z.coerce.number()
.nullable()
.optional(),
isActive: z.boolean().default(true),
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 {
promotion: Promotion;
onSuccess: () => void;
onCancel: () => void;
}
export default function EditPromotionForm({ promotion, onSuccess, onCancel }: EditPromotionFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [discountType, setDiscountType] = useState<'percentage' | 'fixed'>(promotion.discountType);
// Format dates from ISO to YYYY-MM-DD for input elements
const formatDateForInput = (dateString: string | null) => {
if (!dateString) return '';
return new Date(dateString).toISOString().split('T')[0];
};
// Initialize form with promotion values
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
code: promotion.code,
discountType: promotion.discountType,
discountValue: promotion.discountValue,
minOrderAmount: promotion.minOrderAmount,
maxUsage: promotion.maxUsage,
isActive: promotion.isActive,
description: promotion.description,
startDate: formatDateForInput(promotion.startDate),
endDate: formatDateForInput(promotion.endDate),
blacklistedProducts: promotion.blacklistedProducts || [],
applicableProducts: promotion.applicableProducts || 'all',
specificProducts: promotion.specificProducts || [],
},
});
// Keep local state in sync with form
useEffect(() => {
const subscription = form.watch((value, { name }) => {
if (name === 'discountType') {
setDiscountType(value.discountType as 'percentage' | 'fixed');
}
});
return () => subscription.unsubscribe();
}, [form, form.watch]);
// Form submission handler
async function onSubmit(data: z.infer<typeof formSchema>) {
setIsSubmitting(true);
try {
await fetchClient(`/promotions/${promotion._id}`, {
method: 'PUT',
body: data,
});
toast({
title: 'Success',
description: 'Promotion updated successfully',
});
onSuccess();
} catch (error) {
console.error('Error updating promotion:', error);
// Error toast is already shown by fetchClient
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid grid-cols-1 gap-6">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">Promotion Code</FormLabel>
<FormControl>
<Input
placeholder="SUMMER20"
{...field}
onChange={(e) => field.onChange(e.target.value.toUpperCase())}
className="h-10"
/>
</FormControl>
<FormDescription className="text-xs">
Enter a unique code for your promotion. Only letters and numbers.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-4 gap-6">
<FormField
control={form.control}
name="discountType"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel className="text-sm font-medium">Discount Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value);
setDiscountType(value as 'percentage' | 'fixed');
}}
value={field.value}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="percentage" id="edit-percentage" />
<label htmlFor="edit-percentage" className="cursor-pointer">Percentage (%)</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="fixed" id="edit-fixed" />
<label htmlFor="edit-fixed" className="cursor-pointer">Fixed Amount (£)</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="discountValue"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel className="text-sm font-medium">Discount Value</FormLabel>
<FormControl>
<Input
type="number"
step={discountType === 'percentage' ? '1' : '0.01'}
min={0}
max={discountType === 'percentage' ? 100 : undefined}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
{discountType === 'percentage'
? 'Enter a percentage (1-100%)'
: 'Enter an amount in £'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minOrderAmount"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel className="text-sm font-medium">Minimum Order Amount (£)</FormLabel>
<FormControl>
<Input type="number" step="0.01" min="0" className="h-10" {...field} />
</FormControl>
<FormDescription className="text-xs">
Minimum purchase required
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxUsage"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel className="text-sm font-medium">Maximum Usage Count</FormLabel>
<FormControl>
<Input
type="number"
min="1"
placeholder="Unlimited"
className="h-10"
{...field}
value={field.value === null ? '' : field.value}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? null : parseInt(value, 10));
}}
/>
</FormControl>
<FormDescription className="text-xs">
Leave empty for unlimited usage (currently used: {promotion.usageCount} times)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-6">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">Start Date</FormLabel>
<FormControl>
<Input type="date" className="h-10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">End Date (Optional)</FormLabel>
<FormControl>
<Input
type="date"
className="h-10"
{...field}
value={field.value || ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? null : value);
}}
/>
</FormControl>
<FormDescription className="text-xs">
Leave empty for no expiration
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Active Status</FormLabel>
<FormDescription>
Enable or disable this promotion
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* 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"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Enter a brief description for this promotion"
{...field}
/>
</FormControl>
<FormDescription>
Internal notes about this promotion
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Update Promotion
</>
)}
</Button>
</div>
</form>
</Form>
);
}