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