Files
dashboards/src/app/clothes/brands/page.tsx
T

299 lines
9.3 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 { 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>
</>
);
}