537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
"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 <span className="text-red-500">*</span>
|
||
</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 <span className="text-red-500">*</span>
|
||
</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 <span className="text-red-500">*</span>
|
||
</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 <span className="text-red-500">*</span>
|
||
</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;
|
||
setColors(updated);
|
||
}}
|
||
className={`w-8 h-8 rounded-full border-2 transition
|
||
${
|
||
colors[index].name === 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>
|
||
);
|
||
}
|