Initial commit: Dcorya storefront on Meshkee Website API
Next.js/TypeScript/Tailwind rebuild of dcorya.com in Persian/RTL, wired to the live Meshkee Website API for products, blog, comments, cart, and contact.
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dcorya-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.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 -->
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -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,22 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "c804387.parspack.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "dcorya-store",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"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;
|
||||||
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 851 B |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 585 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 773 KiB |
|
After Width: | Height: | Size: 919 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 900 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 1014 KiB |
|
After Width: | Height: | Size: 837 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "درباره ما | فروشگاه اینترنتی دکوریا",
|
||||||
|
description:
|
||||||
|
"آشنایی با فروشگاه اینترنتی دکوریا، تولیدکننده و عرضهکننده تخصصی محصولات چوبی، کریستال و برنز با طراحی معرق و نقاشی دستساز ایرانی.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: "۹۹٪", label: "کیفیت محصول" },
|
||||||
|
{ value: "۹۰٪", label: "تنوع محصول" },
|
||||||
|
{ value: "۹۰٪", label: "رضایت مشتری" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader title="درباره مـــا" subtitle="ABOUT US" />
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold">فروشگاه آنلاین دکوریا</h2>
|
||||||
|
<p className="mt-2 text-neutral-500">زیباترین دکوراتیو چوبی، کریستال و برنز</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-5 leading-8 text-neutral-600">
|
||||||
|
<p>
|
||||||
|
فروشگاه اینترنتی دکوریا یک فروشگاه آنلاین تخصصی در زمینه محصولات چوبی از جمله آینه
|
||||||
|
کنسول، بوفه، ویترین و... (معرق و نقاشی و برنز سبک اروپا) و کریستال با برندهای معتبر
|
||||||
|
دنیا (چک، لهستان، آلمان و...) است.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
گروه دکوریا در زمینه تولید محصولات چوبی و عرضه محصولات کریستال و پایه برنزی فعالیت
|
||||||
|
دارد. محصولات چوبی دکوریا، نظیر آینه کنسول، بوفه، ویترین، انواع میز، با طراحی های
|
||||||
|
منحصر به فرد و زیبای معرق و نقاشی تولید می شود.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
تصاویر خاص از زوایای مختلف برای شما فراهم کردیم تا بدون هیچ شک و دلهره محصول مورد
|
||||||
|
نظرتان را انتخاب کنید. ارائه انواع سرویس های کریستال پذیرایی، گلدان، شمعدان و... با
|
||||||
|
طراحی های شکیل طلا و پلاتین و برنز تخصص ماست.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
تصاویر واقعی و با کیفیت به همراه شرح کاملی از اصالت، ضمانت و نحوه نگهداری محصول،
|
||||||
|
اطمینان شما را از خرید دوچندان می کند. مشتریان ما، عروس و داماد های زیبا پسند،
|
||||||
|
خانواده های خوش ذوق، هتل داران، متخصصان چیدمان داخلی و افرادی هستند که به خاص و شکیل
|
||||||
|
بودن محیط پیرامون خود اهمیت می دهند.
|
||||||
|
</p>
|
||||||
|
<p className="text-center font-bold text-primary">
|
||||||
|
لذت خرید اینترنتی را با دکوریا تجربه کنید!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 grid grid-cols-1 gap-8 border-t border-black/5 pt-12 sm:grid-cols-3">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.label} className="text-center">
|
||||||
|
<p className="text-4xl font-bold text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-neutral-600">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
function CallbackHandler() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const { setSession } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const accessToken = params.get("accessToken");
|
||||||
|
const refreshToken = params.get("refreshToken");
|
||||||
|
const redirect = params.get("redirect") || "/";
|
||||||
|
|
||||||
|
if (accessToken && refreshToken) {
|
||||||
|
setSession(accessToken, refreshToken).finally(() => {
|
||||||
|
router.replace(redirect);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.replace(redirect);
|
||||||
|
}
|
||||||
|
}, [params, router, setSession]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<p className="text-neutral-500">در حال ورود...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CallbackClient() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CallbackHandler />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import CallbackClient from "./CallbackClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "در حال ورود... | فروشگاه اینترنتی دکوریا",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthCallbackPage() {
|
||||||
|
return <CallbackClient />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import CommentsSection from "@/components/CommentsSection";
|
||||||
|
import { meshkee, blogTitle, blogBody, blogCover } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
function formatDate(iso?: string) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||||
|
new Date(iso)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await meshkee.getBlogById(id).catch(() => null);
|
||||||
|
if (!data) return { title: "مطلب یافت نشد | فروشگاه اینترنتی دکوریا" };
|
||||||
|
|
||||||
|
const title = blogTitle(data.blog);
|
||||||
|
const description = data.blog.summary || blogBody(data.blog).replace(/<[^>]+>/g, "").slice(0, 155);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${title} | فروشگاه اینترنتی دکوریا`,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: blogCover(data.blog) ? [blogCover(data.blog) as string] : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const data = await meshkee.getBlogById(id).catch(() => null);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { blog } = data;
|
||||||
|
const title = blogTitle(blog);
|
||||||
|
const cover = blogCover(blog);
|
||||||
|
|
||||||
|
const commentsRes = await meshkee.getComments("blog", blog.id).catch(() => ({ items: [] }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<nav className="mx-auto max-w-3xl px-4 pt-6 text-xs text-neutral-400 sm:px-6 lg:px-8">
|
||||||
|
<Link href="/" className="hover:text-primary">
|
||||||
|
صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
{" / "}
|
||||||
|
<Link href="/blog" className="hover:text-primary">
|
||||||
|
بلاگ
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-800 sm:text-3xl">{title}</h1>
|
||||||
|
<p className="mt-3 text-sm text-neutral-400">
|
||||||
|
{formatDate(blog.publishedAt || blog.createdAt)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cover && (
|
||||||
|
<div className="relative mt-8 aspect-16/9 overflow-hidden rounded-2xl">
|
||||||
|
<Image src={cover} alt={title} fill sizes="100vw" className="object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="prose prose-neutral mt-8 max-w-none text-neutral-600 [&_p]:leading-8"
|
||||||
|
dangerouslySetInnerHTML={{ __html: blogBody(blog) || "<p>متنی برای این مطلب ثبت نشده است.</p>" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-14 border-t border-black/5 pt-10">
|
||||||
|
<h2 className="mb-6 text-xl font-bold text-neutral-800">
|
||||||
|
نظرات ({commentsRes.items.length})
|
||||||
|
</h2>
|
||||||
|
<CommentsSection entityType="blog" entityId={blog.id} initialComments={commentsRes.items} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import { meshkee, blogTitle, blogCover } from "@/lib/meshkee";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "بلاگ دکوریا | فروشگاه اینترنتی دکوریا",
|
||||||
|
description: "مقالات و اخبار فروشگاه اینترنتی دکوریا درباره دکوراسیون داخلی و صنایع دستی.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso?: string) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||||
|
new Date(iso)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogListPage() {
|
||||||
|
const { items } = await meshkee.getBlogs({ pageSize: 24 }).catch(() => ({ items: [] }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader title="بلاگ دکوریا" subtitle="BLOG" />
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-20 text-center text-neutral-500">
|
||||||
|
در حال حاضر مطلبی منتشر نشده است. بهزودی مقالات جدید اینجا نمایش داده میشوند.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((blog) => {
|
||||||
|
const title = blogTitle(blog);
|
||||||
|
const cover = blogCover(blog);
|
||||||
|
const href = `/blog/${blog.id}/${slugify(title)}`;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={blog.id}
|
||||||
|
href={href}
|
||||||
|
className="group overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-shadow hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50">
|
||||||
|
{cover ? (
|
||||||
|
<Image
|
||||||
|
src={cover}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-neutral-300">
|
||||||
|
بدون تصویر
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<h2 className="mb-2 line-clamp-2 font-bold text-neutral-800">{title}</h2>
|
||||||
|
{blog.summary && (
|
||||||
|
<p className="mb-3 line-clamp-2 text-sm leading-7 text-neutral-500">
|
||||||
|
{blog.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
{formatDate(blog.publishedAt || blog.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { useCart } from "@/lib/cart-context";
|
||||||
|
|
||||||
|
function formatToman(value?: number) {
|
||||||
|
if (!value && value !== 0) return null;
|
||||||
|
return `${value.toLocaleString("fa-IR")} تومان`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartPageClient() {
|
||||||
|
const { user, isLoading, loginUrl } = useAuth();
|
||||||
|
const { cart, updateItem, removeItem } = useCart();
|
||||||
|
const items = cart?.items ?? [];
|
||||||
|
const total = items.reduce((sum, item) => sum + (item.unitPrice ?? 0) * item.quantity, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto min-h-[50vh] max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="mb-10 text-2xl font-bold sm:text-3xl">سبد خرید</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-neutral-500">در حال بارگذاری...</p>
|
||||||
|
) : !user ? (
|
||||||
|
<div className="rounded-2xl border border-black/5 bg-neutral-50 p-10 text-center">
|
||||||
|
<p className="mb-5 text-neutral-500">
|
||||||
|
برای مشاهده سبد خرید ابتدا وارد حساب کاربری خود شوید.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={loginUrl}
|
||||||
|
className="inline-block rounded-lg bg-primary px-8 py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
ورود به حساب کاربری
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-black/5 bg-neutral-50 p-10 text-center">
|
||||||
|
<p className="mb-5 text-neutral-500">سبد خرید شما خالی است.</p>
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="inline-block rounded-lg bg-primary px-8 py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
مشاهده محصولات
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-4 rounded-2xl border border-black/5 bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-neutral-100">
|
||||||
|
{item.image && (
|
||||||
|
<Image src={item.image} alt={item.title ?? ""} fill className="object-cover" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-neutral-800">{item.title ?? "کالا"}</p>
|
||||||
|
{formatToman(item.unitPrice) && (
|
||||||
|
<p className="mt-1 text-sm font-bold text-primary">{formatToman(item.unitPrice)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => updateItem(item.id, Math.max(1, item.quantity - 1))}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-black/10 text-neutral-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center text-sm">{item.quantity}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => updateItem(item.id, item.quantity + 1)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-black/10 text-neutral-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
aria-label="حذف"
|
||||||
|
className="text-neutral-400 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path
|
||||||
|
d="M4 7h16M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2m-8 0 1 13a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2l1-13"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{formatToman(total) && (
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-neutral-50 p-5">
|
||||||
|
<span className="font-bold text-neutral-800">جمع کل</span>
|
||||||
|
<span className="text-lg font-extrabold text-primary">{formatToman(total)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import CartPageClient from "./CartPageClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "سبد خرید | فروشگاه اینترنتی دکوریا",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CartPage() {
|
||||||
|
return <CartPageClient />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import ContactForm from "@/components/ContactForm";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "تماس با ما | فروشگاه اینترنتی دکوریا",
|
||||||
|
description:
|
||||||
|
"راههای تماس با فروشگاه اینترنتی دکوریا؛ شماره تلفن، ایمیل، آدرس فروشگاه در قم و فرم ارسال پیام برای پاسخگویی به سوالات شما.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoCards = [
|
||||||
|
{
|
||||||
|
title: "به ما ایمیل بزنید",
|
||||||
|
lines: ["dcorya.shopp@gmail.com"],
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-7 w-7">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path d="m4 6.5 8 6 8-6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "همیشه پاسخگوی شما هستیم",
|
||||||
|
lines: ["025-32400725", "09121514070", "09129368019", "09124592669"],
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-7 w-7">
|
||||||
|
<path
|
||||||
|
d="M6.5 3.5c1 0 2.7 2.6 2.7 3.7 0 1.1-1.6 1.8-1.9 2.6-.4 1 1.2 3 2.4 4.2 1.2 1.2 3.2 2.8 4.2 2.4.8-.3 1.5-1.9 2.6-1.9 1.1 0 3.7 1.7 3.7 2.7 0 1-2.7 3.3-3.7 3.3-1.7 0-5.5-1.7-8.8-5-3.3-3.3-5-7.1-5-8.8 0-1 2.3-3.7 3.3-3.7z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "فروشگاه دکوریا",
|
||||||
|
lines: ["قم،", "خیابان 45 متری صدوقی،", "نبش حافظ"],
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-7 w-7">
|
||||||
|
<path
|
||||||
|
d="M12 21s7-6.5 7-11.5a7 7 0 1 0-14 0C5 14.5 12 21 12 21z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="9.5" r="2.3" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader title="تماس با ما" subtitle="CONTACT US" />
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
||||||
|
<p className="text-neutral-500">فروشگاه اینترنتی دکوریا در خدمت شماست (:</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">
|
||||||
|
هر طور که راحتید با ما تماس بگیرید
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-14 grid grid-cols-1 gap-10 sm:grid-cols-3">
|
||||||
|
{infoCards.map((card) => (
|
||||||
|
<div key={card.title} className="flex flex-col items-center">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 font-bold text-neutral-800">{card.title}</h3>
|
||||||
|
<p dir="ltr" className="text-sm leading-7 text-neutral-500">
|
||||||
|
{card.lines.map((line) => (
|
||||||
|
<span key={line} className="block" dir="rtl">
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-3xl px-4 pb-20 sm:px-6 lg:px-8">
|
||||||
|
<div className="rounded-2xl border border-black/5 bg-neutral-50 p-8 sm:p-10">
|
||||||
|
<h3 className="mb-1 text-xl font-bold">تماس با فروشگاه اینترنتی دکوریا</h3>
|
||||||
|
<p className="mb-6 text-sm text-neutral-500">
|
||||||
|
همیشه پاسخگوی شما هستیم، با ما تماس بگیرید...
|
||||||
|
</p>
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #2b2b2b;
|
||||||
|
--primary: #b60239;
|
||||||
|
--primary-dark: #8a012b;
|
||||||
|
--gold: #ddcfbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-dark: var(--primary-dark);
|
||||||
|
--color-gold: var(--gold);
|
||||||
|
--font-fa: var(--font-iranyekan);
|
||||||
|
--font-en: var(--font-montserrat);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-iranyekan), var(--font-montserrat), sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.7s ease both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import "./globals.css";
|
||||||
|
import { meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee";
|
||||||
|
import Providers from "@/lib/providers";
|
||||||
|
|
||||||
|
const iranyekan = localFont({
|
||||||
|
variable: "--font-iranyekan",
|
||||||
|
display: "swap",
|
||||||
|
src: [
|
||||||
|
{ path: "../../public/fonts/IRANYekan-Light.ttf", weight: "300", style: "normal" },
|
||||||
|
{ path: "../../public/fonts/IRANYekan-Regular.ttf", weight: "400", style: "normal" },
|
||||||
|
{ path: "../../public/fonts/IRANYekan-Medium.ttf", weight: "500", style: "normal" },
|
||||||
|
{ path: "../../public/fonts/IRANYekan-Bold.ttf", weight: "700", style: "normal" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const montserrat = localFont({
|
||||||
|
variable: "--font-montserrat",
|
||||||
|
display: "swap",
|
||||||
|
src: [
|
||||||
|
{ path: "../../public/fonts/Montserrat-Regular.ttf", weight: "400", style: "normal" },
|
||||||
|
{ path: "../../public/fonts/Montserrat-Bold.ttf", weight: "700", style: "normal" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const favicon = await meshkee.getFavicon();
|
||||||
|
const iconUrl = favicon?.faviconUrl || favicon?.logoUrl || "/images/logo.png";
|
||||||
|
const title = "فروشگاه اینترنتی دکوریا";
|
||||||
|
const description =
|
||||||
|
"زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را با دکوریا تجربه کنید.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: new URL(`https://${WEBSITE_DOMAIN}`),
|
||||||
|
title: { default: title, template: `%s` },
|
||||||
|
description,
|
||||||
|
icons: [{ url: iconUrl }],
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
siteName: title,
|
||||||
|
locale: "fa_IR",
|
||||||
|
type: "website",
|
||||||
|
images: [{ url: favicon?.logoUrl || "/images/logo.png" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
const tenant = await meshkee.getTenant().catch(() => null);
|
||||||
|
const businessId = tenant?.id ?? "37";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="fa"
|
||||||
|
dir="rtl"
|
||||||
|
className={`${iranyekan.variable} ${montserrat.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">
|
||||||
|
<Providers businessId={businessId}>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { WEBSITE_DOMAIN } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
// Public static pages only — dynamic content (products, categories, blog posts)
|
||||||
|
// is generated by the Meshkee API's own sitemap-*.xml files. Auth, cart, and
|
||||||
|
// checkout routes are intentionally excluded.
|
||||||
|
const staticPages = [
|
||||||
|
{ path: "/", changefreq: "daily", priority: 1.0 },
|
||||||
|
{ path: "/products", changefreq: "daily", priority: 0.9 },
|
||||||
|
{ path: "/blog", changefreq: "daily", priority: 0.7 },
|
||||||
|
{ path: "/about", changefreq: "monthly", priority: 0.6 },
|
||||||
|
{ path: "/contact", changefreq: "monthly", priority: 0.5 },
|
||||||
|
{ path: "/purchaseguide", changefreq: "monthly", priority: 0.5 },
|
||||||
|
{ path: "/satisfaction", changefreq: "monthly", priority: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
baseUrl: `https://${WEBSITE_DOMAIN}`,
|
||||||
|
staticPages,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Header from "@/components/Header";
|
||||||
|
import Hero from "@/components/Hero";
|
||||||
|
import ProductCategories from "@/components/ProductCategories";
|
||||||
|
import LatestProducts from "@/components/LatestProducts";
|
||||||
|
import BannerRow from "@/components/BannerRow";
|
||||||
|
import ConsoleCategories from "@/components/ConsoleCategories";
|
||||||
|
import AboutDcorya from "@/components/AboutDcorya";
|
||||||
|
import DecorativeProducts from "@/components/DecorativeProducts";
|
||||||
|
import ContactTeaser from "@/components/ContactTeaser";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<ProductCategories />
|
||||||
|
<LatestProducts />
|
||||||
|
<BannerRow />
|
||||||
|
<ConsoleCategories />
|
||||||
|
<AboutDcorya />
|
||||||
|
<DecorativeProducts />
|
||||||
|
<ContactTeaser />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import ProductGallery from "@/components/ProductGallery";
|
||||||
|
import ProductTabs from "@/components/ProductTabs";
|
||||||
|
import AddToCartBox from "@/components/AddToCartBox";
|
||||||
|
import Carousel from "@/components/Carousel";
|
||||||
|
import RemoteProductCard from "@/components/RemoteProductCard";
|
||||||
|
import { meshkee } from "@/lib/meshkee";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await meshkee.getProductById(id).catch(() => null);
|
||||||
|
if (!data) return { title: "محصول یافت نشد | فروشگاه اینترنتی دکوریا" };
|
||||||
|
|
||||||
|
const title = data.product.nameFa || data.product.title;
|
||||||
|
const description =
|
||||||
|
data.product.summary ||
|
||||||
|
data.product.descriptionHtml.replace(/<[^>]+>/g, "").slice(0, 155) ||
|
||||||
|
`${title} در فروشگاه اینترنتی دکوریا`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${title} | فروشگاه اینترنتی دکوریا`,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: data.product.thumbnailUrl ? [data.product.thumbnailUrl] : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const data = await meshkee.getProductById(id).catch(() => null);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { product, relatedProducts } = data;
|
||||||
|
|
||||||
|
const [technicalInfo, commentsRes, storeItemRes] = await Promise.all([
|
||||||
|
meshkee.getProductTechnicalInfo(product.slug).catch(() => null),
|
||||||
|
meshkee.getComments("product", product.id).catch(() => ({ items: [] })),
|
||||||
|
meshkee.getStoreItemByProduct(product.id).catch(() => ({ storeItem: null })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const galleryImages = [
|
||||||
|
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
|
||||||
|
...product.images.map((img) => img.url),
|
||||||
|
].filter((url, i, arr) => arr.indexOf(url) === i);
|
||||||
|
|
||||||
|
const variant = storeItemRes.storeItem?.variants?.[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<nav className="mx-auto max-w-6xl px-4 pt-6 text-xs text-neutral-400 sm:px-6 lg:px-8">
|
||||||
|
<Link href="/" className="hover:text-primary">
|
||||||
|
صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
{" / "}
|
||||||
|
<Link href="/products" className="hover:text-primary">
|
||||||
|
محصولات
|
||||||
|
</Link>
|
||||||
|
{product.categoryNameFa && (
|
||||||
|
<>
|
||||||
|
{" / "}
|
||||||
|
<span>{product.categoryNameFa}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2">
|
||||||
|
<ProductGallery images={galleryImages} title={product.nameFa || product.title} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-800 sm:text-3xl">
|
||||||
|
{product.nameFa || product.title}
|
||||||
|
</h1>
|
||||||
|
{product.categoryNameFa && (
|
||||||
|
<p className="mt-2 text-sm text-neutral-400">{product.categoryNameFa}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddToCartBox variant={variant} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-14">
|
||||||
|
<ProductTabs
|
||||||
|
descriptionHtml={product.descriptionHtml}
|
||||||
|
technicalInfo={technicalInfo}
|
||||||
|
productId={product.id}
|
||||||
|
initialComments={commentsRes.items}
|
||||||
|
commentCount={commentsRes.items.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{relatedProducts.length > 0 && (
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2 className="mb-8 text-xl font-bold text-neutral-800">محصولات مرتبط</h2>
|
||||||
|
<Carousel>
|
||||||
|
{relatedProducts.map((p) => (
|
||||||
|
<RemoteProductCard key={p.id} product={p} />
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import RemoteProductCard from "@/components/RemoteProductCard";
|
||||||
|
import { meshkee } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await meshkee.getCategoryById(id).catch(() => null);
|
||||||
|
if (!data) return { title: "دستهبندی یافت نشد | فروشگاه اینترنتی دکوریا" };
|
||||||
|
|
||||||
|
const title = data.category.nameFa || data.category.name;
|
||||||
|
return {
|
||||||
|
title: `${title} | فروشگاه اینترنتی دکوریا`,
|
||||||
|
description: `مشاهده محصولات دستهبندی ${title} در فروشگاه اینترنتی دکوریا.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { page: pageParam } = await searchParams;
|
||||||
|
const page = Number(pageParam) || 1;
|
||||||
|
const pageSize = 12;
|
||||||
|
|
||||||
|
const categoryData = await meshkee.getCategoryById(id).catch(() => null);
|
||||||
|
if (!categoryData) notFound();
|
||||||
|
|
||||||
|
const { category } = categoryData;
|
||||||
|
const title = category.nameFa || category.name;
|
||||||
|
|
||||||
|
const productsRes = await meshkee.getProducts({ page, pageSize, categoryId: id });
|
||||||
|
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader title={title} subtitle="CATEGORY" />
|
||||||
|
|
||||||
|
<nav className="mx-auto max-w-6xl px-4 pt-6 text-xs text-neutral-400 sm:px-6 lg:px-8">
|
||||||
|
<Link href="/" className="hover:text-primary">
|
||||||
|
صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
{" / "}
|
||||||
|
<Link href="/products" className="hover:text-primary">
|
||||||
|
محصولات
|
||||||
|
</Link>
|
||||||
|
{" / "}
|
||||||
|
<span>{title}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
{productsRes.items.length === 0 ? (
|
||||||
|
<p className="py-20 text-center text-neutral-500">محصولی در این دستهبندی یافت نشد.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{productsRes.items.map((product) => (
|
||||||
|
<div key={product.id} className="[&>a]:w-full">
|
||||||
|
<RemoteProductCard product={product} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-14 flex justify-center gap-2">
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p}
|
||||||
|
href={`/products/category/${id}/${category.slug}?page=${p}`}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
||||||
|
p === page
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "border border-black/10 text-neutral-600 hover:border-primary hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import RemoteProductCard from "@/components/RemoteProductCard";
|
||||||
|
import { meshkee } from "@/lib/meshkee";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "محصولات | فروشگاه اینترنتی دکوریا",
|
||||||
|
description: "مشاهده تمامی محصولات چوبی، کریستال و برنز فروشگاه اینترنتی دکوریا.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProductsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ page?: string; categoryId?: string; q?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const page = Number(params.page) || 1;
|
||||||
|
const pageSize = 12;
|
||||||
|
|
||||||
|
const [productsRes, categoriesRes] = await Promise.all([
|
||||||
|
meshkee.getProducts({ page, pageSize, categoryId: params.categoryId, name: params.q }),
|
||||||
|
meshkee.getCategories("product").catch(() => ({ items: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const topCategories = categoriesRes.items.filter((c) => !c.parentId).slice(0, 10);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader title="محصولات دکوریا" subtitle="PRODUCTS" />
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
{topCategories.length > 0 && (
|
||||||
|
<div className="mb-10 flex flex-wrap justify-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
!params.categoryId
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-black/10 text-neutral-600 hover:border-primary hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</Link>
|
||||||
|
{topCategories.map((cat) => (
|
||||||
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
href={`/products/category/${cat.id}/${slugify(cat.nameFa || cat.name)}`}
|
||||||
|
className="rounded-full border border-black/10 px-4 py-2 text-sm font-medium text-neutral-600 transition-colors hover:border-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
{cat.nameFa}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{productsRes.items.length === 0 ? (
|
||||||
|
<p className="py-20 text-center text-neutral-500">محصولی یافت نشد.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{productsRes.items.map((product) => (
|
||||||
|
<RemoteProductCardWrapper key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-14 flex justify-center gap-2">
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p}
|
||||||
|
href={`/products?page=${p}${params.categoryId ? `&categoryId=${params.categoryId}` : ""}`}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
||||||
|
p === page
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "border border-black/10 text-neutral-600 hover:border-primary hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoteProductCardWrapper({ product }: { product: Parameters<typeof RemoteProductCard>[0]["product"] }) {
|
||||||
|
return (
|
||||||
|
<div className="[&>a]:w-full">
|
||||||
|
<RemoteProductCard product={product} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import Accordion from "@/components/Accordion";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "راهنمای خرید | فروشگاه اینترنتی دکوریا",
|
||||||
|
description:
|
||||||
|
"راهنمای کامل خرید اینترنتی از دکوریا؛ جنس محصولات، نحوه ارسال، شرایط عودت کالا و پاسخ به سوالات متداول مشتریان.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const productFaqs = [
|
||||||
|
{
|
||||||
|
question: "جنس کارهای چوبی چیست؟",
|
||||||
|
answer:
|
||||||
|
"وقتی که صحبت از کلاف بوفه یا ویترین یا هر محصول چوبی دیگر میشود در واقع منظور چارچوب اصلی (اسکلت) آن محصول چوبی است. کلاف ویترین و بوفهها معمولا از جنس چوب سفید و چوب روسی است، البته در برخی کارها از چوب راش هم استفاده میشود که بیشتر راش کشور گرجستان است.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "معرق و نقاشی کارهای چوبی چگونه است؟",
|
||||||
|
answer:
|
||||||
|
"معرق و نقاشی و برنز سبکهایی هستند که بر روی محصولات چوبی دکوریا کار شده است. معرق کار شده روی محصولات، معرق واقعی و کار دست معرقکاران ماهر است؛ تکههای نازک چوب که پس از برشکاری با ظرافت خاصی کنار یکدیگر چیده میشوند. برای محافظت از معرق و روکش سطوح، از مواد ضدآب و ضدخش مانند پلیاستر استفاده میشود. طراحی زیبای دیگر، هنر نقاشی است؛ نقاشی مناظر طبیعت، گلهای رنگارنگ و سبک اروپا به دست نقاشان هنرمند ایرانی در کارگاه دکوریا انجام میشود و روی تمام سطوح چند نوبت پلیاستر کشیده میشود تا رنگها ثابت و ضدآب بمانند.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "درباره برنز روی محصولات توضیح دهید",
|
||||||
|
answer:
|
||||||
|
"فلز برنز از دیرباز یکی از تزئینات زیبای دکوری منازل ایرانی بوده است. مجموعه دکوریا با سلیقه و ظرافت خاصی، قطعات شکیل برنز را روی برخی محصولات چوبی کار کرده که جلوه و زیبایی محصولات را دوچندان میکند. دکوریا از برنز خالص و مرغوب استفاده میکند تا با گذشت زمان تغییر رنگ ندهد.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "اصالت محصولات",
|
||||||
|
answer:
|
||||||
|
"یکی از اهداف مهم مجموعه دکوریا ارائه محصولات زیبا و با اصالت است. محصولات چوبی دکوریا کاملا ایرانی و طراحیشده با هنر جوانان خوشذوق ایرانی است. کریستالهای کشورهای مختلف مانند چک، لهستان، آلمان، چین و ترکیه توسط کارشناسان ما تفکیک شدهاند و روی کریستالهای اصل معمولا نام برند یا کشور تولیدکننده حک شده است.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "نحوه ارسال محصولات",
|
||||||
|
answer:
|
||||||
|
"برای ارسال محصولات کریستالی از پکهای محکم و از پیش طراحیشده استفاده میشود و برای کارهای چوبی از باربریهای مطمئن با تجربه کافی. مامور تحویل، محصول را درب اصلی منتهی به خیابان تحویل میدهد و مشتری موظف است در حضور مامور، محصول را از نظر فنی و ظاهری بررسی و فرم تحویل را امضا کند.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "تغییر سفارش مشتری در نوع محصول پس از هر گونه پرداخت",
|
||||||
|
answer:
|
||||||
|
"مشتریان گرامی میتوانند فقط تا ۲۴ ساعت پس از ثبت سفارش نسبت به تغییر آن اقدام نمایند. پس از این مدت سفارش وارد چرخه تولید و آمادهسازی شده و امکان تغییر وجود ندارد.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "پشیمان شدن مشتری از خرید، پس از هر گونه پرداخت و قبل از تحویل کالا",
|
||||||
|
answer:
|
||||||
|
"مشتریان گرامی میتوانند فقط تا ۲۴ ساعت پس از ثبت سفارش نسبت به لغو آن اقدام نمایند. پس از این مدت امکان لغو سفارش وجود ندارد، زیرا سفارش وارد چرخه تولید شده است.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "معیوب شدن محصول در راه رسیدن به منزل مشتری",
|
||||||
|
answer:
|
||||||
|
"با توجه به بستهبندی ایمن و تحویل به شرکتهای حمل معتبر، هرگونه حادثه در مسیر حمل بر عهده شرکت حملونقل است. لازم است کارتن اصلی بدون تغییر نگهداری شود؛ برچسب زدن یا مخدوش کردن آن امکان مرجوعی را از بین میبرد. مشتری باید هنگام تحویل دقت کافی داشته باشد و در صورت نقص، از تحویل خودداری کند.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "اگر محصول ارسال شده نواقص فنی داشت",
|
||||||
|
answer:
|
||||||
|
"دکوریا این اطمینان را میدهد که در صورت وجود نقص فنی یا عدم تطابق با تصویر سایت، سفارش را عودت میکند. به دلیل هنر دست در طراحی، ممکن است تفاوتهای جزئی مانند شکل گلهای روی کنسول وجود داشته باشد که جزئی از خصوصیات هنر دست است، اما تفاوتهای بارز و مشکلات فنی قطعا رفع خواهد شد.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "تغییر قیمت محصول در طول مدت آمادهسازی آن",
|
||||||
|
answer:
|
||||||
|
"در شرایط نادری مانند بیثباتی بازار ارز، چنانچه دکوریا امکان تامین محصول با قیمت اعلامشده را نداشته باشد، حق کنسل کردن سفارش را دارد و مبلغ پرداختی مشتری عودت داده میشود یا در صورت توافق، مابهالتفاوت اطلاعرسانی میشود.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "انتخاب محصول",
|
||||||
|
desc: "با مشاهده تصاویر واقعی و باکیفیت، محصول مورد نظرتان را از میان دستهبندیهای دکوریا انتخاب کنید.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ثبت سفارش",
|
||||||
|
desc: "سفارش خود را ثبت کرده و اطلاعات ارسال را با دقت وارد نمایید.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "آمادهسازی و بستهبندی",
|
||||||
|
desc: "محصول شما با بستهبندی ایمن و استاندارد آماده تحویل به باربری میشود.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "تحویل درب منزل",
|
||||||
|
desc: "سفارش شما توسط شرکتهای حمل معتبر به مقصد ارسال و تحویل داده میشود.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PurchaseGuidePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader title="راهنمای خریـــــــد" subtitle="PURCHASE GUIDE" />
|
||||||
|
|
||||||
|
<section className="bg-neutral-50 py-16">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 text-center sm:px-6 lg:px-8">
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
ONLINE SHOPPING
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">مزایای خرید اینترنتی</h2>
|
||||||
|
<p className="mx-auto mt-6 max-w-3xl leading-8 text-neutral-600">
|
||||||
|
در دنیای امروز که زمان یکی از مهمترین دغدغههای هر شخص است، همه به دنبال راهی آسان و
|
||||||
|
مطمئن برای خرید هستند. فروشگاه دکوریا با ارائه تصاویر واقعی از ابعاد مختلف با بهترین
|
||||||
|
کیفیت، یک خرید با اطمینان و دلچسب را برای شما به ارمغان میآورد.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-14 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={step.title}
|
||||||
|
className="relative rounded-2xl border border-black/5 bg-white p-6 text-right shadow-sm"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<h3 className="mb-2 font-bold text-neutral-800">{step.title}</h3>
|
||||||
|
<p className="text-sm leading-7 text-neutral-500">{step.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-3xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
FAQ
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">سوالات متداول خرید</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion items={productFaqs} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "رضایت مشتریان | فروشگاه اینترنتی دکوریا",
|
||||||
|
description:
|
||||||
|
"نمونه تصاویر واقعی محصولات دکوریا در منزل مشتریان سراسر ایران؛ نمایش کیفیت و اصالت کارهای چوبی، معرق و برنز ارسالشده.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const photos = [
|
||||||
|
{ src: "/images/satisfaction-1.jpg", title: "میز عسلی معرق", location: "ارسال به قم" },
|
||||||
|
{ src: "/images/satisfaction-2.jpg", title: "آینه کنسول + شمعدان و ساعت", location: "ارسال به قم" },
|
||||||
|
{ src: "/images/satisfaction-3.jpg", title: "کنسول طرح نقاشی و برنز", location: "ارسال به تهران" },
|
||||||
|
{ src: "/images/satisfaction-4.jpg", title: "کنسول طرح نقاشی و برنز", location: "ارسال به تهران" },
|
||||||
|
{
|
||||||
|
src: "/images/satisfaction-5.jpg",
|
||||||
|
title: "میز تی وی با طراحی برنز و نقاشی کار دست، رنگ زمینه صدفی",
|
||||||
|
location: "ارسال به تهران",
|
||||||
|
},
|
||||||
|
{ src: "/images/satisfaction-6.jpg", title: "میز کنارسالنی معرق", location: null },
|
||||||
|
{ src: "/images/satisfaction-7.jpg", title: "میز کنسول طراحی با برنز و نقاشی", location: "ارسال به قم" },
|
||||||
|
{ src: "/images/satisfaction-8.jpg", title: "میز تلویزیون طرح معرق برنز", location: "ارسال به قم" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SatisfactionPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<PageHeader
|
||||||
|
title="رضایت مشتریان"
|
||||||
|
subtitle="SATISFACTION"
|
||||||
|
backgroundImage="/images/satisfaction-header-bg.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
REAL CUSTOMERS, REAL HOMES
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">
|
||||||
|
نمونه کارهای ارسال شده در منزل مشتریان عزیز
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.src}
|
||||||
|
className="group relative aspect-4/5 overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-shadow hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={photo.src}
|
||||||
|
alt={photo.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/10 to-transparent p-5 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||||
|
<h3 className="font-bold text-white">{photo.title}</h3>
|
||||||
|
{photo.location && (
|
||||||
|
<p className="mt-1 text-sm text-white/80">{photo.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: "۹۹٪", label: "کیفیت محصول" },
|
||||||
|
{ value: "۹۰٪", label: "تنوع محصول" },
|
||||||
|
{ value: "۹۰٪", label: "رضایت مشتری" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AboutDcorya() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
||||||
|
<div className="relative order-2 aspect-4/3 overflow-hidden rounded-2xl lg:order-1">
|
||||||
|
<Image
|
||||||
|
src="/images/product-2.jpg"
|
||||||
|
alt="محصولات چوبی دکوریا"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-1 lg:order-2">
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
ABOUT DCORYA
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">فروشگاه آنلاین دکوریا</h2>
|
||||||
|
<p className="mt-2 text-neutral-500">زیباترین دکوراتیو چوبی، کریستال و برنز</p>
|
||||||
|
|
||||||
|
<p className="mt-6 leading-8 text-neutral-600">
|
||||||
|
فروشگاه اینترنتی دکوریا یک فروشگاه آنلاین تخصصی در زمینه محصولات چوبی از جمله آینه
|
||||||
|
کنسول، بوفه، ویترین و... (معرق و نقاشی و برنز سبک اروپا) و کریستال با برندهای معتبر
|
||||||
|
دنیا (چک، لهستان، آلمان و...) است. لذت خرید اینترنتی را با دکوریا تجربه کنید!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="mt-6 inline-block rounded-lg bg-primary px-7 py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
بیشتر بدانید
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-10 grid grid-cols-3 gap-4 border-t border-black/5 pt-8">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.label} className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-primary sm:text-3xl" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs font-medium text-neutral-500 sm:text-sm">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function Accordion({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: { question: string; answer: string }[];
|
||||||
|
}) {
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const isOpen = openIndex === i;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.question}
|
||||||
|
className="overflow-hidden rounded-xl border border-black/5 bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenIndex(isOpen ? null : i)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-right"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span className="font-bold text-neutral-800">{item.question}</span>
|
||||||
|
<span
|
||||||
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-transform duration-300 ${
|
||||||
|
isOpen ? "rotate-45" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-4 w-4">
|
||||||
|
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-all duration-300 ease-in-out"
|
||||||
|
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="border-t border-black/5 px-5 py-4 leading-8 text-neutral-600">
|
||||||
|
{item.answer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { useCart } from "@/lib/cart-context";
|
||||||
|
import { useToast } from "@/lib/toast-context";
|
||||||
|
import type { StoreItemVariant } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
function formatToman(value?: number | null) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return `${value.toLocaleString("fa-IR")} تومان`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddToCartBox({ variant }: { variant: StoreItemVariant | null }) {
|
||||||
|
const { user, loginUrl } = useAuth();
|
||||||
|
const { addItem } = useCart();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const inStock = variant ? variant.unlimited || (variant.stockQuantity ?? 0) > 0 : false;
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center text-sm text-neutral-500">
|
||||||
|
این محصول در حال حاضر برای خرید آنلاین موجود نیست. برای اطلاع از قیمت و موجودی با ما تماس
|
||||||
|
بگیرید.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inStock) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center">
|
||||||
|
<p className="mb-1 text-lg font-extrabold text-neutral-400" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
{formatToman(variant.price)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-neutral-500">ناموجود</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!user) {
|
||||||
|
window.location.href = loginUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await addItem(variant.id, quantity);
|
||||||
|
showToast("محصول به سبد خرید اضافه شد");
|
||||||
|
} catch {
|
||||||
|
showToast("افزودن به سبد خرید با خطا مواجه شد", "error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5">
|
||||||
|
<p
|
||||||
|
className="mb-4 text-2xl font-extrabold text-primary"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{formatToman(variant.price)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-black/10 bg-white px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center text-sm font-medium">{quantity}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuantity((q) => q + 1)}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 rounded-lg bg-primary py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function BannerRow() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-4 sm:px-6 md:grid-cols-2 lg:px-8">
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="group relative block overflow-hidden rounded-2xl bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/banner-1.png"
|
||||||
|
alt="فروش ویژه دکوریا"
|
||||||
|
width={900}
|
||||||
|
height={484}
|
||||||
|
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="group relative block overflow-hidden rounded-2xl shadow-sm"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/console-cat-4.jpg"
|
||||||
|
alt="کلکسیون میز کنسول"
|
||||||
|
width={900}
|
||||||
|
height={484}
|
||||||
|
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/40 text-center text-white">
|
||||||
|
<p className="text-xs tracking-[0.3em]" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
NEW COLLECTION
|
||||||
|
</p>
|
||||||
|
<h3 className="text-2xl font-bold">کلکسیون میز کنسول</h3>
|
||||||
|
<span className="mt-2 rounded-full border border-white/70 px-5 py-2 text-sm font-medium transition-colors group-hover:bg-white group-hover:text-neutral-900">
|
||||||
|
مشاهده محصولات
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function Carousel({ children }: { children: ReactNode }) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollByAmount = (dir: 1 | -1) => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: dir * -el.clientWidth * 0.85, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="flex snap-x snap-mandatory gap-5 overflow-x-auto scroll-smooth pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="قبلی"
|
||||||
|
onClick={() => scrollByAmount(-1)}
|
||||||
|
className="absolute -right-4 top-1/2 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-black/10 bg-white text-neutral-600 shadow-md transition-colors hover:text-primary sm:flex"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="بعدی"
|
||||||
|
onClick={() => scrollByAmount(1)}
|
||||||
|
className="absolute -left-4 top-1/2 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-black/10 bg-white text-neutral-600 shadow-md transition-colors hover:text-primary sm:flex"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCart } from "@/lib/cart-context";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { useClickOutside } from "@/lib/use-click-outside";
|
||||||
|
|
||||||
|
function formatToman(value?: number) {
|
||||||
|
if (!value && value !== 0) return null;
|
||||||
|
return `${value.toLocaleString("fa-IR")} تومان`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartPopover() {
|
||||||
|
const { user, loginUrl } = useAuth();
|
||||||
|
const { cart, itemCount } = useCart();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(ref, () => setOpen(false));
|
||||||
|
|
||||||
|
const items = cart?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
aria-label="سبد خرید"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="relative text-neutral-600 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path
|
||||||
|
d="M3 4h2l1.4 11.2A2 2 0 0 0 8.4 17H18a2 2 0 0 0 2-1.6L21.5 8H6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="9.5" cy="20.5" r="1.3" fill="currentColor" />
|
||||||
|
<circle cx="17.5" cy="20.5" r="1.3" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<span className="absolute -top-2 -left-2 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-full z-50 mt-3 w-80 rounded-2xl border border-black/5 bg-white p-4 text-right shadow-2xl">
|
||||||
|
{!user ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<p className="mb-4 text-sm text-neutral-500">
|
||||||
|
برای مشاهده سبد خرید ابتدا وارد حساب کاربری خود شوید.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={loginUrl}
|
||||||
|
className="inline-block rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
ورود به حساب کاربری
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-neutral-500">سبد خرید شما خالی است.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-72 space-y-3 overflow-y-auto">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-3">
|
||||||
|
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-neutral-100">
|
||||||
|
{item.image && (
|
||||||
|
<Image src={item.image} alt={item.title ?? ""} fill className="object-cover" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-neutral-800">
|
||||||
|
{item.title ?? "کالا"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">تعداد: {item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
{formatToman(item.unitPrice) && (
|
||||||
|
<p className="shrink-0 text-xs font-bold text-primary">
|
||||||
|
{formatToman(item.unitPrice)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/cart"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="mt-4 block rounded-lg bg-primary py-2.5 text-center text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
مشاهده سبد خرید و ادامه
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { useToast } from "@/lib/toast-context";
|
||||||
|
import { meshkee, type Comment } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||||
|
new Date(iso)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentsSection({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
initialComments,
|
||||||
|
}: {
|
||||||
|
entityType: "product" | "blog" | "portfolio" | "video";
|
||||||
|
entityId: string;
|
||||||
|
initialComments: Comment[];
|
||||||
|
}) {
|
||||||
|
const { user, loginUrl } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [comments, setComments] = useState(initialComments);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user || !text.trim()) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { comment } = await meshkee.postComment({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
authorName: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
|
authorEmail: user.email ?? undefined,
|
||||||
|
text: text.trim(),
|
||||||
|
});
|
||||||
|
setComments((prev) => [comment, ...prev]);
|
||||||
|
setText("");
|
||||||
|
showToast("نظر شما با موفقیت ثبت شد و پس از تایید نمایش داده میشود");
|
||||||
|
} catch {
|
||||||
|
showToast("ثبت نظر با خطا مواجه شد", "error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{user ? (
|
||||||
|
<form onSubmit={handleSubmit} className="mb-8">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
placeholder="نظر خود را درباره این محصول بنویسید..."
|
||||||
|
className="w-full rounded-lg border border-neutral-200 px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="mt-3 rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-primary-dark disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "در حال ارسال..." : "ثبت نظر"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="mb-8 rounded-xl border border-black/5 bg-neutral-50 p-5 text-center">
|
||||||
|
<p className="mb-3 text-sm text-neutral-500">برای ثبت نظر ابتدا وارد حساب کاربری خود شوید.</p>
|
||||||
|
<a
|
||||||
|
href={loginUrl}
|
||||||
|
className="inline-block rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
ورود به حساب کاربری
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500">هنوز نظری برای این محصول ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="rounded-xl border border-black/5 bg-white p-4">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-bold text-neutral-800">{comment.authorName}</span>
|
||||||
|
<span className="text-xs text-neutral-400">{formatDate(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-7 text-neutral-600">{comment.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ title: "سه تیکه", icon: "/images/console-cat-1.png" },
|
||||||
|
{ title: "انواع میز پذیرایی", icon: "/images/console-cat-2.png" },
|
||||||
|
{ title: "قلمکاری روی مس", icon: "/images/console-cat-3.png" },
|
||||||
|
{ title: "انواع شمعــــدان", icon: "/images/console-cat-4.jpg" },
|
||||||
|
{ title: "میز کنسول", icon: "/images/console-cat-5.png" },
|
||||||
|
{ title: "میز تی وی", icon: "/images/console-cat-6.png" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ConsoleCategories() {
|
||||||
|
return (
|
||||||
|
<section className="bg-neutral-50 py-16">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-800 sm:text-3xl">
|
||||||
|
زیباترین دکوراتیو چوبی، کریستال و برنز
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-neutral-500">
|
||||||
|
به ما اعتماد کنید! زیرا ما بهترین کیفیت را ارائه می دهیم
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.title}
|
||||||
|
href="#"
|
||||||
|
className="group flex flex-col items-center gap-3 rounded-2xl border border-black/5 bg-white p-5 text-center shadow-sm transition-shadow hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50">
|
||||||
|
<Image
|
||||||
|
src={item.icon}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
sizes="80px"
|
||||||
|
className="object-cover transition-transform group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700">{item.title}</h3>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { meshkee } from "@/lib/meshkee";
|
||||||
|
import { useToast } from "@/lib/toast-context";
|
||||||
|
|
||||||
|
export default function ContactForm() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
|
|
||||||
|
const name = String(data.get("name") || "").trim();
|
||||||
|
const email = String(data.get("email") || "").trim();
|
||||||
|
const cellNumber = String(data.get("cellNumber") || "").trim();
|
||||||
|
const text = String(data.get("text") || "").trim();
|
||||||
|
|
||||||
|
if (!name || !text) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await meshkee.postContactSubmission({
|
||||||
|
title: `پیام از سایت - ${name}`,
|
||||||
|
name,
|
||||||
|
email: email || undefined,
|
||||||
|
cellNumber: cellNumber || undefined,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
showToast("پیام شما با موفقیت ارسال شد");
|
||||||
|
formRef.current?.reset();
|
||||||
|
} catch {
|
||||||
|
showToast("ارسال پیام با خطا مواجه شد. لطفا دوباره تلاش کنید.", "error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="نام و نام خانوادگی"
|
||||||
|
className="rounded-lg border border-neutral-200 px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ایمیل"
|
||||||
|
className="rounded-lg border border-neutral-200 px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="cellNumber"
|
||||||
|
type="tel"
|
||||||
|
placeholder="شماره تماس"
|
||||||
|
className="rounded-lg border border-neutral-200 px-4 py-3 text-sm outline-none transition-colors focus:border-primary sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
name="text"
|
||||||
|
rows={5}
|
||||||
|
placeholder="پیام شما"
|
||||||
|
className="rounded-lg border border-neutral-200 px-4 py-3 text-sm outline-none transition-colors focus:border-primary sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="justify-self-start rounded-lg bg-primary px-8 py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark disabled:opacity-60 sm:col-span-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "در حال ارسال..." : "ارسال پیام"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "به ما ایمیل بزنید",
|
||||||
|
value: "dcorya.shopp@gmail.com",
|
||||||
|
href: "mailto:dcorya.shopp@gmail.com",
|
||||||
|
ltr: true,
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-7 w-7">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path d="m4 6.5 8 6 8-6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "همیشه پاسخگوی شما هستیم",
|
||||||
|
value: "025-32400725",
|
||||||
|
href: "tel:02532400725",
|
||||||
|
ltr: true,
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-7 w-7">
|
||||||
|
<path
|
||||||
|
d="M6.5 3.5c1 0 2.7 2.6 2.7 3.7 0 1.1-1.6 1.8-1.9 2.6-.4 1 1.2 3 2.4 4.2 1.2 1.2 3.2 2.8 4.2 2.4.8-.3 1.5-1.9 2.6-1.9 1.1 0 3.7 1.7 3.7 2.7 0 1-2.7 3.3-3.7 3.3-1.7 0-5.5-1.7-8.8-5-3.3-3.3-5-7.1-5-8.8 0-1 2.3-3.7 3.3-3.7z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "فروشگاه دکوریا",
|
||||||
|
value: "قم، خیابان ۴۵ متری صدوقی، نبش حافظ",
|
||||||
|
href: "#",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-7 w-7">
|
||||||
|
<path
|
||||||
|
d="M12 21s7-6.5 7-11.5a7 7 0 1 0-14 0C5 14.5 12 21 12 21z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="9.5" r="2.3" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ContactTeaser() {
|
||||||
|
return (
|
||||||
|
<section className="bg-neutral-50 py-16">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 text-center sm:px-6 lg:px-8">
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
GET IN TOUCH
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">هر طور که راحتید با ما تماس بگیرید</h2>
|
||||||
|
|
||||||
|
<div className="mt-12 overflow-hidden rounded-2xl border border-black/5 bg-white shadow-lg sm:grid sm:grid-cols-3 sm:divide-x sm:divide-x-reverse sm:divide-black/5">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
key={card.title}
|
||||||
|
href={card.href}
|
||||||
|
className="group flex flex-col items-center gap-3 border-b border-black/5 px-6 py-10 transition-colors last:border-b-0 hover:bg-primary/5 sm:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white shadow-md shadow-primary/20 transition-transform group-hover:scale-105">
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-neutral-800">{card.title}</h3>
|
||||||
|
<p dir={card.ltr ? "ltr" : undefined} className="text-sm leading-7 text-neutral-500">
|
||||||
|
{card.value}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="mt-12 inline-block rounded-lg bg-primary px-8 py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
تماس با ما
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Carousel from "./Carousel";
|
||||||
|
import ProductCard from "./ProductCard";
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{ image: "/images/decor-1.jpg", title: "ساعت و شمعدان دو پسر برنز طلایی" },
|
||||||
|
{ image: "/images/decor-2.jpg", title: "ساعت و شمعدان زن و مرد برنز طلایی آنتیک" },
|
||||||
|
{ image: "/images/decor-3.jpg", title: "ساعت و شمعدان زن و مرد برنز طلایی" },
|
||||||
|
{ image: "/images/decor-4.jpg", title: "ساعت و شمعدان ریسه ای برنز آنتیک" },
|
||||||
|
{ image: "/images/decor-5.jpg", title: "ست ساعت و شمعدان پرنسس ۰۱۱" },
|
||||||
|
{ image: "/images/decor-6.jpg", title: "ست شمعدان و ساعت سنگ و برنز ۰۱۰" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DecorativeProducts() {
|
||||||
|
return (
|
||||||
|
<section className="bg-neutral-50 py-16">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
HANDCRAFTED
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">صنایع دستی و تزئینی دکوریا</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Carousel>
|
||||||
|
{products.map((p) => (
|
||||||
|
<ProductCard key={p.title} {...p} />
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mt-auto border-t border-black/5 bg-neutral-50">
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-10 px-4 py-14 sm:px-6 lg:grid-cols-3 lg:px-8">
|
||||||
|
<div>
|
||||||
|
<Image src="/images/logo.png" alt="دکوریا" width={110} height={36} className="h-9 w-auto" />
|
||||||
|
<p className="mt-4 text-sm leading-7 text-neutral-500">
|
||||||
|
زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را اینبار با
|
||||||
|
دکوریا تجربه کنید!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 font-bold text-neutral-800">خدمات مشتریان</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-neutral-500">
|
||||||
|
<li>
|
||||||
|
<Link href="#" className="transition-colors hover:text-primary">
|
||||||
|
رضایت مشتریان
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/about" className="transition-colors hover:text-primary">
|
||||||
|
درباره دکوریا
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/contact" className="transition-colors hover:text-primary">
|
||||||
|
تماس با دکوریا
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 font-bold text-neutral-800">اطلاعات تماس</h3>
|
||||||
|
<p className="mb-1 text-sm text-neutral-500">همیشه پاسخگوی شما هستیم ....</p>
|
||||||
|
<p dir="ltr" className="text-right text-sm text-neutral-600">
|
||||||
|
025-32400725
|
||||||
|
<br />
|
||||||
|
09121514070
|
||||||
|
<br />
|
||||||
|
09129368019
|
||||||
|
<br />
|
||||||
|
09124592669
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-neutral-600">قم، خیابان 45 متری صدوقی، نبش حافظ</p>
|
||||||
|
<p dir="ltr" className="mt-1 text-right text-sm text-neutral-600">
|
||||||
|
dcorya.shopp@gmail.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-black/5 py-5 text-center text-xs text-neutral-400">
|
||||||
|
© {new Date().getFullYear()} فروشگاه اینترنتی دکوریا. تمامی حقوق محفوظ است.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import CartPopover from "./CartPopover";
|
||||||
|
import ProfileMenu from "./ProfileMenu";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/", label: "صفحه اصلی" },
|
||||||
|
{ href: "/products", label: "محصولات" },
|
||||||
|
{ href: "/blog", label: "بلاگ" },
|
||||||
|
{ href: "/satisfaction", label: "مشتریان راضی" },
|
||||||
|
{ href: "/purchaseguide", label: "راهنمای خرید" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 h-16 border-b border-black/10 bg-white shadow-[0_2px_10px_rgba(0,0,0,0.06)]">
|
||||||
|
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<Link href="/" className="shrink-0">
|
||||||
|
<Image
|
||||||
|
src="/images/logo.png"
|
||||||
|
alt="دکوریا"
|
||||||
|
width={110}
|
||||||
|
height={36}
|
||||||
|
priority
|
||||||
|
className="h-9 w-auto"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden items-center gap-7 lg:flex">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.label}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<button
|
||||||
|
aria-label="جستجو"
|
||||||
|
className="text-neutral-600 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CartPopover />
|
||||||
|
|
||||||
|
<ProfileMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const slides = [
|
||||||
|
{ src: "/images/slide-1.jpg", alt: "کریستال و ظروف دکوراتیو دکوریا" },
|
||||||
|
{ src: "/images/slide-2.jpg", alt: "فروش اقساطی بدون سود محصولات دکوریا" },
|
||||||
|
{ src: "/images/slide-3.jpg", alt: "قلمکاری مس، هنر اصیل ایرانی دکوریا" },
|
||||||
|
{ src: "/images/slide-4.jpg", alt: "میز کنسول چوبی معرقکاری دکوریا" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const goTo = useCallback((index: number) => {
|
||||||
|
setActive((index + slides.length) % slides.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setActive((prev) => (prev + 1) % slides.length);
|
||||||
|
}, 5500);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative h-[calc(100dvh-4rem)] min-h-[420px] w-full overflow-hidden">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<div
|
||||||
|
key={slide.src}
|
||||||
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||||
|
i === active ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={slide.src}
|
||||||
|
alt={slide.alt}
|
||||||
|
fill
|
||||||
|
priority={i === 0}
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-start px-4 pb-20 sm:px-10 sm:pb-24">
|
||||||
|
<div
|
||||||
|
key={active}
|
||||||
|
className="max-w-xs rounded-2xl bg-white/95 px-6 py-6 text-right shadow-2xl backdrop-blur-sm sm:max-w-md sm:px-10 sm:py-8"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="animate-fade-in-up mb-2 block text-2xl tracking-widest text-primary sm:text-3xl"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.1s" }}
|
||||||
|
>
|
||||||
|
Dcorya
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className="animate-fade-in-up text-lg font-bold leading-relaxed text-neutral-800 sm:text-2xl"
|
||||||
|
style={{ animationDelay: "0.25s" }}
|
||||||
|
>
|
||||||
|
ارسال به سراسر ایران و کشورهای همسایه
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="animate-fade-in-up mt-2 text-xs tracking-wide text-neutral-500 sm:text-sm"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.4s" }}
|
||||||
|
>
|
||||||
|
Shipping Across Iran and Neighboring Countries
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="اسلاید قبلی"
|
||||||
|
onClick={() => goTo(active - 1)}
|
||||||
|
className="absolute top-1/2 right-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:right-8"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="اسلاید بعدی"
|
||||||
|
onClick={() => goTo(active + 1)}
|
||||||
|
className="absolute top-1/2 left-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:left-8"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 left-6 z-10 flex gap-2 sm:left-10">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<button
|
||||||
|
key={slide.src}
|
||||||
|
aria-label={`اسلاید ${i + 1}`}
|
||||||
|
onClick={() => goTo(i)}
|
||||||
|
className={`h-2.5 rounded-full transition-all ${
|
||||||
|
i === active ? "w-7 bg-white" : "w-2.5 bg-white/50"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Carousel from "./Carousel";
|
||||||
|
import ProductCard from "./ProductCard";
|
||||||
|
import RemoteProductCard from "./RemoteProductCard";
|
||||||
|
import { meshkee } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
const fallbackProducts = [
|
||||||
|
{ image: "/images/product-1.jpg", title: "ساعت ایستاده CL001", price: "29,700,000 تومان" },
|
||||||
|
{ image: "/images/product-2.jpg", title: "میز کنسول سه درب نیم گرد نقاشی برنزخور B084", price: "49,300,000 تومان" },
|
||||||
|
{ image: "/images/product-3.jpg", title: "میز کنسول دودرب نقاشی برنزخور B003", price: "47,500,000 تومان" },
|
||||||
|
{ image: "/images/product-4.jpg", title: "میز کنسول سه درب نقاشی برنزخور B096", price: "49,500,000 تومان" },
|
||||||
|
{ image: "/images/product-5.jpg", title: "آینه کنسول تک درب نقاشی برنزخور B020", price: "43,500,000 تومان" },
|
||||||
|
{ image: "/images/product-6.jpg", title: "میز کنسول تک درب نقاشی برنزخور B075", price: "35,500,000 تومان" },
|
||||||
|
{ image: "/images/product-7.jpg", title: "میز کنسول ویترین نقاشی برنزخور B083", price: "46,900,000 تومان" },
|
||||||
|
{ image: "/images/product-8.jpg", title: "میز کنسول خمره ای وسط کشو نقاشی برنزخور B087", price: "49,700,000 تومان" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function LatestProducts() {
|
||||||
|
const { items } = await meshkee.getProducts({ pageSize: 8 }).catch(() => ({ items: [] }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-10 flex flex-col items-center gap-3 text-center sm:flex-row sm:items-end sm:justify-between sm:text-right">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
NEW ARRIVALS
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">جدیدترین پیشنهادهای دکوریا</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/products" className="text-sm font-bold text-primary hover:underline">
|
||||||
|
مشاهده همه محصولات ←
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Carousel>
|
||||||
|
{items.length > 0
|
||||||
|
? items.map((p) => <RemoteProductCard key={p.id} product={p} />)
|
||||||
|
: fallbackProducts.map((p) => <ProductCard key={p.title} {...p} />)}
|
||||||
|
</Carousel>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export default function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
backgroundImage = "/images/page-header-bg.jpg",
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
backgroundImage?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative flex h-56 items-center justify-center overflow-hidden bg-[#faf8f5] bg-cover bg-center sm:h-64"
|
||||||
|
style={{ backgroundImage: `url('${backgroundImage}')` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-white/40" />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-neutral-700 sm:text-4xl">{title}</h1>
|
||||||
|
<p
|
||||||
|
className="mt-2 text-xs tracking-[0.3em] text-neutral-400"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ProductCard({
|
||||||
|
image,
|
||||||
|
title,
|
||||||
|
price,
|
||||||
|
}: {
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
price?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="group w-[80vw] shrink-0 snap-start overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl sm:w-[300px]"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 80vw, 300px"
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 text-center">
|
||||||
|
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{price && (
|
||||||
|
<p
|
||||||
|
className="mt-3 text-lg font-extrabold text-primary"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{price}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ title: "هنرهای دستی", subtitle: "ARTS AND CRAFTS", icon: "/images/cat-crafts.png", href: "#" },
|
||||||
|
{ title: "دکوری و تزئینی", subtitle: "DECORATION", icon: "/images/cat-decoration.png", href: "#" },
|
||||||
|
{ title: "آشپز خانه", subtitle: "KITCHEN", icon: "/images/cat-kitchen.png", href: "#" },
|
||||||
|
{ title: "محصولات چوبی", subtitle: "WOOD PRODUCTS", icon: "/images/cat-wood.png", href: "#" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProductCategories() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative bg-cover bg-center py-16"
|
||||||
|
style={{ backgroundImage: "url('/images/category-bg.jpg')" }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-white/60" />
|
||||||
|
<div className="relative mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-800 sm:text-3xl">دکوریا در یک نگاه</h2>
|
||||||
|
<p
|
||||||
|
className="mt-2 text-xs tracking-[0.3em] text-neutral-400"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
WWW.DCORYA.COM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Link
|
||||||
|
key={cat.title}
|
||||||
|
href={cat.href}
|
||||||
|
className="group flex flex-col items-center gap-4 rounded-2xl bg-white/80 p-6 text-center shadow-sm backdrop-blur-sm transition-shadow hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center">
|
||||||
|
<Image src={cat.icon} alt={cat.title} width={64} height={64} className="h-16 w-16 object-contain transition-transform group-hover:scale-110" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-neutral-800">{cat.title}</h3>
|
||||||
|
<p
|
||||||
|
className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{cat.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState, useRef, type MouseEvent } from "react";
|
||||||
|
|
||||||
|
export default function ProductGallery({
|
||||||
|
images,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
images: string[];
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const [zoomPos, setZoomPos] = useState({ x: 50, y: 50 });
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const gallery = images.length > 0 ? images : ["/images/logo.png"];
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
setZoomPos({ x, y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goTo = (index: number) => setActive((index + gallery.length) % gallery.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onClick={() => setLightboxOpen(true)}
|
||||||
|
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-2xl border border-black/5 bg-neutral-50"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={gallery[active]}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
priority
|
||||||
|
className="object-cover transition-transform duration-200 ease-out"
|
||||||
|
style={
|
||||||
|
isHovering
|
||||||
|
? { transform: "scale(1.8)", transformOrigin: `${zoomPos.x}% ${zoomPos.y}%` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gallery.length > 1 && (
|
||||||
|
<div className="mt-4 flex gap-3 overflow-x-auto pb-1">
|
||||||
|
{gallery.map((src, i) => (
|
||||||
|
<button
|
||||||
|
key={src}
|
||||||
|
onClick={() => goTo(i)}
|
||||||
|
className={`relative h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 transition-colors ${
|
||||||
|
i === active ? "border-primary" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image src={src} alt={`${title} ${i + 1}`} fill sizes="80px" className="object-cover" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-100 flex items-center justify-center bg-black/90 p-4"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="بستن"
|
||||||
|
className="absolute left-6 top-6 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="قبلی"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goTo(active - 1);
|
||||||
|
}}
|
||||||
|
className="absolute top-1/2 right-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 sm:right-8"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="بعدی"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goTo(active + 1);
|
||||||
|
}}
|
||||||
|
className="absolute top-1/2 left-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 sm:left-8"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative h-[80vh] w-full max-w-3xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Image src={gallery[active]} alt={title} fill sizes="100vw" className="object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import CommentsSection from "./CommentsSection";
|
||||||
|
import type { Comment, TechnicalInfoResponse } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
type Tab = "description" | "specs" | "comments";
|
||||||
|
|
||||||
|
export default function ProductTabs({
|
||||||
|
descriptionHtml,
|
||||||
|
technicalInfo,
|
||||||
|
productId,
|
||||||
|
initialComments,
|
||||||
|
commentCount,
|
||||||
|
}: {
|
||||||
|
descriptionHtml: string;
|
||||||
|
technicalInfo: TechnicalInfoResponse | null;
|
||||||
|
productId: string;
|
||||||
|
initialComments: Comment[];
|
||||||
|
commentCount: number;
|
||||||
|
}) {
|
||||||
|
const hasSpecs = !!technicalInfo?.form?.fields?.length && technicalInfo.values.length > 0;
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "description", label: "توضیحات" },
|
||||||
|
...(hasSpecs ? ([{ key: "specs", label: "مشخصات فنی" }] as const) : []),
|
||||||
|
{ key: "comments", label: `نظرات (${commentCount})` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [active, setActive] = useState<Tab>("description");
|
||||||
|
|
||||||
|
const specRows =
|
||||||
|
hasSpecs && technicalInfo?.form
|
||||||
|
? [...technicalInfo.form.fields]
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map((field) => {
|
||||||
|
const value = technicalInfo.values.find((v) => v.fieldId === field.id);
|
||||||
|
if (!value) return null;
|
||||||
|
let display = value.textValue ?? "";
|
||||||
|
if (value.optionId && field.options) {
|
||||||
|
display = field.options.find((o) => o.id === value.optionId)?.label ?? display;
|
||||||
|
}
|
||||||
|
if (value.optionIds && field.options) {
|
||||||
|
display = value.optionIds
|
||||||
|
.map((id) => field.options?.find((o) => o.id === id)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("، ");
|
||||||
|
}
|
||||||
|
return { label: field.label, value: display };
|
||||||
|
})
|
||||||
|
.filter((row): row is { label: string; value: string } => !!row && !!row.value)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex gap-2 border-b border-black/10">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActive(tab.key)}
|
||||||
|
className={`relative px-4 py-3 text-sm font-bold transition-colors ${
|
||||||
|
active === tab.key ? "text-primary" : "text-neutral-500 hover:text-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{active === tab.key && (
|
||||||
|
<span className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active === "description" && (
|
||||||
|
<div
|
||||||
|
className="prose prose-neutral max-w-none text-neutral-600 [&_p]:leading-8"
|
||||||
|
dangerouslySetInnerHTML={{ __html: descriptionHtml || "<p>توضیحاتی برای این محصول ثبت نشده است.</p>" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === "specs" && hasSpecs && (
|
||||||
|
<table className="w-full overflow-hidden rounded-xl border border-black/5 text-sm">
|
||||||
|
<tbody>
|
||||||
|
{specRows.map((row, i) => (
|
||||||
|
<tr key={row.label} className={i % 2 === 0 ? "bg-neutral-50" : "bg-white"}>
|
||||||
|
<td className="w-1/3 px-4 py-3 font-medium text-neutral-500">{row.label}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-800">{row.value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === "comments" && (
|
||||||
|
<CommentsSection entityType="product" entityId={productId} initialComments={initialComments} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
|
||||||
|
import { useClickOutside } from "@/lib/use-click-outside";
|
||||||
|
|
||||||
|
export default function ProfileMenu() {
|
||||||
|
const { user, isLoading, isAdmin, loginUrl, logout } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(ref, () => setOpen(false));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <span className="h-5 w-16 animate-pulse rounded bg-neutral-100" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={loginUrl}
|
||||||
|
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M5 20c1.2-3.5 4-5.5 7-5.5s5.8 2 7 5.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
ورود
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M5 20c1.2-3.5 4-5.5 7-5.5s5.8 2 7 5.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
{user.firstName} عزیز
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-full z-50 mt-3 w-48 overflow-hidden rounded-xl border border-black/5 bg-white py-2 text-right shadow-2xl">
|
||||||
|
<a
|
||||||
|
href={`${CUSTOMER_PORTAL_URL}/profile`}
|
||||||
|
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
||||||
|
>
|
||||||
|
پروفایل من
|
||||||
|
</a>
|
||||||
|
{isAdmin && (
|
||||||
|
<a
|
||||||
|
href={`${CUSTOMER_PORTAL_URL}/admin`}
|
||||||
|
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
||||||
|
>
|
||||||
|
مدیریت وبسایت
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full px-4 py-2.5 text-right text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Product } from "@/lib/meshkee";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
|
||||||
|
function formatToman(value?: number | null) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return `${value.toLocaleString("fa-IR")} تومان`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RemoteProductCard({ product }: { product: Product }) {
|
||||||
|
const href = `/products/${product.id}/${slugify(product.nameFa || product.title)}`;
|
||||||
|
const price = formatToman(product.store?.minPrice);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group w-[80vw] shrink-0 snap-start overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl sm:w-[300px]"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50">
|
||||||
|
{product.thumbnailUrl ? (
|
||||||
|
<Image
|
||||||
|
src={product.thumbnailUrl}
|
||||||
|
alt={product.nameFa || product.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 80vw, 300px"
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-neutral-300">بدون تصویر</div>
|
||||||
|
)}
|
||||||
|
{product.store && !product.store.inStock && (
|
||||||
|
<span className="absolute right-3 top-3 rounded-full bg-neutral-800/80 px-3 py-1 text-xs font-medium text-white">
|
||||||
|
ناموجود
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-5 text-center">
|
||||||
|
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
||||||
|
{product.nameFa || product.title}
|
||||||
|
</h3>
|
||||||
|
{price && (
|
||||||
|
<p
|
||||||
|
className="mt-3 text-lg font-extrabold text-primary"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{price}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { meshkee, type AuthUser, CUSTOMER_PORTAL_URL } from "./meshkee";
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "meshkee_access_token";
|
||||||
|
const REFRESH_TOKEN_KEY = "meshkee_refresh_token";
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: AuthUser | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
loginUrl: string;
|
||||||
|
setSession: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
function getLoginUrl() {
|
||||||
|
if (typeof window === "undefined") return CUSTOMER_PORTAL_URL;
|
||||||
|
const returnUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(
|
||||||
|
window.location.pathname
|
||||||
|
)}`;
|
||||||
|
return `${CUSTOMER_PORTAL_URL}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadUser = useCallback(async (token: string) => {
|
||||||
|
try {
|
||||||
|
const { user } = await meshkee.me(token);
|
||||||
|
setUser(user);
|
||||||
|
setAccessToken(token);
|
||||||
|
} catch {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const res = await meshkee.refresh(refreshToken);
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, res.accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, res.refreshToken);
|
||||||
|
setUser(res.user);
|
||||||
|
setAccessToken(res.accessToken);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through to logout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
setUser(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time session bootstrap on mount
|
||||||
|
loadUser(token).finally(() => setIsLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadUser]);
|
||||||
|
|
||||||
|
const setSession = useCallback(
|
||||||
|
async (newAccessToken: string, newRefreshToken: string) => {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||||
|
setIsLoading(true);
|
||||||
|
await loadUser(newAccessToken);
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
[loadUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
setUser(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
!!user && (user.role === "admin" || user.role === "owner" || user.isAdmin === true);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isLoading,
|
||||||
|
isAdmin,
|
||||||
|
loginUrl: getLoginUrl(),
|
||||||
|
setSession,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[user, accessToken, isLoading, isAdmin, setSession, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
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,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { meshkee, type Cart } from "./meshkee";
|
||||||
|
import { useAuth } from "./auth-context";
|
||||||
|
|
||||||
|
type CartContextValue = {
|
||||||
|
cart: Cart | null;
|
||||||
|
itemCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
|
||||||
|
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
||||||
|
removeItem: (itemId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CartContext = createContext<CartContextValue | null>(null);
|
||||||
|
|
||||||
|
export function CartProvider({
|
||||||
|
businessId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
businessId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [cart, setCart] = useState<Cart | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setCart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { cart } = await meshkee.getCart(businessId, accessToken);
|
||||||
|
setCart(cart);
|
||||||
|
} catch {
|
||||||
|
setCart(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, businessId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- refetch cart when auth state changes
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const addItem = useCallback(
|
||||||
|
async (storeItemVariantId: string, quantity = 1) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
const { cart } = await meshkee.addCartItem(businessId, accessToken, {
|
||||||
|
storeItemVariantId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
setCart(cart);
|
||||||
|
},
|
||||||
|
[accessToken, businessId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateItem = useCallback(
|
||||||
|
async (itemId: string, quantity: number) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
const { cart } = await meshkee.updateCartItem(businessId, accessToken, itemId, quantity);
|
||||||
|
setCart(cart);
|
||||||
|
},
|
||||||
|
[accessToken, businessId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeItem = useCallback(
|
||||||
|
async (itemId: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
const { cart } = await meshkee.removeCartItem(businessId, accessToken, itemId);
|
||||||
|
setCart(cart);
|
||||||
|
},
|
||||||
|
[accessToken, businessId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemCount = useMemo(
|
||||||
|
() => cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0,
|
||||||
|
[cart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem }),
|
||||||
|
[cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
const ctx = useContext(CartContext);
|
||||||
|
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
export const API_BASE = "https://api.meshkee.com/api/v1";
|
||||||
|
export const WEBSITE_DOMAIN = "dcorya.com";
|
||||||
|
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
|
||||||
|
|
||||||
|
export type Tenant = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameFa: string;
|
||||||
|
slug: string;
|
||||||
|
domain: string;
|
||||||
|
primaryColor: string | null;
|
||||||
|
defaultLocale: "en" | "fa";
|
||||||
|
logoUrl: string | null;
|
||||||
|
logoDarkUrl: string | null;
|
||||||
|
faviconUrl: string | null;
|
||||||
|
specialProductsSource: "product" | "store_item";
|
||||||
|
ePayment?: { enabled: boolean; defaultGateway: string | null; gateways: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Favicon = {
|
||||||
|
faviconUrl: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
logoDarkUrl: string | null;
|
||||||
|
hasDedicatedFavicon: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductImage = { mediaId: string; url: string };
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: string;
|
||||||
|
businessId: string;
|
||||||
|
title: string;
|
||||||
|
nameFa: string;
|
||||||
|
summary: string;
|
||||||
|
descriptionHtml: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryNameFa: string | null;
|
||||||
|
categoryParentName: string | null;
|
||||||
|
categoryParentNameFa: string | null;
|
||||||
|
brandId: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
tags: string[];
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
image: string | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
images: ProductImage[];
|
||||||
|
galleryMediaIds: string[];
|
||||||
|
commentCount: number;
|
||||||
|
variantCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
store?: {
|
||||||
|
variantCount: number;
|
||||||
|
minPrice: number | null;
|
||||||
|
maxPrice: number | null;
|
||||||
|
inStock: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductListResponse = {
|
||||||
|
items: Product[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductDetailResponse = {
|
||||||
|
product: Product;
|
||||||
|
relatedProducts: Product[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechnicalField = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
options?: { id: string; label: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechnicalInfoResponse = {
|
||||||
|
form: { fields: TechnicalField[] } | null;
|
||||||
|
values: {
|
||||||
|
fieldId: string;
|
||||||
|
textValue?: string | null;
|
||||||
|
optionId?: string | null;
|
||||||
|
optionIds?: string[] | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
nameFa: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Blog = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
nameFa?: string;
|
||||||
|
titleFa?: string;
|
||||||
|
slug: string;
|
||||||
|
summary?: string;
|
||||||
|
descriptionHtml?: string;
|
||||||
|
contentHtml?: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
images?: ProductImage[];
|
||||||
|
publishedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
commentCount?: number;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function blogTitle(blog: Blog): string {
|
||||||
|
return blog.nameFa || blog.titleFa || blog.title || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogBody(blog: Blog): string {
|
||||||
|
return blog.descriptionHtml || blog.contentHtml || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogCover(blog: Blog): string | null {
|
||||||
|
return blog.thumbnailUrl || blog.image || blog.images?.[0]?.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlogListResponse = {
|
||||||
|
items: Blog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Comment = {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
authorName: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreItemVariant = {
|
||||||
|
id: string;
|
||||||
|
price: number;
|
||||||
|
stockQuantity: number | null;
|
||||||
|
unlimited?: boolean;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreItem = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
variants: StoreItemVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CartItem = {
|
||||||
|
id: string;
|
||||||
|
storeItemVariantId: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
title?: string;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Cart = {
|
||||||
|
id?: string;
|
||||||
|
items: CartItem[];
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
cellNumber: string;
|
||||||
|
email?: string | null;
|
||||||
|
role?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthResponse = {
|
||||||
|
user: AuthUser;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`Meshkee API error ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit & { token?: string }
|
||||||
|
): Promise<T> {
|
||||||
|
const { token, ...rest } = init ?? {};
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(rest.headers as Record<string, string> | undefined),
|
||||||
|
};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...rest,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isJson = res.headers.get("content-type")?.includes("application/json");
|
||||||
|
const body = isJson ? await res.json().catch(() => null) : null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(res.status, body);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qs(params: Record<string, string | number | boolean | undefined | null>) {
|
||||||
|
const s = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== "") s.set(k, String(v));
|
||||||
|
}
|
||||||
|
const str = s.toString();
|
||||||
|
return str ? `?${str}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meshkee = {
|
||||||
|
getTenant: () => request<Tenant>(`/tenants/${WEBSITE_DOMAIN}`, { next: { revalidate: 300 } } as RequestInit),
|
||||||
|
|
||||||
|
getFavicon: () =>
|
||||||
|
request<Favicon>(`/tenants/${WEBSITE_DOMAIN}/website/favicon`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
} as RequestInit).catch(() => null),
|
||||||
|
|
||||||
|
getProducts: (params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
name?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
} = {}) =>
|
||||||
|
request<ProductListResponse>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/products${qs(params)}`,
|
||||||
|
{ next: { revalidate: 60 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getProductBySlug: (slug: string) =>
|
||||||
|
request<ProductDetailResponse>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/products/${slug}`,
|
||||||
|
{ next: { revalidate: 60 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getProductById: (id: string) =>
|
||||||
|
request<ProductDetailResponse>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/products/by-id/${id}`,
|
||||||
|
{ next: { revalidate: 60 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getProductTechnicalInfo: (slug: string) =>
|
||||||
|
request<TechnicalInfoResponse>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/products/${slug}/technical-info`,
|
||||||
|
{ next: { revalidate: 300 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getStoreItemByProduct: (productId: string) =>
|
||||||
|
request<{ storeItem: StoreItem | null }>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/store-items/by-product/${productId}`,
|
||||||
|
{ next: { revalidate: 30 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getCategories: (entityType: "product" = "product") =>
|
||||||
|
request<{ items: Category[] }>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`,
|
||||||
|
{ next: { revalidate: 300 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getCategoryById: (id: string) =>
|
||||||
|
request<{ category: Category }>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/categories/by-id/${id}`,
|
||||||
|
{ next: { revalidate: 300 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getBlogs: (params: { page?: number; pageSize?: number; tag?: string } = {}) =>
|
||||||
|
request<BlogListResponse>(`/tenants/${WEBSITE_DOMAIN}/blogs${qs(params)}`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
} as RequestInit),
|
||||||
|
|
||||||
|
getBlogBySlug: (slug: string) =>
|
||||||
|
request<{ blog: Blog }>(`/tenants/${WEBSITE_DOMAIN}/blogs/${slug}`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
} as RequestInit),
|
||||||
|
|
||||||
|
getBlogById: (id: string) =>
|
||||||
|
request<{ blog: Blog }>(`/tenants/${WEBSITE_DOMAIN}/blogs/by-id/${id}`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
} as RequestInit),
|
||||||
|
|
||||||
|
getComments: (entityType: "product" | "blog" | "portfolio" | "video", entityId: string) =>
|
||||||
|
request<{ items: Comment[] }>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/comments${qs({ entityType, entityId })}`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
),
|
||||||
|
|
||||||
|
postComment: (body: {
|
||||||
|
entityType: "product" | "blog" | "portfolio" | "video";
|
||||||
|
entityId: string;
|
||||||
|
authorName: string;
|
||||||
|
authorEmail?: string;
|
||||||
|
text: string;
|
||||||
|
}) =>
|
||||||
|
request<{ comment: Comment; message: string }>(`/tenants/${WEBSITE_DOMAIN}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
postContactSubmission: (body: {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
cellNumber?: string;
|
||||||
|
text: string;
|
||||||
|
}) =>
|
||||||
|
request<{ submission: unknown; message: string }>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/contact-submissions`,
|
||||||
|
{ method: "POST", body: JSON.stringify(body) }
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
login: (body: { cellNumber: string; password: string }) =>
|
||||||
|
request<AuthResponse>(`/auth/login`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
register: (body: {
|
||||||
|
cellNumber: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
domain: string;
|
||||||
|
acknowledgeExistingAccount?: boolean;
|
||||||
|
}) => request<AuthResponse>(`/auth/register`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
me: (token: string) => request<{ user: AuthUser }>(`/auth/me`, { token }),
|
||||||
|
|
||||||
|
refresh: (refreshToken: string) =>
|
||||||
|
request<AuthResponse>(`/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// --- Cart (requires businessId + bearer token) ---
|
||||||
|
getCart: (businessId: string, token: string) =>
|
||||||
|
request<{ cart: Cart }>(`/businesses/${businessId}/cart`, { token, cache: "no-store" }),
|
||||||
|
|
||||||
|
addCartItem: (
|
||||||
|
businessId: string,
|
||||||
|
token: string,
|
||||||
|
body: { storeItemVariantId: string; quantity?: number }
|
||||||
|
) =>
|
||||||
|
request<{ message: string; cart: Cart }>(`/businesses/${businessId}/cart/items`, {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateCartItem: (businessId: string, token: string, itemId: string, quantity: number) =>
|
||||||
|
request<{ message: string; cart: Cart }>(
|
||||||
|
`/businesses/${businessId}/cart/items/${itemId}`,
|
||||||
|
{ method: "PATCH", token, body: JSON.stringify({ quantity }) }
|
||||||
|
),
|
||||||
|
|
||||||
|
removeCartItem: (businessId: string, token: string, itemId: string) =>
|
||||||
|
request<{ message: string; cart: Cart }>(
|
||||||
|
`/businesses/${businessId}/cart/items/${itemId}`,
|
||||||
|
{ method: "DELETE", token }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||