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
+374
View File
@@ -0,0 +1,374 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ChangeEvent, FormEvent, Suspense, useEffect, useRef, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import ImageCropper from "@/components/ui/ImageCropper";
import { FormModal } from "@/components/ui/Modal";
import RichTextArea from "@/components/ui/RichTextArea";
import { ApiError, uploadMedia } from "@/lib/api";
import { createBlog, fetchBlog, updateBlog } from "@/lib/blog-api";
const DEFAULT_IMAGE = "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg";
function NewBlogPostForm() {
const router = useRouter();
const searchParams = useSearchParams();
const editId = searchParams.get("edit");
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [author, setAuthor] = useState("");
const [abstract, setAbstract] = useState("");
const [body, setBody] = useState("");
const [imagePreview, setImagePreview] = useState(DEFAULT_IMAGE);
const [imageFile, setImageFile] = useState<File | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [metaOpen, setMetaOpen] = useState(false);
const [metaTitle, setMetaTitle] = useState("");
const [metaDescription, setMetaDescription] = useState("");
const [metaKeywords, setMetaKeywords] = useState("");
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(Boolean(editId));
const [error, setError] = useState<string | null>(null);
const hasMeta = Boolean(
metaTitle.trim() || metaDescription.trim() || metaKeywords.trim(),
);
useEffect(() => {
if (!editId) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const post = await fetchBlog(editId);
if (cancelled) return;
setTitle(post.title);
setDate(post.date);
setAuthor(post.author);
setAbstract(post.abstract);
setBody(post.body);
setImagePreview(post.image);
setTags(post.tags);
setMetaTitle(post.metaTitle);
setMetaDescription(post.metaDescription);
setMetaKeywords(post.metaKeywords);
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری مطلب ناموفق بود.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [editId]);
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}
function addTag() {
const next = tagInput.trim();
if (!next || tags.includes(next)) return;
setTags((prev) => [...prev, next]);
setTagInput("");
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!title.trim()) {
setError("عنوان الزامی است.");
return;
}
setSaving(true);
setError(null);
try {
let imageUrl: string | undefined;
let imageKey: string | undefined;
if (imageFile) {
const uploaded = await uploadMedia(imageFile, "blog");
imageUrl = uploaded.url;
imageKey = uploaded.storageKey;
}
const payload = {
title: title.trim(),
summary: abstract.trim() || undefined,
body: body || undefined,
author: author.trim() || undefined,
metaTitle: metaTitle.trim() || undefined,
metaDescription: metaDescription.trim() || undefined,
published: true,
...(imageUrl ? { imageUrl, imageKey } : {}),
};
if (editId) {
await updateBlog(editId, payload);
} else {
await createBlog(payload);
}
router.push("/blog");
router.refresh();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ثبت مطلب ناموفق بود.",
);
} finally {
setSaving(false);
}
}
function saveMeta(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setMetaOpen(false);
}
return (
<>
<ClothesShell
eyebrow={editId ? "Edit Post" : "New Post"}
title={editId ? "ویرایش مطلب" : "مطلب جدید"}
description="کاور ۳:۲ را برش دهید و محتوای مطلب را کامل کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "بلاگ", href: "/blog" },
{ label: editId ? "ویرایش" : "مطلب جدید" },
]}
>
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری...</p>
) : (
<form
onSubmit={handleSubmit}
className="grid grid-cols-1 gap-3 lg:grid-cols-[300px_1fr]"
>
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
<p className="mb-2 text-sm text-ink">تصویر کاور</p>
<p className="mb-3 text-xs leading-6 text-muted">نسبت ۳:۲</p>
<ImageCropper
src={imagePreview}
aspect="3:2"
alt="کاور مطلب"
className="blog-cover-cropper"
/>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="sr-only"
onChange={handleImageChange}
/>
<button
type="button"
className="mt-3 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
onClick={() => fileRef.current?.click()}
>
انتخاب تصویر
</button>
</aside>
<div
className="space-y-3 dash-enter"
style={{ animationDelay: "70ms" }}
>
{error ? (
<p className="rounded-lg border border-line bg-white px-4 py-3 text-sm text-ink">
{error}
</p>
) : null}
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
<div className="clothes-field sm:col-span-8">
<label htmlFor="blog-title">عنوان</label>
<input
id="blog-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="عنوان مطلب"
required
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor="blog-date">تاریخ</label>
<input
id="blog-date"
type="date"
dir="ltr"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="blog-author">نویسنده</label>
<input
id="blog-author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="نام نویسنده"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="blog-abstract">خلاصه</label>
<textarea
id="blog-abstract"
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
placeholder="چکیده کوتاه مطلب..."
className="!min-h-[4.5rem]"
/>
</div>
<div className="clothes-field sm:col-span-12">
<span className="text-[0.7rem] text-muted">متن</span>
<RichTextArea value={body} onChange={setBody} />
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4">
<p className="mb-3 text-sm text-ink">برچسب‌ها</p>
<div className="mb-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
<button
type="button"
className="text-muted hover:text-ink"
onClick={() =>
setTags((prev) => prev.filter((item) => item !== tag))
}
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
placeholder="برچسب جدید"
className="flex-1 border border-line px-3 py-2 text-sm outline-none focus:border-ink"
/>
<button
type="button"
onClick={addTag}
className="border border-ink bg-ink px-4 py-2 text-sm text-white hover:bg-white hover:text-ink"
>
افزودن
</button>
</div>
</section>
<div className="flex flex-wrap items-center justify-between gap-2">
<button
type="button"
onClick={() => setMetaOpen(true)}
className="border border-line bg-white px-4 py-2.5 text-sm text-ink transition-colors hover:border-ink"
>
متا تگ‌ها
{hasMeta ? (
<span className="ms-2 text-xs text-muted">• تنظیم شده</span>
) : null}
</button>
<div className="flex gap-2">
<Link
href="/blog"
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
>
انصراف
</Link>
<button
type="submit"
disabled={saving}
className="auth-button !mt-0 !max-w-none !w-auto px-6 disabled:opacity-60"
>
<span>
{saving
? "در حال ثبت..."
: editId
? "ذخیره تغییرات"
: "ثبت مطلب"}
</span>
</button>
</div>
</div>
</div>
</form>
)}
</ClothesShell>
<FormModal
open={metaOpen}
title="متا تگ‌ها"
onClose={() => setMetaOpen(false)}
onSubmit={saveMeta}
submitLabel="ذخیره متا"
>
<div className="clothes-field">
<label htmlFor="blog-meta-title">Meta Title</label>
<input
id="blog-meta-title"
dir="ltr"
className="!text-end"
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
placeholder="SEO title"
/>
</div>
<div className="clothes-field">
<label htmlFor="blog-meta-desc">Meta Description</label>
<textarea
id="blog-meta-desc"
dir="ltr"
className="!min-h-[5rem] !text-end"
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
placeholder="SEO description"
/>
</div>
<div className="clothes-field">
<label htmlFor="blog-meta-keys">Meta Keywords</label>
<input
id="blog-meta-keys"
dir="ltr"
className="!text-end"
value={metaKeywords}
onChange={(e) => setMetaKeywords(e.target.value)}
placeholder="keyword, keyword"
/>
</div>
</FormModal>
</>
);
}
export default function NewBlogPostPage() {
return (
<Suspense fallback={<p className="p-6 text-sm text-muted">در حال بارگذاری...</p>}>
<NewBlogPostForm />
</Suspense>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { FormEvent, useCallback, useEffect, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
ClearFilterIcon,
EditIcon,
FilterIcon,
MetaTagsIcon,
TrashIcon,
} from "@/components/ui/IconButton";
import { FormModal } from "@/components/ui/Modal";
import Pagination, { usePagination } from "@/components/ui/Pagination";
import { BlogPost, formatBlogDate } from "@/data/blog";
import { ApiError } from "@/lib/api";
import { deleteBlog, fetchBlogs, updateBlog } from "@/lib/blog-api";
import { useConfirm } from "@/components/ui/ConfirmDialog";
const PAGE_SIZE = 3;
type Filters = {
name: string;
dateFrom: string;
dateTo: string;
};
const emptyFilters: Filters = {
name: "",
dateFrom: "",
dateTo: "",
};
export default function BlogListPage() {
const router = useRouter();
const confirmDialog = useConfirm();
const [items, setItems] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters);
const [metaPost, setMetaPost] = useState<BlogPost | null>(null);
const [metaTitle, setMetaTitle] = useState("");
const [metaDescription, setMetaDescription] = useState("");
const [metaKeywords, setMetaKeywords] = useState("");
const [savingMeta, setSavingMeta] = useState(false);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
setItems(await fetchBlogs());
} catch (err) {
setError(
err instanceof ApiError ? err.message : "بارگذاری بلاگ ناموفق بود.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const list = items.filter((post) => {
const q = applied.name.trim();
const matchesName =
!q ||
post.title.includes(q) ||
post.author.includes(q) ||
post.abstract.includes(q);
const fromOk = !applied.dateFrom || post.date >= applied.dateFrom;
const toOk = !applied.dateTo || post.date <= applied.dateTo;
return matchesName && fromOk && toOk;
});
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
usePagination(list, PAGE_SIZE, JSON.stringify(applied));
function openMeta(post: BlogPost) {
setMetaPost(post);
setMetaTitle(post.metaTitle);
setMetaDescription(post.metaDescription);
setMetaKeywords(post.metaKeywords);
}
function closeMeta() {
setMetaPost(null);
}
async function saveMeta(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!metaPost) return;
setSavingMeta(true);
setError(null);
try {
const updated = await updateBlog(metaPost.id, {
metaTitle: metaTitle.trim() || null,
metaDescription: metaDescription.trim() || null,
});
setItems((prev) =>
prev.map((post) =>
post.id === metaPost.id
? { ...updated, metaKeywords: metaKeywords.trim() }
: post,
),
);
closeMeta();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره متا ناموفق بود.",
);
} finally {
setSavingMeta(false);
}
}
async function removePost(id: string) {
if (!(await confirmDialog("این مطلب حذف شود؟"))) return;
setError(null);
try {
await deleteBlog(id);
setItems((prev) => prev.filter((post) => post.id !== id));
} catch (err) {
setError(
err instanceof ApiError ? err.message : "حذف مطلب ناموفق بود.",
);
}
}
return (
<>
<ClothesShell
eyebrow="Blog"
title="بلاگ"
description="مطالب بلاگ را فیلتر کنید و متا تگ‌ها را مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "بلاگ" },
]}
plusHref="/blog/new"
plusLabel="مطلب جدید"
>
{error ? (
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{error}
</p>
) : null}
<section className="clothes-filter-bar mb-2 dash-enter">
<div className="clothes-field">
<label htmlFor="filter-blog-name">نام</label>
<input
id="filter-blog-name"
value={draft.name}
onChange={(e) =>
setDraft((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="عنوان، نویسنده..."
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-blog-from">از تاریخ</label>
<input
id="filter-blog-from"
type="date"
dir="ltr"
value={draft.dateFrom}
onChange={(e) =>
setDraft((prev) => ({ ...prev, dateFrom: e.target.value }))
}
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-blog-to">تا تاریخ</label>
<input
id="filter-blog-to"
type="date"
dir="ltr"
value={draft.dateTo}
onChange={(e) =>
setDraft((prev) => ({ ...prev, dateTo: e.target.value }))
}
/>
</div>
<div className="clothes-filter-actions">
<IconButton
label="اعمال فیلتر"
variant="solid"
onClick={() => setApplied(draft)}
>
<FilterIcon />
</IconButton>
<IconButton
label="حذف فیلتر"
variant="line"
onClick={() => {
setDraft(emptyFilters);
setApplied(emptyFilters);
}}
>
<ClearFilterIcon />
</IconButton>
</div>
</section>
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری...</p>
) : (
<>
<div className="space-y-1">
{pageItems.map((post, i) => (
<article
key={post.id}
className="blog-card dash-enter"
style={{ animationDelay: `${70 + i * 45}ms` }}
>
<div className="blog-card-image">
<Image
src={post.image}
alt={post.title}
fill
unoptimized
sizes="180px"
className="object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-base text-ink sm:text-lg">
{post.title}
</h2>
<time
dateTime={post.date}
dir="ltr"
className="text-xs tabular-nums text-muted"
>
{formatBlogDate(post.date)}
</time>
</div>
<p className="mt-1 text-sm text-muted">{post.author}</p>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-ink/80">
{post.abstract}
</p>
{post.tags.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<span
key={tag}
className="tag-chip !rounded-md !py-0.5"
>
{tag}
</span>
))}
</div>
) : null}
</div>
<div className="tree-row-actions shrink-0">
<IconButton
label="ویرایش"
variant="line"
onClick={() => router.push(`/blog/new?edit=${post.id}`)}
>
<EditIcon />
</IconButton>
<IconButton
label="متا تگ"
variant="line"
onClick={() => openMeta(post)}
>
<MetaTagsIcon />
</IconButton>
<IconButton
label="حذف"
variant="line"
onClick={() => void removePost(post.id)}
>
<TrashIcon />
</IconButton>
</div>
</article>
))}
{list.length === 0 ? (
<p className="rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
مطلبی با این فیلترها پیدا نشد.
</p>
) : null}
</div>
{list.length > 0 ? (
<Pagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
onChange={setPage}
/>
) : null}
</>
)}
</ClothesShell>
<FormModal
open={Boolean(metaPost)}
title="متا تگ‌ها"
onClose={closeMeta}
onSubmit={saveMeta}
submitLabel={savingMeta ? "..." : "ذخیره متا"}
>
<div className="clothes-field">
<label htmlFor="meta-title">Meta Title</label>
<input
id="meta-title"
dir="ltr"
className="!text-end"
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
placeholder="SEO title"
/>
</div>
<div className="clothes-field">
<label htmlFor="meta-desc">Meta Description</label>
<textarea
id="meta-desc"
dir="ltr"
className="!min-h-[5rem] !text-end"
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
placeholder="SEO description"
/>
</div>
<div className="clothes-field">
<label htmlFor="meta-keys">Meta Keywords</label>
<input
id="meta-keys"
dir="ltr"
className="!text-end"
value={metaKeywords}
onChange={(e) => setMetaKeywords(e.target.value)}
placeholder="keyword, keyword"
/>
</div>
</FormModal>
</>
);
}
+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>
</>
);
}
+627
View File
@@ -0,0 +1,627 @@
"use client";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
AddChildIcon,
EditIcon,
TrashIcon,
VariantIcon,
} from "@/components/ui/IconButton";
import { FormModal } from "@/components/ui/Modal";
import {
CategoryNode,
CategoryVariants,
colorCatalog,
emptyVariants,
flattenCategories,
packageCatalog,
sizeCatalog,
} from "@/data/clothes";
import { ApiError } from "@/lib/api";
import {
createCategory,
deleteCategory,
fetchCategoriesTree,
updateCategory,
} from "@/lib/clothes-api";
import { useConfirm } from "@/components/ui/ConfirmDialog";
export default function CategoriesPage() {
const confirmDialog = useConfirm();
const [tree, setTree] = useState<CategoryNode[]>([]);
const [open, setOpen] = useState<Record<string, boolean>>({});
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 [parentId, setParentId] = useState("");
const [variantTarget, setVariantTarget] = useState<CategoryNode | null>(null);
const [draftVariants, setDraftVariants] = useState<CategoryVariants>(
emptyVariants(),
);
const [enabledTypes, setEnabledTypes] = useState({
color: false,
size: false,
package: false,
});
const parentOptions = useMemo(() => flattenCategories(tree), [tree]);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const cats = await fetchCategoriesTree();
setTree(cats);
setOpen((prev) => {
const next = { ...prev };
for (const opt of flattenCategories(cats)) {
if (next[opt.id] === undefined) next[opt.id] = true;
}
return next;
});
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری دسته‌ها ناموفق بود.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
function toggle(id: string) {
setOpen((prev) => ({ ...prev, [id]: !prev[id] }));
}
function resetForm() {
setEditingId(null);
setNameFa("");
setNameEn("");
setParentId("");
}
function closeModal() {
setModalOpen(false);
resetForm();
}
function openCreate(parent?: string) {
resetForm();
setParentId(parent ?? "");
setModalOpen(true);
}
function openEdit(node: CategoryNode) {
setEditingId(node.id);
setNameFa(node.nameFa);
setNameEn(node.nameEn);
setParentId("");
setModalOpen(true);
}
function openVariants(node: CategoryNode) {
const current = node.variants ?? emptyVariants();
setVariantTarget(node);
setDraftVariants(current);
setEnabledTypes({
color: current.colors.length > 0,
size: current.sizes.length > 0,
package: current.packages.length > 0,
});
}
function closeVariants() {
setVariantTarget(null);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!nameFa.trim()) return;
setSaving(true);
setError(null);
try {
if (editingId) {
const next = await updateCategory(editingId, {
nameFa: nameFa.trim(),
nameEn: nameEn.trim() || undefined,
});
setTree(next);
} else {
const next = await createCategory({
nameFa: nameFa.trim(),
nameEn: nameEn.trim() || undefined,
parentId: parentId || undefined,
});
setTree(next);
if (parentId) setOpen((prev) => ({ ...prev, [parentId]: true }));
}
closeModal();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره دسته ناموفق بود.",
);
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!(await confirmDialog("این دسته حذف شود؟"))) return;
setError(null);
try {
const next = await deleteCategory(id);
setTree(next);
} catch (err) {
setError(
err instanceof ApiError ? err.message : "حذف دسته ناموفق بود.",
);
}
}
function toggleValue(
field: "colors" | "sizes" | "packages",
value: string,
) {
setDraftVariants((prev) => {
const list = prev[field] as string[];
const next = list.includes(value)
? list.filter((item) => item !== value)
: [...list, value];
return { ...prev, [field]: next };
});
}
const allColorsSelected =
colorCatalog.length > 0 &&
colorCatalog.every((color) => draftVariants.colors.includes(color.id));
const allSizesSelected =
sizeCatalog.length > 0 &&
sizeCatalog.every((size) => draftVariants.sizes.includes(size.id));
function toggleAllColors() {
setDraftVariants((prev) => ({
...prev,
colors: allColorsSelected ? [] : colorCatalog.map((color) => color.id),
}));
}
function toggleAllSizes() {
setDraftVariants((prev) => ({
...prev,
sizes: allSizesSelected ? [] : sizeCatalog.map((size) => size.id),
}));
}
async function handleVariantsSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!variantTarget) return;
setSaving(true);
setError(null);
try {
const nextTree = await updateCategory(variantTarget.id, {
colors: enabledTypes.color ? draftVariants.colors : [],
sizes: enabledTypes.size ? draftVariants.sizes : [],
packages: enabledTypes.package ? draftVariants.packages : [],
});
setTree(nextTree);
closeVariants();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره تنوع ناموفق بود.",
);
} finally {
setSaving(false);
}
}
return (
<>
<ClothesShell
eyebrow="Categories"
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="tree-box dash-enter space-y-2">
{tree.length === 0 ? (
<p className="rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
هنوز دسته‌ای ثبت نشده است.
</p>
) : null}
{tree.map((node, i) => (
<CategoryBranch
key={node.id}
node={node}
depth={0}
number={`${i + 1}`}
open={open}
onToggle={toggle}
onEdit={openEdit}
onAddChild={(id) => openCreate(id)}
onVariants={openVariants}
onDelete={handleDelete}
delay={80 + i * 60}
/>
))}
</div>
)}
</ClothesShell>
<FormModal
open={modalOpen}
title={editingId ? "ویرایش دسته" : "دسته جدید"}
onClose={closeModal}
onSubmit={handleSubmit}
submitLabel={saving ? "..." : editingId ? "ذخیره" : "افزودن دسته"}
>
{!editingId ? (
<div className="clothes-field">
<label htmlFor="category-parent">دسته والد</label>
<select
id="category-parent"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">بدون والد (ریشه)</option>
{parentOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
<div className="clothes-field">
<label htmlFor="category-name-fa">نام فارسی</label>
<input
id="category-name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
placeholder="مثلاً مانتو"
required
/>
</div>
<div className="clothes-field">
<label htmlFor="category-name-en">نام انگلیسی</label>
<input
id="category-name-en"
dir="ltr"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="Manteau"
/>
</div>
</FormModal>
<FormModal
open={Boolean(variantTarget)}
title={`تنوع — ${variantTarget?.nameFa ?? ""}`}
onClose={closeVariants}
onSubmit={handleVariantsSubmit}
submitLabel={saving ? "..." : "ذخیره تنوع"}
wide
>
<p className="text-xs leading-6 text-muted">
مشخص کنید این دسته کدام تنوع‌ها را دارد.
</p>
<div className="variant-type-block">
<label className="variant-type-toggle">
<input
type="checkbox"
checked={enabledTypes.color}
onChange={(e) => {
const on = e.target.checked;
setEnabledTypes((prev) => ({ ...prev, color: on }));
if (!on) {
setDraftVariants((prev) => ({ ...prev, colors: [] }));
}
}}
/>
<span>رنگ</span>
</label>
{enabledTypes.color ? (
<div className="variant-chip-grid">
<button
type="button"
className={`variant-chip variant-chip-all${
allColorsSelected ? " is-active" : ""
}`}
onClick={toggleAllColors}
>
همه رنگ‌ها
</button>
{colorCatalog.map((color) => {
const checked = draftVariants.colors.includes(color.id);
return (
<label
key={color.id}
className={`variant-chip${checked ? " is-active" : ""}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleValue("colors", color.id)}
/>
<span
className="variant-swatch"
style={{ background: color.hex }}
/>
<span>{color.nameFa}</span>
</label>
);
})}
</div>
) : null}
</div>
<div className="variant-type-block">
<label className="variant-type-toggle">
<input
type="checkbox"
checked={enabledTypes.size}
onChange={(e) => {
const on = e.target.checked;
setEnabledTypes((prev) => ({ ...prev, size: on }));
if (!on) {
setDraftVariants((prev) => ({ ...prev, sizes: [] }));
}
}}
/>
<span>سایز</span>
</label>
{enabledTypes.size ? (
<div className="variant-chip-grid">
<button
type="button"
className={`variant-chip variant-chip-all${
allSizesSelected ? " is-active" : ""
}`}
onClick={toggleAllSizes}
>
همه سایزها
</button>
{sizeCatalog.map((size) => {
const checked = draftVariants.sizes.includes(size.id);
return (
<label
key={size.id}
className={`variant-chip${checked ? " is-active" : ""}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleValue("sizes", size.id)}
/>
<span dir="ltr" className="font-montserrat text-xs">
{size.nameFa}
</span>
</label>
);
})}
</div>
) : null}
</div>
<div className="variant-type-block">
<label className="variant-type-toggle">
<input
type="checkbox"
checked={enabledTypes.package}
onChange={(e) => {
const on = e.target.checked;
setEnabledTypes((prev) => ({ ...prev, package: on }));
if (!on) {
setDraftVariants((prev) => ({ ...prev, packages: [] }));
}
}}
/>
<span>پکیج</span>
</label>
{enabledTypes.package ? (
<div className="variant-chip-grid">
{packageCatalog.map((pack) => {
const checked = draftVariants.packages.includes(pack.id);
return (
<label
key={pack.id}
className={`variant-chip${checked ? " is-active" : ""}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleValue("packages", pack.id)}
/>
<span>
{pack.nameFa}
<span
dir="ltr"
className="ms-1 font-montserrat text-[0.7rem] text-muted"
>
({pack.id} pcs)
</span>
</span>
</label>
);
})}
</div>
) : null}
</div>
</FormModal>
</>
);
}
function CategoryBranch({
node,
depth,
number,
open,
onToggle,
onEdit,
onAddChild,
onVariants,
onDelete,
delay,
}: {
node: CategoryNode;
depth: number;
number: string;
open: Record<string, boolean>;
onToggle: (id: string) => void;
onEdit: (node: CategoryNode) => void;
onAddChild: (id: string) => void;
onVariants: (node: CategoryNode) => void;
onDelete: (id: string) => void;
delay: number;
}) {
const hasChildren = Boolean(node.children?.length);
const isOpen = open[node.id] ?? false;
const variantCount =
(node.variants?.colors.length ? 1 : 0) +
(node.variants?.sizes.length ? 1 : 0) +
(node.variants?.packages.length ? 1 : 0);
return (
<div className="space-y-2">
<div
className="tree-row dash-enter"
style={{
animationDelay: `${delay}ms`,
marginInlineStart: depth ? `${depth * 2.75}rem` : undefined,
}}
>
<div className="flex min-w-0 items-center gap-2">
{hasChildren ? (
<button
type="button"
onClick={() => onToggle(node.id)}
className="flex h-7 w-7 cursor-pointer items-center justify-center border border-line text-muted hover:text-ink"
aria-label={isOpen ? "بستن" : "باز کردن"}
>
{isOpen ? <ChevronDownIcon /> : <ChevronLeftIcon />}
</button>
) : (
<span className="h-7 w-7" />
)}
<span
dir="ltr"
className="shrink-0 text-base font-medium tabular-nums tracking-normal text-muted/55 sm:text-lg"
style={{ unicodeBidi: "isolate" }}
>
{number}
</span>
<div className="min-w-0">
<p className="truncate text-base text-ink sm:text-lg">{node.nameFa}</p>
<p
dir="ltr"
className="text-end font-display text-[0.7rem] tracking-[0.06em] text-muted sm:text-xs"
>
{node.nameEn}
</p>
</div>
</div>
<div className="tree-row-actions">
<IconButton label="ویرایش" variant="line" onClick={() => onEdit(node)}>
<EditIcon />
</IconButton>
<IconButton
label="افزودن فرزند"
variant="line"
onClick={() => onAddChild(node.id)}
>
<AddChildIcon />
</IconButton>
<IconButton
label="تنوع"
variant="line"
badge={variantCount || null}
onClick={() => onVariants(node)}
>
<VariantIcon />
</IconButton>
<IconButton
label="حذف"
variant="line"
onClick={() => onDelete(node.id)}
>
<TrashIcon />
</IconButton>
</div>
</div>
{hasChildren && isOpen
? node.children!.map((child, i) => (
<CategoryBranch
key={child.id}
node={child}
depth={depth + 1}
number={`${number}.${i + 1}`}
open={open}
onToggle={onToggle}
onEdit={onEdit}
onAddChild={onAddChild}
onVariants={onVariants}
onDelete={onDelete}
delay={delay + (i + 1) * 40}
/>
))
: null}
</div>
);
}
function ChevronDownIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronLeftIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M15 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+886
View File
@@ -0,0 +1,886 @@
"use client";
import Image from "next/image";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
ClearFilterIcon,
DiscountIcon,
EditIcon,
FilterIcon,
TrashIcon,
VariantIcon,
} from "@/components/ui/IconButton";
import Modal, { FormModal } from "@/components/ui/Modal";
import Pagination, { usePagination } from "@/components/ui/Pagination";
import {
Brand,
CategoryNode,
discountPercent,
findCategoryNode,
flattenCategories,
formatPrice,
getCategoryVariantGroups,
getVariantLabel,
Product,
ProductVariantPrice,
} from "@/data/clothes";
import {
deleteProduct,
fetchBrands,
fetchCategoriesTree,
fetchProducts,
toVariantPayload,
updateProduct,
} from "@/lib/clothes-api";
import { ApiError } from "@/lib/api";
import { useConfirm } from "@/components/ui/ConfirmDialog";
const PAGE_SIZE = 4;
type Filters = {
name: string;
categoryId: string;
priceMin: string;
priceMax: string;
};
const emptyFilters: Filters = {
name: "",
categoryId: "",
priceMin: "",
priceMax: "",
};
function emptyVariantRow(price = ""): ProductVariantPrice {
return {
id: `vp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
colorId: "",
sizeId: "",
packageId: "",
price,
discountPrice: "",
};
}
function isBaseRow(row: ProductVariantPrice) {
return !row.colorId && !row.sizeId && !row.packageId;
}
export default function ProductListPage() {
const confirmDialog = useConfirm();
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [brands, setBrands] = useState<Brand[]>([]);
const categoryOptions = useMemo(
() => flattenCategories(categoryTree),
[categoryTree],
);
const brandById = useMemo(
() => new Map(brands.map((b) => [b.id, b])),
[brands],
);
const [items, setItems] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters);
const [detailProduct, setDetailProduct] = useState<Product | null>(null);
const [variantProduct, setVariantProduct] = useState<Product | null>(null);
const [variantRows, setVariantRows] = useState<ProductVariantPrice[]>([
emptyVariantRow(),
]);
const [discountProduct, setDiscountProduct] = useState<Product | null>(null);
const [discountRows, setDiscountRows] = useState<ProductVariantPrice[]>([]);
const reload = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const [products, cats, brandRows] = await Promise.all([
fetchProducts({
name: applied.name.trim() || undefined,
categoryId: applied.categoryId || undefined,
minPrice: applied.priceMin ? Number(applied.priceMin) : undefined,
maxPrice: applied.priceMax ? Number(applied.priceMax) : undefined,
}),
fetchCategoriesTree(),
fetchBrands(),
]);
setItems(products);
setCategoryTree(cats);
setBrands(brandRows);
} catch (err) {
setLoadError(
err instanceof ApiError
? err.message
: "بارگذاری محصولات ناموفق بود. آیا API روشن است؟",
);
} finally {
setLoading(false);
}
}, [applied]);
useEffect(() => {
void reload();
}, [reload]);
const list = items;
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
usePagination(list, PAGE_SIZE, JSON.stringify(applied));
const variantGroups = useMemo(
() =>
variantProduct
? getCategoryVariantGroups(variantProduct.categoryId, categoryTree)
: { colors: [], sizes: [], packages: [] },
[variantProduct, categoryTree],
);
const hasAnyVariantOption =
variantGroups.colors.length > 0 ||
variantGroups.sizes.length > 0 ||
variantGroups.packages.length > 0;
function openVariants(product: Product) {
setVariantProduct(product);
setVariantRows(
product.variantPrices?.length
? product.variantPrices.map((row) => ({ ...row }))
: [emptyVariantRow(String(product.price))],
);
}
function closeVariants() {
setVariantProduct(null);
}
function openDiscount(product: Product) {
setDiscountProduct(product);
if (product.variantPrices?.length) {
setDiscountRows(product.variantPrices.map((row) => ({ ...row })));
} else {
setDiscountRows([
{
...emptyVariantRow(String(product.price)),
discountPrice:
typeof product.discountPrice === "number"
? String(product.discountPrice)
: "",
},
]);
}
}
function closeDiscount() {
setDiscountProduct(null);
}
function updateRow(
id: string,
field: keyof Omit<ProductVariantPrice, "id">,
value: string,
) {
setVariantRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, [field]: value } : row)),
);
}
function updateDiscountPrice(id: string, value: string) {
setDiscountRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, discountPrice: value } : row,
),
);
}
function addRow() {
setVariantRows((prev) => [
...prev,
emptyVariantRow(variantProduct ? String(variantProduct.price) : ""),
]);
}
function removeRow(id: string) {
setVariantRows((prev) =>
prev.length <= 1 ? prev : prev.filter((row) => row.id !== id),
);
}
async function saveVariants(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!variantProduct) return;
try {
const updated = await updateProduct(variantProduct.id, {
variants: toVariantPayload(variantRows),
});
setItems((prev) =>
prev.map((product) =>
product.id === updated.id ? updated : product,
),
);
closeVariants();
} catch (err) {
alert(err instanceof ApiError ? err.message : "ذخیره تنوع ناموفق بود");
}
}
async function saveDiscount(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!discountProduct) return;
const nextRows = discountRows.map((row) => ({ ...row }));
const discounted = nextRows
.map((row) => Number(row.discountPrice))
.filter((n) => !Number.isNaN(n) && n > 0);
const hadVariants = Boolean(discountProduct.variantPrices?.length);
const baseDiscount = nextRows.find((row) => isBaseRow(row))?.discountPrice;
const nextProductDiscount = baseDiscount
? Number(baseDiscount) || null
: discounted.length
? Math.min(...discounted)
: null;
try {
const updated = await updateProduct(discountProduct.id, {
discountPrice: nextProductDiscount,
...(hadVariants
? { variants: toVariantPayload(nextRows) }
: {}),
});
setItems((prev) =>
prev.map((product) =>
product.id === updated.id ? updated : product,
),
);
closeDiscount();
} catch (err) {
alert(err instanceof ApiError ? err.message : "ذخیره تخفیف ناموفق بود");
}
}
async function handleDelete(productId: string) {
if (!(await confirmDialog("این محصول حذف شود؟"))) return;
try {
await deleteProduct(productId);
setItems((prev) => prev.filter((p) => p.id !== productId));
} catch (err) {
alert(err instanceof ApiError ? err.message : "حذف ناموفق بود");
}
}
return (
<>
<ClothesShell
eyebrow="Products"
title="فهرست لباس‌ها"
description="محصولات را فیلتر کنید و کارت هر لباس را مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "لباس‌ها", href: "/clothes" },
{ label: "فهرست" },
]}
plusHref="/clothes/new"
plusLabel="افزودن لباس"
>
<section className="clothes-filter-bar mb-4 dash-enter">
<div className="clothes-field">
<label htmlFor="filter-name">نام</label>
<input
id="filter-name"
value={draft.name}
onChange={(e) =>
setDraft((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="جستجو..."
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-category">دسته‌بندی</label>
<select
id="filter-category"
value={draft.categoryId}
onChange={(e) =>
setDraft((prev) => ({ ...prev, categoryId: e.target.value }))
}
>
<option value="">همه</option>
{categoryOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
</div>
<div className="clothes-field">
<label htmlFor="filter-min">حداقل قیمت</label>
<input
id="filter-min"
inputMode="numeric"
dir="ltr"
className="font-montserrat"
value={draft.priceMin}
onChange={(e) =>
setDraft((prev) => ({ ...prev, priceMin: e.target.value }))
}
placeholder="1000000"
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-max">حداکثر قیمت</label>
<input
id="filter-max"
inputMode="numeric"
dir="ltr"
className="font-montserrat"
value={draft.priceMax}
onChange={(e) =>
setDraft((prev) => ({ ...prev, priceMax: e.target.value }))
}
placeholder="5000000"
/>
</div>
<div className="clothes-filter-actions">
<IconButton
label="اعمال فیلتر"
variant="solid"
onClick={() => setApplied(draft)}
>
<FilterIcon />
</IconButton>
<IconButton
label="حذف فیلتر"
variant="line"
onClick={() => {
setDraft(emptyFilters);
setApplied(emptyFilters);
}}
>
<ClearFilterIcon />
</IconButton>
</div>
</section>
{loadError ? (
<p className="mb-4 text-sm text-red-600">{loadError}</p>
) : null}
{loading ? (
<p className="mb-4 text-sm text-muted">در حال بارگذاری محصولات…</p>
) : null}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{!loading && pageItems.length === 0 ? (
<p className="col-span-full text-sm text-muted">
محصولی یافت نشد.
</p>
) : null}
{pageItems.map((product, i) => {
const hasDiscount =
typeof product.discountPrice === "number" &&
product.discountPrice < product.price;
const percent = hasDiscount
? discountPercent(product.price, product.discountPrice!)
: null;
const variantCount = product.variantPrices?.length ?? 0;
const discountCount = product.variantPrices?.length
? product.variantPrices.filter((row) => {
const n = Number(row.discountPrice);
return row.discountPrice && !Number.isNaN(n) && n > 0;
}).length
: hasDiscount
? 1
: 0;
return (
<article
key={product.id}
className="product-card dash-enter cursor-pointer"
style={{ animationDelay: `${80 + i * 50}ms` }}
onClick={() => setDetailProduct(product)}
>
<div className="relative aspect-[2/3] overflow-hidden bg-[#f3f3f1]">
<Image
src={product.image}
alt={product.nameFa}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover"
/>
{percent ? (
<span className="absolute top-2 start-2 bg-ink px-2 py-0.5 font-montserrat text-[0.7rem] text-white">
{percent}%-
</span>
) : null}
</div>
<div className="flex flex-1 flex-col gap-1 p-3 text-start">
<h2 className="line-clamp-1 text-sm text-ink">
{product.nameFa}
</h2>
<p
dir="ltr"
className="text-end font-display text-xs tracking-[0.06em] text-muted"
>
{product.nameEn}
</p>
<div className="mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1">
{hasDiscount ? (
<>
<span className="text-sm text-ink">
<span className="font-montserrat">
{formatPrice(product.discountPrice!)}
</span>
<span className="ms-1">تومان</span>
</span>
<span className="text-xs text-muted line-through">
<span className="font-montserrat">
{formatPrice(product.price)}
</span>
<span className="ms-1">تومان</span>
</span>
</>
) : (
<span className="text-sm text-ink">
<span className="font-montserrat">
{formatPrice(product.price)}
</span>
<span className="ms-1">تومان</span>
</span>
)}
</div>
</div>
<div
className="product-card-actions"
onClick={(e) => e.stopPropagation()}
>
<IconButton label="ویرایش" variant="ghost">
<EditIcon />
</IconButton>
<IconButton
label="حذف"
variant="ghost"
onClick={() => void handleDelete(product.id)}
>
<TrashIcon />
</IconButton>
<IconButton
label="تنوع و قیمت"
variant="ghost"
badge={variantCount || null}
onClick={() => openVariants(product)}
>
<VariantIcon />
</IconButton>
<IconButton
label="تخفیف"
variant="ghost"
badge={discountCount || null}
onClick={() => openDiscount(product)}
>
<DiscountIcon />
</IconButton>
</div>
</article>
);
})}
</div>
{list.length === 0 ? (
<p className="mt-8 text-center text-sm text-muted">
محصولی با این فیلترها پیدا نشد.
</p>
) : (
<Pagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
onChange={setPage}
/>
)}
</ClothesShell>
<Modal
open={Boolean(detailProduct)}
title="جزئیات محصول"
onClose={() => setDetailProduct(null)}
wide="full"
footer={
<button
type="button"
className="modal-btn-ghost"
onClick={() => setDetailProduct(null)}
>
بستن
</button>
}
>
{detailProduct ? (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[280px_1fr]">
<div className="space-y-3">
<div className="relative mx-auto aspect-[2/3] w-full max-w-[280px] overflow-hidden rounded-md bg-[#f3f3f1] lg:max-w-none">
<Image
src={detailProduct.image}
alt={detailProduct.nameFa}
fill
unoptimized
sizes="280px"
className="object-cover"
/>
</div>
{(detailProduct.gallery?.length ?? 0) > 0 ? (
<div>
<p className="mb-2 text-xs text-muted">گالری</p>
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 lg:grid-cols-3">
{detailProduct.gallery!.map((src) => (
<div
key={src}
className="relative aspect-square overflow-hidden rounded-md bg-[#f3f3f1]"
>
<Image
src={src}
alt=""
fill
unoptimized
sizes="90px"
className="object-cover"
/>
</div>
))}
</div>
</div>
) : null}
</div>
<div className="space-y-4 text-start">
<div>
<h3 className="text-xl text-ink sm:text-2xl">
{detailProduct.nameFa}
</h3>
{detailProduct.nameEn ? (
<p
dir="ltr"
className="mt-1 text-end font-montserrat text-sm text-muted sm:text-base"
>
{detailProduct.nameEn}
</p>
) : null}
</div>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">دسته‌بندی</dt>
<dd className="mt-0.5 text-ink">
{findCategoryNode(categoryTree, detailProduct.categoryId)
?.nameFa || "—"}
</dd>
</div>
<div>
<dt className="text-xs text-muted">برند</dt>
<dd className="mt-0.5 text-ink">
{brandById.get(detailProduct.brandId)?.nameFa || "—"}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted">قیمت</dt>
<dd className="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-ink">
{typeof detailProduct.discountPrice === "number" &&
detailProduct.discountPrice < detailProduct.price ? (
<>
<span>
<span className="font-montserrat">
{formatPrice(detailProduct.discountPrice)}
</span>
<span className="ms-1">تومان</span>
</span>
<span className="text-xs text-muted line-through">
<span className="font-montserrat">
{formatPrice(detailProduct.price)}
</span>
<span className="ms-1">تومان</span>
</span>
</>
) : (
<span>
<span className="font-montserrat">
{formatPrice(detailProduct.price)}
</span>
<span className="ms-1">تومان</span>
</span>
)}
</dd>
</div>
</dl>
{detailProduct.summary ? (
<div>
<p className="mb-1 text-xs text-muted">خلاصه</p>
<p className="text-sm leading-7 text-ink/90 whitespace-pre-wrap">
{detailProduct.summary}
</p>
</div>
) : null}
{detailProduct.description ? (
<div>
<p className="mb-1 text-xs text-muted">توضیحات</p>
<p className="text-sm leading-7 text-ink/90 whitespace-pre-wrap">
{detailProduct.description}
</p>
</div>
) : null}
{detailProduct.tags.length ? (
<div>
<p className="mb-1.5 text-xs text-muted">برچسب‌ها</p>
<div className="flex flex-wrap gap-1.5">
{detailProduct.tags.map((tag) => (
<span key={tag} className="tag-chip !rounded-md !py-0.5">
{tag}
</span>
))}
</div>
</div>
) : null}
<div>
<p className="mb-1.5 text-xs text-muted">تنوع‌ها</p>
{detailProduct.variantPrices?.length ? (
<ul className="space-y-1.5">
{detailProduct.variantPrices.map((row) => {
const rowDiscount = Number(row.discountPrice);
const hasRowDiscount =
row.discountPrice &&
!Number.isNaN(rowDiscount) &&
rowDiscount > 0;
return (
<li
key={row.id}
className="flex flex-wrap items-baseline justify-between gap-2 border border-line px-2.5 py-2 text-sm"
>
<span className="text-ink">
{getVariantLabel(
row,
detailProduct.categoryId,
categoryTree,
)}
</span>
<span className="font-montserrat text-muted">
{hasRowDiscount ? (
<>
<span className="text-ink">
{formatPrice(rowDiscount)}
</span>
<span className="ms-2 line-through">
{formatPrice(Number(row.price) || 0)}
</span>
</>
) : (
formatPrice(Number(row.price) || 0)
)}
</span>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted">تنوعی ثبت نشده است.</p>
)}
</div>
</div>
</div>
) : null}
</Modal>
<FormModal
open={Boolean(variantProduct)}
title={`تنوع و قیمت — ${variantProduct?.nameFa ?? ""}`}
onClose={closeVariants}
onSubmit={saveVariants}
submitLabel="ذخیره"
wide="xl"
>
<p className="text-xs leading-6 text-muted">
هر نوع تنوع (رنگ، سایز، پکیج) جداست. می‌توانید خالی بگذارید.
</p>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-sm text-ink">ردیف‌ها</span>
<button
type="button"
onClick={addRow}
className="border border-ink bg-ink px-3 py-1.5 text-xs text-white transition-colors hover:bg-white hover:text-ink"
>
+ ردیف جدید
</button>
</div>
<div className="space-y-2">
{variantRows.map((row, index) => (
<div key={row.id} className="variant-price-row">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted">
ردیف {new Intl.NumberFormat("en-US").format(index + 1)}
</span>
<button
type="button"
className="text-xs text-muted hover:text-ink disabled:opacity-30"
disabled={variantRows.length <= 1}
onClick={() => removeRow(row.id)}
>
حذف
</button>
</div>
<div className="variant-price-grid">
<div className="variant-price-fields">
{variantGroups.colors.length ? (
<div className="clothes-field">
<label htmlFor={`vp-color-${row.id}`}>رنگ</label>
<select
id={`vp-color-${row.id}`}
value={row.colorId}
onChange={(e) =>
updateRow(row.id, "colorId", e.target.value)
}
>
<option value="">—</option>
{variantGroups.colors.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
{variantGroups.sizes.length ? (
<div className="clothes-field">
<label htmlFor={`vp-size-${row.id}`}>سایز</label>
<select
id={`vp-size-${row.id}`}
value={row.sizeId}
onChange={(e) =>
updateRow(row.id, "sizeId", e.target.value)
}
>
<option value="">—</option>
{variantGroups.sizes.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
{variantGroups.packages.length ? (
<div className="clothes-field">
<label htmlFor={`vp-pack-${row.id}`}>پکیج</label>
<select
id={`vp-pack-${row.id}`}
value={row.packageId}
onChange={(e) =>
updateRow(row.id, "packageId", e.target.value)
}
>
<option value="">—</option>
{variantGroups.packages.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
{!hasAnyVariantOption ? (
<p className="text-xs text-muted sm:col-span-3">
تنوعی برای این دسته تعریف نشده.
</p>
) : null}
</div>
<div className="variant-price-amount">
<div className="clothes-field">
<label htmlFor={`vp-price-${row.id}`}>قیمت</label>
<input
id={`vp-price-${row.id}`}
dir="ltr"
inputMode="numeric"
className="!text-end font-montserrat"
value={row.price}
onChange={(e) =>
updateRow(row.id, "price", e.target.value)
}
placeholder="3570000"
/>
</div>
</div>
</div>
</div>
))}
</div>
</FormModal>
<FormModal
open={Boolean(discountProduct)}
title={`تخفیف — ${discountProduct?.nameFa ?? ""}`}
onClose={closeDiscount}
onSubmit={saveDiscount}
submitLabel="ذخیره تخفیف"
wide
>
<p className="text-xs leading-6 text-muted">
تنوع‌های موجود و قیمت فعلی را ببینید و قیمت تخفیف را وارد کنید.
</p>
<div className="space-y-2">
{discountRows.map((row) => {
const currentPrice = Number(row.price);
const label = discountProduct
? getVariantLabel(row, discountProduct.categoryId, categoryTree)
: "بدون تنوع";
return (
<div key={row.id} className="variant-price-row">
<div className="mb-2 text-sm text-ink">{label}</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="clothes-field">
<span className="block text-xs text-muted">قیمت فعلی</span>
<p className="mt-1.5 text-sm text-ink">
<span className="font-montserrat">
{Number.isNaN(currentPrice)
? row.price || "—"
: formatPrice(currentPrice)}
</span>
{!Number.isNaN(currentPrice) && row.price ? (
<span className="ms-1">تومان</span>
) : null}
</p>
</div>
<div className="clothes-field">
<label htmlFor={`disc-new-${row.id}`}>قیمت تخفیف</label>
<input
id={`disc-new-${row.id}`}
dir="ltr"
inputMode="numeric"
className="!text-end font-montserrat"
value={row.discountPrice ?? ""}
onChange={(e) =>
updateDiscountPrice(row.id, e.target.value)
}
placeholder="3570000"
/>
</div>
</div>
</div>
);
})}
</div>
</FormModal>
</>
);
}
+423
View File
@@ -0,0 +1,423 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import CategoryTreeSelect from "@/components/ui/CategoryTreeSelect";
import type { Brand, CategoryNode } from "@/data/clothes";
import { ApiError, uploadMedia } from "@/lib/api";
import {
createProduct,
fetchBrands,
fetchCategoriesTree,
} from "@/lib/clothes-api";
export default function AddProductPage() {
const router = useRouter();
const coverRef = useRef<HTMLInputElement>(null);
const galleryRef = useRef<HTMLInputElement>(null);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [brands, setBrands] = useState<Brand[]>([]);
const [categoryId, setCategoryId] = useState("");
const [brandId, setBrandId] = useState("");
const [nameFa, setNameFa] = useState("");
const [nameEn, setNameEn] = useState("");
const [price, setPrice] = useState("");
const [abstract, setAbstract] = useState("");
const [desc, setDesc] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [coverPreview, setCoverPreview] = useState(
"/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [galleryItems, setGalleryItems] = useState<
Array<{ preview: string; file: File }>
>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingMeta, setLoadingMeta] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [cats, brandRows] = await Promise.all([
fetchCategoriesTree(),
fetchBrands(),
]);
if (cancelled) return;
setCategoryTree(cats);
setBrands(brandRows);
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری دسته‌ها و برندها ناموفق بود.",
);
}
} finally {
if (!cancelled) setLoadingMeta(false);
}
})();
return () => {
cancelled = true;
};
}, []);
function addTag() {
const next = tagInput.trim();
if (!next || tags.includes(next)) return;
setTags((prev) => [...prev, next]);
setTagInput("");
}
function handleCoverChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setCoverFile(file);
setCoverPreview(URL.createObjectURL(file));
}
function handleGalleryChange(event: ChangeEvent<HTMLInputElement>) {
const files = event.target.files;
if (!files?.length) return;
const next = Array.from(files).map((file) => ({
preview: URL.createObjectURL(file),
file,
}));
setGalleryItems((prev) => [...prev, ...next]);
event.target.value = "";
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!nameFa.trim()) {
setError("نام فارسی الزامی است.");
return;
}
const priceTrimmed = price.trim();
const priceNum = priceTrimmed ? Number(priceTrimmed) : 0;
if (priceTrimmed && (!Number.isFinite(priceNum) || priceNum < 0)) {
setError("قیمت معتبر وارد کنید.");
return;
}
setSaving(true);
setError(null);
try {
let imageUrl: string | undefined;
let imageKey: string | undefined;
if (coverFile) {
const uploaded = await uploadMedia(coverFile, "main");
imageUrl = uploaded.url;
imageKey = uploaded.storageKey;
}
const galleryUrls: string[] = [];
const galleryKeys: string[] = [];
for (const item of galleryItems) {
const uploaded = await uploadMedia(item.file, "gallery");
galleryUrls.push(uploaded.url);
galleryKeys.push(uploaded.storageKey);
}
const roundedPrice = Math.round(priceNum);
await createProduct({
nameFa: nameFa.trim(),
nameEn: nameEn.trim() || undefined,
summary: abstract.trim() || undefined,
description: desc.trim() || undefined,
price: roundedPrice,
categoryId: categoryId || undefined,
brandId: brandId || undefined,
tags,
imageUrl,
imageKey,
galleryUrls,
galleryKeys,
published: true,
...(priceTrimmed
? {
variants: [
{
colorId: "",
sizeId: "",
packageId: "",
price: roundedPrice,
},
],
}
: {}),
});
router.push("/clothes/list");
router.refresh();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ثبت محصول ناموفق بود.",
);
} finally {
setSaving(false);
}
}
return (
<ClothesShell
eyebrow="New Product"
title="افزودن لباس"
description="کاور را برش دهید، اطلاعات محصول را کامل کنید و گالری را اضافه کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "لباس‌ها", href: "/clothes" },
{ label: "فهرست", href: "/clothes/list" },
{ label: "افزودن" },
]}
>
<form
onSubmit={handleSubmit}
className="grid grid-cols-1 gap-3 lg:grid-cols-[280px_1fr]"
>
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
<p className="mb-3 text-sm text-ink">برش تصویر کاور</p>
<p className="mb-4 text-xs leading-6 text-muted">
نسبت ۲:۳ — قاب داخلی محدوده برش نهایی را نشان می‌دهد.
</p>
<div className="crop-frame">
<Image
src={coverPreview}
alt="پیش‌نمایش کاور"
fill
unoptimized
className="object-cover"
sizes="280px"
/>
</div>
<input
ref={coverRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="sr-only"
onChange={handleCoverChange}
/>
<button
type="button"
className="mt-4 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
onClick={() => coverRef.current?.click()}
>
انتخاب تصویر کاور
</button>
</aside>
<div className="space-y-3 dash-enter" style={{ animationDelay: "70ms" }}>
{error ? (
<p className="rounded-lg border border-line bg-white px-4 py-3 text-sm text-ink">
{error}
</p>
) : null}
{loadingMeta ? (
<p className="text-sm text-muted">در حال بارگذاری...</p>
) : null}
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
<div className="clothes-field sm:col-span-8">
<label htmlFor="category">دسته‌بندی</label>
<CategoryTreeSelect
id="category"
nodes={categoryTree}
value={categoryId}
onChange={setCategoryId}
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor="brand">برند</label>
<select
id="brand"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
>
<option value="">انتخاب کنید</option>
{brands.map((brand) => (
<option key={brand.id} value={brand.id}>
{brand.nameFa} / {brand.nameEn}
</option>
))}
</select>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="name-fa">نام فارسی</label>
<input
id="name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
placeholder="مثلاً مانتو مشکی کلاسیک"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="name-en">نام انگلیسی</label>
<input
id="name-en"
dir="ltr"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="Classic Black Manteau"
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="price">قیمت (تومان)</label>
<input
id="price"
dir="ltr"
inputMode="numeric"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="1250000"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="abstract">خلاصه</label>
<textarea
id="abstract"
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
placeholder="توضیح کوتاه برای کارت محصول..."
className="!min-h-[4.5rem]"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="desc">توضیحات</label>
<textarea
id="desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="جزئیات کامل محصول..."
/>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm text-ink">گالری تصاویر</p>
<p className="text-xs text-muted">چند تصویر برای جزئیات محصول</p>
</div>
<input
ref={galleryRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="sr-only"
onChange={handleGalleryChange}
/>
<button
type="button"
className="border border-line px-3 py-1.5 text-xs text-ink hover:border-ink"
onClick={() => galleryRef.current?.click()}
>
آپلود تصویر
</button>
</div>
<div className="gallery-grid">
{galleryItems.map((item, index) => (
<div
key={item.preview}
className="relative overflow-hidden rounded-lg"
>
<Image
src={item.preview}
alt=""
width={160}
height={160}
unoptimized
className="aspect-square h-full w-full object-cover"
/>
<button
type="button"
className="absolute top-1 start-1 bg-ink/80 px-1.5 text-xs text-white"
onClick={() =>
setGalleryItems((prev) =>
prev.filter((_, i) => i !== index),
)
}
>
×
</button>
</div>
))}
<button
type="button"
className="gallery-slot"
onClick={() => galleryRef.current?.click()}
>
+ افزودن
</button>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4">
<p className="mb-3 text-sm text-ink">برچسب‌ها</p>
<div className="mb-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
<button
type="button"
className="text-muted hover:text-ink"
onClick={() =>
setTags((prev) => prev.filter((item) => item !== tag))
}
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
placeholder="برچسب جدید"
className="flex-1 border border-line px-3 py-2 text-sm outline-none focus:border-ink"
/>
<button
type="button"
onClick={addTag}
className="border border-ink bg-ink px-4 py-2 text-sm text-white hover:bg-white hover:text-ink"
>
افزودن
</button>
</div>
</section>
<div className="flex justify-end gap-2">
<Link
href="/clothes/list"
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
>
انصراف
</Link>
<button
type="submit"
disabled={saving || loadingMeta}
className="auth-button !mt-0 !max-w-none !w-auto px-6 disabled:opacity-60"
>
<span>{saving ? "در حال ثبت..." : "ثبت محصول"}</span>
</button>
</div>
</div>
</form>
</ClothesShell>
);
}
+132
View File
@@ -0,0 +1,132 @@
import DashboardBanner from "@/components/dashboard/DashboardBanner";
import DashTileGrid, {
DashTileItem,
} from "@/components/dashboard/DashTileGrid";
import ClothesShell from "@/components/clothes/ClothesShell";
const clothesTiles: DashTileItem[] = [
{
href: "/clothes/list",
title: "لباس‌های من",
titleEn: "My Clothes",
description: "فهرست و مدیریت محصولات",
icon: <ClothesIcon />,
},
{
href: "/clothes/new",
title: "افزودن لباس",
titleEn: "Add a New Cloth",
description: "ثبت محصول جدید در فروشگاه",
icon: <AddClothIcon />,
},
{
href: "/clothes/categories",
title: "دسته‌بندی‌ها",
titleEn: "Categories",
description: "ساختار و گروه‌بندی لباس‌ها",
icon: <CategoriesIcon />,
},
{
href: "/clothes/brands",
title: "برندها",
titleEn: "Brands",
description: "مدیریت برندهای فروشگاه",
icon: <BrandsIcon />,
},
];
export default function ClothesPage() {
const bannerDelay = 80 + clothesTiles.length * 70;
return (
<ClothesShell
eyebrow="Clothes"
title="لباس‌های من"
description="محصولات، دسته‌بندی‌ها و برندها را از اینجا مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "لباس‌ها" },
]}
>
<DashTileGrid tiles={clothesTiles} />
<DashboardBanner
src="/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg"
alt="نمای کالکشن لباس مانا"
caption="Soft color."
priority
style={{ animationDelay: `${bannerDelay}ms` }}
/>
</ClothesShell>
);
}
function iconProps() {
return {
width: 56,
height: 56,
viewBox: "0 0 24 24",
fill: "none",
"aria-hidden": true as const,
};
}
function ClothesIcon() {
return (
<svg {...iconProps()}>
<path
d="M8.5 4.5 12 7l3.5-2.5L19 6l-2 4.5V19H7V10.5L5 6l3.5-1.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
</svg>
);
}
function AddClothIcon() {
return (
<svg {...iconProps()}>
<path
d="M12 7v10M7 12h10"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
<rect
x="4.5"
y="4.5"
width="15"
height="15"
rx="1.5"
stroke="currentColor"
strokeWidth="1.15"
/>
</svg>
);
}
function CategoriesIcon() {
return (
<svg {...iconProps()}>
<rect x="4.5" y="4.5" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
<rect x="13" y="4.5" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
<rect x="4.5" y="13" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
<rect x="13" y="13" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
</svg>
);
}
function BrandsIcon() {
return (
<svg {...iconProps()}>
<path
d="M5 6.5h8.5L19 12l-5.5 5.5H5V6.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
<circle cx="9" cy="12" r="1.2" fill="currentColor" />
</svg>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Image from "next/image";
import ManaLogo from "@/components/ui/ManaLogo";
export const metadata: Metadata = {
title: "به‌زودی | مانا کالکشن",
description: "فروشگاه آنلاین مانا کالکشن به‌زودی در دسترس خواهد بود.",
};
const STORE_LOGIN =
process.env.NEXT_PUBLIC_STORE_URL?.replace(/\/$/, "") ||
"http://store.mana-collection.com:3000";
export default function ComingSoonPage() {
return (
<main className="coming-soon relative min-h-dvh overflow-hidden bg-[#f7f7f5] text-ink">
{/* dir=ltr keeps content on visual left, image on visual right */}
<div dir="ltr" className="grid min-h-dvh grid-cols-1 lg:grid-cols-2">
{/* Content — left */}
<section className="relative order-2 flex min-h-[52dvh] flex-col lg:order-1 lg:min-h-dvh">
<div className="mana-canvas absolute inset-0" aria-hidden />
<div
dir="rtl"
className="relative z-10 flex flex-1 flex-col px-7 py-8 sm:px-12 sm:py-10 lg:px-16 lg:py-14"
>
<header className="coming-soon-seq coming-soon-seq-1 flex shrink-0 justify-center">
<ManaLogo
href=""
width={400}
priority
showTagline
variant="white"
blackBg
className="w-[400px] max-w-[min(400px,100%)]"
/>
</header>
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center sm:py-16">
<div className="max-w-md">
<p
dir="ltr"
className="coming-soon-seq coming-soon-seq-2 mb-4 font-display text-sm tracking-[0.2em] text-muted sm:text-base"
>
Coming soon
</p>
<h1 className="coming-soon-seq coming-soon-seq-3 mb-5 text-4xl leading-tight text-ink sm:text-5xl">
به‌زودی
</h1>
<p className="coming-soon-seq coming-soon-seq-4 text-sm leading-8 text-muted sm:text-base sm:leading-8">
در این آدرس میزبان شما خواهیم بود.
</p>
</div>
</div>
<footer className="coming-soon-seq coming-soon-seq-5 shrink-0 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<p className="text-xs leading-6 text-muted">
حساب دارید؟ وارد شوید.
</p>
<a
href={`${STORE_LOGIN}/login`}
className="coming-soon-login inline-flex items-center justify-center border border-ink bg-ink px-7 py-2.5 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
ورود
</a>
</div>
</footer>
</div>
</section>
{/* Image — right */}
<aside className="coming-soon-seq-image relative order-1 min-h-[42dvh] overflow-hidden lg:order-2 lg:min-h-dvh">
<Image
src="/images/image1.jpg"
alt="مانا کالکشن"
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="coming-soon-image object-cover object-center"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-black/10" />
</aside>
</div>
</main>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
ClearFilterIcon,
FilterIcon,
} from "@/components/ui/IconButton";
import Pagination, { usePagination } from "@/components/ui/Pagination";
import {
Customer,
formatJoinedDate,
} from "@/data/customers";
import { ApiError } from "@/lib/api";
import { fetchCustomers } from "@/lib/customers-api";
const PAGE_SIZE = 5;
type Filters = {
name: string;
phone: string;
};
const emptyFilters: Filters = {
name: "",
phone: "",
};
export default function CustomersPage() {
const [items, setItems] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
setItems(await fetchCustomers());
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری مشتریان ناموفق بود.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const list = items.filter((customer) => {
const nameQ = applied.name.trim();
const phoneQ = applied.phone.trim();
const matchesName = !nameQ || customer.nameFa.includes(nameQ);
const matchesPhone = !phoneQ || customer.phone.includes(phoneQ);
return matchesName && matchesPhone;
});
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
usePagination(list, PAGE_SIZE, JSON.stringify(applied));
return (
<ClothesShell
eyebrow="Customers"
title="مشتریان"
description="مشتریانی که از فروشگاه شما خرید کرده‌اند."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "مشتریان" },
]}
>
{error ? (
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{error}
</p>
) : null}
<section className="clothes-filter-bar mb-2 dash-enter">
<div className="clothes-field">
<label htmlFor="filter-customer-name">نام</label>
<input
id="filter-customer-name"
value={draft.name}
onChange={(e) =>
setDraft((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="جستجوی نام..."
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-customer-phone">شماره موبایل</label>
<input
id="filter-customer-phone"
dir="ltr"
className="!text-end"
inputMode="tel"
value={draft.phone}
onChange={(e) =>
setDraft((prev) => ({ ...prev, phone: e.target.value }))
}
placeholder="09..."
/>
</div>
<div className="clothes-filter-actions">
<IconButton
label="اعمال فیلتر"
variant="solid"
onClick={() => setApplied(draft)}
>
<FilterIcon />
</IconButton>
<IconButton
label="حذف فیلتر"
variant="line"
onClick={() => {
setDraft(emptyFilters);
setApplied(emptyFilters);
}}
>
<ClearFilterIcon />
</IconButton>
</div>
</section>
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری...</p>
) : (
<>
<div className="space-y-1">
{pageItems.map((customer, i) => (
<article
key={customer.id}
className="customer-card dash-enter"
style={{ animationDelay: `${70 + i * 45}ms` }}
>
<div className="min-w-0 flex-1">
<h2 className="truncate text-base text-ink/65 sm:text-lg">
{customer.nameFa}
</h2>
<p
dir="ltr"
className="font-montserrat mt-0.5 text-end text-[0.7rem] font-medium tracking-wide text-muted/70 sm:text-xs"
>
{customer.phone}
</p>
</div>
<div className="customer-joined shrink-0">
<span className="customer-joined-label">عضویت</span>
<time
dateTime={customer.joinedAt}
dir="ltr"
className="customer-joined-date"
>
{formatJoinedDate(customer.joinedAt)}
</time>
</div>
</article>
))}
{list.length === 0 ? (
<p className="rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
مشتری‌ای با این فیلترها پیدا نشد.
</p>
) : null}
</div>
{list.length > 0 ? (
<Pagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
onChange={setPage}
/>
) : null}
</>
)}
</ClothesShell>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1668
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import localFont from "next/font/local";
import Providers from "@/components/Providers";
import RequireStoreAuth from "@/components/RequireStoreAuth";
import "./globals.css";
const doran = localFont({
src: [
{
path: "../fonts/doran/Doran-Thin.woff",
weight: "100",
style: "normal",
},
{
path: "../fonts/doran/Doran-Regular.woff",
weight: "400",
style: "normal",
},
{
path: "../fonts/doran/Doran-Medium.woff",
weight: "500",
style: "normal",
},
{
path: "../fonts/doran/Doran-Bold.woff",
weight: "700",
style: "normal",
},
],
variable: "--font-doran",
display: "swap",
});
const cailyne = localFont({
src: [
{
path: "../fonts/Cailyne.otf",
weight: "400",
style: "normal",
},
],
variable: "--font-cailyne",
display: "swap",
});
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-montserrat",
display: "swap",
});
export const metadata: Metadata = {
title: "مانا کالکشن | Mana Collection",
description: "فروشگاه لباس‌های مینیمال و شیک — مانا کالکشن",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="fa"
dir="rtl"
className={`${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full font-sans text-foreground">
<Providers>
<RequireStoreAuth>{children}</RequireStoreAuth>
</Providers>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import AuthForm from "@/components/auth/AuthForm";
export default function LoginPage() {
return <AuthForm />;
}
+176
View File
@@ -0,0 +1,176 @@
import DashboardBanner from "@/components/dashboard/DashboardBanner";
import DashboardCharts from "@/components/dashboard/DashboardCharts";
import DashboardHeader from "@/components/dashboard/DashboardHeader";
import DashTileGrid, {
DashTileItem,
} from "@/components/dashboard/DashTileGrid";
const tiles: DashTileItem[] = [
{
href: "/clothes",
title: "لباس‌های من",
titleEn: "My Clothes",
description: "موجودی، دسته‌بندی و محصولات",
icon: <ClothesIcon />,
},
{
href: "/customers",
title: "مشتریان من",
titleEn: "My Customers",
description: "فهرست و جزئیات مشتریان",
icon: <CustomersIcon />,
},
{
href: "/blog",
title: "بلاگ من",
titleEn: "My Blog",
description: "مطالب، کاور و متا تگ‌ها",
icon: <BlogIcon />,
},
{
href: "/orders",
title: "سفارش‌های من",
titleEn: "My Orders",
description: "پیگیری ارسال و پرداخت",
icon: <OrdersIcon />,
},
{
href: "/profile",
title: "پروفایل من",
titleEn: "My Profile",
description: "حساب، فروشگاه و امنیت",
icon: <ProfileIcon />,
},
];
export default function HomePage() {
const bannerDelay = 80 + tiles.length * 70;
return (
<div className="mana-canvas relative flex min-h-dvh flex-col">
<DashboardHeader />
<main className="relative z-10 mx-auto flex w-full max-w-6xl flex-1 flex-col px-3 pt-10 pb-6 sm:px-5 sm:pt-12 sm:pb-8 lg:px-6">
<div className="mb-3 dash-enter" style={{ animationDelay: "0ms" }}>
<h1
dir="ltr"
className="flex flex-wrap items-baseline justify-end gap-x-2.5 gap-y-1 text-2xl font-medium text-ink sm:text-4xl"
>
<span className="font-display text-base tracking-[0.06em] text-muted sm:text-xl">
Admin
</span>
<span aria-hidden className="text-sm text-muted/60 sm:text-base">
•
</span>
<span dir="rtl">داشبورد مدیریت</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-7 text-muted sm:text-base">
از اینجا لباس‌ها، مشتریان، بلاگ، سفارش‌ها و پروفایل فروشگاه را مدیریت کنید.
</p>
</div>
<DashTileGrid tiles={tiles} />
<DashboardBanner
src="/images/image4.jpg"
alt="نمای کالکشن مانا"
caption="Quiet elegance."
priority
style={{ animationDelay: `${bannerDelay}ms` }}
/>
<DashboardCharts startDelayMs={80 + (tiles.length + 1) * 70} />
</main>
</div>
);
}
function iconProps() {
return {
width: 56,
height: 56,
viewBox: "0 0 24 24",
fill: "none",
"aria-hidden": true as const,
};
}
function ClothesIcon() {
return (
<svg {...iconProps()}>
<path
d="M8.5 4.5 12 7l3.5-2.5L19 6l-2 4.5V19H7V10.5L5 6l3.5-1.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
</svg>
);
}
function CustomersIcon() {
return (
<svg {...iconProps()}>
<circle cx="9" cy="8" r="2.75" stroke="currentColor" strokeWidth="1.15" />
<circle cx="16.5" cy="9.5" r="2.1" stroke="currentColor" strokeWidth="1.15" />
<path
d="M3.5 18.5c1.2-2.6 3.1-3.9 5.5-3.9s4.3 1.3 5.5 3.9M13.2 14.8c1.1-.5 2.4-.7 3.8-.4 1.7.4 3 1.5 3.9 3.1"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function BlogIcon() {
return (
<svg {...iconProps()}>
<path
d="M6 5.5h12v13H6V5.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
<path
d="M9 9h6M9 12.5h6M9 16h4"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function OrdersIcon() {
return (
<svg {...iconProps()}>
<path
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
<path
d="M9 7a3 3 0 0 1 6 0"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function ProfileIcon() {
return (
<svg {...iconProps()}>
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="1.15" />
<path
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
+365
View File
@@ -0,0 +1,365 @@
"use client";
import { ChangeEvent, FormEvent, useRef, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import ImageCropper from "@/components/ui/ImageCropper";
type AddressItem = {
id: string;
label: string;
province: string;
city: string;
fullAddress: string;
landline: string;
};
const DEFAULT_AVATAR = "/images/ryanwar-hanif-98XENwtPMCc-unsplash.jpg";
const provinces = [
"تهران",
"اصفهان",
"فارس",
"خراسان رضوی",
"آذربایجان شرقی",
"آذربایجان غربی",
"گیلان",
"مازندران",
"خوزستان",
"کرمان",
"یزد",
"قم",
"البرز",
"همدان",
"کردستان",
"کرمانشاه",
"هرمزگان",
"بوشهر",
"سیستان و بلوچستان",
"گلستان",
"قزوین",
"زنجان",
"اردبیل",
"لرستان",
"مرکزی",
"سمنان",
"چهارمحال و بختیاری",
"کهگیلویه و بویراحمد",
"ایلام",
"خراسان شمالی",
"خراسان جنوبی",
];
function emptyAddress(): AddressItem {
return {
id: `addr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
label: "",
province: "",
city: "",
fullAddress: "",
landline: "",
};
}
export default function ProfilePage() {
const fileRef = useRef<HTMLInputElement>(null);
const [avatar, setAvatar] = useState(DEFAULT_AVATAR);
const [nameFa, setNameFa] = useState("سارا محمدی");
const [nameEn, setNameEn] = useState("Sara Mohammadi");
const [phone] = useState("09121234567");
const [email, setEmail] = useState("sara@manacollection.com");
const [instagram, setInstagram] = useState("@manacollection");
const [about, setAbout] = useState(
"علاقه‌مند به طراحی مینیمال و استایل‌های آرام روزمره.",
);
const [addresses, setAddresses] = useState<AddressItem[]>([
{
id: "addr-1",
label: "منزل",
province: "تهران",
city: "تهران",
fullAddress: "خیابان ولیعصر، بالاتر از پارک وی، پلاک ۱۲",
landline: "02122001122",
},
]);
function handleAvatarChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setAvatar(URL.createObjectURL(file));
}
function updateAddress(
id: string,
field: keyof Omit<AddressItem, "id">,
value: string,
) {
setAddresses((prev) =>
prev.map((item) =>
item.id === id ? { ...item, [field]: value } : item,
),
);
}
function addAddress() {
setAddresses((prev) => [...prev, emptyAddress()]);
}
function removeAddress(id: string) {
setAddresses((prev) =>
prev.length <= 1 ? prev : prev.filter((item) => item.id !== id),
);
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
}
return (
<ClothesShell
eyebrow="Profile"
title="پروفایل"
description="اطلاعات شخصی، تصویر آواتار و آدرس‌های خود را مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "پروفایل" },
]}
>
<form
onSubmit={handleSubmit}
className="grid grid-cols-1 gap-3 lg:grid-cols-[260px_1fr]"
>
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
<p className="mb-2 text-sm text-ink">آواتار</p>
<p className="mb-3 text-xs leading-6 text-muted">نسبت ۱:۱</p>
<ImageCropper
src={avatar}
aspect="1:1"
alt="آواتار پروفایل"
className="profile-avatar-cropper"
/>
<input
ref={fileRef}
type="file"
accept="image/*"
className="sr-only"
onChange={handleAvatarChange}
/>
<button
type="button"
className="mt-3 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
onClick={() => fileRef.current?.click()}
>
انتخاب تصویر
</button>
</aside>
<div className="space-y-3 dash-enter" style={{ animationDelay: "70ms" }}>
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-name-fa">نام فارسی</label>
<input
id="profile-name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
placeholder="نام و نام خانوادگی"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-name-en">نام انگلیسی</label>
<input
id="profile-name-en"
dir="ltr"
className="!text-end font-montserrat"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="Full name"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-phone">شماره موبایل</label>
<input
id="profile-phone"
dir="ltr"
className="!text-end font-montserrat profile-input-locked"
value={phone}
readOnly
aria-readonly="true"
title="شماره موبایل قابل تغییر نیست"
/>
<span className="text-[0.65rem] text-muted">قابل تغییر نیست</span>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-email">ایمیل</label>
<input
id="profile-email"
type="email"
dir="ltr"
className="!text-end font-montserrat"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
required
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="profile-instagram">اینستاگرام</label>
<input
id="profile-instagram"
dir="ltr"
className="!text-end font-montserrat"
value={instagram}
onChange={(e) => setInstagram(e.target.value)}
placeholder="@username"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="profile-about">درباره من</label>
<textarea
id="profile-about"
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="چند خط درباره خودتان..."
className="!min-h-[6rem]"
/>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm text-ink">آدرس‌ها</p>
<p className="text-xs text-muted">
می‌توانید چند آدرس اضافه کنید.
</p>
</div>
<button
type="button"
onClick={addAddress}
className="border border-ink bg-ink px-3 py-1.5 text-xs text-white transition-colors hover:bg-white hover:text-ink"
>
+ آدرس جدید
</button>
</div>
<div className="space-y-3">
{addresses.map((address, index) => (
<div key={address.id} className="address-block">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm text-ink">
آدرس {new Intl.NumberFormat("fa-IR").format(index + 1)}
</p>
<button
type="button"
className="text-xs text-muted transition-colors hover:text-ink disabled:opacity-30"
disabled={addresses.length <= 1}
onClick={() => removeAddress(address.id)}
>
حذف
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-label-${address.id}`}>برچسب</label>
<input
id={`addr-label-${address.id}`}
value={address.label}
onChange={(e) =>
updateAddress(address.id, "label", e.target.value)
}
placeholder="منزل، محل کار..."
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-province-${address.id}`}>
استان
</label>
<select
id={`addr-province-${address.id}`}
value={address.province}
onChange={(e) =>
updateAddress(address.id, "province", e.target.value)
}
>
<option value="" disabled>
انتخاب استان
</option>
{provinces.map((province) => (
<option key={province} value={province}>
{province}
</option>
))}
</select>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-city-${address.id}`}>شهر</label>
<input
id={`addr-city-${address.id}`}
value={address.city}
onChange={(e) =>
updateAddress(address.id, "city", e.target.value)
}
placeholder="نام شهر"
/>
</div>
<div className="clothes-field sm:col-span-8">
<label htmlFor={`addr-full-${address.id}`}>
آدرس کامل
</label>
<textarea
id={`addr-full-${address.id}`}
value={address.fullAddress}
onChange={(e) =>
updateAddress(
address.id,
"fullAddress",
e.target.value,
)
}
placeholder="خیابان، کوچه، پلاک، واحد..."
className="!min-h-[4.5rem]"
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-landline-${address.id}`}>
تلفن ثابت
</label>
<input
id={`addr-landline-${address.id}`}
dir="ltr"
className="!text-end font-montserrat"
inputMode="tel"
value={address.landline}
onChange={(e) =>
updateAddress(address.id, "landline", e.target.value)
}
placeholder="021..."
/>
</div>
</div>
</div>
))}
</div>
</section>
<div className="flex justify-end gap-2">
<button
type="button"
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
>
انصراف
</button>
<button
type="submit"
className="auth-button !mt-0 !max-w-none !w-auto px-6"
>
<span>ذخیره پروفایل</span>
</button>
</div>
</div>
</form>
</ClothesShell>
);
}