Initial commit: Mana Collection dashboards (Next.js).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:50:24 +03:30
co-authored by Cursor
commit 18b29f5b62
95 changed files with 16414 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
"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>
</>
);
}