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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:50:24 +03:30
co-authored by Cursor
commit 18b29f5b62
95 changed files with 16414 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://api.mana-collection.com:3002/api/v1
NEXT_PUBLIC_STORE_URL=http://store.mana-collection.com:3000
NEXT_PUBLIC_SITE_URL=http://mana-collection.com:3000
+27
View File
@@ -0,0 +1,27 @@
# dependencies
node_modules/
# next
.next/
out/
build/
dist/
# env
.env
.env*.local
!.env.example
# debug / ide
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.idea/
.vscode/
*.pem
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+26
View File
@@ -0,0 +1,26 @@
# Mana Collection — Dashboards
Shared Next.js app for **store admin** and **customer** account UI.
## Local domains
```bash
# once (needs admin password)
../scripts/setup-hosts.sh
```
| URL | App |
|-----|-----|
| http://store.mana-collection.com:3000 | Store dashboard |
| http://customer.mana-collection.com:3000 | Customer (same app) |
| http://api.mana-collection.com:3002/api/v1 | Backend API |
```bash
cp .env.example .env.local
npm install
npm run dev
```
API: `NEXT_PUBLIC_API_URL` → `http://api.mana-collection.com:3002/api/v1`
Store staff login: phone + password or OTP. Customer registration uses OTP; store panel requires `STORE_ADMIN` / `SUPER_ADMIN`.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+27
View File
@@ -0,0 +1,27 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: [
"mana-collection.com",
"*.mana-collection.com",
"store.mana-collection.com",
"customer.mana-collection.com",
"admin.mana-collection.com",
"www.mana-collection.com",
],
images: {
localPatterns: [
{
pathname: "/images/**",
},
],
remotePatterns: [
{
protocol: "https",
hostname: "c804387.parspack.net",
},
],
},
};
export default nextConfig;
+6782
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mana-collection-dashboards",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3000",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

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

After

Width:  |  Height:  |  Size: 25 KiB

