386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import { useForm, type Resolver } from "react-hook-form"
|
||
import { zodResolver } from "@hookform/resolvers/zod"
|
||
import * as z from "zod"
|
||
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 { Textarea } from "@/components/ui/textarea"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import { Switch } from "@/components/ui/switch"
|
||
import { useRouter } from "next/navigation"
|
||
import { toast } from "sonner"
|
||
import { Loader2, X, UploadCloud } from "lucide-react"
|
||
import imageCompression from 'browser-image-compression'
|
||
import { createClient } from "@/lib/supabase-browser"
|
||
import Image from "next/image"
|
||
|
||
import { createProduct, updateProduct, deleteProductImage } from "@/app/(dashboard)/dashboard/products/actions"
|
||
|
||
const productSchema = z.object({
|
||
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
|
||
product_code: z.string().optional(),
|
||
category: z.string().min(1, "Kategori seçiniz"),
|
||
description: z.string().optional(),
|
||
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
|
||
image_url: z.string().optional(),
|
||
is_active: z.boolean().default(true),
|
||
images: z.array(z.string()).optional()
|
||
})
|
||
|
||
type ProductFormValues = z.infer<typeof productSchema>
|
||
|
||
|
||
// Define the shape of data coming from Supabase
|
||
interface Product {
|
||
id: number
|
||
name: string
|
||
product_code?: string | null
|
||
category: string
|
||
description: string | null
|
||
price: number
|
||
image_url: string | null
|
||
created_at: string
|
||
is_active?: boolean
|
||
// images? we might need to fetch them separately if they are in another table,
|
||
// but for now let's assume update passes them if fetched, or we can handle it later.
|
||
// Ideally the server component fetches relation.
|
||
}
|
||
|
||
interface ProductFormProps {
|
||
initialData?: Product
|
||
}
|
||
|
||
export function ProductForm({ initialData }: ProductFormProps) {
|
||
const router = useRouter()
|
||
const [loading, setLoading] = useState(false)
|
||
const [uploading, setUploading] = useState(false)
|
||
const [previewImages, setPreviewImages] = useState<string[]>(
|
||
initialData?.image_url ? [initialData.image_url] : []
|
||
)
|
||
// Note: initialData probably only has single image_url field unless we updated the fetch query.
|
||
// For MVP phase 1, we just sync with image_url or expect 'images' prop if we extended it.
|
||
// I will add a local state for images.
|
||
|
||
const form = useForm<ProductFormValues>({
|
||
resolver: zodResolver(productSchema) as Resolver<ProductFormValues>,
|
||
defaultValues: initialData ? {
|
||
name: initialData.name,
|
||
product_code: initialData.product_code || "",
|
||
category: initialData.category,
|
||
description: initialData.description || "",
|
||
price: initialData.price,
|
||
image_url: initialData.image_url || "",
|
||
is_active: initialData.is_active ?? true,
|
||
images: initialData.image_url ? [initialData.image_url] : []
|
||
} : {
|
||
name: "",
|
||
product_code: "",
|
||
category: "",
|
||
description: "",
|
||
price: 0,
|
||
image_url: "",
|
||
is_active: true,
|
||
images: []
|
||
},
|
||
})
|
||
|
||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = event.target.files
|
||
if (!files || files.length === 0) return
|
||
|
||
setUploading(true)
|
||
const supabase = createClient()
|
||
const uploadedUrls: string[] = [...form.getValues("images") || []]
|
||
|
||
try {
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i]
|
||
|
||
// Compression
|
||
const options = {
|
||
maxSizeMB: 1, // Max 1MB
|
||
maxWidthOrHeight: 1920,
|
||
useWebWorker: true
|
||
}
|
||
|
||
let compressedFile = file
|
||
try {
|
||
compressedFile = await imageCompression(file, options)
|
||
} catch (error) {
|
||
console.error("Compression error:", error)
|
||
// Fallback to original
|
||
}
|
||
|
||
// Upload
|
||
const fileExt = file.name.split('.').pop()
|
||
const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`
|
||
const filePath = `products/${fileName}`
|
||
|
||
const { error: uploadError } = await supabase.storage
|
||
.from('products') // Assuming 'products' bucket exists
|
||
.upload(filePath, compressedFile)
|
||
|
||
if (uploadError) {
|
||
console.error(uploadError)
|
||
toast.error(`Resim yüklenemedi: ${file.name}`)
|
||
continue
|
||
}
|
||
|
||
// Get URL
|
||
const { data } = supabase.storage.from('products').getPublicUrl(filePath)
|
||
uploadedUrls.push(data.publicUrl)
|
||
}
|
||
|
||
// Update form
|
||
form.setValue("images", uploadedUrls)
|
||
// Set first image as main
|
||
if (uploadedUrls.length > 0) {
|
||
form.setValue("image_url", uploadedUrls[0])
|
||
}
|
||
setPreviewImages(uploadedUrls)
|
||
|
||
} catch {
|
||
toast.error("Yükleme sırasında hata oluştu")
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
const removeImage = async (index: number) => {
|
||
const imageToDelete = previewImages[index]
|
||
|
||
// If editing an existing product and image is from server (starts with http/https usually)
|
||
if (initialData && imageToDelete.startsWith("http")) {
|
||
const result = await deleteProductImage(imageToDelete, initialData.id)
|
||
if (!result.success) {
|
||
toast.error("Resim silinirken hata oluştu")
|
||
return
|
||
}
|
||
toast.success("Resim silindi")
|
||
}
|
||
|
||
const currentImages = [...form.getValues("images") || []]
|
||
// Filter out the deleted image URL if it matches
|
||
const newImages = currentImages.filter(url => url !== imageToDelete)
|
||
|
||
form.setValue("images", newImages)
|
||
|
||
if (newImages.length > 0) {
|
||
form.setValue("image_url", newImages[0])
|
||
} else {
|
||
form.setValue("image_url", "")
|
||
}
|
||
setPreviewImages(newImages)
|
||
}
|
||
|
||
async function onSubmit(data: ProductFormValues) {
|
||
try {
|
||
setLoading(true)
|
||
|
||
let result
|
||
if (initialData) {
|
||
result = await updateProduct(initialData.id, data)
|
||
} else {
|
||
result = await createProduct(data)
|
||
}
|
||
|
||
if (!result.success) {
|
||
toast.error(result.error || "Bir hata oluştu")
|
||
return
|
||
}
|
||
|
||
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
|
||
router.push("/dashboard/products")
|
||
router.refresh()
|
||
} catch {
|
||
toast.error("Bir aksilik oldu")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
|
||
|
||
<div className="flex items-center justify-between">
|
||
<FormField
|
||
control={form.control}
|
||
name="is_active"
|
||
render={({ field }) => (
|
||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm w-full max-w-xs">
|
||
<div className="space-y-0.5">
|
||
<FormLabel className="text-base">Aktif Durum</FormLabel>
|
||
<FormDescription>
|
||
Ürün sitede görüntülensin mi?
|
||
</FormDescription>
|
||
</div>
|
||
<FormControl>
|
||
<Switch
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="name"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Ürün Adı</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="Çelik Kasa Model X" {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
<FormField
|
||
control={form.control}
|
||
name="product_code"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Ürün Kodu</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="KOD-123" {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="category"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Kategori</FormLabel>
|
||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Kategori seçin" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectItem value="ev">Ev Tipi</SelectItem>
|
||
<SelectItem value="ofis">Ofis Tipi</SelectItem>
|
||
<SelectItem value="otel">Otel Kasası</SelectItem>
|
||
<SelectItem value="ozel">Özel Üretim</SelectItem>
|
||
<SelectItem value="diger">Diğer</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="price"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Fiyat (₺)</FormLabel>
|
||
<FormControl>
|
||
<Input type="number" placeholder="0.00" {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className="space-y-4">
|
||
<FormLabel>Ürün Görselleri</FormLabel>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
{previewImages.map((url, i) => (
|
||
<div key={i} className="relative aspect-square border rounded-md overflow-hidden group">
|
||
<Image
|
||
src={url}
|
||
alt="Preview"
|
||
fill
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeImage(i)}
|
||
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<label className="flex flex-col items-center justify-center w-full aspect-square border-2 border-dashed rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||
{uploading ? (
|
||
<Loader2 className="w-8 h-8 text-gray-500 animate-spin" />
|
||
) : (
|
||
<UploadCloud className="w-8 h-8 text-gray-500" />
|
||
)}
|
||
<p className="mb-2 text-sm text-gray-500">Resim Yükle</p>
|
||
</div>
|
||
<input
|
||
type="file"
|
||
className="hidden"
|
||
multiple
|
||
accept="image/*"
|
||
onChange={handleImageUpload}
|
||
disabled={uploading}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<FormDescription>
|
||
Birden fazla resim seçebilirsiniz. Resimler otomatik olarak sıkıştırılacaktır.
|
||
</FormDescription>
|
||
</div>
|
||
|
||
{/* Hidden input for main image url fallback if needed */}
|
||
<input type="hidden" {...form.register("image_url")} />
|
||
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="description"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Açıklama</FormLabel>
|
||
<FormControl>
|
||
<Textarea placeholder="Ürün özellikleri..." {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<Button type="submit" disabled={loading || uploading}>
|
||
{(loading || uploading) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{initialData ? "Güncelle" : "Oluştur"}
|
||
</Button>
|
||
</form>
|
||
</Form>
|
||
)
|
||
}
|