jaecoo-kelapagading/components/form/product/create-product-form.tsx

528 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Upload, Plus, UploadCloud } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Label } from "@radix-ui/react-dropdown-menu";
import Image from "next/image";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { createProduct } from "@/service/product";
import withReactContent from "sweetalert2-react-content";
import Swal from "sweetalert2";
import { useRouter } from "next/navigation";
import { loading } from "@/config/swal";
const formSchema = z.object({
name: z.string().min(1, "Nama produk wajib diisi"),
variant: z.string().min(1, "Tipe varian wajib diisi"),
price: z.coerce.number().min(1, "Harga produk wajib diisi"),
banner: z.instanceof(FileList).optional(),
});
export default function AddProductForm() {
const [colors, setColors] = useState<
{ id: number; name: string; file: File | null }[]
>([{ id: 1, name: "", file: null }]);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [specs, setSpecs] = useState<
{ id: number; title: string; images: string[]; files: File[] }[]
>([{ id: 1, title: "", images: [], files: [] }]);
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [uploadTarget, setUploadTarget] = useState<{
type: "spec";
index: number;
} | null>(null);
const fileInputId = "spec-upload-input";
const fileInputRef = useRef<HTMLInputElement | null>(null);
const isUploadingRef = useRef(false);
const [file, setFile] = useState<File | null>(null);
const router = useRouter();
const MySwal = withReactContent(Swal);
const [priceDisplay, setPriceDisplay] = useState("");
const handleFileChange = (e: any) => {
const selected = e.target.files[0];
if (selected) setFile(selected);
};
const handleAddSpec = () => {
setSpecs((prev) => [
...prev,
{ id: prev.length + 1, title: "", images: [], files: [] },
]);
};
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
const handleSpecTitleChange = (index: number, value: string) => {
const updated = [...specs];
updated[index].title = value;
setSpecs(updated);
};
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
const selectedFile = input.files?.[0];
if (!selectedFile || !uploadTarget) return;
// 🔒 CEGAH DOUBLE EVENT
if (isUploadingRef.current) return;
isUploadingRef.current = true;
setSpecs((prev) => {
const updated = [...prev];
const spec = updated[uploadTarget.index];
// max 5 gambar
if (spec.files.length >= 5) {
isUploadingRef.current = false;
return prev;
}
// cegah file sama
if (
spec.files.some(
(f) => f.name === selectedFile.name && f.size === selectedFile.size,
)
) {
isUploadingRef.current = false;
return prev;
}
const previewUrl = URL.createObjectURL(selectedFile);
spec.images = [...spec.images, previewUrl];
spec.files = [...spec.files, selectedFile];
return updated;
});
// reset input
input.value = "";
setIsUploadDialogOpen(false);
// release lock
setTimeout(() => {
isUploadingRef.current = false;
}, 0);
};
const onSubmit = async (data: z.infer<typeof formSchema>) => {
try {
loading();
const formData = new FormData();
formData.append("title", data.name);
formData.append("variant", data.variant);
formData.append("price", data.price.toString());
formData.append("status", "1");
formData.append("is_active", "1");
// banner
if (file) {
formData.append("file", file);
}
// 🔥 colors JSON (object)
const colorsPayload = colors.map((c) => ({
name: c.name,
}));
formData.append("colors", JSON.stringify(colorsPayload));
// 🔥 color images
colors.forEach((c) => {
if (c.file) {
formData.append("color_images", c.file);
}
});
// 🔥 specifications JSON (include image count for backend processing)
const specificationsPayload = specs.map((s) => ({
title: s.title,
imageCount: s.files.length,
}));
formData.append("specifications", JSON.stringify(specificationsPayload));
// 🔥 specification images (multiple files per spec)
specs.forEach((s) => {
s.files.forEach((file) => {
formData.append("specification_images", file);
});
});
await createProduct(formData);
successSubmit("/admin/product");
} catch (err) {
console.error(err);
alert("Gagal mengirim produk");
}
};
function successSubmit(redirect: any) {
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "OK",
}).then((result) => {
if (result.isConfirmed) {
router.push(redirect);
}
});
}
const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value.replace(/\D/g, "");
setPriceDisplay(formatRupiah(rawValue));
setValue("price", rawValue ? Number(rawValue) : 0);
};
const handleAddColor = () => {
setColors((prev) => [
...prev,
{ id: prev.length + 1, name: "", file: null },
]);
};
const handleColorFileChange = (
index: number,
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0] || null;
const updated = [...colors];
updated[index].file = file;
setColors(updated);
};
const formatRupiah = (value: string) => {
const number = value.replace(/\D/g, "");
return number ? `Rp ${Number(number).toLocaleString("id-ID")}` : "";
};
return (
<Card className="w-full max-w-full mx-auto shadow-md border-none">
<CardHeader>
<CardTitle className="text-xl font-bold text-teal-900">
Form Tambah Produk Baru
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label>Nama Produk *</Label>
<Input placeholder="Masukkan Nama Produk" {...register("name")} />
{errors.name && (
<p className="text-sm text-red-500 mt-1">
{errors.name.message}
</p>
)}
</div>
<div>
<Label>Tipe Varian *</Label>
<Input
placeholder="Contoh: AWD, SHS, EV"
{...register("variant")}
/>
{errors.variant && (
<p className="text-sm text-red-500 mt-1">
{errors.variant.message}
</p>
)}
</div>
<div>
<Label>Harga Produk *</Label>
<Input
placeholder="Masukkan Harga Produk"
value={priceDisplay}
onChange={handlePriceChange}
/>
{errors.price && (
<p className="text-sm text-red-500 mt-1">
{errors.price.message}
</p>
)}
</div>
</div>
<div>
<Label className="text-gray-700">
Upload Banner <span className="text-red-500">*</span>
</Label>
<label
htmlFor="uploadFile"
className="mt-1 border-2 border-dashed border-gray-300 rounded-xl p-6 flex flex-col items-center justify-center text-gray-500 cursor-pointer hover:border-[#1F6779]/50 transition"
>
<UploadCloud className="w-10 h-10 text-[#1F6779] mb-2" />
<p className="text-sm font-medium">
Klik untuk upload atau drag & drop
</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG (max 2 MB)</p>
<input
id="uploadFile"
type="file"
accept="image/png, image/jpeg"
className="hidden"
onChange={handleFileChange}
/>
{file && (
<p className="mt-2 text-xs text-[#1F6779] font-medium">
{file.name}
</p>
)}
</label>
</div>
{/* Upload Produk */}
<div>
<Label>Upload Produk *</Label>
{colors.map((color, index) => (
<div
key={color.id}
className="border p-4 rounded-lg mt-4 space-y-4"
>
<Label className="text-sm font-semibold">
Pilih Warna {index + 1}
</Label>
<Input
value={color.name}
onChange={(e) => {
const updated = [...colors];
updated[index].name = e.target.value;
setColors(updated);
}}
/>
{/* Pilihan Warna */}
<div className="flex flex-wrap gap-2">
{[
"#1E4E52",
"#597E8D",
"#6B6B6B",
"#BEBEBE",
"#E2E2E2",
"#F4F4F4",
"#FFFFFF",
"#F9360A",
"#9A2A00",
"#7A1400",
"#4B0200",
"#B48B84",
"#FFA598",
].map((colorCode) => (
<button
key={colorCode}
type="button"
onClick={() => {
const updated = [...colors];
updated[index].name = colorCode; // 🔥 INI KUNCINYA
setColors(updated);
setSelectedColor(colorCode);
}}
className={`w-8 h-8 rounded-full border-2 transition ${
selectedColor === colorCode
? "border-teal-700 scale-110"
: "border-gray-200"
}`}
style={{ backgroundColor: colorCode }}
/>
))}
</div>
{/* Upload Foto Warna */}
<div>
<label
htmlFor={`color-file-${index}`}
className="border-2 border-dashed rounded-lg flex flex-col items-center justify-center py-6 cursor-pointer hover:bg-gray-50 transition"
>
<Upload className="w-6 h-6 text-gray-400 mb-2" />
<p className="text-sm text-gray-500 text-center">
Klik untuk upload foto mobil warna ini
</p>
<p className="text-xs text-gray-400">PNG, JPG (max 5 MB)</p>
<input
id={`color-file-${index}`}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => handleColorFileChange(index, e)}
/>
</label>
{color.file && (
<p className="text-xs text-teal-700 mt-2">
{color.file.name}
</p>
)}
</div>
</div>
))}
<Button
type="button"
onClick={handleAddColor}
className="w-full bg-teal-800 hover:bg-teal-900 text-white mt-4"
>
<Plus className="w-4 h-4 mr-2" /> Tambah Warna Baru
</Button>
</div>
<div className="mt-8">
<Label className="font-semibold text-lg text-teal-900">
Spesifikasi Produk <span className="text-red-500">*</span>
</Label>
{specs.map((spec, index) => (
<div
key={spec.id}
className="mt-6 border-2 rounded-lg border-black p-3"
>
<Label className="text-sm font-semibold">
Judul Spesifikasi {index + 1}
</Label>
<Input
placeholder="Contoh: Mesin Turbo 1.6L"
className="mt-1"
value={spec.title}
onChange={(e) => handleSpecTitleChange(index, e.target.value)}
/>
<Label className="text-sm font-semibold mt-4 block">
Foto Spesifikasi {index + 1}
</Label>
<div className="flex flex-wrap gap-4 mt-2">
{spec.images.map((img, i) => (
<div key={i} className="relative">
<Image
src={img}
width={120}
height={120}
alt="spec"
className="rounded-lg border object-cover"
/>
<button
type="button"
onClick={() => {
setSpecs((prev) => {
const updated = [...prev];
updated[index].images.splice(i, 1);
updated[index].files.splice(i, 1);
return updated;
});
}}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
>
×
</button>
</div>
))}
<Button
type="button"
className="bg-teal-800 hover:bg-teal-900 text-white"
onClick={() => {
setUploadTarget({ type: "spec", index });
setIsUploadDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-2" />
Tambah Foto
</Button>
</div>
<div className="my-4 border-b"></div>
</div>
))}
<Button
type="button"
onClick={handleAddSpec}
className="w-full bg-teal-800 hover:bg-teal-900 text-white py-3 rounded-lg mt-6 flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
Tambahkan Spesifikasi Baru
</Button>
</div>
<Button
type="submit"
className=" bg-teal-800 hover:bg-teal-900 text-white mt-6"
>
Submit
</Button>
</form>
</CardContent>
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-teal-900 font-semibold">
Upload File
</DialogTitle>
</DialogHeader>
<div className="border-2 border-dashed rounded-xl p-8 text-center">
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
<p className="text-sm text-gray-500">
Klik tombol di bawah untuk memilih file
</p>
<p className="text-xs text-gray-400">PNG, JPG (max 2 MB)</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileSelected}
/>
<DialogFooter className="flex gap-3 pt-4">
<Button
variant="secondary"
onClick={() => setIsUploadDialogOpen(false)}
>
Batal
</Button>
<Button
onClick={() => fileInputRef.current?.click()}
className="bg-teal-800 hover:bg-teal-900 text-white"
>
Pilih File
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}