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.
This commit is contained in:
Dcorya Store
2026-09-05 15:40:20 +03:30
commit c126a6914a
105 changed files with 10536 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dcorya-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+41
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+22
View File
@@ -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;
+6896
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

+73
View File
@@ -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 />
</>
);
}
+39
View File
@@ -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>
);
}
+11
View File
@@ -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 />;
}
+102
View File
@@ -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 />
</>
);
}
+88
View File
@@ -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 />
</>
);
}
+114
View File
@@ -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 />
</>
);
}
+11
View File
@@ -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 />;
}
+100
View File
@@ -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 />
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+44
View File
@@ -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;
}
+65
View File
@@ -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,
});
}
+29
View File
@@ -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 />
</>
);
}
+127
View File
@@ -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 />
</>
);
}
+105
View File
@@ -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>
);
}
+138
View File
@@ -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 />
</>
);
}
+76
View File
@@ -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 />
</>
);
}
+58
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+123
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+118
View File
@@ -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;
}
+105
View File
@@ -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;
}
+391
View File
@@ -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 };

Some files were not shown because too many files have changed in this diff Show More