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

528 lines
17 KiB
TypeScript
Raw Normal View History

2025-11-18 06:56:39 +00:00
"use client";
2026-01-28 18:19:14 +00:00
import { useRef, useState } from "react";
2025-11-18 06:56:39 +00:00
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";
2025-11-18 06:56:39 +00:00
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";
2026-01-28 18:19:14 +00:00
import { loading } from "@/config/swal";
2025-11-18 06:56:39 +00:00
const formSchema = z.object({
name: z.string().min(1, "Nama produk wajib diisi"),
variant: z.string().min(1, "Tipe varian wajib diisi"),
2025-11-20 06:57:16 +00:00
price: z.coerce.number().min(1, "Harga produk wajib diisi"),
2025-11-18 06:56:39 +00:00
banner: z.instanceof(FileList).optional(),
});
export default function AddProductForm() {
2026-01-26 04:26:26 +00:00
const [colors, setColors] = useState<
{ id: number; name: string; file: File | null }[]
>([{ id: 1, name: "", file: null }]);
2025-11-18 06:56:39 +00:00
const [selectedColor, setSelectedColor] = useState<string | null>(null);
2026-01-27 17:39:40 +00:00
const [specs, setSpecs] = useState<
{ id: number; title: string; images: string[]; files: File[] }[]
>([{ id: 1, title: "", images: [], files: [] }]);
2026-01-28 18:19:14 +00:00
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [uploadTarget, setUploadTarget] = useState<{
type: "spec";
index: number;
} | null>(null);
2026-01-28 18:19:14 +00:00
const fileInputId = "spec-upload-input";
2026-01-28 18:19:14 +00:00
const fileInputRef = useRef<HTMLInputElement | null>(null);
const isUploadingRef = useRef(false);
2026-01-27 17:39:40 +00:00
2025-11-18 06:56:39 +00:00
const [file, setFile] = useState<File | null>(null);
const router = useRouter();
const MySwal = withReactContent(Swal);
2026-01-19 11:25:14 +00:00
const [priceDisplay, setPriceDisplay] = useState("");
2025-11-18 06:56:39 +00:00
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: [] },
]);
};
2025-11-18 06:56:39 +00:00
const {
register,
handleSubmit,
2026-01-19 11:25:14 +00:00
setValue,
2025-11-18 06:56:39 +00:00
formState: { errors },
} = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
2026-01-27 17:39:40 +00:00
const handleSpecTitleChange = (index: number, value: string) => {
const updated = [...specs];
updated[index].title = value;
setSpecs(updated);
};
2026-01-28 18:19:14 +00:00
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
const selectedFile = input.files?.[0];
2026-01-28 18:19:14 +00:00
if (!selectedFile || !uploadTarget) return;
2026-01-28 18:19:14 +00:00
// 🔒 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;
}
2026-01-28 18:19:14 +00:00
// 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);
2026-01-28 18:19:14 +00:00
// release lock
setTimeout(() => {
isUploadingRef.current = false;
}, 0);
2026-01-27 17:39:40 +00:00
};
2025-11-18 06:56:39 +00:00
const onSubmit = async (data: z.infer<typeof formSchema>) => {
try {
2026-01-28 18:19:14 +00:00
loading();
2025-11-18 06:56:39 +00:00
const formData = new FormData();
formData.append("title", data.name);
formData.append("variant", data.variant);
formData.append("price", data.price.toString());
2026-01-26 04:26:26 +00:00
formData.append("status", "1");
formData.append("is_active", "1");
// banner
2025-11-18 06:56:39 +00:00
if (file) {
formData.append("file", file);
}
2026-01-26 04:26:26 +00:00
// 🔥 colors JSON (object)
const colorsPayload = colors.map((c) => ({
name: c.name,
}));
formData.append("colors", JSON.stringify(colorsPayload));
2025-11-18 06:56:39 +00:00
2026-01-26 04:26:26 +00:00
// 🔥 color images
2026-01-27 08:18:24 +00:00
colors.forEach((c) => {
2026-01-26 04:26:26 +00:00
if (c.file) {
2026-01-27 08:18:24 +00:00
formData.append("color_images", c.file);
2026-01-26 04:26:26 +00:00
}
});
// 🔥 specifications JSON (include image count for backend processing)
2026-01-27 17:39:40 +00:00
const specificationsPayload = specs.map((s) => ({
title: s.title,
imageCount: s.files.length,
2026-01-27 17:39:40 +00:00
}));
formData.append("specifications", JSON.stringify(specificationsPayload));
// 🔥 specification images (multiple files per spec)
2026-01-27 17:39:40 +00:00
specs.forEach((s) => {
s.files.forEach((file) => {
formData.append("specification_images", file);
});
2026-01-27 17:39:40 +00:00
});
2026-01-26 04:26:26 +00:00
await createProduct(formData);
2025-11-18 06:56:39 +00:00
successSubmit("/admin/product");
2026-01-26 04:26:26 +00:00
} catch (err) {
console.error(err);
2025-11-18 06:56:39 +00:00
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);
}
});
}
2026-01-19 11:25:14 +00:00
const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value.replace(/\D/g, "");
setPriceDisplay(formatRupiah(rawValue));
setValue("price", rawValue ? Number(rawValue) : 0);
};
2025-11-18 06:56:39 +00:00
const handleAddColor = () => {
2026-01-26 04:26:26 +00:00
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);
2025-11-18 06:56:39 +00:00
};
2026-01-19 11:25:14 +00:00
const formatRupiah = (value: string) => {
const number = value.replace(/\D/g, "");
return number ? `Rp ${Number(number).toLocaleString("id-ID")}` : "";
};
2025-11-18 06:56:39 +00:00
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"
2026-01-19 11:25:14 +00:00
value={priceDisplay}
onChange={handlePriceChange}
2025-11-18 06:56:39 +00:00
/>
2026-01-19 11:25:14 +00:00
2025-11-18 06:56:39 +00:00
{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>
2026-01-27 08:18:24 +00:00
<Input
value={color.name}
onChange={(e) => {
const updated = [...colors];
updated[index].name = e.target.value;
setColors(updated);
}}
/>
2025-11-18 06:56:39 +00:00
{/* 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"
2026-01-27 08:18:24 +00:00
onClick={() => {
const updated = [...colors];
updated[index].name = colorCode; // 🔥 INI KUNCINYA
setColors(updated);
setSelectedColor(colorCode);
}}
2025-11-18 06:56:39 +00:00
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>
2026-01-26 04:26:26 +00:00
<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"
>
2025-11-18 06:56:39 +00:00
<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>
2026-01-26 04:26:26 +00:00
2025-11-18 06:56:39 +00:00
<input
2026-01-26 04:26:26 +00:00
id={`color-file-${index}`}
2025-11-18 06:56:39 +00:00
type="file"
accept="image/png,image/jpeg"
className="hidden"
2026-01-26 04:26:26 +00:00
onChange={(e) => handleColorFileChange(index, e)}
2025-11-18 06:56:39 +00:00
/>
2026-01-26 04:26:26 +00:00
</label>
{color.file && (
<p className="text-xs text-teal-700 mt-2">
{color.file.name}
</p>
)}
2025-11-18 06:56:39 +00:00
</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) => (
2026-01-28 18:19:14 +00:00
<div
key={spec.id}
className="mt-6 border-2 rounded-lg border-black p-3"
>
2025-11-18 06:56:39 +00:00
<Label className="text-sm font-semibold">
Judul Spesifikasi {index + 1}
</Label>
<Input
2026-01-27 17:39:40 +00:00
placeholder="Contoh: Mesin Turbo 1.6L"
2025-11-18 06:56:39 +00:00
className="mt-1"
2026-01-27 17:39:40 +00:00
value={spec.title}
onChange={(e) => handleSpecTitleChange(index, e.target.value)}
2025-11-18 06:56:39 +00:00
/>
<Label className="text-sm font-semibold mt-4 block">
Foto Spesifikasi {index + 1}
</Label>
2026-01-27 17:39:40 +00:00
<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>
2025-11-18 06:56:39 +00:00
</div>
))}
<Button
2025-11-18 06:56:39 +00:00
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>
2025-11-18 06:56:39 +00:00
</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>
2026-01-28 18:19:14 +00:00
<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">
2026-01-28 18:19:14 +00:00
Klik tombol di bawah untuk memilih file
</p>
<p className="text-xs text-gray-400">PNG, JPG (max 2 MB)</p>
</div>
2026-01-28 18:19:14 +00:00
<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
2026-01-28 18:19:14 +00:00
onClick={() => fileInputRef.current?.click()}
className="bg-teal-800 hover:bg-teal-900 text-white"
>
Pilih File
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-11-18 06:56:39 +00:00
</Card>
);
}