Dynamically import ChatDetail, NewChatForm, and ProductSelector components with skeleton loading states for improved performance. Refine product selection logic in promotion forms to show blacklist selector only for 'all' mode and clarify labels and descriptions for better user understanding.
467 lines
17 KiB
TypeScript
467 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 dynamic from 'next/dynamic';
|
|
const ProductSelector = dynamic(() => import('./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 only for "all" mode */}
|
|
{form.watch('applicableProducts') === 'all' && (
|
|
<FormField
|
|
control={form.control}
|
|
name="blacklistedProducts"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium">
|
|
Exclude Products (Blacklist)
|
|
</FormLabel>
|
|
<FormControl>
|
|
<ProductSelector
|
|
selectedProductIds={field.value}
|
|
onSelectionChange={field.onChange}
|
|
placeholder={
|
|
"Select products to exclude from this promotion..."
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Select products that should not be eligible for this promotion
|
|
</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'
|
|
? 'Eligible 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>
|
|
);
|
|
}
|