+1668
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import localFont from "next/font/local";
import Providers from "@/components/Providers";
import RequireStoreAuth from "@/components/RequireStoreAuth";
import "./globals.css";
const doran = localFont({
src: [
{
path: "../fonts/doran/Doran-Thin.woff",
weight: "100",
style: "normal",
},
{
path: "../fonts/doran/Doran-Regular.woff",
weight: "400",
style: "normal",
},
{
path: "../fonts/doran/Doran-Medium.woff",
weight: "500",
style: "normal",
},
{
path: "../fonts/doran/Doran-Bold.woff",
weight: "700",
style: "normal",
},
],
variable: "--font-doran",
display: "swap",
});
const cailyne = localFont({
src: [
{
path: "../fonts/Cailyne.otf",
weight: "400",
style: "normal",
},
],
variable: "--font-cailyne",
display: "swap",
});
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-montserrat",
display: "swap",
});
export const metadata: Metadata = {
title: "مانا کالکشن | Mana Collection",
description: "فروشگاه لباس‌های مینیمال و شیک — مانا کالکشن",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="fa"
dir="rtl"
className={`${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full font-sans text-foreground">
<Providers>
<RequireStoreAuth>{children}</RequireStoreAuth>
</Providers>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import AuthForm from "@/components/auth/AuthForm";
export default function LoginPage() {
return <AuthForm />;
}
+176
View File
@@ -0,0 +1,176 @@
import DashboardBanner from "@/components/dashboard/DashboardBanner";
import DashboardCharts from "@/components/dashboard/DashboardCharts";
import DashboardHeader from "@/components/dashboard/DashboardHeader";
import DashTileGrid, {
DashTileItem,
} from "@/components/dashboard/DashTileGrid";
const tiles: DashTileItem[] = [
{
href: "/clothes",
title: "لباس‌های من",
titleEn: "My Clothes",
description: "موجودی، دسته‌بندی و محصولات",
icon: <ClothesIcon />,
},
{
href: "/customers",
title: "مشتریان من",
titleEn: "My Customers",
description: "فهرست و جزئیات مشتریان",
icon: <CustomersIcon />,
},
{
href: "/blog",
title: "بلاگ من",
titleEn: "My Blog",
description: "مطالب، کاور و متا تگ‌ها",
icon: <BlogIcon />,
},
{
href: "/orders",
title: "سفارش‌های من",
titleEn: "My Orders",
description: "پیگیری ارسال و پرداخت",
icon: <OrdersIcon />,
},
{
href: "/profile",
title: "پروفایل من",
titleEn: "My Profile",
description: "حساب، فروشگاه و امنیت",
icon: <ProfileIcon />,
},
];
export default function HomePage() {
const bannerDelay = 80 + tiles.length * 70;
return (
<div className="mana-canvas relative flex min-h-dvh flex-col">
<DashboardHeader />
<main className="relative z-10 mx-auto flex w-full max-w-6xl flex-1 flex-col px-3 pt-10 pb-6 sm:px-5 sm:pt-12 sm:pb-8 lg:px-6">
<div className="mb-3 dash-enter" style={{ animationDelay: "0ms" }}>
<h1
dir="ltr"
className="flex flex-wrap items-baseline justify-end gap-x-2.5 gap-y-1 text-2xl font-medium text-ink sm:text-4xl"
>
<span className="font-display text-base tracking-[0.06em] text-muted sm:text-xl">
Admin
</span>
<span aria-hidden className="text-sm text-muted/60 sm:text-base">
•
</span>
<span dir="rtl">داشبورد مدیریت</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-7 text-muted sm:text-base">
از اینجا لباس‌ها، مشتریان، بلاگ، سفارش‌ها و پروفایل فروشگاه را مدیریت کنید.
</p>
</div>
<DashTileGrid tiles={tiles} />
<DashboardBanner
src="/images/image4.jpg"
alt="نمای کالکشن مانا"
caption="Quiet elegance."
priority
style={{ animationDelay: `${bannerDelay}ms` }}
/>
<DashboardCharts startDelayMs={80 + (tiles.length + 1) * 70} />
</main>
</div>
);
}
function iconProps() {
return {
width: 56,
height: 56,
viewBox: "0 0 24 24",
fill: "none",
"aria-hidden": true as const,
};
}
function ClothesIcon() {
return (
<svg {...iconProps()}>
<path
d="M8.5 4.5 12 7l3.5-2.5L19 6l-2 4.5V19H7V10.5L5 6l3.5-1.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
</svg>
);
}
function CustomersIcon() {
return (
<svg {...iconProps()}>
<circle cx="9" cy="8" r="2.75" stroke="currentColor" strokeWidth="1.15" />
<circle cx="16.5" cy="9.5" r="2.1" stroke="currentColor" strokeWidth="1.15" />
<path
d="M3.5 18.5c1.2-2.6 3.1-3.9 5.5-3.9s4.3 1.3 5.5 3.9M13.2 14.8c1.1-.5 2.4-.7 3.8-.4 1.7.4 3 1.5 3.9 3.1"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function BlogIcon() {
return (
<svg {...iconProps()}>
<path
d="M6 5.5h12v13H6V5.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
<path
d="M9 9h6M9 12.5h6M9 16h4"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function OrdersIcon() {
return (
<svg {...iconProps()}>
<path
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
stroke="currentColor"
strokeWidth="1.15"
strokeLinejoin="round"
/>
<path
d="M9 7a3 3 0 0 1 6 0"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
function ProfileIcon() {
return (
<svg {...iconProps()}>
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="1.15" />
<path
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</svg>
);
}
+365
View File
@@ -0,0 +1,365 @@
"use client";
import { ChangeEvent, FormEvent, useRef, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import ImageCropper from "@/components/ui/ImageCropper";
type AddressItem = {
id: string;
label: string;
province: string;
city: string;
fullAddress: string;
landline: string;
};
const DEFAULT_AVATAR = "/images/ryanwar-hanif-98XENwtPMCc-unsplash.jpg";
const provinces = [
"تهران",
"اصفهان",
"فارس",
"خراسان رضوی",
"آذربایجان شرقی",
"آذربایجان غربی",
"گیلان",
"مازندران",
"خوزستان",
"کرمان",
"یزد",
"قم",
"البرز",
"همدان",
"کردستان",
"کرمانشاه",
"هرمزگان",
"بوشهر",
"سیستان و بلوچستان",
"گلستان",
"قزوین",
"زنجان",
"اردبیل",
"لرستان",
"مرکزی",
"سمنان",
"چهارمحال و بختیاری",
"کهگیلویه و بویراحمد",
"ایلام",
"خراسان شمالی",
"خراسان جنوبی",
];
function emptyAddress(): AddressItem {
return {
id: `addr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
label: "",
province: "",
city: "",
fullAddress: "",
landline: "",
};
}
export default function ProfilePage() {
const fileRef = useRef<HTMLInputElement>(null);
const [avatar, setAvatar] = useState(DEFAULT_AVATAR);
const [nameFa, setNameFa] = useState("سارا محمدی");
const [nameEn, setNameEn] = useState("Sara Mohammadi");
const [phone] = useState("09121234567");
const [email, setEmail] = useState("sara@manacollection.com");
const [instagram, setInstagram] = useState("@manacollection");
const [about, setAbout] = useState(
"علاقه‌مند به طراحی مینیمال و استایل‌های آرام روزمره.",
);
const [addresses, setAddresses] = useState<AddressItem[]>([
{
id: "addr-1",
label: "منزل",
province: "تهران",
city: "تهران",
fullAddress: "خیابان ولیعصر، بالاتر از پارک وی، پلاک ۱۲",
landline: "02122001122",
},
]);
function handleAvatarChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setAvatar(URL.createObjectURL(file));
}
function updateAddress(
id: string,
field: keyof Omit<AddressItem, "id">,
value: string,
) {
setAddresses((prev) =>
prev.map((item) =>
item.id === id ? { ...item, [field]: value } : item,
),
);
}
function addAddress() {
setAddresses((prev) => [...prev, emptyAddress()]);
}
function removeAddress(id: string) {
setAddresses((prev) =>
prev.length <= 1 ? prev : prev.filter((item) => item.id !== id),
);
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
}
return (
<ClothesShell
eyebrow="Profile"
title="پروفایل"
description="اطلاعات شخصی، تصویر آواتار و آدرس‌های خود را مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "پروفایل" },
]}
>
<form
onSubmit={handleSubmit}
className="grid grid-cols-1 gap-3 lg:grid-cols-[260px_1fr]"
>
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
<p className="mb-2 text-sm text-ink">آواتار</p>
<p className="mb-3 text-xs leading-6 text-muted">نسبت ۱:۱</p>
<ImageCropper
src={avatar}
aspect="1:1"
alt="آواتار پروفایل"
className="profile-avatar-cropper"
/>
<input
ref={fileRef}
type="file"
accept="image/*"
className="sr-only"
onChange={handleAvatarChange}
/>
<button
type="button"
className="mt-3 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
onClick={() => fileRef.current?.click()}
>
انتخاب تصویر
</button>
</aside>
<div className="space-y-3 dash-enter" style={{ animationDelay: "70ms" }}>
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-name-fa">نام فارسی</label>
<input
id="profile-name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
placeholder="نام و نام خانوادگی"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-name-en">نام انگلیسی</label>
<input
id="profile-name-en"
dir="ltr"
className="!text-end font-montserrat"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="Full name"
required
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-phone">شماره موبایل</label>
<input
id="profile-phone"
dir="ltr"
className="!text-end font-montserrat profile-input-locked"
value={phone}
readOnly
aria-readonly="true"
title="شماره موبایل قابل تغییر نیست"
/>
<span className="text-[0.65rem] text-muted">قابل تغییر نیست</span>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="profile-email">ایمیل</label>
<input
id="profile-email"
type="email"
dir="ltr"
className="!text-end font-montserrat"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
required
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="profile-instagram">اینستاگرام</label>
<input
id="profile-instagram"
dir="ltr"
className="!text-end font-montserrat"
value={instagram}
onChange={(e) => setInstagram(e.target.value)}
placeholder="@username"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="profile-about">درباره من</label>
<textarea
id="profile-about"
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="چند خط درباره خودتان..."
className="!min-h-[6rem]"
/>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm text-ink">آدرس‌ها</p>
<p className="text-xs text-muted">
می‌توانید چند آدرس اضافه کنید.
</p>
</div>
<button
type="button"
onClick={addAddress}
className="border border-ink bg-ink px-3 py-1.5 text-xs text-white transition-colors hover:bg-white hover:text-ink"
>
+ آدرس جدید
</button>
</div>
<div className="space-y-3">
{addresses.map((address, index) => (
<div key={address.id} className="address-block">
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm text-ink">
آدرس {new Intl.NumberFormat("fa-IR").format(index + 1)}
</p>
<button
type="button"
className="text-xs text-muted transition-colors hover:text-ink disabled:opacity-30"
disabled={addresses.length <= 1}
onClick={() => removeAddress(address.id)}
>
حذف
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-label-${address.id}`}>برچسب</label>
<input
id={`addr-label-${address.id}`}
value={address.label}
onChange={(e) =>
updateAddress(address.id, "label", e.target.value)
}
placeholder="منزل، محل کار..."
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-province-${address.id}`}>
استان
</label>
<select
id={`addr-province-${address.id}`}
value={address.province}
onChange={(e) =>
updateAddress(address.id, "province", e.target.value)
}
>
<option value="" disabled>
انتخاب استان
</option>
{provinces.map((province) => (
<option key={province} value={province}>
{province}
</option>
))}
</select>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-city-${address.id}`}>شهر</label>
<input
id={`addr-city-${address.id}`}
value={address.city}
onChange={(e) =>
updateAddress(address.id, "city", e.target.value)
}
placeholder="نام شهر"
/>
</div>
<div className="clothes-field sm:col-span-8">
<label htmlFor={`addr-full-${address.id}`}>
آدرس کامل
</label>
<textarea
id={`addr-full-${address.id}`}
value={address.fullAddress}
onChange={(e) =>
updateAddress(
address.id,
"fullAddress",
e.target.value,
)
}
placeholder="خیابان، کوچه، پلاک، واحد..."
className="!min-h-[4.5rem]"
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor={`addr-landline-${address.id}`}>
تلفن ثابت
</label>
<input
id={`addr-landline-${address.id}`}
dir="ltr"
className="!text-end font-montserrat"
inputMode="tel"
value={address.landline}
onChange={(e) =>
updateAddress(address.id, "landline", e.target.value)
}
placeholder="021..."
/>
</div>
</div>
</div>
))}
</div>
</section>
<div className="flex justify-end gap-2">
<button
type="button"
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
>
انصراف
</button>
<button
type="submit"
className="auth-button !mt-0 !max-w-none !w-auto px-6"
>
<span>ذخیره پروفایل</span>
</button>
</div>
</div>
</form>
</ClothesShell>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { ConfirmProvider } from "@/components/ui/ConfirmDialog";
import { AuthProvider } from "@/lib/auth";
import type { ReactNode } from "react";
export default function Providers({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ConfirmProvider>{children}</ConfirmProvider>
</AuthProvider>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
const PUBLIC_PATHS = ["/login", "/coming-soon"];
const WEBSITE_HOSTS = new Set([
"mana-collection.com",
"www.mana-collection.com",
]);
export default function RequireStoreAuth({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth();
const pathname = usePathname();
const router = useRouter();
const host =
typeof window !== "undefined"
? window.location.hostname.toLowerCase()
: "";
const onWebsite = WEBSITE_HOSTS.has(host);
const isPublic =
onWebsite ||
PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
useEffect(() => {
if (loading || isPublic) return;
if (!user) {
router.replace("/login");
return;
}
if (user.role === "CUSTOMER") {
router.replace("/login?as=customer");
}
}, [user, loading, isPublic, router]);
if (isPublic) return <>{children}</>;
if (loading) {
return (
<div className="flex min-h-dvh items-center justify-center text-sm text-muted">
در حال بارگذاری…
</div>
);
}
if (!user || user.role === "CUSTOMER") return null;
return <>{children}</>;
}
+538
View File
@@ -0,0 +1,538 @@
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import {
FormEvent,
InputHTMLAttributes,
ReactNode,
Suspense,
useState,
} from "react";
import ManaLogo from "@/components/ui/ManaLogo";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/lib/auth";
type AuthMode = "login" | "register";
type LoginMethod = "password" | "otp";
function AuthFormInner() {
const router = useRouter();
const searchParams = useSearchParams();
const {
login,
sendLoginOtp,
loginWithOtp,
registerSendCode,
registerVerify,
} = useAuth();
const [mode, setMode] = useState<AuthMode>("login");
const [loginMethod, setLoginMethod] = useState<LoginMethod>("password");
const [otpSent, setOtpSent] = useState(false);
const [registerOtpSent, setRegisterOtpSent] = useState(false);
const [pendingCell, setPendingCell] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isLogin = mode === "login";
const customerHint = searchParams.get("as") === "customer";
function afterAuth(role: string) {
if (role === "CUSTOMER") {
setError(
"این پنل مخصوص فروشندگان است. مشتریان از وب‌سایت مانا کالکشن وارد شوند.",
);
return;
}
router.replace("/");
}
async function handleLoginPassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const cellNumber = String(form.get("cellNumber") ?? "").trim();
const password = String(form.get("password") ?? "");
try {
const user = await login(cellNumber, password);
afterAuth(user.role);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود");
} finally {
setBusy(false);
}
}
async function handleLoginOtp(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const cellNumber = String(form.get("cellNumber") ?? "").trim();
const code = String(form.get("code") ?? "").trim();
try {
if (!otpSent) {
await sendLoginOtp(cellNumber);
setPendingCell(cellNumber);
setOtpSent(true);
} else {
const user = await loginWithOtp(cellNumber || pendingCell, code);
afterAuth(user.role);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "خطا در ورود با کد");
} finally {
setBusy(false);
}
}
async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const firstName = String(form.get("firstName") ?? "").trim();
const lastName = String(form.get("lastName") ?? "").trim();
const cellNumber = String(form.get("cellNumber") ?? "").trim();
const password = String(form.get("password") ?? "");
const confirm = String(form.get("confirm") ?? "");
const code = String(form.get("code") ?? "").trim();
try {
if (!registerOtpSent) {
if (password !== confirm) {
setError("رمز عبور و تکرار آن یکسان نیست");
return;
}
await registerSendCode({ firstName, lastName, cellNumber, password });
setPendingCell(cellNumber);
setRegisterOtpSent(true);
} else {
const user = await registerVerify(cellNumber || pendingCell, code);
afterAuth(user.role);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود");
} finally {
setBusy(false);
}
}
return (
<div className="auth-canvas relative flex min-h-dvh items-center justify-center overflow-hidden px-4 py-10 sm:px-6 lg:px-10">
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center gap-7">
<div className="animate-auth-fade flex w-full flex-col items-center">
<div dir="ltr" className="flex flex-col items-center">
<ManaLogo
href={
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
"http://mana-collection.com:3000"
}
width={320}
priority
showTagline
variant="white"
blackBg
hoverSwap
taglineSize={19}
className="w-[320px] max-w-[min(320px,100%)]"
/>
</div>
</div>
<div className="flex w-full flex-col items-stretch gap-0 overflow-hidden border border-line bg-surface shadow-[0_24px_80px_rgba(0,0,0,0.06)] md:h-[min(680px,86vh)] md:flex-row">
<section className="animate-auth-rise relative flex w-full flex-col px-7 py-8 sm:px-10 sm:py-10 md:w-[62%] md:overflow-hidden md:px-12 lg:px-14">
<div className="mb-6 flex shrink-0 items-center gap-6 border-b border-line">
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className={`relative cursor-pointer py-3 text-sm transition-colors ${
isLogin ? "text-ink" : "text-muted hover:text-ink"
}`}
>
ورود
{isLogin && (
<span className="absolute inset-x-0 -bottom-px h-px bg-ink" />
)}
</button>
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className={`relative cursor-pointer py-3 text-sm transition-colors ${
!isLogin ? "text-ink" : "text-muted hover:text-ink"
}`}
>
ثبت نام
{!isLogin && (
<span className="absolute inset-x-0 -bottom-px h-px bg-ink" />
)}
</button>
</div>
{customerHint ? (
<p className="mb-4 text-xs leading-6 text-muted">
حساب شما مشتری است. برای مدیریت فروشگاه باید با حساب فروشنده وارد
شوید.
</p>
) : null}
{error ? (
<p className="mb-4 text-xs leading-6 text-red-600">{error}</p>
) : null}
<div className="grid min-h-0 flex-1">
<div
className={`col-start-1 row-start-1 flex items-center transition-opacity duration-300 ${
isLogin
? "relative z-10 opacity-100"
: "pointer-events-none invisible z-0 opacity-0"
}`}
aria-hidden={!isLogin}
>
<AuthPanel
title="ورود به وبسایت"
subtitle="با شماره موبایل و رمز عبور یا کد یکبارمصرف وارد پنل فروشگاه شوید."
onSubmit={
loginMethod === "password"
? handleLoginPassword
: handleLoginOtp
}
>
<Field
id="login-phone"
label="شماره موبایل"
name="cellNumber"
type="tel"
inputMode="numeric"
autoComplete="tel"
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required={isLogin}
defaultValue={pendingCell}
/>
{loginMethod === "password" ? (
<Field
id="login-password"
label="رمز عبور"
name="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
dir="ltr"
required={isLogin}
minLength={4}
/>
) : otpSent ? (
<Field
id="login-otp"
label="کد تأیید"
name="code"
type="text"
inputMode="numeric"
placeholder="123456"
dir="ltr"
required
minLength={4}
maxLength={8}
/>
) : null}
<button
type="submit"
className="auth-button"
disabled={busy}
tabIndex={isLogin ? 0 : -1}
>
<span>
{busy
? "لطفاً صبر کنید…"
: loginMethod === "otp" && !otpSent
? "ارسال کد"
: "ورود به حساب"}
</span>
</button>
<div className="mt-1 flex items-center justify-between gap-3 text-xs">
{loginMethod === "password" ? (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 text-muted transition-colors hover:text-ink"
onClick={() => {
setLoginMethod("otp");
setOtpSent(false);
setError(null);
}}
>
<OtpIcon />
ورود از طریق کد یکبار مصرف
</button>
) : (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 text-muted transition-colors hover:text-ink"
onClick={() => {
setLoginMethod("password");
setOtpSent(false);
setError(null);
}}
>
<KeyMiniIcon />
ورود با رمز عبور
</button>
)}
<button
type="button"
className="cursor-pointer text-muted transition-colors hover:text-ink"
onClick={() => {
setError("بازیابی رمز عبور به‌زودی فعال می‌شود.");
}}
>
فراموشی رمز عبور
</button>
</div>
</AuthPanel>
</div>
<div
className={`col-start-1 row-start-1 flex items-center transition-opacity duration-300 ${
!isLogin
? "relative z-10 opacity-100"
: "pointer-events-none invisible z-0 opacity-0"
}`}
aria-hidden={isLogin}
>
<AuthPanel
title="عضویت در مانا کالکشن"
subtitle="با تکمیل اطلاعات زیر، ما یک حساب کاربری برای شما ایجاد میکنیم."
onSubmit={handleRegister}
>
{!registerOtpSent ? (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
id="register-first"
label="نام"
name="firstName"
type="text"
autoComplete="given-name"
required={!isLogin}
/>
<Field
id="register-last"
label="نام خانوادگی"
name="lastName"
type="text"
autoComplete="family-name"
required={!isLogin}
/>
</div>
<Field
id="register-phone"
label="شماره موبایل"
name="cellNumber"
type="tel"
inputMode="numeric"
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required={!isLogin}
/>
<Field
id="register-password"
label="رمز عبور"
name="password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required={!isLogin}
minLength={4}
/>
<Field
id="register-confirm"
label="تکرار رمز عبور"
name="confirm"
type="password"
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required={!isLogin}
minLength={4}
/>
</>
) : (
<>
<Field
id="register-phone-confirm"
label="شماره موبایل"
name="cellNumber"
type="tel"
dir="ltr"
defaultValue={pendingCell}
required
/>
<Field
id="register-otp"
label="کد تأیید پیامکی"
name="code"
type="text"
inputMode="numeric"
dir="ltr"
required
minLength={4}
maxLength={8}
/>
</>
)}
<button
type="submit"
className="auth-button"
disabled={busy}
tabIndex={!isLogin ? 0 : -1}
>
<span>
{busy
? "لطفاً صبر کنید…"
: registerOtpSent
? "تأیید و ایجاد حساب"
: "ارسال کد تأیید"}
</span>
</button>
</AuthPanel>
</div>
</div>
<p className="mt-6 shrink-0 text-xs leading-6 text-muted">
با ادامه، شرایط استفاده و حریم خصوصی مانا را می‌پذیرید.
</p>
</section>
<aside className="animate-auth-fade relative hidden overflow-hidden border-line md:block md:h-auto md:w-[38%] md:border-r">
<Image
src="/images/aiony-haust-owp8uQgoK8U-unsplash.jpg"
alt="نمای مد مینیمال مانا کالکشن"
fill
priority
sizes="(max-width: 768px) 100vw, 38vw"
className="animate-image-drift object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/45 via-black/5 to-transparent" />
<p className="absolute bottom-6 right-6 left-6 font-display text-lg text-white/95">
Quiet elegance.
</p>
</aside>
<aside className="relative h-44 w-full overflow-hidden border-t border-line md:hidden">
<Image
src="/images/aiony-haust-owp8uQgoK8U-unsplash.jpg"
alt="نمای مد مینیمال مانا کالکشن"
fill
priority
sizes="(max-width: 768px) 100vw, 40vw"
className="object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
</aside>
</div>
</div>
</div>
);
}
export default function AuthForm() {
return (
<Suspense
fallback={
<div className="flex min-h-dvh items-center justify-center text-sm text-muted">
در حال بارگذاری…
</div>
}
>
<AuthFormInner />
</Suspense>
);
}
function AuthPanel({
title,
subtitle,
onSubmit,
children,
}: {
title: string;
subtitle: string;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
children: ReactNode;
}) {
return (
<div className="w-full">
<h1 className="mb-2 text-xl font-medium text-ink sm:text-2xl">{title}</h1>
<p className="mb-8 max-w-sm text-sm leading-7 text-muted">{subtitle}</p>
<form onSubmit={onSubmit} className="flex w-full flex-col gap-4">
{children}
</form>
</div>
);
}
function Field({
id,
label,
...props
}: {
id: string;
label: string;
} & InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="w-full">
<label htmlFor={id} className="auth-label">
{label}
</label>
<input id={id} className="auth-input" {...props} />
</div>
);
}
function OtpIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect
x="5.5"
y="2.5"
width="13"
height="19"
rx="2"
stroke="currentColor"
strokeWidth="1.4"
/>
<path
d="M10 17.5h4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
<circle cx="12" cy="9" r="1.2" fill="currentColor" />
</svg>
);
}
function KeyMiniIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="8" cy="14" r="3" stroke="currentColor" strokeWidth="1.4" />
<path
d="M10.5 11.5 19 3l2 2-2.5 2.5L20 9l-2 2-1.5-1.5"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
import { ReactNode } from "react";
import DashboardHeader from "@/components/dashboard/DashboardHeader";
import Breadcrumb, { Crumb } from "@/components/ui/Breadcrumb";
type ClothesShellProps = {
eyebrow: string;
title: string;
description?: string;
breadcrumbs?: Crumb[];
plusHref?: string;
onPlusClick?: () => void;
plusLabel?: string;
children: ReactNode;
};
export default function ClothesShell({
eyebrow,
title,
description,
breadcrumbs,
plusHref,
onPlusClick,
plusLabel = "افزودن",
children,
}: ClothesShellProps) {
const plusContent = (
<>
<span aria-hidden className="text-xl leading-none">
+
</span>
<span className="text-sm">{plusLabel}</span>
</>
);
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-10 sm:px-5 sm:pt-12 sm:pb-12 lg:px-6">
<div className="mb-5 flex flex-wrap items-start justify-between gap-4 dash-enter">
<div>
{breadcrumbs?.length ? <Breadcrumb items={breadcrumbs} /> : null}
<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">
{eyebrow}
</span>
<span aria-hidden className="text-sm text-muted/60 sm:text-base">
•
</span>
<span dir="rtl">{title}</span>
</h1>
{description ? (
<p className="mt-3 max-w-xl text-sm leading-7 text-muted">
{description}
</p>
) : null}
</div>
{onPlusClick ? (
<button
type="button"
onClick={onPlusClick}
className="clothes-plus"
aria-label={plusLabel}
>
{plusContent}
</button>
) : plusHref ? (
<Link href={plusHref} className="clothes-plus" aria-label={plusLabel}>
{plusContent}
</Link>
) : null}
</div>
{children}
</main>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import { ReactNode } from "react";
export type DashTileItem = {
href: string;
title: string;
titleEn: string;
description: string;
icon: ReactNode;
};
type DashTileGridProps = {
tiles: DashTileItem[];
startDelayMs?: number;
stepMs?: number;
};
export default function DashTileGrid({
tiles,
startDelayMs = 80,
stepMs = 70,
}: DashTileGridProps) {
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-5">
{tiles.map((tile, i) => (
<Link
key={tile.href + tile.titleEn}
href={tile.href}
className="dash-tile dash-enter"
style={{ animationDelay: `${startDelayMs + i * stepMs}ms` }}
>
<span className="mx-auto flex text-muted/65">{tile.icon}</span>
<div className="mt-5 text-center">
<h2 className="text-lg leading-tight text-ink">{tile.title}</h2>
<p
dir="ltr"
className="mt-0.5 font-display text-[0.7rem] tracking-[0.08em] text-muted"
>
{tile.titleEn}
</p>
<p className="mt-3 whitespace-nowrap text-xs leading-6 text-muted">
{tile.description}
</p>
</div>
<span className="mt-5 text-center text-xs tracking-wide text-muted">
مشاهده
<span aria-hidden className="mx-1">
←
</span>
</span>
</Link>
))}
</div>
);
}
@@ -0,0 +1,61 @@
import Image from "next/image";
import type { CSSProperties } from "react";
type DashboardBannerProps = {
src: string;
alt: string;
caption?: string;
hoverTitle?: string;
hoverSubtitle?: string;
className?: string;
style?: CSSProperties;
priority?: boolean;
};
export default function DashboardBanner({
src,
alt,
caption = "Quiet elegance.",
hoverTitle = "مدیریت فروش ویژه ی من",
hoverSubtitle = "Special Sale",
className = "",
style,
priority = false,
}: DashboardBannerProps) {
return (
<div
className={`dash-banner group relative mt-2 h-[150px] w-full overflow-hidden rounded-lg border border-line dash-enter ${className}`}
style={style}
>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 1152px) 100vw, 1152px"
className="object-cover object-center"
priority={priority}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
{caption ? (
<p
dir="ltr"
className="absolute bottom-5 start-5 z-[1] font-display text-lg text-white/95 transition-opacity duration-300 group-hover:opacity-0 sm:text-xl"
>
{caption}
</p>
) : null}
<div className="dash-banner-wipe" aria-hidden>
<div className="flex flex-col items-center gap-1.5 px-6 text-center">
<p className="text-base text-ink sm:text-lg">{hoverTitle}</p>
<p
dir="ltr"
className="font-display text-sm tracking-[0.08em] text-muted sm:text-base"
>
{hoverSubtitle}
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,248 @@
const visitSeries = [
42, 58, 51, 67, 73, 69, 88, 81, 95, 102, 97, 110, 118, 124, 119, 132, 140,
136, 148, 155, 149, 162, 170, 166, 178, 185, 179, 192, 201, 214,
];
const topProducts = [
{ name: "مانتو مشکی کلاسیک", visits: 842 },
{ name: "پیراهن سفید مینیمال", visits: 765 },
{ name: "کت پشمی طوسی", visits: 698 },
{ name: "شلوار بگ کرم", visits: 641 },
{ name: "بلوز ابریشمی", visits: 590 },
{ name: "دامن پلیسه", visits: 552 },
{ name: "کت جین روشن", visits: 521 },
];
function persianDayMonth(date: Date) {
const parts = new Intl.DateTimeFormat("en-US-u-ca-persian", {
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const month = parts.find((part) => part.type === "month")?.value ?? "01";
const day = parts.find((part) => part.type === "day")?.value ?? "01";
return `${day}-${month}`;
}
function buildVisitDays(count: number) {
const today = new Date();
today.setHours(12, 0, 0, 0);
return Array.from({ length: count }, (_, index) => {
const date = new Date(today);
date.setDate(today.getDate() - (count - 1 - index));
return {
value: visitSeries[index],
label: persianDayMonth(date),
};
});
}
function niceMax(value: number) {
const padded = value * 1.08;
const magnitude = 10 ** Math.floor(Math.log10(padded));
return Math.ceil(padded / magnitude) * magnitude;
}
export default function DashboardCharts({
startDelayMs = 0,
}: {
startDelayMs?: number;
}) {
const days = buildVisitDays(visitSeries.length);
const maxVisit = niceMax(Math.max(...visitSeries));
const totalVisits = visitSeries.reduce((sum, n) => sum + n, 0);
const maxProduct = Math.max(...topProducts.map((product) => product.visits));
const chartW = 560;
const chartH = 220;
const pad = { top: 14, right: 12, bottom: 34, left: 40 };
const plotW = chartW - pad.left - pad.right;
const plotH = chartH - pad.top - pad.bottom;
const step = plotW / (days.length - 1);
const yTicks = [0, 0.25, 0.5, 0.75, 1].map((ratio) => ({
ratio,
value: Math.round(maxVisit * ratio),
}));
const xLabelIndexes = [0, 5, 10, 15, 20, 25, days.length - 1];
const points = days
.map((day, index) => {
const x = pad.left + index * step;
const y = pad.top + plotH - (day.value / maxVisit) * plotH;
return `${x},${y}`;
})
.join(" ");
const areaPoints = `${pad.left},${pad.top + plotH} ${points} ${
pad.left + (days.length - 1) * step
},${pad.top + plotH}`;
return (
<section className="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-2">
<article
className="dash-chart dash-enter border border-line bg-surface p-4"
style={{ animationDelay: `${startDelayMs}ms` }}
>
<div className="mb-3 flex items-end justify-between gap-4">
<div>
<h3 className="text-base text-ink sm:text-lg">
بازدید کل وب‌سایت
</h3>
<p
dir="ltr"
className="mt-0.5 font-display text-[0.7rem] tracking-[0.08em] text-muted"
>
Last 30 days
</p>
</div>
<p dir="ltr" className="font-display text-xl text-ink sm:text-2xl">
{totalVisits.toLocaleString("en-US")}
</p>
</div>
<div className="w-full overflow-hidden" dir="ltr">
<svg
viewBox={`0 0 ${chartW} ${chartH}`}
className="h-52 w-full"
role="img"
aria-label="نمودار بازدید ۳۰ روز اخیر"
>
<defs>
<linearGradient id="visitFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0a0a0a" stopOpacity="0.14" />
<stop offset="100%" stopColor="#0a0a0a" stopOpacity="0" />
</linearGradient>
</defs>
{/* Plot frame */}
<line
x1={pad.left}
y1={pad.top}
x2={pad.left}
y2={pad.top + plotH}
stroke="#cfcfcf"
strokeWidth="1"
/>
<line
x1={pad.left}
y1={pad.top + plotH}
x2={pad.left + plotW}
y2={pad.top + plotH}
stroke="#cfcfcf"
strokeWidth="1"
/>
{yTicks.map(({ ratio, value }) => {
const y = pad.top + plotH - ratio * plotH;
return (
<g key={value}>
<line
x1={pad.left}
x2={pad.left + plotW}
y1={y}
y2={y}
stroke="#ececec"
strokeWidth="1"
/>
<text
x={pad.left - 8}
y={y + 3}
textAnchor="end"
fill="#6b6b6b"
fontSize="10"
fontFamily="var(--font-cailyne), Georgia, serif"
>
{value}
</text>
</g>
);
})}
{xLabelIndexes.map((index) => {
const x = pad.left + index * step;
return (
<g key={days[index].label + index}>
<line
x1={x}
x2={x}
y1={pad.top + plotH}
y2={pad.top + plotH + 4}
stroke="#cfcfcf"
strokeWidth="1"
/>
<text
x={x}
y={pad.top + plotH + 18}
textAnchor="middle"
fill="#6b6b6b"
fontSize="10"
fontFamily="var(--font-cailyne), Georgia, serif"
>
{days[index].label}
</text>
</g>
);
})}
<polygon points={areaPoints} fill="url(#visitFill)" />
<polyline
points={points}
fill="none"
stroke="#0a0a0a"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
</div>
</article>
<article
className="dash-chart dash-enter border border-line bg-surface p-4"
style={{ animationDelay: `${startDelayMs + 70}ms` }}
>
<div className="mb-3">
<h3 className="text-base text-ink sm:text-lg">
پربازدیدترین محصولات
</h3>
<p
dir="ltr"
className="mt-0.5 font-display text-[0.7rem] tracking-[0.08em] text-muted"
>
Top 7 product visits
</p>
</div>
<ul className="space-y-2.5">
{topProducts.map((product, index) => (
<li
key={product.name}
className="grid grid-cols-[1.5rem_1fr_auto] items-center gap-2"
>
<span dir="ltr" className="font-display text-[0.65rem] text-muted">
{String(index + 1).padStart(2, "0")}
</span>
<div className="min-w-0">
<div className="mb-1 truncate text-xs text-ink">
{product.name}
</div>
<div className="h-[2px] overflow-hidden bg-line">
<div
className="h-full bg-ink"
style={{
width: `${(product.visits / maxProduct) * 100}%`,
}}
/>
</div>
</div>
<span dir="ltr" className="text-[0.7rem] text-muted">
{product.visits}
</span>
</li>
))}
</ul>
</article>
</section>
);
}
@@ -0,0 +1,197 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import ManaLogo from "@/components/ui/ManaLogo";
import { useAuth } from "@/lib/auth";
type DashboardHeaderProps = {
userName?: string;
};
export default function DashboardHeader({
userName,
}: DashboardHeaderProps) {
const { user, logout } = useAuth();
const router = useRouter();
const displayName = userName ?? user?.name ?? "کاربر";
const [menuOpen, setMenuOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false);
const userRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(event: MouseEvent) {
if (!userRef.current?.contains(event.target as Node)) {
setUserOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
async function handleLogout() {
setUserOpen(false);
await logout();
router.replace("/login");
}
return (
<>
<header className="relative z-30 border-b border-line/80 bg-surface/90 backdrop-blur-sm">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-3 sm:gap-4">
<button
type="button"
aria-label="منو"
aria-expanded={menuOpen}
onClick={() => {
setMenuOpen((open) => !open);
setUserOpen(false);
}}
className="flex h-10 w-10 cursor-pointer items-center justify-center text-ink transition-opacity hover:opacity-60"
>
<MenuIcon open={menuOpen} />
</button>
<ManaLogo href="/" height={31} variant="black" />
</div>
<div ref={userRef} className="relative">
<button
type="button"
aria-expanded={userOpen}
aria-haspopup="menu"
onClick={() => {
setUserOpen((open) => !open);
setMenuOpen(false);
}}
className="flex cursor-pointer items-center gap-2.5 text-ink transition-opacity hover:opacity-70"
>
<span className="max-w-[9rem] truncate text-sm sm:max-w-none">
{displayName}
</span>
<span className="flex h-9 w-9 items-center justify-center border border-line bg-white">
<UserIcon />
</span>
</button>
{userOpen && (
<div role="menu" className="dash-menu-panel">
<Link
href="/profile"
role="menuitem"
className="dash-menu-item"
onClick={() => setUserOpen(false)}
>
<UserIcon />
پروفایل
</Link>
<Link
href="/change-password"
role="menuitem"
className="dash-menu-item"
onClick={() => setUserOpen(false)}
>
<KeyIcon />
تغییر رمز عبور
</Link>
<div className="h-px bg-line" />
<button
type="button"
role="menuitem"
className="dash-menu-item w-full text-muted"
onClick={() => void handleLogout()}
>
<LogoutIcon />
خروج
</button>
</div>
)}
</div>
</div>
</header>
{menuOpen && (
<nav
aria-label="منوی اصلی"
className="relative z-20 border-b border-line bg-surface"
>
<div className="mx-auto flex w-full max-w-6xl flex-wrap gap-x-8 gap-y-3 px-4 py-4 text-sm sm:px-6 lg:px-8">
<Link href="/clothes" className="text-ink transition-opacity hover:opacity-60">
لباس‌های من
</Link>
<Link href="/customers" className="text-ink transition-opacity hover:opacity-60">
مشتریان من
</Link>
<Link href="/blog" className="text-ink transition-opacity hover:opacity-60">
بلاگ من
</Link>
<Link href="/orders" className="text-ink transition-opacity hover:opacity-60">
سفارش‌های من
</Link>
<Link href="/profile" className="text-ink transition-opacity hover:opacity-60">
پروفایل من
</Link>
</div>
</nav>
)}
</>
);
}
function MenuIcon({ open }: { open: boolean }) {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
<path
d={open ? "M4 4L16 16M16 4L4 16" : "M3 5H17M3 10H17M3 15H17"}
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="square"
/>
</svg>
);
}
function UserIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.4" />
<path
d="M5 19c1.6-3.2 4-4.8 7-4.8s5.4 1.6 7 4.8"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="8" cy="14" r="3.25" stroke="currentColor" strokeWidth="1.4" />
<path
d="M10.5 11.5 19 3l2 2-2.5 2.5L20 9l-2 2-1.5-1.5"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LogoutIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M10 4H5v16h5M13 12h8M17 8l4 4-4 4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
export type Crumb = {
label: string;
href?: string;
};
export default function Breadcrumb({ items }: { items: Crumb[] }) {
return (
<nav aria-label="مسیر صفحه" className="mb-3">
<ol className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={`${item.label}-${index}`} className="flex items-center gap-2">
{index > 0 ? (
<span aria-hidden className="text-muted/50">
/
</span>
) : null}
{item.href && !isLast ? (
<Link
href={item.href}
className="transition-colors hover:text-ink"
>
{item.label}
</Link>
) : (
<span className={isLast ? "text-ink" : undefined}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
CategoryNode,
categoryMatchesQuery,
findCategoryPath,
} from "@/data/clothes";
type CategoryTreeSelectProps = {
nodes: CategoryNode[];
value: string;
onChange: (id: string) => void;
placeholder?: string;
id?: string;
};
export default function CategoryTreeSelect({
nodes,
value,
onChange,
placeholder = "جستجو و انتخاب دسته...",
id = "category",
}: CategoryTreeSelectProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Record<string, boolean>>({
c1: true,
"c1-1": true,
c2: true,
});
const selectedLabel = useMemo(() => {
const path = findCategoryPath(nodes, value);
return path ? path.join(" / ") : "";
}, [nodes, value]);
useEffect(() => {
function onDocClick(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, []);
function toggleExpand(nodeId: string) {
setExpanded((prev) => ({ ...prev, [nodeId]: !prev[nodeId] }));
}
function selectNode(nodeId: string) {
onChange(nodeId);
setOpen(false);
setQuery("");
}
const inputValue = open ? query : selectedLabel;
return (
<div ref={rootRef} className="category-tree-select">
<input
id={id}
value={inputValue}
placeholder={placeholder}
autoComplete="off"
onFocus={() => setOpen(true)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
aria-expanded={open}
aria-haspopup="listbox"
role="combobox"
/>
{open ? (
<div className="category-tree-panel" role="listbox">
<CategoryTreeList
nodes={nodes}
depth={0}
query={query}
expanded={expanded}
selectedId={value}
onToggle={toggleExpand}
onSelect={selectNode}
/>
</div>
) : null}
</div>
);
}
function CategoryTreeList({
nodes,
depth,
query,
expanded,
selectedId,
onToggle,
onSelect,
}: {
nodes: CategoryNode[];
depth: number;
query: string;
expanded: Record<string, boolean>;
selectedId: string;
onToggle: (id: string) => void;
onSelect: (id: string) => void;
}) {
const filtered = nodes.filter((node) => categoryMatchesQuery(node, query));
if (!filtered.length) {
return depth === 0 ? (
<p className="category-tree-empty">دسته‌ای پیدا نشد.</p>
) : null;
}
const forceOpen = Boolean(query.trim());
return (
<ul className="category-tree-list">
{filtered.map((node) => {
const hasChildren = Boolean(node.children?.length);
const isOpen = forceOpen || (expanded[node.id] ?? false);
const isSelected = selectedId === node.id;
return (
<li key={node.id}>
<div
className={`category-tree-row${isSelected ? " is-selected" : ""}`}
style={{ paddingInlineStart: `${0.55 + depth * 1.1}rem` }}
>
{hasChildren ? (
<button
type="button"
className="category-tree-toggle"
aria-label={isOpen ? "بستن" : "باز کردن"}
onClick={() => onToggle(node.id)}
>
{isOpen ? "−" : "+"}
</button>
) : (
<span className="category-tree-toggle-spacer" />
)}
<button
type="button"
role="option"
aria-selected={isSelected}
className="category-tree-label"
onClick={() => onSelect(node.id)}
>
<span>{node.nameFa}</span>
<span dir="ltr" className="category-tree-en">
{node.nameEn}
</span>
</button>
</div>
{hasChildren && isOpen ? (
<CategoryTreeList
nodes={node.children!}
depth={depth + 1}
query={query}
expanded={expanded}
selectedId={selectedId}
onToggle={onToggle}
onSelect={onSelect}
/>
) : null}
</li>
);
})}
</ul>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
export type ConfirmOptions = {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
};
type ConfirmContextValue = {
confirm: (options: ConfirmOptions | string) => Promise<boolean>;
};
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
type Pending = ConfirmOptions & {
resolve: (value: boolean) => void;
};
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<Pending | null>(null);
const pendingRef = useRef<Pending | null>(null);
const close = useCallback((value: boolean) => {
const current = pendingRef.current;
pendingRef.current = null;
setPending(null);
current?.resolve(value);
}, []);
const confirm = useCallback((options: ConfirmOptions | string) => {
const opts =
typeof options === "string" ? { message: options } : options;
return new Promise<boolean>((resolve) => {
const next: Pending = {
title: opts.title ?? "تأیید حذف",
message: opts.message,
confirmLabel: opts.confirmLabel ?? "حذف",
cancelLabel: opts.cancelLabel ?? "انصراف",
resolve,
};
pendingRef.current = next;
setPending(next);
});
}, []);
useEffect(() => {
if (!pending) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close(false);
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [pending, close]);
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
{pending ? (
<div className="confirm-root" role="presentation">
<button
type="button"
className="confirm-backdrop"
aria-label="انصراف"
onClick={() => close(false)}
/>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
className="confirm-panel"
>
<h2 id="confirm-title" className="confirm-title">
{pending.title}
</h2>
<p id="confirm-message" className="confirm-message">
{pending.message}
</p>
<div className="confirm-actions">
<button
type="button"
className="confirm-btn-cancel"
onClick={() => close(false)}
>
{pending.cancelLabel}
</button>
<button
type="button"
className="confirm-btn-danger"
onClick={() => close(true)}
>
{pending.confirmLabel}
</button>
</div>
</div>
</div>
) : null}
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const ctx = useContext(ConfirmContext);
if (!ctx) {
throw new Error("useConfirm must be used within ConfirmProvider");
}
return ctx.confirm;
}
+147
View File
@@ -0,0 +1,147 @@
import { ButtonHTMLAttributes, ReactNode } from "react";
type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
label: string;
children: ReactNode;
variant?: "ghost" | "solid" | "line";
badge?: number | string | null;
};
const stroke = {
stroke: "currentColor",
strokeWidth: 1,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
fill: "none",
};
export default function IconButton({
label,
children,
variant = "ghost",
badge,
className = "",
type = "button",
...props
}: IconButtonProps) {
const showBadge =
badge !== null &&
badge !== undefined &&
badge !== "" &&
!(typeof badge === "number" && badge <= 0);
return (
<button
type={type}
aria-label={label}
data-tooltip={label}
className={`icon-btn icon-btn-${variant} ${className}`}
{...props}
>
{children}
{showBadge ? (
<span className="icon-btn-badge font-montserrat" aria-hidden>
{badge}
</span>
) : null}
</button>
);
}
export function EditIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path {...stroke} d="M12.5 5.5 18.5 11.5" />
<path {...stroke} d="M4 20h4L18.5 9.5a2 2 0 0 0-3-3L5 17v3Z" />
</svg>
);
}
export function TrashIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path {...stroke} d="M5 7h14" />
<path {...stroke} d="M9.5 7V5.5h5V7" />
<path {...stroke} d="M8 7l.7 12h6.6L16 7" />
<path {...stroke} d="M10.5 11v5M13.5 11v5" />
</svg>
);
}
export function VariantIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<rect {...stroke} x="4.5" y="4.5" width="6" height="6" rx="0.5" />
<rect {...stroke} x="13.5" y="4.5" width="6" height="6" rx="0.5" />
<rect {...stroke} x="4.5" y="13.5" width="6" height="6" rx="0.5" />
<rect {...stroke} x="13.5" y="13.5" width="6" height="6" rx="0.5" />
</svg>
);
}
export function DiscountIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<circle {...stroke} cx="8" cy="8" r="1.4" />
<circle {...stroke} cx="16" cy="16" r="1.4" />
<path {...stroke} d="M7.5 16.5 16.5 7.5" />
</svg>
);
}
export function FilterIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path {...stroke} d="M5 6.5h14l-5 6v5.5l-4 1.5v-7L5 6.5Z" />
</svg>
);
}
export function ClearFilterIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path {...stroke} d="M7 7 17 17M17 7 7 17" />
</svg>
);
}
export function AddChildIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<circle {...stroke} cx="12" cy="12" r="7.5" />
<path {...stroke} d="M12 8.5v7M8.5 12h7" />
</svg>
);
}
export function SubscriptionIcon({ premium = false }: { premium?: boolean }) {
if (premium) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path
{...stroke}
d="M5.5 9.5 8.2 12l3.8-5.5 3.8 5.5 2.7-2.5V17.5H5.5V9.5Z"
/>
<path {...stroke} d="M5.5 17.5h13" />
</svg>
);
}
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<circle {...stroke} cx="12" cy="9" r="3.2" />
<path {...stroke} d="M6.5 18.5c1.4-2.6 3.3-3.9 5.5-3.9s4.1 1.3 5.5 3.9" />
</svg>
);
}
export function MetaTagsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path {...stroke} d="M4.5 8.5h15" />
<path {...stroke} d="M4.5 12.5h10" />
<path {...stroke} d="M4.5 16.5h12" />
<path {...stroke} d="M17.5 11.5 20 14l-2.5 2.5" />
</svg>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import Image from "next/image";
import {
PointerEvent,
WheelEvent,
useEffect,
useRef,
useState,
} from "react";
type AspectRatio = "1:1" | "3:2" | "2:3";
type ImageCropperProps = {
src: string;
aspect: AspectRatio;
alt?: string;
className?: string;
};
const MIN_ZOOM = 1;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.15;
const aspectClass: Record<AspectRatio, string> = {
"1:1": "crop-ratio-1-1",
"3:2": "crop-ratio-3-2",
"2:3": "crop-ratio-2-3",
};
export default function ImageCropper({
src,
aspect,
alt = "پیش‌نمایش برش",
className = "",
}: ImageCropperProps) {
const [zoom, setZoom] = useState(MIN_ZOOM);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
useEffect(() => {
setZoom(MIN_ZOOM);
setOffset({ x: 0, y: 0 });
}, [src, aspect]);
function clampZoom(value: number) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
}
function zoomBy(delta: number) {
setZoom((prev) => clampZoom(prev + delta));
}
useEffect(() => {
if (zoom <= MIN_ZOOM) setOffset({ x: 0, y: 0 });
}, [zoom]);
function onWheel(event: WheelEvent<HTMLDivElement>) {
event.preventDefault();
zoomBy(event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
}
function onPointerDown(event: PointerEvent<HTMLDivElement>) {
if (zoom <= MIN_ZOOM) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offset.x,
originY: offset.y,
};
}
function onPointerMove(event: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setOffset({
x: drag.originX + (event.clientX - drag.startX),
y: drag.originY + (event.clientY - drag.startY),
});
}
function onPointerUp(event: PointerEvent<HTMLDivElement>) {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
}
const unoptimized = src.startsWith("blob:");
return (
<div className={`image-cropper ${className}`}>
<div
className={`crop-frame crop-frame-brand ${aspectClass[aspect]}`}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{ cursor: zoom > MIN_ZOOM ? "grab" : "default" }}
>
<div
className="crop-frame-media"
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
}}
>
<Image
src={src}
alt={alt}
fill
className="object-cover"
sizes="220px"
unoptimized={unoptimized}
draggable={false}
/>
</div>
</div>
<div className="crop-zoom-bar">
<button
type="button"
className="crop-zoom-btn"
aria-label="کوچک‌نمایی"
disabled={zoom <= MIN_ZOOM}
onClick={() => zoomBy(-ZOOM_STEP)}
>
−
</button>
<input
type="range"
className="crop-zoom-range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
aria-label="بزرگ‌نمایی"
onChange={(e) => {
const next = clampZoom(Number(e.target.value));
setZoom(next);
if (next <= MIN_ZOOM) setOffset({ x: 0, y: 0 });
}}
/>
<button
type="button"
className="crop-zoom-btn"
aria-label="بزرگ‌نمایی"
disabled={zoom >= MAX_ZOOM}
onClick={() => zoomBy(ZOOM_STEP)}
>
+
</button>
</div>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import Image from "next/image";
import Link from "next/link";
const LOGO_ASPECT = 1172 / 304;
export const MANA_LOGO_SRC = {
default: "/images/brand/Mana-Logo.png",
black: "/images/brand/Mana-Logo-Black.png",
white: "/images/brand/Mana-Logo-White.png",
} as const;
type ManaLogoProps = {
href?: string;
className?: string;
priority?: boolean;
/** Visual height of the wordmark */
height?: number;
/** Visual width of the wordmark (overrides height when set) */
width?: number;
/** Framed lockup like the brand board */
framed?: boolean;
/** Show “The Style you desire” under the mark */
showTagline?: boolean;
taglineClassName?: string;
/** Logo color version */
variant?: keyof typeof MANA_LOGO_SRC;
/** Wrap mark on a black plate (slogan stays outside) */
blackBg?: boolean;
/** Slogan font size in px (default 24) */
taglineSize?: number;
/** On hover, invert logo plate + slogan colors */
hoverSwap?: boolean;
};
function LogoImage({
src,
width,
height,
priority,
className = "",
}: {
src: string;
width: number;
height: number;
priority?: boolean;
className?: string;
}) {
return (
<Image
src={src}
alt="Mana Collection"
width={width}
height={height}
priority={priority}
unoptimized
style={{ width, height: "auto" }}
className={`object-contain ${className}`}
/>
);
}
export default function ManaLogo({
href = "/",
className = "",
priority = false,
height = 36,
width: widthProp,
framed = false,
showTagline = false,
taglineClassName = "",
variant = "default",
blackBg = false,
taglineSize = 24,
hoverSwap = false,
}: ManaLogoProps) {
const width = widthProp ?? Math.round(height * LOGO_ASPECT);
const renderHeight = widthProp
? Math.round(widthProp / LOGO_ASPECT)
: height;
const src =
variant === "white"
? MANA_LOGO_SRC.white
: variant === "black"
? MANA_LOGO_SRC.black
: MANA_LOGO_SRC.default;
const swapSrc =
variant === "white" ? MANA_LOGO_SRC.black : MANA_LOGO_SRC.white;
const image = (
<span className="relative inline-flex">
<LogoImage
src={src}
width={width}
height={renderHeight}
priority={priority}
className={
hoverSwap
? "transition-opacity duration-500 group-hover:opacity-0"
: ""
}
/>
{hoverSwap ? (
<LogoImage
src={swapSrc}
width={width}
height={renderHeight}
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
) : null}
</span>
);
const markInner = framed ? (
<span
className="inline-flex border border-ink/80 p-[3px]"
style={{ boxShadow: "inset 0 0 0 1px rgba(10,10,10,0.55)" }}
>
<span className="inline-flex items-center justify-center px-5 py-3 sm:px-7 sm:py-3.5">
{image}
</span>
</span>
) : (
image
);
const mark = blackBg ? (
<span
className={`inline-flex w-full items-center justify-center bg-ink px-2.5 py-2 sm:px-3 sm:py-2.5 ${
hoverSwap ? "mana-logo-swap-plate" : ""
}`}
>
{markInner}
</span>
) : (
markInner
);
const tagline = showTagline ? (
<span
className={`self-start bg-white px-4 py-1 font-display tracking-[0.12em] text-[#8a8a8a] sm:px-5 ${
hoverSwap ? "mana-logo-swap-tagline" : ""
} ${taglineClassName}`}
style={{ fontSize: taglineSize }}
>
<span>The Style you desire</span>
</span>
) : null;
const lockup = (
<span
dir="ltr"
className={`inline-flex flex-col items-stretch gap-0 ${className}`}
>
{mark}
{tagline}
</span>
);
if (!href) {
return lockup;
}
const isExternal = /^https?:\/\//i.test(href);
const interactiveClass = `group inline-flex cursor-pointer ${
hoverSwap ? "" : "transition-opacity duration-200 hover:opacity-80"
}`;
if (isExternal) {
return (
<a
href={href}
className={interactiveClass}
aria-label="Mana Collection"
>
{lockup}
</a>
);
}
return (
<Link href={href} className={interactiveClass} aria-label="Mana Collection">
{lockup}
</Link>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { FormEvent, ReactNode, useEffect } from "react";
type ModalProps = {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
wide?: boolean | "xl" | "full";
};
export default function Modal({
open,
title,
onClose,
children,
footer,
wide = false,
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
const panelClass =
wide === "full"
? "modal-panel modal-panel-full"
: wide === "xl"
? "modal-panel modal-panel-xl"
: wide
? "modal-panel modal-panel-wide"
: "modal-panel";
return (
<div className="modal-root" role="presentation">
<button
type="button"
className="modal-backdrop"
aria-label="بستن"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={panelClass}
>
<div className="modal-header">
<h2 id="modal-title" className="text-base text-ink sm:text-lg">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="بستن"
>
×
</button>
</div>
<div className="modal-body">{children}</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
);
}
type FormModalProps = {
open: boolean;
title: string;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
submitLabel?: string;
children: ReactNode;
wide?: boolean | "xl" | "full";
};
export function FormModal({
open,
title,
onClose,
onSubmit,
submitLabel = "ذخیره",
children,
wide = false,
}: FormModalProps) {
return (
<Modal
open={open}
title={title}
onClose={onClose}
wide={wide}
footer={
<>
<button type="button" className="modal-btn-ghost" onClick={onClose}>
انصراف
</button>
<button type="submit" form="modal-form" className="modal-btn-solid">
{submitLabel}
</button>
</>
}
>
<form
id="modal-form"
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
onSubmit(e);
}}
>
{children}
</form>
</Modal>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useMemo, useState } from "react";
export function usePagination<T>(
items: T[],
pageSize: number,
resetKey?: string | number,
) {
const [page, setPage] = useState(1);
const totalItems = items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize) || 1);
useEffect(() => {
setPage(1);
}, [resetKey, pageSize]);
const safePage = Math.min(Math.max(1, page), totalPages);
const pageItems = useMemo(() => {
const start = (safePage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, safePage, pageSize]);
return {
page: safePage,
setPage,
pageItems,
totalPages,
totalItems,
pageSize,
};
}
type PaginationProps = {
page: number;
totalPages: number;
totalItems: number;
pageSize: number;
onChange: (page: number) => void;
};
export default function Pagination({
page,
totalPages,
totalItems,
pageSize,
onChange,
}: PaginationProps) {
if (totalItems === 0) return null;
const from = (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, totalItems);
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
return (
<div className="pagination" aria-label="صفحه‌بندی">
<p className="pagination-meta">
{new Intl.NumberFormat("fa-IR").format(from)}–
{new Intl.NumberFormat("fa-IR").format(to)} از{" "}
{new Intl.NumberFormat("fa-IR").format(totalItems)}
</p>
<div className="pagination-controls">
<button
type="button"
className="pagination-btn"
disabled={page <= 1}
aria-label="صفحه قبل"
onClick={() => onChange(page - 1)}
>
قبلی
</button>
{pages.map((n) => (
<button
key={n}
type="button"
className={`pagination-btn pagination-num${
n === page ? " is-active" : ""
}`}
aria-current={n === page ? "page" : undefined}
onClick={() => onChange(n)}
>
{new Intl.NumberFormat("fa-IR").format(n)}
</button>
))}
<button
type="button"
className="pagination-btn"
disabled={page >= totalPages}
aria-label="صفحه بعد"
onClick={() => onChange(page + 1)}
>
بعدی
</button>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import {
ChangeEvent,
useEffect,
useRef,
} from "react";
type RichTextAreaProps = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
};
export default function RichTextArea({
value,
onChange,
placeholder = "متن مطلب را بنویسید...",
}: RichTextAreaProps) {
const editorRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = editorRef.current;
if (!el) return;
if (el.innerHTML !== value) {
el.innerHTML = value || "";
}
}, [value]);
function emitChange() {
onChange(editorRef.current?.innerHTML ?? "");
}
function runCommand(command: string) {
editorRef.current?.focus();
document.execCommand(command, false);
emitChange();
}
function insertImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !editorRef.current) return;
const url = URL.createObjectURL(file);
editorRef.current.focus();
document.execCommand(
"insertHTML",
false,
`<img src="${url}" alt="" class="rich-inline-image" />`,
);
emitChange();
event.target.value = "";
}
return (
<div className="rich-text">
<div className="rich-text-toolbar">
<button type="button" onClick={() => runCommand("bold")} aria-label="پررنگ">
B
</button>
<button
type="button"
onClick={() => runCommand("italic")}
aria-label="ایتالیک"
className="italic"
>
I
</button>
<button type="button" onClick={() => runCommand("underline")} aria-label="زیرخط">
U
</button>
<span className="rich-text-divider" />
<button
type="button"
onClick={() => fileRef.current?.click()}
aria-label="افزودن تصویر"
>
تصویر
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="sr-only"
onChange={insertImage}
/>
</div>
<div
ref={editorRef}
className="rich-text-editor"
contentEditable
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
onInput={emitChange}
suppressContentEditableWarning
/>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
export type BlogPost = {
id: string;
title: string;
date: string;
author: string;
abstract: string;
body: string;
image: string;
tags: string[];
metaTitle: string;
metaDescription: string;
metaKeywords: string;
};
export const blogPosts: BlogPost[] = [
{
id: "bl1",
title: "زیبایی در سادگی",
date: "2026-03-12",
author: "سارا محمدی",
abstract: "نگاهی به مینیمالیسم در طراحی لباس و نقش خطوط آرام در کالکشن مانا.",
body: "<p>سادگی فقط حذف نیست؛ انتخاب آگاهانه است. در این مطلب درباره بافت، رنگ و فرم صحبت می‌کنیم.</p>",
image: "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
tags: ["مینیمال", "طراحی"],
metaTitle: "زیبایی در سادگی | مانا",
metaDescription: "نگاهی به مینیمالیسم در طراحی لباس",
metaKeywords: "مینیمال، لباس، مانا",
},
{
id: "bl2",
title: "پارچه‌های فصل بهار",
date: "2026-02-28",
author: "نیلوفر احمدی",
abstract: "انتخاب پارچه مناسب برای هوای ملایم بهار و ترکیب رنگ‌های خنثی.",
body: "<p>بهار فرصت بازی با بافت‌های سبک است. لینن، پنبه نرم و لایه‌های شفاف.</p>",
image: "/images/jonathan-borba-XJt51hAa3z8-unsplash.jpg",
tags: ["بهار", "پارچه"],
metaTitle: "پارچه‌های فصل بهار | مانا",
metaDescription: "راهنمای انتخاب پارچه برای بهار",
metaKeywords: "بهار، پارچه، کالکشن",
},
{
id: "bl3",
title: "استایل روزمره شیک",
date: "2026-01-18",
author: "مریم کریمی",
abstract: "چگونه با چند قطعه پایه، استایلی مرتب و ماندگار بسازید.",
body: "<p>قطعات پایه مثل مانتو کلاسیک و پیراهن ساده، ستون استایل روزمره‌اند.</p>",
image: "/images/aiony-haust-owp8uQgoK8U-unsplash.jpg",
tags: ["استایل", "روزمره"],
metaTitle: "استایل روزمره شیک | مانا",
metaDescription: "ساخت استایل روزمره با قطعات پایه",
metaKeywords: "استایل، روزمره",
},
{
id: "bl4",
title: "رنگ‌های خنثی در کمد",
date: "2025-12-05",
author: "الهام رضایی",
abstract: "چرا پالت مشکی، سفید و بژ همیشه کار می‌کند و چگونه ترکیب شود.",
body: "<p>رنگ‌های خنثی فضای تنفس به استایل می‌دهند و ترکیب آن‌ها آسان است.</p>",
image: "/images/ola-szkolda-J61EvbE1UCE-unsplash.jpg",
tags: ["رنگ", "کمد"],
metaTitle: "رنگ‌های خنثی در کمد | مانا",
metaDescription: "کار با پالت خنثی در استایل",
metaKeywords: "رنگ، مشکی، بژ",
},
];
export function formatBlogDate(isoDate: string) {
const date = new Date(`${isoDate}T12:00:00`);
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
+436
View File
@@ -0,0 +1,436 @@
export type Brand = {
id: string;
nameFa: string;
nameEn: string;
image: string;
};
export type CategoryVariants = {
colors: string[];
sizes: string[];
packages: Array<"6" | "12">;
};
export type CategoryNode = {
id: string;
nameFa: string;
nameEn: string;
children?: CategoryNode[];
variants?: CategoryVariants;
};
export type ProductVariantPrice = {
id: string;
colorId: string;
sizeId: string;
packageId: string;
price: string;
discountPrice?: string;
};
export type Product = {
id: string;
nameFa: string;
nameEn: string;
summary?: string;
description?: string;
categoryId: string;
brandId: string;
price: number;
discountPrice?: number;
image: string;
gallery?: string[];
tags: string[];
variantPrices?: ProductVariantPrice[];
};
/** Mock DB catalogs for category variants */
export const colorCatalog = [
{ id: "black", nameFa: "مشکی", hex: "#0a0a0a" },
{ id: "white", nameFa: "سفید", hex: "#f5f5f5" },
{ id: "cream", nameFa: "کرم", hex: "#e8dcc8" },
{ id: "beige", nameFa: "بژ", hex: "#c4a882" },
{ id: "navy", nameFa: "سرمه‌ای", hex: "#1b2a4a" },
{ id: "olive", nameFa: "زیتونی", hex: "#556b2f" },
{ id: "burgundy", nameFa: "زرشکی", hex: "#6b1e2a" },
{ id: "gray", nameFa: "طوسی", hex: "#8a8a8a" },
{ id: "brown", nameFa: "قهوه‌ای", hex: "#6b4423" },
{ id: "red", nameFa: "قرمز", hex: "#b91c1c" },
];
export const sizeCatalog = [
{ id: "xs", nameFa: "XS" },
{ id: "s", nameFa: "S" },
{ id: "m", nameFa: "M" },
{ id: "l", nameFa: "L" },
{ id: "xl", nameFa: "XL" },
{ id: "xxl", nameFa: "XXL" },
{ id: "36", nameFa: "36" },
{ id: "38", nameFa: "38" },
{ id: "40", nameFa: "40" },
{ id: "42", nameFa: "42" },
{ id: "44", nameFa: "44" },
];
export const packageCatalog: Array<{ id: "6" | "12"; nameFa: string }> = [
{ id: "6", nameFa: "۶ تایی" },
{ id: "12", nameFa: "۱۲ تایی" },
];
export const emptyVariants = (): CategoryVariants => ({
colors: [],
sizes: [],
packages: [],
});
export const brands: Brand[] = [
{
id: "b1",
nameFa: "مانا",
nameEn: "Mana",
image: "/images/ola-szkolda-J61EvbE1UCE-unsplash.jpg",
},
{
id: "b2",
nameFa: "نویر",
nameEn: "Noir",
image: "/images/aiony-haust-owp8uQgoK8U-unsplash.jpg",
},
{
id: "b3",
nameFa: "لومن",
nameEn: "Lumen",
image: "/images/jonathan-borba-XJt51hAa3z8-unsplash.jpg",
},
{
id: "b4",
nameFa: "اتلیه",
nameEn: "Atelier",
image: "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
},
];
export const categories: CategoryNode[] = [
{
id: "c1",
nameFa: "زنانه",
nameEn: "Women",
children: [
{
id: "c1-1",
nameFa: "مانتو",
nameEn: "Manteau",
variants: {
colors: ["black", "cream", "navy", "gray"],
sizes: ["s", "m", "l", "xl"],
packages: ["6"],
},
children: [
{ id: "c1-1-1", nameFa: "بهاره", nameEn: "Spring" },
{ id: "c1-1-2", nameFa: "پاییزه", nameEn: "Autumn" },
],
},
{
id: "c1-2",
nameFa: "پیراهن",
nameEn: "Dress",
variants: {
colors: ["white", "olive", "red", "beige"],
sizes: ["36", "38", "40", "42"],
packages: ["6", "12"],
},
},
{
id: "c1-3",
nameFa: "شلوار",
nameEn: "Trousers",
variants: {
colors: ["black", "beige", "olive"],
sizes: ["38", "40", "42", "44"],
packages: [],
},
},
],
},
{
id: "c2",
nameFa: "مردانه",
nameEn: "Men",
children: [
{ id: "c2-1", nameFa: "کت", nameEn: "Jacket" },
{ id: "c2-2", nameFa: "پیراهن رسمی", nameEn: "Shirt" },
],
},
{
id: "c3",
nameFa: "اکسسوری",
nameEn: "Accessories",
children: [
{ id: "c3-1", nameFa: "کیف", nameEn: "Bag" },
{ id: "c3-2", nameFa: "کفش", nameEn: "Shoes" },
],
},
];
export const products: Product[] = [
{
id: "p1",
nameFa: "مانتو مشکی کلاسیک",
nameEn: "Classic Black Manteau",
categoryId: "c1-1",
brandId: "b1",
price: 4200000,
discountPrice: 3570000,
image: "/images/aiony-haust-owp8uQgoK8U-unsplash.jpg",
tags: ["کلاسیک", "مشکی"],
},
{
id: "p2",
nameFa: "پیراهن سفید مینیمال",
nameEn: "Minimal White Dress",
categoryId: "c1-2",
brandId: "b3",
price: 2800000,
image: "/images/jonathan-borba-XJt51hAa3z8-unsplash.jpg",
tags: ["مینیمال", "سفید"],
},
{
id: "p3",
nameFa: "کت پشمی طوسی",
nameEn: "Grey Wool Coat",
categoryId: "c2-1",
brandId: "b2",
price: 5100000,
discountPrice: 4590000,
image: "/images/ola-szkolda-J61EvbE1UCE-unsplash.jpg",
tags: ["زمستانه"],
},
{
id: "p4",
nameFa: "شلوار بگ کرم",
nameEn: "Cream Baggy Pants",
categoryId: "c1-3",
brandId: "b1",
price: 1950000,
image: "/images/chyntia-juls-HlVjI5WmoQY-unsplash.jpg",
tags: ["روزمره"],
},
{
id: "p5",
nameFa: "بلوز ابریشمی زیتونی",
nameEn: "Olive Silk Blouse",
categoryId: "c1-2",
brandId: "b4",
price: 2400000,
discountPrice: 1920000,
image: "/images/valerie-elash-T5tIoXFJfSk-unsplash.jpg",
tags: ["ابریشم"],
},
{
id: "p6",
nameFa: "دامن پلیسه مشکی",
nameEn: "Black Pleated Skirt",
categoryId: "c1-2",
brandId: "b2",
price: 1680000,
image: "/images/eve-maier-u1OuYQa0WtQ-unsplash.jpg",
tags: ["مجلسی"],
},
{
id: "p7",
nameFa: "کت جین روشن",
nameEn: "Light Denim Jacket",
categoryId: "c2-1",
brandId: "b3",
price: 3200000,
discountPrice: 2880000,
image: "/images/amir-asghari-xoEb3kS_YoM-unsplash.jpg",
tags: ["جین"],
},
{
id: "p8",
nameFa: "پیراهن زرد ابریشمی",
nameEn: "Yellow Silk Dress",
categoryId: "c1-2",
brandId: "b4",
price: 3900000,
image: "/images/ryanwar-hanif-98XENwtPMCc-unsplash.jpg",
tags: ["رنگارنگ"],
},
];
export function formatPrice(value: number) {
return new Intl.NumberFormat("en-US").format(value);
}
export function discountPercent(price: number, discountPrice: number) {
return Math.round(((price - discountPrice) / price) * 100);
}
export function flattenCategories(
nodes: CategoryNode[],
prefix = "",
): { id: string; label: string }[] {
return nodes.flatMap((node) => {
const label = prefix ? `${prefix} / ${node.nameFa}` : node.nameFa;
return [
{ id: node.id, label },
...(node.children ? flattenCategories(node.children, label) : []),
];
});
}
export function findCategoryNode(
nodes: CategoryNode[],
id: string,
): CategoryNode | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children?.length) {
const found = findCategoryNode(node.children, id);
if (found) return found;
}
}
return null;
}
/** Resolve variants from category or nearest parent that has them */
export function resolveCategoryVariants(
categoryId: string,
nodes: CategoryNode[] = categories,
): CategoryVariants | null {
let current = findCategoryNode(nodes, categoryId);
while (current) {
if (
current.variants &&
(current.variants.colors.length ||
current.variants.sizes.length ||
current.variants.packages.length)
) {
return current.variants;
}
const parent = findParentCategory(nodes, current.id);
if (!parent) break;
current = parent;
}
return null;
}
function findParentCategory(
nodes: CategoryNode[],
childId: string,
): CategoryNode | null {
for (const node of nodes) {
if (node.children?.some((c) => c.id === childId)) return node;
if (node.children?.length) {
const found = findParentCategory(node.children, childId);
if (found) return found;
}
}
return null;
}
export function getCategoryVariantOptions(
categoryId: string,
nodes: CategoryNode[] = categories,
) {
const groups = getCategoryVariantGroups(categoryId, nodes);
return [
...groups.colors.map((opt) => ({
value: `color:${opt.value}`,
label: `رنگ · ${opt.label}`,
})),
...groups.sizes.map((opt) => ({
value: `size:${opt.value}`,
label: `سایز · ${opt.label}`,
})),
...groups.packages.map((opt) => ({
value: `package:${opt.value}`,
label: `پکیج · ${opt.label}`,
})),
];
}
export function getCategoryVariantGroups(
categoryId: string,
nodes: CategoryNode[] = categories,
) {
const variants = resolveCategoryVariants(categoryId, nodes);
const empty = {
colors: [] as { value: string; label: string }[],
sizes: [] as { value: string; label: string }[],
packages: [] as { value: string; label: string }[],
};
if (!variants) return empty;
return {
colors: variants.colors
.map((id) => {
const color = colorCatalog.find((item) => item.id === id);
return color ? { value: id, label: color.nameFa } : null;
})
.filter(Boolean) as { value: string; label: string }[],
sizes: variants.sizes
.map((id) => {
const size = sizeCatalog.find((item) => item.id === id);
return size ? { value: id, label: size.nameFa } : null;
})
.filter(Boolean) as { value: string; label: string }[],
packages: variants.packages
.map((id) => {
const pack = packageCatalog.find((item) => item.id === id);
return pack
? { value: id, label: `${pack.nameFa} (${id} pcs)` }
: null;
})
.filter(Boolean) as { value: string; label: string }[],
};
}
export function getVariantLabel(
row: Pick<ProductVariantPrice, "colorId" | "sizeId" | "packageId">,
categoryId: string,
nodes: CategoryNode[] = categories,
) {
const groups = getCategoryVariantGroups(categoryId, nodes);
const parts: string[] = [];
if (row.colorId) {
const color = groups.colors.find((opt) => opt.value === row.colorId);
if (color) parts.push(`رنگ · ${color.label}`);
}
if (row.sizeId) {
const size = groups.sizes.find((opt) => opt.value === row.sizeId);
if (size) parts.push(`سایز · ${size.label}`);
}
if (row.packageId) {
const pack = groups.packages.find((opt) => opt.value === row.packageId);
if (pack) parts.push(`پکیج · ${pack.label}`);
}
return parts.length ? parts.join(" / ") : "بدون تنوع";
}
export function findCategoryPath(
nodes: CategoryNode[],
id: string,
trail: string[] = [],
): string[] | null {
for (const node of nodes) {
const next = [...trail, node.nameFa];
if (node.id === id) return next;
if (node.children?.length) {
const found = findCategoryPath(node.children, id, next);
if (found) return found;
}
}
return null;
}
export function categoryMatchesQuery(node: CategoryNode, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
const self =
node.nameFa.includes(query.trim()) ||
node.nameEn.toLowerCase().includes(q);
if (self) return true;
return Boolean(node.children?.some((child) => categoryMatchesQuery(child, query)));
}
+81
View File
@@ -0,0 +1,81 @@
export type SubscriptionStatus = "regular" | "premium";
export type Customer = {
id: string;
nameFa: string;
phone: string;
joinedAt: string;
subscription: SubscriptionStatus;
};
export const customers: Customer[] = [
{
id: "cu1",
nameFa: "سارا محمدی",
phone: "09121234567",
joinedAt: "2025-11-12",
subscription: "premium",
},
{
id: "cu2",
nameFa: "نیلوفر احمدی",
phone: "09351239876",
joinedAt: "2026-01-04",
subscription: "regular",
},
{
id: "cu3",
nameFa: "مریم کریمی",
phone: "09197654321",
joinedAt: "2025-08-21",
subscription: "premium",
},
{
id: "cu4",
nameFa: "الهام رضایی",
phone: "09014567890",
joinedAt: "2026-02-18",
subscription: "regular",
},
{
id: "cu5",
nameFa: "هانیه موسوی",
phone: "09128887766",
joinedAt: "2025-12-02",
subscription: "regular",
},
{
id: "cu6",
nameFa: "یاسمن نوری",
phone: "09361112233",
joinedAt: "2026-03-09",
subscription: "premium",
},
{
id: "cu7",
nameFa: "زهرا حسینی",
phone: "09201230000",
joinedAt: "2025-06-30",
subscription: "regular",
},
{
id: "cu8",
nameFa: "فرناز اکبری",
phone: "09193456789",
joinedAt: "2026-04-01",
subscription: "premium",
},
];
export function formatJoinedDate(isoDate: string) {
const date = new Date(`${isoDate}T12:00:00`);
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
export function subscriptionLabel(status: SubscriptionStatus) {
return status === "premium" ? "پرمیوم" : "عادی";
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+188
View File
@@ -0,0 +1,188 @@
const API_URL =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ||
"http://api.mana-collection.com:3002/api/v1";
export class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, message: string, body?: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
type RequestOptions = {
method?: string;
body?: unknown;
token?: string | null;
auth?: boolean;
};
function getStoredAccessToken() {
if (typeof window === "undefined") return null;
return localStorage.getItem("mana_access_token");
}
function getStoredRefreshToken() {
if (typeof window === "undefined") return null;
return localStorage.getItem("mana_refresh_token");
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("mana_access_token", accessToken);
localStorage.setItem("mana_refresh_token", refreshToken);
}
export function clearTokens() {
localStorage.removeItem("mana_access_token");
localStorage.removeItem("mana_refresh_token");
localStorage.removeItem("mana_user");
}
export function storeUser(user: unknown) {
localStorage.setItem("mana_user", JSON.stringify(user));
}
export function readStoredUser<T>() {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem("mana_user");
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function parseError(res: Response) {
const body = await res.json().catch(() => null);
const message =
(body && typeof body === "object" && "message" in body
? Array.isArray((body as { message: unknown }).message)
? (body as { message: string[] }).message.join("، ")
: String((body as { message: unknown }).message)
: null) || res.statusText || "خطای سرور";
return new ApiError(res.status, message, body);
}
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = getStoredRefreshToken();
if (!refreshToken) return null;
if (!refreshPromise) {
refreshPromise = (async () => {
try {
const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = (await res.json()) as {
accessToken: string;
refreshToken: string;
user: unknown;
};
setTokens(data.accessToken, data.refreshToken);
storeUser(data.user);
return data.accessToken;
} catch {
clearTokens();
return null;
} finally {
refreshPromise = null;
}
})();
}
return refreshPromise;
}
export async function api<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const headers: Record<string, string> = {};
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}
const useAuth = options.auth !== false;
let token = options.token ?? (useAuth ? getStoredAccessToken() : null);
if (token) headers.Authorization = `Bearer ${token}`;
const doFetch = () =>
fetch(`${API_URL}${path.startsWith("/") ? path : `/${path}`}`, {
method: options.method ?? (options.body !== undefined ? "POST" : "GET"),
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
let res = await doFetch();
if (res.status === 401 && useAuth && !options.token) {
const next = await refreshAccessToken();
if (next) {
headers.Authorization = `Bearer ${next}`;
res = await doFetch();
}
}
if (!res.ok) throw await parseError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export type MediaKind =
| "main"
| "gallery"
| "blog"
| "brand"
| "store"
| "avatar"
| "temp";
export type MediaUploadResult = {
url: string;
storageKey: string;
storageDisk: string;
};
export async function uploadMedia(
file: File,
kind: MediaKind,
): Promise<MediaUploadResult> {
const form = new FormData();
form.append("file", file);
const headers: Record<string, string> = {};
let token = getStoredAccessToken();
if (token) headers.Authorization = `Bearer ${token}`;
const doFetch = () =>
fetch(`${API_URL}/media/upload?kind=${encodeURIComponent(kind)}`, {
method: "POST",
headers,
body: form,
});
let res = await doFetch();
if (res.status === 401) {
const next = await refreshAccessToken();
if (next) {
headers.Authorization = `Bearer ${next}`;
res = await doFetch();
}
}
if (!res.ok) throw await parseError(res);
return (await res.json()) as MediaUploadResult;
}
export { API_URL };
+201
View File
@@ -0,0 +1,201 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
api,
clearTokens,
readStoredUser,
setTokens,
storeUser,
} from "@/lib/api";
export type AuthStore = {
id: string;
slug: string;
nameFa: string;
nameEn?: string;
status: "pending" | "active" | "rejected" | "suspended";
} | null;
export type AuthSessionUser = {
id: string;
firstName: string;
lastName: string;
cellNumber: string;
role: "CUSTOMER" | "STORE_ADMIN" | "SUPER_ADMIN";
name: string;
store?: AuthStore;
};
type TokenResponse = {
accessToken: string;
refreshToken: string;
user: AuthSessionUser;
};
type AuthContextValue = {
user: AuthSessionUser | null;
loading: boolean;
login: (cellNumber: string, password: string) => Promise<AuthSessionUser>;
loginWithOtp: (cellNumber: string, code: string) => Promise<AuthSessionUser>;
sendLoginOtp: (cellNumber: string) => Promise<void>;
registerSendCode: (input: {
firstName: string;
lastName: string;
cellNumber: string;
password: string;
}) => Promise<void>;
registerVerify: (
cellNumber: string,
code: string,
) => Promise<AuthSessionUser>;
logout: () => Promise<void>;
refreshMe: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function persistSession(data: TokenResponse) {
setTokens(data.accessToken, data.refreshToken);
storeUser(data.user);
return data.user;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthSessionUser | null>(null);
const [loading, setLoading] = useState(true);
const refreshMe = useCallback(async () => {
try {
const me = await api<AuthSessionUser>("/auth/me");
storeUser(me);
setUser(me);
} catch {
clearTokens();
setUser(null);
}
}, []);
useEffect(() => {
const cached = readStoredUser<AuthSessionUser>();
if (cached) setUser(cached);
const token =
typeof window !== "undefined"
? localStorage.getItem("mana_access_token")
: null;
if (!token) {
setLoading(false);
return;
}
void refreshMe().finally(() => setLoading(false));
}, [refreshMe]);
const login = useCallback(async (cellNumber: string, password: string) => {
const data = await api<TokenResponse>("/auth/login", {
auth: false,
body: { cellNumber, password },
});
const next = persistSession(data);
setUser(next);
return next;
}, []);
const sendLoginOtp = useCallback(async (cellNumber: string) => {
await api("/auth/login/send-code", {
auth: false,
body: { cellNumber },
});
}, []);
const loginWithOtp = useCallback(async (cellNumber: string, code: string) => {
const data = await api<TokenResponse>("/auth/login/verify", {
auth: false,
body: { cellNumber, code },
});
const next = persistSession(data);
setUser(next);
return next;
}, []);
const registerSendCode = useCallback(
async (input: {
firstName: string;
lastName: string;
cellNumber: string;
password: string;
}) => {
await api("/auth/register/send-code", { auth: false, body: input });
},
[],
);
const registerVerify = useCallback(
async (cellNumber: string, code: string) => {
const data = await api<TokenResponse>("/auth/register/verify", {
auth: false,
body: { cellNumber, code },
});
const next = persistSession(data);
setUser(next);
return next;
},
[],
);
const logout = useCallback(async () => {
const refreshToken =
typeof window !== "undefined"
? localStorage.getItem("mana_refresh_token")
: null;
try {
if (refreshToken) {
await api("/auth/logout", { auth: false, body: { refreshToken } });
}
} catch {
/* ignore */
}
clearTokens();
setUser(null);
}, []);
const value = useMemo(
() => ({
user,
loading,
login,
loginWithOtp,
sendLoginOtp,
registerSendCode,
registerVerify,
logout,
refreshMe,
}),
[
user,
loading,
login,
loginWithOtp,
sendLoginOtp,
registerSendCode,
registerVerify,
logout,
refreshMe,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+59
View File
@@ -0,0 +1,59 @@
import { api } from "@/lib/api";
import type { BlogPost } from "@/data/blog";
export type ApiBlog = {
id: string;
title: string;
summary: string;
body: string;
author: string;
imageUrl: string | null;
imageKey?: string | null;
published: boolean;
metaTitle: string | null;
metaDescription: string | null;
slug: string | null;
createdAt: string;
updatedAt?: string;
};
export function mapBlog(row: ApiBlog): BlogPost {
const created = row.createdAt ? new Date(row.createdAt) : new Date();
return {
id: row.id,
title: row.title,
date: created.toISOString().slice(0, 10),
author: row.author || "",
abstract: row.summary || "",
body: row.body || "",
image: row.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
tags: [],
metaTitle: row.metaTitle || "",
metaDescription: row.metaDescription || "",
metaKeywords: "",
};
}
export async function fetchBlogs() {
const rows = await api<ApiBlog[]>("/blogs");
return rows.map(mapBlog);
}
export async function fetchBlog(id: string) {
const row = await api<ApiBlog>(`/blogs/${id}`);
return mapBlog(row);
}
export async function createBlog(body: Record<string, unknown>) {
const row = await api<ApiBlog>("/blogs", { method: "POST", body });
return mapBlog(row);
}
export async function updateBlog(id: string, body: Record<string, unknown>) {
const row = await api<ApiBlog>(`/blogs/${id}`, { method: "PATCH", body });
return mapBlog(row);
}
export async function deleteBlog(id: string) {
await api(`/blogs/${id}`, { method: "DELETE" });
}
+257
View File
@@ -0,0 +1,257 @@
import { api } from "@/lib/api";
import type {
Brand,
CategoryNode,
Product,
ProductVariantPrice,
} from "@/data/clothes";
export type ApiProductVariant = {
id: string;
colorId: string;
sizeId: string;
packageId: string;
price: number;
discountPrice: number | null;
};
export type ApiProduct = {
id: string;
nameFa: string;
nameEn: string;
summary?: string;
description?: string;
categoryId: string | null;
brandId: string | null;
price: number;
discountPrice: number | null;
imageUrl: string | null;
galleryUrls?: string[];
tags: string[];
variants: ApiProductVariant[];
};
export type ApiCategory = {
id: string;
parentId: string | null;
nameFa: string;
nameEn: string;
sortOrder: number;
colors: string[];
sizes: string[];
packages: string[];
children?: ApiCategory[];
};
export function mapVariant(v: ApiProductVariant): ProductVariantPrice {
return {
id: v.id,
colorId: v.colorId ?? "",
sizeId: v.sizeId ?? "",
packageId: v.packageId ?? "",
price: String(v.price ?? ""),
discountPrice:
v.discountPrice != null ? String(v.discountPrice) : undefined,
};
}
export function mapProduct(p: ApiProduct): Product {
return {
id: p.id,
nameFa: p.nameFa,
nameEn: p.nameEn ?? "",
summary: p.summary ?? "",
description: p.description ?? "",
categoryId: p.categoryId ?? "",
brandId: p.brandId ?? "",
price: p.price,
discountPrice: p.discountPrice ?? undefined,
image: p.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
gallery: p.galleryUrls ?? [],
tags: p.tags ?? [],
variantPrices: (p.variants ?? []).map(mapVariant),
};
}
export function toVariantPayload(rows: ProductVariantPrice[]) {
return rows.map((row) => ({
...(row.id && !row.id.startsWith("vp-") ? { id: row.id } : {}),
colorId: row.colorId || "",
sizeId: row.sizeId || "",
packageId: row.packageId || "",
price: Number(row.price) || 0,
discountPrice: row.discountPrice
? Number(row.discountPrice) || undefined
: undefined,
}));
}
export async function fetchProducts(params?: {
name?: string;
categoryId?: string;
minPrice?: number;
maxPrice?: number;
}) {
const q = new URLSearchParams();
if (params?.name) q.set("name", params.name);
if (params?.categoryId) q.set("categoryId", params.categoryId);
if (params?.minPrice != null) q.set("minPrice", String(params.minPrice));
if (params?.maxPrice != null) q.set("maxPrice", String(params.maxPrice));
const qs = q.toString();
const rows = await api<ApiProduct[]>(`/products${qs ? `?${qs}` : ""}`);
return rows.map(mapProduct);
}
export async function updateProduct(
id: string,
body: Record<string, unknown>,
) {
const row = await api<ApiProduct>(`/products/${id}`, {
method: "PATCH",
body,
});
return mapProduct(row);
}
export async function deleteProduct(id: string) {
await api(`/products/${id}`, { method: "DELETE" });
}
export async function createProduct(body: Record<string, unknown>) {
const row = await api<ApiProduct>(`/products`, {
method: "POST",
body,
});
return mapProduct(row);
}
export type ApiBrand = {
id: string;
nameFa: string;
nameEn: string;
imageUrl: string | null;
imageKey?: string | null;
};
export function mapBrand(b: ApiBrand): Brand {
return {
id: b.id,
nameFa: b.nameFa,
nameEn: b.nameEn ?? "",
image: b.imageUrl || "/images/ola-szkolda-J61EvbE1UCE-unsplash.jpg",
};
}
export async function fetchBrands() {
const rows = await api<ApiBrand[]>("/brands");
return rows.map(mapBrand);
}
export async function createBrand(body: {
nameFa: string;
nameEn?: string;
imageUrl?: string;
imageKey?: string;
}) {
const row = await api<ApiBrand>("/brands", { method: "POST", body });
return mapBrand(row);
}
export async function updateBrand(
id: string,
body: {
nameFa?: string;
nameEn?: string;
imageUrl?: string | null;
imageKey?: string | null;
},
) {
const row = await api<ApiBrand>(`/brands/${id}`, { method: "PATCH", body });
return mapBrand(row);
}
export async function deleteBrand(id: string) {
await api(`/brands/${id}`, { method: "DELETE" });
}
export async function createCategory(body: {
nameFa: string;
nameEn?: string;
parentId?: string;
colors?: string[];
sizes?: string[];
packages?: string[];
sortOrder?: number;
}) {
await api("/categories", { method: "POST", body });
return fetchCategoriesTree();
}
export async function updateCategory(
id: string,
body: {
nameFa?: string;
nameEn?: string;
parentId?: string | null;
colors?: string[];
sizes?: string[];
packages?: string[];
sortOrder?: number;
},
) {
await api(`/categories/${id}`, { method: "PATCH", body });
return fetchCategoriesTree();
}
export async function deleteCategory(id: string) {
await api(`/categories/${id}`, { method: "DELETE" });
return fetchCategoriesTree();
}
function nestCategories(flat: ApiCategory[]): CategoryNode[] {
const byParent = new Map<string | null, ApiCategory[]>();
for (const cat of flat) {
const key = cat.parentId;
const list = byParent.get(key) ?? [];
list.push(cat);
byParent.set(key, list);
}
function build(parentId: string | null): CategoryNode[] {
const nodes = (byParent.get(parentId) ?? []).sort(
(a, b) => a.sortOrder - b.sortOrder,
);
return nodes.map((node) => ({
id: node.id,
nameFa: node.nameFa,
nameEn: node.nameEn,
variants: {
colors: node.colors ?? [],
sizes: node.sizes ?? [],
packages: (node.packages ?? []) as Array<"6" | "12">,
},
children: build(node.id),
}));
}
return build(null);
}
export async function fetchCategoriesTree(): Promise<CategoryNode[]> {
const rows = await api<ApiCategory[]>("/categories");
if (rows.some((r) => r.children && r.children.length)) {
const mapNode = (node: ApiCategory): CategoryNode => ({
id: node.id,
nameFa: node.nameFa,
nameEn: node.nameEn,
variants: {
colors: node.colors ?? [],
sizes: node.sizes ?? [],
packages: (node.packages ?? []) as Array<"6" | "12">,
},
children: (node.children ?? []).map(mapNode),
});
return rows.filter((r) => !r.parentId).map(mapNode);
}
return nestCategories(rows);
}
+39
View File
@@ -0,0 +1,39 @@
import { api } from "@/lib/api";
import type { Customer } from "@/data/customers";
export type ApiCustomer = {
id: string;
firstName?: string;
lastName?: string;
name?: string;
cellNumber: string;
email?: string | null;
instagram?: string | null;
about?: string | null;
avatarUrl?: string | null;
disabled?: boolean;
createdAt: string;
};
export function mapCustomer(row: ApiCustomer): Customer {
const name =
row.name?.trim() ||
`${row.firstName ?? ""} ${row.lastName ?? ""}`.trim() ||
"بدون نام";
const joined = row.createdAt
? new Date(row.createdAt).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
return {
id: row.id,
nameFa: name,
phone: row.cellNumber,
joinedAt: joined,
subscription: "regular",
};
}
export async function fetchCustomers() {
const rows = await api<ApiCustomer[]>("/customers");
return rows.map(mapCustomer);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
const WEBSITE_HOSTS = new Set([
"mana-collection.com",
"www.mana-collection.com",
]);
export function middleware(request: NextRequest) {
const host = request.headers.get("host")?.split(":")[0]?.toLowerCase() ?? "";
if (!WEBSITE_HOSTS.has(host)) {
return NextResponse.next();
}
const { pathname } = request.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/images") ||
pathname.startsWith("/fonts") ||
pathname === "/favicon.ico"
) {
return NextResponse.next();
}
if (pathname === "/coming-soon") {
return NextResponse.next();
}
const url = request.nextUrl.clone();
url.pathname = "/coming-soon";
return NextResponse.redirect(url);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|.*\\.(?:svg|png|jpg|jpeg|gif|webp|woff|woff2|otf|ttf)$).*)"],
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}