first commit
This commit is contained in:
419
components/admin/ProductForm.tsx
Normal file
419
components/admin/ProductForm.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Save, Upload, X, ImageIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import MediaSelector, { MediaFile } from '@/components/admin/MediaSelector'
|
||||
|
||||
interface Product {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
discount: number
|
||||
images: string[]
|
||||
stock: number
|
||||
manageStock: boolean
|
||||
sku: string
|
||||
isActive: boolean
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product
|
||||
}
|
||||
|
||||
export function ProductForm({ product }: ProductFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [formData, setFormData] = useState<Product>({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount: 0,
|
||||
images: [],
|
||||
stock: 0,
|
||||
manageStock: true,
|
||||
sku: '',
|
||||
isActive: true,
|
||||
categoryId: '',
|
||||
...product
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setCategoriesLoading(true)
|
||||
const response = await fetch('/api/categories')
|
||||
const data = await response.json()
|
||||
// Handle the new API response structure
|
||||
setCategories(data.categories || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
setCategories([]) // Fallback to empty array
|
||||
} finally {
|
||||
setCategoriesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof Product, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleAddImageUrl = () => {
|
||||
if (!imageUrl.trim()) {
|
||||
toast.error('Please enter a valid image URL')
|
||||
return
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(imageUrl)
|
||||
} catch {
|
||||
toast.error('Please enter a valid URL')
|
||||
return
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, imageUrl.trim()]
|
||||
}))
|
||||
|
||||
setImageUrl('')
|
||||
toast.success('Image URL added')
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const url = product
|
||||
? `/api/admin/products/${product.id}`
|
||||
: '/api/admin/products'
|
||||
|
||||
const method = product ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save product')
|
||||
|
||||
toast.success(`Product ${product ? 'updated' : 'created'} successfully`)
|
||||
router.push('/admin/products')
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${product ? 'update' : 'create'} product`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/admin/products">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Products
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : 'Save Product'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Product Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sku">SKU</Label>
|
||||
<Input
|
||||
id="sku"
|
||||
value={formData.sku}
|
||||
onChange={(e) => handleInputChange('sku', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
value={formData.categoryId}
|
||||
onValueChange={(value) => handleInputChange('categoryId', value)}
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={
|
||||
categoriesLoading
|
||||
? "Loading categories..."
|
||||
: categories.length === 0
|
||||
? "No categories available"
|
||||
: "Select category"
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!categoriesLoading && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Media Selector Section */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Product Images</Label>
|
||||
<MediaSelector
|
||||
allowedTypes={['image/*']}
|
||||
onSelect={(file: MediaFile) => {
|
||||
const imageUrl = file.url
|
||||
if (imageUrl && typeof imageUrl === 'string' && imageUrl.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, imageUrl]
|
||||
}))
|
||||
toast.success('Image added successfully')
|
||||
} else {
|
||||
toast.error('Invalid file selected - no valid URL found')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg hover:border-gray-400 cursor-pointer">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>Browse & Upload Images</span>
|
||||
</div>
|
||||
</MediaSelector>
|
||||
<p className="text-xs text-gray-500">
|
||||
Browse existing images or upload new ones. Supports JPG, PNG, WebP formats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL Input Section (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Add Image URL (Optional)</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddImageUrl()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddImageUrl}
|
||||
disabled={!imageUrl.trim()}
|
||||
>
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add images from external URLs (e.g., Unsplash, your CDN, etc.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image Preview Grid */}
|
||||
{formData.images.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Image Preview</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{formData.images.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Product image ${index + 1}`}
|
||||
width={150}
|
||||
height={150}
|
||||
className="rounded-lg object-cover w-full h-32 border"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = '/placeholder-image.jpg'; // fallback image
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg truncate">
|
||||
{image.startsWith('/products/') ? 'Uploaded' : 'External URL'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.images.length === 0 && (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">No images added yet</p>
|
||||
<p className="text-xs text-gray-400">Upload files or add URLs to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pricing & Inventory</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="price">Price (₹)</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={(e) => handleInputChange('price', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="discount">Discount (%)</Label>
|
||||
<Input
|
||||
id="discount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.discount}
|
||||
onChange={(e) => handleInputChange('discount', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label htmlFor="manageStock">Manage Stock</Label>
|
||||
<Switch
|
||||
id="manageStock"
|
||||
checked={formData.manageStock}
|
||||
onCheckedChange={(checked) => handleInputChange('manageStock', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Enable this to track inventory levels for this product
|
||||
</p>
|
||||
|
||||
{formData.manageStock && (
|
||||
<div>
|
||||
<Label htmlFor="stock">Stock Quantity</Label>
|
||||
<Input
|
||||
id="stock"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.stock}
|
||||
onChange={(e) => handleInputChange('stock', parseInt(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isActive">Active Status</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{formData.isActive ? 'Product is visible to customers' : 'Product is hidden from customers'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user