daisy-client / src /components /AddItemModal.tsx
Jessedbrown's picture
feat/init-daisy-client
6d643ca
Raw History Blame Contribute Delete
9.75 kB
import { zodResolver } from '@hookform/resolvers/zod';
import { Camera, Search, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCreateClosetItem } from '@/hooks/useClosetQueries';
import { UPLOAD_CONFIG } from '@/lib/config';
import { CLOTHING_CATEGORIES } from '@/types';
const addItemSchema = z.object({
name: z.string().min(1, 'Name is required'),
brand: z.string().min(1, 'Brand is required'),
category: z.string().min(1, 'Category is required'),
});
type AddItemFormData = z.infer<typeof addItemSchema>;
interface AddItemModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AddItemModal({ isOpen, onClose }: AddItemModalProps) {
const [mode, setMode] = useState<'choice' | 'manual' | 'search'>('choice');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const createItemMutation = useCreateClosetItem();
const form = useForm<AddItemFormData>({
resolver: zodResolver(addItemSchema),
defaultValues: {
name: '',
brand: '',
category: '',
},
});
const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.size > UPLOAD_CONFIG.MAX_FILE_SIZE) {
toast.error(
`Image size must be less than ${UPLOAD_CONFIG.MAX_FILE_SIZE_MB}MB`
);
return;
}
if (!file.type.startsWith('image/')) {
toast.error('Please select a valid image file');
return;
}
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const removeImage = () => {
setImageFile(null);
setImagePreview(null);
const fileInput = document.getElementById(
'image-upload'
) as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
const onSubmit = (data: AddItemFormData) => {
createItemMutation.mutate(
{
...data,
imageFile: imageFile ?? undefined,
},
{
onSuccess: () => {
onClose();
form.reset();
setImageFile(null);
setImagePreview(null);
},
}
);
};
const handleClose = () => {
onClose();
setMode('choice');
form.reset();
setImageFile(null);
setImagePreview(null);
};
const handleSearchProducts = () => {
window.location.href = '/search';
handleClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] max-w-md overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'choice'
? 'Add to Closet'
: mode === 'manual'
? 'Add with Photos'
: 'Search Products'}
</DialogTitle>
</DialogHeader>
{mode === 'choice' ? (
<div className="space-y-4">
<div
className="flex cursor-pointer items-center gap-4 rounded-lg border-2 border-dashed border-gray-300 p-6 transition-colors hover:border-gray-400"
onClick={() => setMode('manual')}
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-50">
<Camera className="h-6 w-6 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">Add with Photos</h3>
<p className="text-sm text-gray-600">
Upload photos of items you own
</p>
</div>
</div>
<div
className="flex cursor-pointer items-center gap-4 rounded-lg border-2 border-dashed border-gray-300 p-6 transition-colors hover:border-gray-400"
onClick={handleSearchProducts}
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
<Search className="h-6 w-6 text-green-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">Search Items</h3>
<p className="text-sm text-gray-600">
Find items from brand catalogs
</p>
</div>
</div>
</div>
) : mode === 'manual' ? (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Photo (Optional)</label>
{imagePreview ? (
<div className="relative">
<img
src={imagePreview}
alt="Preview"
className="h-48 w-full rounded-lg border object-cover"
/>
<button
type="button"
onClick={removeImage}
className="absolute right-2 top-2 rounded-full bg-red-500 p-1 text-white transition-colors hover:bg-red-600"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="relative rounded-lg border-2 border-dashed border-gray-300 p-6 text-center transition-colors hover:border-gray-400">
<Upload className="mx-auto mb-2 h-8 w-8 text-gray-400" />
<p className="mb-2 text-sm text-gray-600">
Click to upload or drag and drop
</p>
<p className="text-xs text-gray-500">
PNG, JPG, HEIC up to {UPLOAD_CONFIG.MAX_FILE_SIZE_MB}MB
</p>
<input
id="image-upload"
type="file"
accept="image/*"
onChange={handleImageChange}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</div>
)}
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Classic White Shirt"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="e.g., Uniqlo" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{CLOTHING_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => setMode('choice')}
disabled={createItemMutation.isPending}
>
Back
</Button>
<Button
type="submit"
disabled={createItemMutation.isPending}
>
{createItemMutation.isPending ? 'Adding...' : 'Add to Closet'}
</Button>
</DialogFooter>
</form>
</Form>
) : null}
{mode === 'choice' && (
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}