Files
ember-market-frontend/components/dashboard/promotions/EditPromotionForm.tsx
NotII e2db2d5027 Optimize form loading and update product selection UI
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.
2025-08-08 16:14:41 +01:00

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