Created
February 5, 2025 05:48
-
-
Save ktisakib/b4deaacac56082c631b8388ab68b7450 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
i want select just one image so modify the code | |
"use client" | |
import { useActionState, useOptimistic, useTransition } from "react" | |
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" | |
import { Button } from "@/components/ui/button" | |
import { Plus, X, UploadCloud, QrCode } from "lucide-react" | |
import { Label } from "@/components/ui/label" | |
import { Input } from "@/components/ui/input" | |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" | |
import { useState } from "react" | |
import { CreateProductSchema, createProductSchema } from "@/types/schemas/product-schema" | |
import { createProduct } from "@/actions/product-action" | |
import { z } from "zod" | |
import { toast } from "sonner" | |
import { getFirbaseStorage } from "@/lib/firebase" | |
import { ref, uploadBytes, getDownloadURL } from "firebase/storage" | |
import { convertToWebP } from "@/lib/utils" | |
import { Dropdown } from "@/types/common" | |
import { ProductDropdownCommand } from "@/components/custom-ui/dropdown-command" | |
import { BarcodeScannerDialog } from "./barcode-scanner-dialog"; | |
interface PreviewImage { | |
file: File; | |
previewUrl: string; | |
} | |
export function ProductCreateDialog({ categories }: { categories: Dropdown }) { | |
const [selectedImages, setSelectedImages] = useState<PreviewImage[]>([]); | |
const [productData, setProductData] = useState<CreateProductSchema>({ | |
name: '', | |
description: '', | |
price:0, | |
inventoryCount: 0, | |
category: '', | |
manufacturer: '', | |
sku: '', | |
barcode: '', | |
imageUrl:" , | |
isAvailable: true, | |
}); | |
// const [createProductState, createProductAction, isActionPending] = useActionState(createProduct, null) | |
const [errors, setErrors] = useState<Partial<Record<keyof CreateProductSchema, string>>>({}) | |
const [isPending, startTransition] = useTransition() | |
const [barcode, setBarcode] = useState(''); | |
const [isScanning, setIsScanning] = useState(false); | |
const handleFileSelect = (files: FileList) => { | |
const maxImages = 5; | |
if (selectedImages.length + files.length > maxImages) { | |
toast.error(`Maximum ${maxImages} images allowed`); | |
return; | |
} | |
// Create preview URLs for selected files | |
const newImages = Array.from(files) | |
.filter(file => file.type.startsWith('image/')) | |
.map(file => ({ | |
file, | |
previewUrl: URL.createObjectURL(file) | |
})); | |
setSelectedImages(prev => [...prev, ...newImages]); | |
}; | |
const removeImage = (indexToRemove: number) => { | |
setSelectedImages(prev => { | |
// Revoke the URL of the removed image | |
URL.revokeObjectURL(prev[indexToRemove].previewUrl); | |
return prev.filter((_, index) => index !== indexToRemove); | |
}); | |
}; | |
const updateProductData = (field: keyof CreateProductSchema, value: string | number | boolean | string[]) => { | |
setProductData(prev => ({ ...prev, [field]: value })) | |
} | |
const validateForm = () => { | |
try { | |
createProductSchema.parse(productData); | |
setErrors({}); | |
return true; | |
} catch (error) { | |
if (error instanceof z.ZodError) { | |
setErrors(error.flatten().fieldErrors as Partial<Record<keyof CreateProductSchema, string>>); | |
} | |
return false; | |
} | |
}; | |
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | |
event.preventDefault(); | |
if (!validateForm()) return; | |
startTransition(async () => { | |
try { | |
if (selectedImages.length > 0) { | |
const storage = getFirbaseStorage(); | |
const imageUploadPromises = selectedImages.map(async ({ file }) => { | |
const webpFile = await convertToWebP(file); | |
const storageRef = ref(storage, `products/${Date.now()}-${webpFile.name}`); | |
const snapshot = await uploadBytes(storageRef, webpFile); | |
return await getDownloadURL(snapshot.ref); | |
}); | |
const uploadedUrls = await Promise.all(imageUploadPromises); | |
productData.imageUrls = uploadedUrls; | |
// Cleanup preview URLs | |
selectedImages.forEach(image => URL.revokeObjectURL(image.previewUrl)); | |
setSelectedImages([]); | |
} | |
// Call the action inside startTransition | |
await toast.promise(createProduct(productData), { | |
loading: 'Creating product...', | |
success: () => { | |
setProductData({ | |
name: '', | |
description: '', | |
price: 0, | |
inventoryCount: 0, | |
category: '', | |
manufacturer: '', | |
sku: '', | |
barcode: '', | |
imageUrls: [], | |
isAvailable: true, | |
}); | |
return 'Product created successfully'; | |
}, | |
error: (err) => err.message || 'Failed to create product' | |
}); | |
} catch (error: any) { | |
console.error('Error during product creation:', error); | |
toast.error(error.message || "Failed to create product"); | |
} | |
}); | |
}; | |
const handleManualBarcodeInput = (e: React.ChangeEvent<HTMLInputElement>) => { | |
setBarcode(e.target.value); | |
}; | |
const handleScannedBarcode = (scannedCode: string) => { | |
setProductData(prev => ({ ...prev, barcode: scannedCode })); | |
setIsScanning(false); | |
}; | |
return ( | |
<Dialog> | |
<DialogTrigger asChild> | |
<Button variant="outline" size="sm" className="gap-2"> | |
<span className="hidden md:flex"> Add product</span> | |
<Plus className="size-4" aria-hidden="true" /> | |
</Button> | |
</DialogTrigger> | |
<DialogContent | |
className="md:max-w-2xl max-h-[80vh] max-w-sm rounded-2xl overflow-y-auto" | |
> | |
<DialogHeader> | |
<DialogTitle>Create New Product</DialogTitle> | |
</DialogHeader> | |
<form onSubmit={handleSubmit} className="space-y-6 mt-4"> | |
{/* Product Images - Full width */} | |
<div className="space-y-2 relative col-span-2"> | |
<Label>Product Images</Label> | |
<div className="relative"> | |
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4"> | |
{selectedImages.map(({ previewUrl }, index) => ( | |
<div key={previewUrl} className="relative aspect-square"> | |
<img | |
src={previewUrl} | |
alt={`Preview ${index + 1}`} | |
className="rounded-lg object-cover w-full h-full" | |
/> | |
<button | |
type="button" | |
onClick={() => removeImage(index)} | |
className="absolute -top-2 -right-2 bg-red-500 rounded-full p-1 text-white hover:bg-red-600" | |
> | |
<X className="h-4 w-4" /> | |
</button> | |
</div> | |
))} | |
</div> | |
{selectedImages.length < 5 && ( | |
<div | |
className="border-2 relative border-dashed rounded-lg p-4 text-center cursor-pointer hover:border-gray-400 transition-colors" | |
onDragOver={(e) => e.preventDefault()} | |
onDrop={(e) => { | |
e.preventDefault(); | |
handleFileSelect(e.dataTransfer.files); | |
}} | |
> | |
<input | |
type="file" | |
accept="image/*" | |
multiple | |
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" | |
onChange={(e) => e.target.files && handleFileSelect(e.target.files)} | |
/> | |
<UploadCloud className="mx-auto h-12 w-12 text-gray-400" /> | |
<p className="mt-2 text-sm text-gray-600"> | |
Drag and drop or click to upload ({selectedImages.length}/5) | |
</p> | |
</div> | |
)} | |
</div> | |
{errors.imageUrls && <p className="text-sm text-red-500">{errors.imageUrls}</p>} | |
</div> | |
{/* Two column grid */} | |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
<div className="space-y-2"> | |
<Label htmlFor="name">Product Name</Label> | |
<Input | |
id="name" | |
value={productData.name} | |
onChange={(e) => updateProductData('name', e.target.value)} | |
placeholder="Enter product name" | |
/> | |
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>} | |
</div> | |
<div className="space-y-2"> | |
<Label htmlFor="category">Category</Label> | |
<ProductDropdownCommand | |
categories={categories?.data} | |
selectedCategory={categories?.data?.find(cat => cat.value === productData.category)} | |
onCategorySelect={(category) => updateProductData('category', category.value)} | |
/> | |
{errors.category && <p className="text-sm text-red-500">{errors.category}</p>} | |
</div> | |
<div className="space-y-2"> | |
<Label htmlFor="price">Price</Label> | |
<Input | |
id="price" | |
type="number" | |
step="0.01" | |
min="0" | |
value={productData.price || ""} | |
onChange={(e) => updateProductData('price', e.target.value === "" ? 0 : parseFloat(e.target.value))} | |
placeholder="0.00" | |
/> | |
{errors.price && <p className="text-sm text-red-500">{errors.price}</p>} | |
</div> | |
<div className="space-y-2"> | |
<Label htmlFor="inventoryCount">Inventory Count</Label> | |
<Input | |
id="inventoryCount" | |
type="number" | |
min="0" | |
value={productData.inventoryCount || ""} | |
onChange={(e) => updateProductData('inventoryCount', e.target.value === "" ? 0 : parseInt(e.target.value))} | |
placeholder="0" | |
/> | |
{errors.inventoryCount && <p className="text-sm text-red-500">{errors.inventoryCount}</p>} | |
</div> | |
<div className="space-y-2"> | |
<Label htmlFor="manufacturer">Manufacturer</Label> | |
<Input | |
id="manufacturer" | |
value={productData.manufacturer} | |
onChange={(e) => updateProductData('manufacturer', e.target.value)} | |
placeholder="Manufacturer name" | |
/> | |
{errors.manufacturer && <p className="text-sm text-red-500">{errors.manufacturer}</p>} | |
</div> | |
<div className="space-y-2"> | |
<Label htmlFor="sku">SKU</Label> | |
<Input | |
id="sku" | |
value={productData.sku} | |
onChange={(e) => updateProductData('sku', e.target.value)} | |
placeholder="Stock Keeping Unit" | |
/> | |
{errors.sku && <p className="text-sm text-red-500">{errors.sku}</p>} | |
</div> | |
<BarcodeScannerDialog | |
value={productData.barcode || ''} | |
onChange={(value) => updateProductData('barcode', value)} | |
error={errors.barcode} | |
/> | |
<div className="space-y-2"> | |
<Label htmlFor="description">Description</Label> | |
<Input | |
id="description" | |
value={productData.description} | |
onChange={(e) => updateProductData('description', e.target.value)} | |
placeholder="Product description" | |
/> | |
{errors.description && <p className="text-sm text-red-500">{errors.description}</p>} | |
</div> | |
</div> | |
<Button type="submit" className="w-full cursor-pointer" disabled={isPending}> | |
{isPending ? "Creating..." : "Create Product"} | |
</Button> | |
</form> | |
</DialogContent> | |
</Dialog> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment