Initial commit: Mana Collection dashboards (Next.js).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import AuthForm from "@/components/auth/AuthForm";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthForm />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user