299 lines
9.3 KiB
TypeScript
299 lines
9.3 KiB
TypeScript
"use client";
|
||
|
||
import { ChangeEvent, FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||
import IconButton, {
|
||
EditIcon,
|
||
TrashIcon,
|
||
} from "@/components/ui/IconButton";
|
||
import ImageCropper from "@/components/ui/ImageCropper";
|
||
import { FormModal } from "@/components/ui/Modal";
|
||
import type { Brand } from "@/data/clothes";
|
||
import { ApiError, uploadMedia } from "@/lib/api";
|
||
import {
|
||
createBrand,
|
||
deleteBrand,
|
||
fetchBrands,
|
||
updateBrand,
|
||
} from "@/lib/clothes-api";
|
||
import { useConfirm } from "@/components/ui/ConfirmDialog";
|
||
|
||
type AspectRatio = "1:1" | "3:2";
|
||
|
||
const DEFAULT_IMAGE = "/images/ola-szkolda-J61EvbE1UCE-unsplash.jpg";
|
||
|
||
export default function BrandsPage() {
|
||
const confirmDialog = useConfirm();
|
||
const [items, setItems] = useState<Brand[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [nameFa, setNameFa] = useState("");
|
||
const [nameEn, setNameEn] = useState("");
|
||
const [aspect, setAspect] = useState<AspectRatio>("1:1");
|
||
const [imagePreview, setImagePreview] = useState(DEFAULT_IMAGE);
|
||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const reload = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
setItems(await fetchBrands());
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof ApiError ? err.message : "بارگذاری برندها ناموفق بود.",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
}, [reload]);
|
||
|
||
function resetForm() {
|
||
setEditingId(null);
|
||
setNameFa("");
|
||
setNameEn("");
|
||
setAspect("1:1");
|
||
setImagePreview(DEFAULT_IMAGE);
|
||
setImageFile(null);
|
||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
}
|
||
|
||
function closeModal() {
|
||
setModalOpen(false);
|
||
resetForm();
|
||
}
|
||
|
||
function openCreate() {
|
||
resetForm();
|
||
setModalOpen(true);
|
||
}
|
||
|
||
function openEdit(brand: Brand) {
|
||
setEditingId(brand.id);
|
||
setNameFa(brand.nameFa);
|
||
setNameEn(brand.nameEn);
|
||
setImagePreview(brand.image || DEFAULT_IMAGE);
|
||
setImageFile(null);
|
||
setModalOpen(true);
|
||
}
|
||
|
||
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
setImageFile(file);
|
||
setImagePreview(URL.createObjectURL(file));
|
||
}
|
||
|
||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!nameFa.trim()) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
let imageUrl: string | undefined;
|
||
let imageKey: string | undefined;
|
||
if (imageFile) {
|
||
const uploaded = await uploadMedia(imageFile, "brand");
|
||
imageUrl = uploaded.url;
|
||
imageKey = uploaded.storageKey;
|
||
}
|
||
|
||
if (editingId) {
|
||
await updateBrand(editingId, {
|
||
nameFa: nameFa.trim(),
|
||
nameEn: nameEn.trim() || undefined,
|
||
...(imageUrl ? { imageUrl, imageKey } : {}),
|
||
});
|
||
} else {
|
||
await createBrand({
|
||
nameFa: nameFa.trim(),
|
||
nameEn: nameEn.trim() || undefined,
|
||
imageUrl,
|
||
imageKey,
|
||
});
|
||
}
|
||
await reload();
|
||
closeModal();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof ApiError ? err.message : "ذخیره برند ناموفق بود.",
|
||
);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
if (!(await confirmDialog("این برند حذف شود؟"))) return;
|
||
setError(null);
|
||
try {
|
||
await deleteBrand(id);
|
||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof ApiError ? err.message : "حذف برند ناموفق بود.",
|
||
);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<ClothesShell
|
||
eyebrow="Brands"
|
||
title="برندها"
|
||
description="برندهای فروشگاه را با تصویر و نام فارسی/انگلیسی مدیریت کنید."
|
||
breadcrumbs={[
|
||
{ label: "داشبورد", href: "/" },
|
||
{ label: "لباسها", href: "/clothes" },
|
||
{ label: "برندها" },
|
||
]}
|
||
onPlusClick={openCreate}
|
||
plusLabel="برند جدید"
|
||
>
|
||
{error ? (
|
||
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
{loading ? (
|
||
<p className="text-sm text-muted">در حال بارگذاری...</p>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
{items.length === 0 ? (
|
||
<p className="col-span-full rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
|
||
هنوز برندی ثبت نشده است.
|
||
</p>
|
||
) : null}
|
||
{items.map((brand, i) => (
|
||
<article
|
||
key={brand.id}
|
||
className="brand-card dash-enter"
|
||
style={{ animationDelay: `${80 + i * 60}ms` }}
|
||
>
|
||
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-md bg-[#f3f3f1]">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={brand.image}
|
||
alt={brand.nameFa}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<h2 className="text-base text-ink sm:text-lg">{brand.nameFa}</h2>
|
||
<p
|
||
dir="ltr"
|
||
className="text-end font-display text-[0.7rem] tracking-[0.08em] text-muted sm:text-xs"
|
||
>
|
||
{brand.nameEn}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="tree-row-actions shrink-0">
|
||
<IconButton
|
||
label="ویرایش"
|
||
variant="line"
|
||
onClick={() => openEdit(brand)}
|
||
>
|
||
<EditIcon />
|
||
</IconButton>
|
||
<IconButton
|
||
label="حذف"
|
||
variant="line"
|
||
onClick={() => void handleDelete(brand.id)}
|
||
>
|
||
<TrashIcon />
|
||
</IconButton>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</ClothesShell>
|
||
|
||
<FormModal
|
||
open={modalOpen}
|
||
title={editingId ? "ویرایش برند" : "برند جدید"}
|
||
onClose={closeModal}
|
||
onSubmit={handleSubmit}
|
||
submitLabel={saving ? "..." : editingId ? "ذخیره" : "افزودن برند"}
|
||
wide
|
||
>
|
||
<div className="clothes-field">
|
||
<span className="text-[0.7rem] text-muted">نسبت برش</span>
|
||
<div className="crop-ratio-radios" role="radiogroup" aria-label="نسبت برش">
|
||
<label className="crop-ratio-option">
|
||
<input
|
||
type="radio"
|
||
name="brand-aspect"
|
||
value="1:1"
|
||
checked={aspect === "1:1"}
|
||
onChange={() => setAspect("1:1")}
|
||
/>
|
||
<span>۱:۱</span>
|
||
</label>
|
||
<label className="crop-ratio-option">
|
||
<input
|
||
type="radio"
|
||
name="brand-aspect"
|
||
value="3:2"
|
||
checked={aspect === "3:2"}
|
||
onChange={() => setAspect("3:2")}
|
||
/>
|
||
<span>۳:۲</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="clothes-field">
|
||
<span className="text-[0.7rem] text-muted">تصویر برند</span>
|
||
<ImageCropper src={imagePreview} aspect={aspect} alt="پیشنمایش برش برند" />
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
className="sr-only"
|
||
onChange={handleImageChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="mt-2 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
انتخاب تصویر
|
||
</button>
|
||
</div>
|
||
|
||
<div className="clothes-field">
|
||
<label htmlFor="brand-name-fa">نام فارسی</label>
|
||
<input
|
||
id="brand-name-fa"
|
||
value={nameFa}
|
||
onChange={(e) => setNameFa(e.target.value)}
|
||
placeholder="مثلاً مانا"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="clothes-field">
|
||
<label htmlFor="brand-name-en">نام انگلیسی</label>
|
||
<input
|
||
id="brand-name-en"
|
||
dir="ltr"
|
||
className="!text-end"
|
||
value={nameEn}
|
||
onChange={(e) => setNameEn(e.target.value)}
|
||
placeholder="Mana"
|
||
/>
|
||
</div>
|
||
</FormModal>
|
||
</>
|
||
);
|
||
}
|