Wire Meshkee storefront: static images, store-items, guest cart, and auth.

Connect homepage slots and specials, switch catalog to store-items, add the customer-portal mini-cart and account menu, and publish the static-image slot catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-30 09:31:07 +03:30
co-authored by Cursor
parent a60e84a2b2
commit 9e2dc2c61d
40 changed files with 2154 additions and 831 deletions
+96
View File
@@ -0,0 +1,96 @@
# Dcorya — AI context
Persian RTL decorative home-goods storefront for **dcorya.com** (دکوریا). Next.js App Router.
## Meshkee API
- Docs: https://api.meshkee.com/docs/website
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
- Base: `https://api.meshkee.com/api/v1`
- Tenant domain: `dcorya.com` (`WEBSITE_DOMAIN` in `src/lib/meshkee.ts`)
- Customer portal: `https://customer.dcorya.com`
- Public reads: `/tenants/{domain}/...` (no auth). Cart: `/businesses/{businessId}/...` + Bearer JWT.
- Do not invent CMS, upload, or dashboard APIs.
## Stack
- Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4
- `output: "standalone"` in `next.config.ts` (required for Meshkee websites VM deploy)
- Helpers: `src/lib/meshkee.ts`, static images: `src/lib/static-images.ts`
## Routes
| Path | Source |
| --- | --- |
| `/` | Homepage + static image slots + 3 store-special carousels |
| `/products` | Product catalog |
| `/products/[id]/[slug]` | Product details |
| `/products/category/[id]/[slug]` | Category listing |
| `/blog`, `/blog/[id]/[slug]` | Blog |
| `/about`, `/contact`, `/cart`, `/purchaseguide`, `/satisfaction` | Static / form pages |
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
| `/meshkee/sitemap-config.json` | Sitemap config |
## Static image slots
Homepage images: `GET /tenants/{domain}/website/static-images?pageKey=home`.
Catalog: `GET /meshkee/static-image-slots` → `STATIC_IMAGE_SLOTS` in `src/lib/static-images.ts`.
| key | kind | itemCount | UI section | aspectRatio |
| --- | --- | --- | --- | --- |
| `slider` | list | null | `Hero` full-bleed | `1920:1100` |
| `categories` | list | null | `ProductCategories` icons | `1:1` |
| `two-banner-below-slider` | list | 2 | `BannerRow` | `2:1` |
| `second-categories` | list | null | `ConsoleCategories` | `1:1` |
| `about-us-image` | single | 1 | `AboutDcorya` photo | `4:3` |
Rules:
- `url` → `<img src>`; `linkUrl` → wrap in `<a>` / `Link`
- Use `titleFa` → Persian label, `titleEn` → English subtitle, `subtext` when the original UI shows them
- Empty/fail → keep current local placeholders under `/public/images/`
- Use `slot.aspectRatio` from the API (catalog fallback if missing)
- **If a slot’s CSS size or aspect ratio changes during development, update `STATIC_IMAGE_SLOTS` in the same change** — Meshkee reads the catalog as the source of truth
## Local fallbacks
- Slider: `/images/slide-1.jpg` … `slide-4.jpg`
- Categories: `/images/cat-*.png`
- Banners: `/images/banner-1.png`, `/images/console-cat-4.jpg`
- Second categories: `/images/console-cat-*.png|jpg`
- About: `/images/product-2.jpg`
## Auth / account menu
- Login / user icon → `https://customer.dcorya.com` (`CUSTOMER_PORTAL_URL`)
- Detect session from parent-domain cookies `meshkee_customer_access_token` / `meshkee_customer_refresh_token` (storefront only reads; customer dashboard writes)
- Logged in: show `{name} عزیز` with dropdown **پروفایل من**, **خروج**, and if website admin **مدیریت وبسایت**
- Admin handoff: `POST /api/auth/handoff` → `https://business.dcorya.com?handoff=…`
- Admin = super_admin or membership on this business (`isOwner` / `teamRole`)
- Favicon: `GET /tenants/{domain}/website/favicon` → `faviconUrl` / `logoUrl`, else `/images/logo.png`
## Shopping cart (mini-cart only)
- Header cart icon + quantity badge + popup (lines, qty, remove, total, **ادامه خرید**)
- Guest cart key `meshkee-guest-cart` in `localStorage` + parent-domain cookie
- Line `id` = `storeItemVariantId`; price = `discountedPrice ?? price`
- Continue → `customer.dcorya.com/checkout/cart?guestCart=…` (or login redirect when logged out)
- Do **not** build storefront `/cart`, checkout, or call `/businesses/.../cart`
## Catalog
- Listings / carousels / search: `GET /tenants/{domain}/store-items` (not `/products` list)
- Detail still uses `GET /products/by-id/{id}` for description/gallery/comments + `store-items/by-product/{id}` for variants
- Mega menu on **محصولات**: categories level 1–2. Links: `/products/category/{id}/{slug}`
- Product card: shared `ProductCard` (1:1 image, `object-cover`); no price → **تماس بگیرید**
## Homepage product carousels
`GET /tenants/{domain}/store-specials` (`source` mirrors `specialProductsSource`). Shared card: `ProductCard` (1:1 image box, `object-contain`).
| key | eyebrow | placement |
| --- | --- | --- |
| `new-collection` | NEW ARRIVALS | After categories |
| `special-sale` | SPECIAL SALE | After console categories |
| `our-handmades` | HANDCRAFTED | After about (muted bg) |
Empty special `items` → local placeholders (`new-collection` also tries latest products first). Title comes from the special when set.
+4
View File
@@ -5,6 +5,10 @@ const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
images: { images: {
remotePatterns: [ remotePatterns: [
{
protocol: "https",
hostname: "cdn.meshkee.com",
},
{ {
protocol: "https", protocol: "https",
hostname: "c804387.parspack.net", hostname: "c804387.parspack.net",
-114
View File
@@ -631,9 +631,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -650,9 +647,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -669,9 +663,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -688,9 +679,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -707,9 +695,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -726,9 +711,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -745,9 +727,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -764,9 +743,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -783,9 +759,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -808,9 +781,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -833,9 +803,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -858,9 +825,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -883,9 +847,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -908,9 +869,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -933,9 +891,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -958,9 +913,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1228,9 +1180,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1247,9 +1196,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1266,9 +1212,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1285,9 +1228,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1526,9 +1466,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1546,9 +1483,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1566,9 +1500,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1586,9 +1517,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2139,9 +2067,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2156,9 +2081,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2173,9 +2095,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2190,9 +2109,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2207,9 +2123,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2224,9 +2137,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2241,9 +2151,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2258,9 +2165,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2275,9 +2179,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2292,9 +2193,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4980,9 +4878,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5004,9 +4899,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5028,9 +4920,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5052,9 +4941,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+17
View File
@@ -0,0 +1,17 @@
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
export async function POST(request: Request) {
const token = getBearerToken(request);
if (!token) return unauthorizedResponse();
const res = await meshkeeFetch("/auth/handoff", {
method: "POST",
token,
});
if (!res.ok) {
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
}
return jsonResponse(res.data);
}
+10
View File
@@ -0,0 +1,10 @@
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
export async function GET(request: Request) {
const token = getBearerToken(request);
if (!token) return unauthorizedResponse();
const res = await meshkeeFetch("/auth/me", { token });
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
return jsonResponse(res.data);
}
-114
View File
@@ -1,114 +0,0 @@
"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 />
</>
);
}
+2 -8
View File
@@ -1,11 +1,5 @@
import type { Metadata } from "next"; import { redirect } from "next/navigation";
import CartPageClient from "./CartPageClient";
export const metadata: Metadata = {
title: "سبد خرید | فروشگاه اینترنتی دکوریا",
robots: { index: false, follow: false },
};
export default function CartPage() { export default function CartPage() {
return <CartPageClient />; redirect("/");
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee"; import { DEFAULT_FAVICON, meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee";
import Providers from "@/lib/providers"; import Providers from "@/lib/providers";
const iranyekan = localFont({ const iranyekan = localFont({
@@ -26,7 +26,7 @@ const montserrat = localFont({
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const favicon = await meshkee.getFavicon(); const favicon = await meshkee.getFavicon();
const iconUrl = favicon?.faviconUrl || favicon?.logoUrl || "/images/logo.png"; const iconUrl = favicon?.faviconUrl || favicon?.logoUrl || DEFAULT_FAVICON;
const title = "فروشگاه اینترنتی دکوریا"; const title = "فروشگاه اینترنتی دکوریا";
const description = const description =
"زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را با دکوریا تجربه کنید."; "زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را با دکوریا تجربه کنید.";
@@ -35,14 +35,14 @@ export async function generateMetadata(): Promise<Metadata> {
metadataBase: new URL(`https://${WEBSITE_DOMAIN}`), metadataBase: new URL(`https://${WEBSITE_DOMAIN}`),
title: { default: title, template: `%s` }, title: { default: title, template: `%s` },
description, description,
icons: [{ url: iconUrl }], icons: [{ url: iconUrl, type: "image/png" }],
openGraph: { openGraph: {
title, title,
description, description,
siteName: title, siteName: title,
locale: "fa_IR", locale: "fa_IR",
type: "website", type: "website",
images: [{ url: favicon?.logoUrl || "/images/logo.png" }], images: [{ url: favicon?.logoUrl || DEFAULT_FAVICON }],
}, },
}; };
} }
@@ -0,0 +1,16 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+126 -10
View File
@@ -1,26 +1,142 @@
import Header from "@/components/Header"; import Header from "@/components/Header";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import ProductCategories from "@/components/ProductCategories"; import ProductCategories from "@/components/ProductCategories";
import LatestProducts from "@/components/LatestProducts"; import ProductCarousel from "@/components/ProductCarousel";
import BannerRow from "@/components/BannerRow"; import BannerRow from "@/components/BannerRow";
import ConsoleCategories from "@/components/ConsoleCategories"; import ConsoleCategories from "@/components/ConsoleCategories";
import AboutDcorya from "@/components/AboutDcorya"; import AboutDcorya from "@/components/AboutDcorya";
import DecorativeProducts from "@/components/DecorativeProducts";
import ContactTeaser from "@/components/ContactTeaser"; import ContactTeaser from "@/components/ContactTeaser";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import {
fetchStaticImageSlots,
overlaySlotImages,
pickSlot,
type SlotMediaItem,
} from "@/lib/static-images";
import { fetchStoreSpecials, resolveSpecialCarousel } from "@/lib/specials";
export const revalidate = 60;
const sliderFallbacks: SlotMediaItem[] = [
{ id: "slide-1", image: "/images/slide-1.jpg" },
{ id: "slide-2", image: "/images/slide-2.jpg" },
{ id: "slide-3", image: "/images/slide-3.jpg" },
{ id: "slide-4", image: "/images/slide-4.jpg" },
];
const categoryFallbacks: SlotMediaItem[] = [
{
id: "cat-crafts",
image: "/images/cat-crafts.png",
href: "#",
title: "هنرهای دستی",
titleEn: "ARTS AND CRAFTS",
},
{
id: "cat-decoration",
image: "/images/cat-decoration.png",
href: "#",
title: "دکوری و تزئینی",
titleEn: "DECORATION",
},
{
id: "cat-kitchen",
image: "/images/cat-kitchen.png",
href: "#",
title: "آشپز خانه",
titleEn: "KITCHEN",
},
{
id: "cat-wood",
image: "/images/cat-wood.png",
href: "#",
title: "محصولات چوبی",
titleEn: "WOOD PRODUCTS",
},
];
const bannerFallbacks: SlotMediaItem[] = [
{ id: "banner-1", image: "/images/banner-1.png", href: "#" },
{
id: "banner-2",
image: "/images/console-cat-4.jpg",
href: "#",
title: "کلکسیون میز کنسول",
titleEn: "NEW COLLECTION",
},
];
const secondCategoryFallbacks: SlotMediaItem[] = [
{ id: "console-1", image: "/images/console-cat-1.png", href: "#", title: "سه تیکه" },
{ id: "console-2", image: "/images/console-cat-2.png", href: "#", title: "انواع میز پذیرایی" },
{ id: "console-3", image: "/images/console-cat-3.png", href: "#", title: "قلمکاری روی مس" },
{ id: "console-4", image: "/images/console-cat-4.jpg", href: "#", title: "انواع شمعــــدان" },
{ id: "console-5", image: "/images/console-cat-5.png", href: "#", title: "میز کنسول" },
{ id: "console-6", image: "/images/console-cat-6.png", href: "#", title: "میز تی وی" },
];
const aboutFallback: SlotMediaItem[] = [
{ id: "about", image: "/images/product-2.jpg", title: "محصولات چوبی دکوریا" },
];
export default async function Home() {
const [slots, specials] = await Promise.all([
fetchStaticImageSlots("home"),
fetchStoreSpecials(),
]);
const [newCollection, specialSale, handmades] = await Promise.all([
resolveSpecialCarousel("new-collection", specials),
resolveSpecialCarousel("special-sale", specials),
resolveSpecialCarousel("our-handmades", specials),
]);
const slider = overlaySlotImages(pickSlot(slots, "slider"), sliderFallbacks, "slider");
const categories = overlaySlotImages(pickSlot(slots, "categories"), categoryFallbacks, "categories");
const banners = overlaySlotImages(
pickSlot(slots, "two-banner-below-slider"),
bannerFallbacks,
"two-banner-below-slider",
);
const secondCategories = overlaySlotImages(
pickSlot(slots, "second-categories"),
secondCategoryFallbacks,
"second-categories",
);
const about = overlaySlotImages(
pickSlot(slots, "about-us-image"),
aboutFallback,
"about-us-image",
);
export default function Home() {
return ( return (
<> <>
<Header /> <Header />
<main> <main>
<Hero /> <Hero slides={slider.items} aspectRatio={slider.aspectRatio} />
<ProductCategories /> <ProductCategories items={categories.items} aspectRatio={categories.aspectRatio} />
<LatestProducts /> <ProductCarousel
<BannerRow /> id={newCollection.id}
<ConsoleCategories /> eyebrow={newCollection.eyebrow}
<AboutDcorya /> title={newCollection.title}
<DecorativeProducts /> products={newCollection.products}
/>
<BannerRow items={banners.items} aspectRatio={banners.aspectRatio} />
<ConsoleCategories items={secondCategories.items} aspectRatio={secondCategories.aspectRatio} />
<ProductCarousel
id={specialSale.id}
eyebrow={specialSale.eyebrow}
title={specialSale.title}
products={specialSale.products}
/>
<AboutDcorya image={about.items[0]} aspectRatio={about.aspectRatio} />
<ProductCarousel
id={handmades.id}
eyebrow={handmades.eyebrow}
title={handmades.title}
products={handmades.products}
muted={handmades.muted}
/>
<ContactTeaser /> <ContactTeaser />
</main> </main>
<Footer /> <Footer />
+16 -7
View File
@@ -46,12 +46,18 @@ export default async function ProductDetailPage({
const data = await meshkee.getProductById(id).catch(() => null); const data = await meshkee.getProductById(id).catch(() => null);
if (!data) notFound(); if (!data) notFound();
const { product, relatedProducts } = data; const { product } = data;
const [technicalInfo, commentsRes, storeItemRes] = await Promise.all([ const [technicalInfo, commentsRes, storeItemRes, relatedStore] = await Promise.all([
meshkee.getProductTechnicalInfo(product.slug).catch(() => null), meshkee.getProductTechnicalInfo(product.slug).catch(() => null),
meshkee.getComments("product", product.id).catch(() => ({ items: [] })), meshkee.getComments("product", product.id).catch(() => ({ items: [] })),
meshkee.getStoreItemByProduct(product.id).catch(() => ({ storeItem: null })), meshkee.getStoreItemByProduct(product.id).catch(() => ({ storeItem: null })),
meshkee
.getStoreItems({
pageSize: 8,
categoryId: product.categoryId || undefined,
})
.catch(() => ({ items: [] })),
]); ]);
const galleryImages = [ const galleryImages = [
@@ -59,7 +65,10 @@ export default async function ProductDetailPage({
...product.images.map((img) => img.url), ...product.images.map((img) => img.url),
].filter((url, i, arr) => arr.indexOf(url) === i); ].filter((url, i, arr) => arr.indexOf(url) === i);
const variant = storeItemRes.storeItem?.variants?.[0] ?? null; const variants = storeItemRes.storeItem?.variants ?? [];
const related = relatedStore.items.filter(
(item) => String(item.productId) !== String(product.id),
);
return ( return (
<> <>
@@ -94,7 +103,7 @@ export default async function ProductDetailPage({
)} )}
<div className="mt-6"> <div className="mt-6">
<AddToCartBox variant={variant} /> <AddToCartBox variants={variants} />
</div> </div>
</div> </div>
</div> </div>
@@ -109,12 +118,12 @@ export default async function ProductDetailPage({
/> />
</div> </div>
{relatedProducts.length > 0 && ( {related.length > 0 && (
<div className="mt-16"> <div className="mt-16">
<h2 className="mb-8 text-xl font-bold text-neutral-800">محصولات مرتبط</h2> <h2 className="mb-8 text-xl font-bold text-neutral-800">محصولات مرتبط</h2>
<Carousel> <Carousel>
{relatedProducts.map((p) => ( {related.map((variant) => (
<RemoteProductCard key={p.id} product={p} /> <RemoteProductCard key={variant.id} variant={variant} />
))} ))}
</Carousel> </Carousel>
</div> </div>
@@ -41,8 +41,8 @@ export default async function CategoryPage({
const { category } = categoryData; const { category } = categoryData;
const title = category.nameFa || category.name; const title = category.nameFa || category.name;
const productsRes = await meshkee.getProducts({ page, pageSize, categoryId: id }); const storeRes = await meshkee.getStoreItems({ page, pageSize, categoryId: id });
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize)); const totalPages = Math.max(1, Math.ceil(storeRes.total / storeRes.pageSize));
return ( return (
<> <>
@@ -63,15 +63,13 @@ export default async function CategoryPage({
</nav> </nav>
<section className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8"> <section className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
{productsRes.items.length === 0 ? ( {storeRes.items.length === 0 ? (
<p className="py-20 text-center text-neutral-500">محصولی در این دسته‌بندی یافت نشد.</p> <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"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{productsRes.items.map((product) => ( {storeRes.items.map((variant) => (
<div key={product.id} className="[&>a]:w-full"> <RemoteProductCard key={variant.id} variant={variant} layout="grid" />
<RemoteProductCard product={product} />
</div>
))} ))}
</div> </div>
+7 -15
View File
@@ -21,13 +21,13 @@ export default async function ProductsPage({
const page = Number(params.page) || 1; const page = Number(params.page) || 1;
const pageSize = 12; const pageSize = 12;
const [productsRes, categoriesRes] = await Promise.all([ const [storeRes, categoriesRes] = await Promise.all([
meshkee.getProducts({ page, pageSize, categoryId: params.categoryId, name: params.q }), meshkee.getStoreItems({ page, pageSize, categoryId: params.categoryId, name: params.q }),
meshkee.getCategories("product").catch(() => ({ items: [] })), meshkee.getCategories("product").catch(() => ({ items: [] })),
]); ]);
const topCategories = categoriesRes.items.filter((c) => !c.parentId).slice(0, 10); const topCategories = categoriesRes.items.filter((c) => !c.parentId).slice(0, 10);
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize)); const totalPages = Math.max(1, Math.ceil(storeRes.total / storeRes.pageSize));
return ( return (
<> <>
@@ -60,13 +60,13 @@ export default async function ProductsPage({
</div> </div>
)} )}
{productsRes.items.length === 0 ? ( {storeRes.items.length === 0 ? (
<p className="py-20 text-center text-neutral-500">محصولی یافت نشد.</p> <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"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{productsRes.items.map((product) => ( {storeRes.items.map((variant) => (
<RemoteProductCardWrapper key={product.id} product={product} /> <RemoteProductCard key={variant.id} variant={variant} layout="grid" />
))} ))}
</div> </div>
@@ -75,7 +75,7 @@ export default async function ProductsPage({
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link <Link
key={p} key={p}
href={`/products?page=${p}${params.categoryId ? `&categoryId=${params.categoryId}` : ""}`} href={`/products?page=${p}${params.categoryId ? `&categoryId=${params.categoryId}` : ""}${params.q ? `&q=${encodeURIComponent(params.q)}` : ""}`}
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium transition-colors ${ className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium transition-colors ${
p === page p === page
? "bg-primary text-white" ? "bg-primary text-white"
@@ -95,11 +95,3 @@ export default async function ProductsPage({
</> </>
); );
} }
function RemoteProductCardWrapper({ product }: { product: Parameters<typeof RemoteProductCard>[0]["product"] }) {
return (
<div className="[&>a]:w-full">
<RemoteProductCard product={product} />
</div>
);
}
+31 -11
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SlotMedia } from "@/components/SlotMedia";
import type { SlotMediaItem } from "@/lib/static-images";
const stats = [ const stats = [
{ value: "۹۹٪", label: "کیفیت محصول" }, { value: "۹۹٪", label: "کیفیت محصول" },
@@ -7,22 +8,38 @@ const stats = [
{ value: "۹۰٪", label: "رضایت مشتری" }, { value: "۹۰٪", label: "رضایت مشتری" },
]; ];
export default function AboutDcorya() { type AboutDcoryaProps = {
image?: SlotMediaItem;
aspectRatio?: string;
};
export default function AboutDcorya({ image, aspectRatio }: AboutDcoryaProps) {
const src = image?.image ?? "/images/product-2.jpg";
return ( return (
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8"> <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="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"> <div
<Image className={`relative order-2 overflow-hidden rounded-2xl lg:order-1 ${
src="/images/product-2.jpg" aspectRatio ? "" : "aspect-4/3"
alt="محصولات چوبی دکوریا" }`}
fill >
sizes="(max-width: 1024px) 100vw, 50vw" <SlotMedia
className="object-cover" src={src}
href={image?.href}
fallbackSrc={image?.fallbackSrc ?? "/images/product-2.jpg"}
alt={image?.title || "محصولات چوبی دکوریا"}
aspectRatio={aspectRatio}
className="block w-full"
imgClassName="absolute inset-0 h-full w-full object-cover"
/> />
</div> </div>
<div className="order-1 lg:order-2"> <div className="order-1 lg:order-2">
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}> <p
className="text-sm font-semibold tracking-widest text-primary"
style={{ fontFamily: "var(--font-montserrat)" }}
>
ABOUT DCORYA ABOUT DCORYA
</p> </p>
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">فروشگاه آنلاین دکوریا</h2> <h2 className="mt-2 text-2xl font-bold sm:text-3xl">فروشگاه آنلاین دکوریا</h2>
@@ -44,7 +61,10 @@ export default function AboutDcorya() {
<div className="mt-10 grid grid-cols-3 gap-4 border-t border-black/5 pt-8"> <div className="mt-10 grid grid-cols-3 gap-4 border-t border-black/5 pt-8">
{stats.map((stat) => ( {stats.map((stat) => (
<div key={stat.label} className="text-center"> <div key={stat.label} className="text-center">
<p className="text-2xl font-bold text-primary sm:text-3xl" style={{ fontFamily: "var(--font-montserrat)" }}> <p
className="text-2xl font-bold text-primary sm:text-3xl"
style={{ fontFamily: "var(--font-montserrat)" }}
>
{stat.value} {stat.value}
</p> </p>
<p className="mt-1 text-xs font-medium text-neutral-500 sm:text-sm">{stat.label}</p> <p className="mt-1 text-xs font-medium text-neutral-500 sm:text-sm">{stat.label}</p>
+77 -28
View File
@@ -1,26 +1,26 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useAuth } from "@/lib/auth-context";
import { useCart } from "@/lib/cart-context"; import { useCart } from "@/lib/cart-context";
import { useToast } from "@/lib/toast-context"; import { useToast } from "@/lib/toast-context";
import type { StoreItemVariant } from "@/lib/meshkee"; import type { StoreVariant } from "@/lib/meshkee";
import { formatToman, variantInStock, variantSellingPrice, variantTitle } from "@/lib/store";
import { slugify } from "@/lib/slug";
function formatToman(value?: number | null) { export default function AddToCartBox({ variants }: { variants: StoreVariant[] }) {
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 { addItem } = useCart();
const { showToast } = useToast(); const { showToast } = useToast();
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const inStock = variant ? variant.unlimited || (variant.stockQuantity ?? 0) > 0 : false; const sellable = useMemo(() => variants.filter(variantInStock), [variants]);
const [selectedId, setSelectedId] = useState<string | null>(null);
if (!variant) { const active =
sellable.find((variant) => variant.id === selectedId) ||
(sellable.length === 1 ? sellable[0] : null);
if (variants.length === 0) {
return ( return (
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center text-sm text-neutral-500"> <div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center text-sm text-neutral-500">
این محصول در حال حاضر برای خرید آنلاین موجود نیست. برای اطلاع از قیمت و موجودی با ما تماس این محصول در حال حاضر برای خرید آنلاین موجود نیست. برای اطلاع از قیمت و موجودی با ما تماس
@@ -29,45 +29,92 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
); );
} }
if (!inStock) { if (sellable.length === 0) {
const first = variants[0];
return ( return (
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center"> <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)" }}> <p
{formatToman(variant.price)} className="mb-1 text-lg font-extrabold text-neutral-400"
style={{ fontFamily: "var(--font-montserrat)" }}
>
{formatToman(variantSellingPrice(first)) || "تماس بگیرید"}
</p> </p>
<p className="text-sm font-medium text-neutral-500">ناموجود</p> <p className="text-sm font-medium text-neutral-500">ناموجود</p>
</div> </div>
); );
} }
const handleAdd = async () => { const handleAdd = () => {
if (!user) { if (!active) {
window.location.href = loginUrl; showToast("لطفاً یک گزینه را انتخاب کنید", "error");
return; return;
} }
const price = variantSellingPrice(active);
if (price == null) {
showToast("قیمت این محصول مشخص نیست. لطفاً تماس بگیرید", "error");
return;
}
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await addItem(variant.id, quantity); const name = variantTitle(active);
addItem({
id: String(active.id),
name,
slug: slugify(name),
price,
originalPrice:
active.discountedPrice != null && active.price != null ? active.price : undefined,
image: active.productImage || undefined,
quantity,
});
showToast("محصول به سبد خرید اضافه شد"); showToast("محصول به سبد خرید اضافه شد");
} catch {
showToast("افزودن به سبد خرید با خطا مواجه شد", "error");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const displayPrice = active ? variantSellingPrice(active) : null;
return ( return (
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5"> <div className="rounded-xl border border-black/5 bg-neutral-50 p-5">
<p {displayPrice != null ? (
className="mb-4 text-2xl font-extrabold text-primary" <p
style={{ fontFamily: "var(--font-montserrat)" }} className="mb-4 text-2xl font-extrabold text-primary"
> style={{ fontFamily: "var(--font-montserrat)" }}
{formatToman(variant.price)} >
</p> {formatToman(displayPrice)}
</p>
) : (
<p className="mb-4 text-lg font-bold text-primary">تماس بگیرید</p>
)}
{sellable.length > 1 ? (
<div className="mb-4 space-y-2">
<p className="text-sm font-medium text-neutral-600">انتخاب گزینه</p>
<div className="flex flex-wrap gap-2">
{sellable.map((variant) => (
<button
key={variant.id}
type="button"
onClick={() => setSelectedId(variant.id)}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active?.id === variant.id
? "border-primary bg-primary text-white"
: "border-black/10 bg-white text-neutral-700 hover:border-primary hover:text-primary"
}`}
>
{variant.label || variantTitle(variant)}
</button>
))}
</div>
</div>
) : null}
<div className="flex items-center gap-4"> <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"> <div className="flex items-center gap-3 rounded-lg border border-black/10 bg-white px-2 py-1.5">
<button <button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))} 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" className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
> >
@@ -75,6 +122,7 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
</button> </button>
<span className="w-6 text-center text-sm font-medium">{quantity}</span> <span className="w-6 text-center text-sm font-medium">{quantity}</span>
<button <button
type="button"
onClick={() => setQuantity((q) => q + 1)} onClick={() => setQuantity((q) => q + 1)}
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary" className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
> >
@@ -83,8 +131,9 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
</div> </div>
<button <button
type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={isSubmitting} disabled={isSubmitting || (sellable.length > 1 && !active)}
className="flex-1 rounded-lg bg-primary py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark disabled:opacity-60" 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 ? "در حال افزودن..." : "افزودن به سبد خرید"} {isSubmitting ? "در حال افزودن..." : "افزودن به سبد خرید"}
+59 -36
View File
@@ -1,43 +1,66 @@
import Image from "next/image"; import { SlotMedia } from "@/components/SlotMedia";
import Link from "next/link"; import type { SlotMediaItem } from "@/lib/static-images";
export default function BannerRow() { type BannerRowProps = {
items: SlotMediaItem[];
aspectRatio?: string;
};
export default function BannerRow({ items, aspectRatio }: BannerRowProps) {
return ( 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"> <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 {items.map((banner) => {
href="#" const hasOverlay = Boolean(banner.title || banner.titleEn || banner.text);
className="group relative block overflow-hidden rounded-2xl bg-white shadow-sm" const media = (
> <div
<Image className={`group relative block overflow-hidden rounded-2xl shadow-sm ${
src="/images/banner-1.png" hasOverlay ? "" : "bg-white"
alt="فروش ویژه دکوریا" }`}
width={900} >
height={484} <SlotMedia
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72" src={banner.image}
/> href={undefined}
</Link> fallbackSrc={banner.fallbackSrc}
alt={banner.title || banner.titleEn || ""}
aspectRatio={aspectRatio}
className={!aspectRatio ? "block h-56 w-full sm:h-72" : "block w-full"}
imgClassName="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
{hasOverlay ? (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/40 text-center text-white">
{banner.titleEn ? (
<p className="text-xs tracking-[0.3em]" style={{ fontFamily: "var(--font-montserrat)" }}>
{banner.titleEn}
</p>
) : null}
{banner.title ? <h3 className="text-2xl font-bold">{banner.title}</h3> : null}
{banner.text ? <p className="text-sm">{banner.text}</p> : null}
<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>
) : null}
</div>
);
<Link if (!banner.href) {
href="#" return <div key={banner.id}>{media}</div>;
className="group relative block overflow-hidden rounded-2xl shadow-sm" }
>
<Image if (banner.href.startsWith("/") || banner.href.startsWith("#")) {
src="/images/console-cat-4.jpg" return (
alt="کلکسیون میز کنسول" <a key={banner.id} href={banner.href} className="block">
width={900} {media}
height={484} </a>
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)" }}> return (
NEW COLLECTION <a key={banner.id} href={banner.href} className="block" rel="noopener noreferrer">
</p> {media}
<h3 className="text-2xl font-bold">کلکسیون میز کنسول</h3> </a>
<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> </section>
); );
} }
+53 -40
View File
@@ -2,9 +2,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useCart } from "@/lib/cart-context"; import { useCart } from "@/lib/cart-context";
import { useAuth } from "@/lib/auth-context";
import { useClickOutside } from "@/lib/use-click-outside"; import { useClickOutside } from "@/lib/use-click-outside";
function formatToman(value?: number) { function formatToman(value?: number) {
@@ -13,17 +11,15 @@ function formatToman(value?: number) {
} }
export default function CartPopover() { export default function CartPopover() {
const { user, loginUrl } = useAuth(); const { items, itemCount, total, updateItem, removeItem, continueUrl } = useCart();
const { cart, itemCount } = useCart();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false)); useClickOutside(ref, () => setOpen(false));
const items = cart?.items ?? [];
return ( return (
<div className="relative" ref={ref}> <div className="relative" ref={ref}>
<button <button
type="button"
aria-label="سبد خرید" aria-label="سبد خرید"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="relative text-neutral-600 transition-colors hover:text-primary" className="relative text-neutral-600 transition-colors hover:text-primary"
@@ -39,28 +35,16 @@ export default function CartPopover() {
<circle cx="9.5" cy="20.5" r="1.3" fill="currentColor" /> <circle cx="9.5" cy="20.5" r="1.3" fill="currentColor" />
<circle cx="17.5" cy="20.5" r="1.3" fill="currentColor" /> <circle cx="17.5" cy="20.5" r="1.3" fill="currentColor" />
</svg> </svg>
{itemCount > 0 && ( {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"> <span className="absolute -top-2 -left-2 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-white">
{itemCount} {itemCount}
</span> </span>
)} ) : null}
</button> </button>
{open && ( {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"> <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 ? ( {items.length === 0 ? (
<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> <p className="py-6 text-center text-sm text-neutral-500">سبد خرید شما خالی است.</p>
) : ( ) : (
<> <>
@@ -68,35 +52,64 @@ export default function CartPopover() {
{items.map((item) => ( {items.map((item) => (
<div key={item.id} className="flex items-center gap-3"> <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"> <div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-neutral-100">
{item.image && ( {item.image ? (
<Image src={item.image} alt={item.title ?? ""} fill className="object-cover" /> <Image src={item.image} alt={item.name} fill className="object-cover" />
)} ) : null}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-800"> <p className="truncate text-sm font-medium text-neutral-800">{item.name}</p>
{item.title ?? "کالا"} <div className="mt-1 flex items-center gap-2">
</p> <button
<p className="text-xs text-neutral-500">تعداد: {item.quantity}</p> type="button"
onClick={() => updateItem(item.id, item.quantity - 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-black/10 text-xs text-neutral-600 hover:text-primary"
>
−
</button>
<span className="w-5 text-center text-xs">{item.quantity}</span>
<button
type="button"
onClick={() => updateItem(item.id, item.quantity + 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-black/10 text-xs text-neutral-600 hover:text-primary"
>
+
</button>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{formatToman(item.price) ? (
<p className="text-xs font-bold text-primary">{formatToman(item.price)}</p>
) : null}
<button
type="button"
onClick={() => removeItem(item.id)}
className="text-[10px] text-neutral-400 hover:text-primary"
>
حذف
</button>
</div> </div>
{formatToman(item.unitPrice) && (
<p className="shrink-0 text-xs font-bold text-primary">
{formatToman(item.unitPrice)}
</p>
)}
</div> </div>
))} ))}
</div> </div>
<Link
href="/cart" {formatToman(total) ? (
<div className="mt-4 flex items-center justify-between border-t border-black/5 pt-3 text-sm">
<span className="font-medium text-neutral-600">جمع</span>
<span className="font-extrabold text-primary">{formatToman(total)}</span>
</div>
) : null}
<a
href={continueUrl}
onClick={() => setOpen(false)} 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" 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> </a>
</> </>
)} )}
</div> </div>
)} ) : null}
</div> </div>
); );
} }
+60 -29
View File
@@ -1,16 +1,12 @@
import Image from "next/image"; import { SlotMedia } from "@/components/SlotMedia";
import Link from "next/link"; import type { SlotMediaItem } from "@/lib/static-images";
const items = [ type ConsoleCategoriesProps = {
{ title: "سه تیکه", icon: "/images/console-cat-1.png" }, items: SlotMediaItem[];
{ title: "انواع میز پذیرایی", icon: "/images/console-cat-2.png" }, aspectRatio?: string;
{ 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() { export default function ConsoleCategories({ items, aspectRatio }: ConsoleCategoriesProps) {
return ( return (
<section className="bg-neutral-50 py-16"> <section className="bg-neutral-50 py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
@@ -24,24 +20,59 @@ export default function ConsoleCategories() {
</div> </div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-6">
{items.map((item) => ( {items.map((item) => {
<Link const content = (
key={item.title} <>
href="#" <div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50">
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" <SlotMedia
> fill
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50"> src={item.image}
<Image fallbackSrc={item.fallbackSrc}
src={item.icon} alt={item.title || item.titleEn || ""}
alt={item.title} aspectRatio={aspectRatio}
fill imgClassName="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-110"
sizes="80px" />
className="object-cover transition-transform group-hover:scale-110" </div>
/> {item.title ? (
</div> <h3 className="text-sm font-medium text-neutral-700">{item.title}</h3>
<h3 className="text-sm font-medium text-neutral-700">{item.title}</h3> ) : null}
</Link> {item.titleEn ? (
))} <p
className="text-[10px] tracking-wide text-neutral-400"
style={{ fontFamily: "var(--font-montserrat)" }}
>
{item.titleEn}
</p>
) : null}
{item.text ? <p className="text-xs text-neutral-500">{item.text}</p> : null}
</>
);
const 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";
if (!item.href) {
return (
<div key={item.id} className={className}>
{content}
</div>
);
}
if (item.href.startsWith("/") || item.href.startsWith("#")) {
return (
<a key={item.id} href={item.href} className={className}>
{content}
</a>
);
}
return (
<a key={item.id} href={item.href} className={className} rel="noopener noreferrer">
{content}
</a>
);
})}
</div> </div>
</div> </div>
</section> </section>
-32
View File
@@ -1,32 +0,0 @@
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>
);
}
+15 -4
View File
@@ -2,18 +2,22 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import CartPopover from "./CartPopover"; import CartPopover from "./CartPopover";
import ProfileMenu from "./ProfileMenu"; import ProfileMenu from "./ProfileMenu";
import ProductsMegaMenu from "./ProductsMegaMenu";
import { buildCategoryTree } from "@/lib/categories";
import { meshkee } from "@/lib/meshkee";
const navLinks = [ const navLinks = [
{ href: "/", label: "صفحه اصلی" },
{ href: "/products", label: "محصولات" },
{ href: "/blog", label: "بلاگ" }, { href: "/blog", label: "بلاگ" },
{ href: "/satisfaction", label: "مشتریان راضی" }, { href: "/satisfaction", label: "مشتریان راضی" },
{ href: "/purchaseguide", label: "راهنمای خرید" }, { href: "/purchaseguide", label: "راهنمای خرید" },
]; ];
export default function Header() { export default async function Header() {
const categoriesRes = await meshkee.getCategories("product").catch(() => ({ items: [] }));
const categories = buildCategoryTree(categoriesRes.items);
return ( 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)]"> <header className="relative 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"> <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"> <Link href="/" className="shrink-0">
<Image <Image
@@ -27,6 +31,13 @@ export default function Header() {
</Link> </Link>
<nav className="hidden items-center gap-7 lg:flex"> <nav className="hidden items-center gap-7 lg:flex">
<Link
href="/"
className="text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
>
صفحه اصلی
</Link>
<ProductsMegaMenu categories={categories} />
{navLinks.map((link) => ( {navLinks.map((link) => (
<Link <Link
key={link.label} key={link.label}
+86 -59
View File
@@ -1,53 +1,66 @@
"use client"; "use client";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { SlotMedia } from "@/components/SlotMedia";
import type { SlotMediaItem } from "@/lib/static-images";
const slides = [ const DEFAULT_TITLE = "ارسال به سراسر ایران و کشورهای همسایه";
{ src: "/images/slide-1.jpg", alt: "کریستال و ظروف دکوراتیو دکوریا" }, const DEFAULT_TITLE_EN = "Shipping Across Iran and Neighboring Countries";
{ src: "/images/slide-2.jpg", alt: "فروش اقساطی بدون سود محصولات دکوریا" },
{ src: "/images/slide-3.jpg", alt: "قلمکاری مس، هنر اصیل ایرانی دکوریا" },
{ src: "/images/slide-4.jpg", alt: "میز کنسول چوبی معرق‌کاری دکوریا" },
];
export default function Hero() { type HeroProps = {
slides: SlotMediaItem[];
aspectRatio?: string;
};
export default function Hero({ slides = [] }: HeroProps) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const safeSlides = slides.length > 0 ? slides : [{ id: "fallback", image: "/images/slide-1.jpg" }];
const count = safeSlides.length;
const goTo = useCallback((index: number) => { const goTo = useCallback(
setActive((index + slides.length) % slides.length); (index: number) => {
}, []); setActive((index + count) % count);
},
[count],
);
useEffect(() => { useEffect(() => {
if (count <= 1) return;
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
setActive((prev) => (prev + 1) % slides.length); setActive((prev) => (prev + 1) % count);
}, 5500); }, 5500);
return () => { return () => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
}; };
}, []); }, [count]);
const current = safeSlides[active];
const titleFa = current?.title || DEFAULT_TITLE;
const titleEn = current?.titleEn || DEFAULT_TITLE_EN;
const subtext = current?.text;
return ( return (
<section className="relative h-[calc(100dvh-4rem)] min-h-[420px] w-full overflow-hidden"> <section className="relative h-[calc(100dvh-4rem)] min-h-[420px] w-full overflow-hidden">
{slides.map((slide, i) => ( {safeSlides.map((slide, i) => (
<div <div
key={slide.src} key={slide.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${ className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
i === active ? "opacity-100" : "opacity-0" i === active ? "opacity-100" : "opacity-0"
}`} }`}
> >
<Image <SlotMedia
src={slide.src}
alt={slide.alt}
fill fill
priority={i === 0} src={slide.image}
sizes="100vw" href={slide.href}
className="object-cover" fallbackSrc={slide.fallbackSrc}
alt={slide.title || slide.titleEn || "اسلاید دکوریا"}
imgClassName="absolute inset-0 h-full w-full object-cover"
/> />
</div> </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 className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-start px-4 pb-20 sm:px-10 sm:pb-24">
<div <div
key={active} 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" 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"
@@ -62,48 +75,62 @@ export default function Hero() {
className="animate-fade-in-up text-lg font-bold leading-relaxed text-neutral-800 sm:text-2xl" className="animate-fade-in-up text-lg font-bold leading-relaxed text-neutral-800 sm:text-2xl"
style={{ animationDelay: "0.25s" }} style={{ animationDelay: "0.25s" }}
> >
ارسال به سراسر ایران و کشورهای همسایه {titleFa}
</h1> </h1>
<p {titleEn ? (
className="animate-fade-in-up mt-2 text-xs tracking-wide text-neutral-500 sm:text-sm" <p
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.4s" }} 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> {titleEn}
</p>
) : null}
{subtext ? (
<p
className="animate-fade-in-up mt-2 text-xs text-neutral-500 sm:text-sm"
style={{ animationDelay: "0.5s" }}
>
{subtext}
</p>
) : null}
</div> </div>
</div> </div>
<button {count > 1 ? (
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 <button
key={slide.src} aria-label="اسلاید قبلی"
aria-label={`اسلاید ${i + 1}`} onClick={() => goTo(active - 1)}
onClick={() => goTo(i)} 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"
className={`h-2.5 rounded-full transition-all ${ >
i === active ? "w-7 bg-white" : "w-2.5 bg-white/50" <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>
</div> <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">
{safeSlides.map((slide, i) => (
<button
key={slide.id}
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>
</>
) : null}
</section> </section>
); );
} }
-42
View File
@@ -1,42 +0,0 @@
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>
);
}
+48 -10
View File
@@ -1,40 +1,78 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export type ProductCardProps = {
href?: string;
image: string;
title: string;
price?: string | null;
inStock?: boolean;
/** `carousel` keeps fixed snap widths; `grid` fills the parent cell. */
layout?: "carousel" | "grid";
};
export default function ProductCard({ export default function ProductCard({
href = "#",
image, image,
title, title,
price, price,
}: { inStock = true,
image: string; layout = "carousel",
title: string; }: ProductCardProps) {
price?: string; const widthClass =
}) { layout === "grid"
? "w-full"
: "w-[80vw] shrink-0 snap-start sm:w-[300px]";
return ( return (
<Link <Link
href="#" 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]" className={`group overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl ${widthClass}`}
> >
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50"> <div className="relative aspect-square overflow-hidden bg-neutral-50">
<Image <Image
src={image} src={image}
alt={title} alt={title}
fill fill
sizes="(max-width: 640px) 80vw, 300px" sizes={layout === "grid" ? "(max-width: 640px) 100vw, 33vw" : "(max-width: 640px) 80vw, 300px"}
className="object-cover transition-transform duration-500 group-hover:scale-105" className="object-cover transition-transform duration-500 group-hover:scale-105"
/> />
{!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>
) : null}
</div> </div>
<div className="p-5 text-center"> <div className="p-5 text-center">
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800"> <h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
{title} {title}
</h3> </h3>
{price && ( {price ? (
<p <p
className="mt-3 text-lg font-extrabold text-primary" className="mt-3 text-lg font-extrabold text-primary"
style={{ fontFamily: "var(--font-montserrat)" }} style={{ fontFamily: "var(--font-montserrat)" }}
> >
{price} {price}
</p> </p>
) : (
<p className="mt-3 inline-flex items-center justify-center gap-1.5 text-sm font-bold text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
aria-hidden
>
<path
d="M8.5 4.5h2.2l1.1 3.3-1.4 1.4a12.5 12.5 0 0 0 4.9 4.9l1.4-1.4 3.3 1.1v2.2c0 .9-.7 1.6-1.6 1.6A13.9 13.9 0 0 1 4.5 6.1c0-.9.7-1.6 1.6-1.6Z"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
تماس بگیرید
</p>
)} )}
</div> </div>
</Link> </Link>
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import Carousel from "@/components/Carousel";
import ProductCard, { type ProductCardProps } from "@/components/ProductCard";
export type ProductCarouselProps = {
id?: string;
eyebrow?: string;
title: string;
href?: string;
linkLabel?: string;
products: ProductCardProps[];
muted?: boolean;
};
export default function ProductCarousel({
id,
eyebrow,
title,
href = "/products",
linkLabel = "مشاهده همه محصولات ←",
products,
muted = false,
}: ProductCarouselProps) {
if (products.length === 0) return null;
return (
<section
id={id}
className={muted ? "bg-neutral-50 py-16" : "py-16"}
>
<div className="mx-auto max-w-6xl px-4 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>
{eyebrow ? (
<p
className="text-sm font-semibold tracking-widest text-primary"
style={{ fontFamily: "var(--font-montserrat)" }}
>
{eyebrow}
</p>
) : null}
<h2 className={`text-2xl font-bold sm:text-3xl ${eyebrow ? "mt-2" : ""}`}>{title}</h2>
</div>
{href ? (
<Link href={href} className="text-sm font-bold text-primary hover:underline">
{linkLabel}
</Link>
) : null}
</div>
<Carousel>
{products.map((product) => (
<ProductCard key={`${product.href}-${product.title}`} {...product} layout="carousel" />
))}
</Carousel>
</div>
</section>
);
}
+60 -29
View File
@@ -1,14 +1,12 @@
import Image from "next/image"; import { SlotMedia } from "@/components/SlotMedia";
import Link from "next/link"; import type { SlotMediaItem } from "@/lib/static-images";
const categories = [ type ProductCategoriesProps = {
{ title: "هنرهای دستی", subtitle: "ARTS AND CRAFTS", icon: "/images/cat-crafts.png", href: "#" }, items: SlotMediaItem[];
{ title: "دکوری و تزئینی", subtitle: "DECORATION", icon: "/images/cat-decoration.png", href: "#" }, aspectRatio?: string;
{ title: "آشپز خانه", subtitle: "KITCHEN", icon: "/images/cat-kitchen.png", href: "#" }, };
{ title: "محصولات چوبی", subtitle: "WOOD PRODUCTS", icon: "/images/cat-wood.png", href: "#" },
];
export default function ProductCategories() { export default function ProductCategories({ items, aspectRatio }: ProductCategoriesProps) {
return ( return (
<section <section
className="relative bg-cover bg-center py-16" className="relative bg-cover bg-center py-16"
@@ -27,26 +25,59 @@ export default function ProductCategories() {
</div> </div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
{categories.map((cat) => ( {items.map((cat) => {
<Link const content = (
key={cat.title} <>
href={cat.href} <div className="flex h-20 w-20 items-center justify-center overflow-hidden">
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" <SlotMedia
> src={cat.image}
<div className="flex h-20 w-20 items-center justify-center"> fallbackSrc={cat.fallbackSrc}
<Image src={cat.icon} alt={cat.title} width={64} height={64} className="h-16 w-16 object-contain transition-transform group-hover:scale-110" /> alt={cat.title || cat.titleEn || ""}
</div> aspectRatio={aspectRatio}
<div> className="h-16 w-16"
<h3 className="font-bold text-neutral-800">{cat.title}</h3> imgClassName="absolute inset-0 h-full w-full object-contain transition-transform group-hover:scale-110"
<p />
className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400" </div>
style={{ fontFamily: "var(--font-montserrat)" }} <div>
> {cat.title ? <h3 className="font-bold text-neutral-800">{cat.title}</h3> : null}
{cat.subtitle} {cat.titleEn ? (
</p> <p
</div> className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400"
</Link> style={{ fontFamily: "var(--font-montserrat)" }}
))} >
{cat.titleEn}
</p>
) : null}
{cat.text ? <p className="mt-1 text-xs text-neutral-500">{cat.text}</p> : null}
</div>
</>
);
const 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";
if (!cat.href) {
return (
<div key={cat.id} className={className}>
{content}
</div>
);
}
if (cat.href.startsWith("/") || cat.href.startsWith("#")) {
return (
<a key={cat.id} href={cat.href} className={className}>
{content}
</a>
);
}
return (
<a key={cat.id} href={cat.href} className={className} rel="noopener noreferrer">
{content}
</a>
);
})}
</div> </div>
</div> </div>
</section> </section>
+150
View File
@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useRef, useState } from "react";
import { categoryHref, type CategoryNode } from "@/lib/categories";
import { useClickOutside } from "@/lib/use-click-outside";
type ProductsMegaMenuProps = {
categories: CategoryNode[];
};
export default function ProductsMegaMenu({ categories }: ProductsMegaMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const menuId = useId();
useClickOutside(rootRef, () => setOpen(false));
useEffect(() => {
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
};
}, []);
const clearClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const openMenu = () => {
clearClose();
setOpen(true);
};
const scheduleClose = () => {
clearClose();
closeTimer.current = setTimeout(() => setOpen(false), 140);
};
if (categories.length === 0) {
return (
<Link
href="/products"
className="text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
>
محصولات
</Link>
);
}
return (
<div
ref={rootRef}
className="flex items-center"
onMouseEnter={openMenu}
onMouseLeave={scheduleClose}
>
<Link
href="/products"
className={`inline-flex items-center gap-1.5 text-sm font-medium transition-colors ${
open ? "text-primary" : "text-neutral-700 hover:text-primary"
}`}
aria-expanded={open}
aria-haspopup="true"
aria-controls={menuId}
onFocus={openMenu}
>
محصولات
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden
>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
<div
id={menuId}
role="menu"
className={`absolute inset-x-0 top-full z-50 border-b border-black/5 bg-white/95 shadow-[0_18px_40px_rgba(0,0,0,0.08)] backdrop-blur-sm transition-all duration-150 ${
open
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none -translate-y-1 opacity-0"
}`}
>
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between gap-4 border-b border-black/5 pb-4">
<div>
<p
className="text-xs font-semibold tracking-[0.2em] text-primary"
style={{ fontFamily: "var(--font-montserrat)" }}
>
CATEGORIES
</p>
<p className="mt-1 text-sm font-bold text-neutral-800">دسته‌بندی محصولات دکوریا</p>
</div>
<Link
href="/products"
className="text-sm font-bold text-primary hover:underline"
onClick={() => setOpen(false)}
>
مشاهده همه محصولات ←
</Link>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-8 md:grid-cols-3 xl:grid-cols-6">
{categories.map((parent) => (
<div key={parent.id} className="min-w-0">
<Link
href={categoryHref(parent)}
className="block text-sm font-bold text-neutral-900 transition-colors hover:text-primary"
onClick={() => setOpen(false)}
>
{parent.nameFa || parent.name}
</Link>
{parent.children.length > 0 ? (
<ul className="mt-3 space-y-1.5">
{parent.children.map((child) => (
<li key={child.id}>
<Link
href={categoryHref(child)}
className="block truncate text-xs leading-6 text-neutral-500 transition-colors hover:text-primary"
onClick={() => setOpen(false)}
>
{child.nameFa || child.name}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
</div>
</div>
</div>
);
}
+86 -23
View File
@@ -1,16 +1,63 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState, type MouseEvent } from "react";
import { useAuth } from "@/lib/auth-context"; import { useAuth, userDisplayName } from "@/lib/auth-context";
import { CUSTOMER_PORTAL_URL } from "@/lib/meshkee"; import { BUSINESS_DASHBOARD_URL, CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
import { useClickOutside } from "@/lib/use-click-outside"; import { useClickOutside } from "@/lib/use-click-outside";
function UserIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
<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>
);
}
export default function ProfileMenu() { export default function ProfileMenu() {
const { user, isLoading, isAdmin, loginUrl, logout } = useAuth(); const { user, isLoading, isAdmin, loginUrl, logout, getAccessToken } = useAuth();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [handoffBusy, setHandoffBusy] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false)); useClickOutside(ref, () => setOpen(false));
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();
if (handoffBusy) return;
const popup = window.open("about:blank", "_blank");
setHandoffBusy(true);
try {
const token = getAccessToken();
const res = await fetch("/api/auth/handoff", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!res.ok) throw new Error("handoff failed");
const data = (await res.json()) as { ticket?: string };
const url = new URL(BUSINESS_DASHBOARD_URL);
if (data.ticket) url.searchParams.set("handoff", data.ticket);
if (popup) {
popup.opener = null;
popup.location.replace(url.toString());
} else {
window.open(url.toString(), "_blank", "noopener,noreferrer");
}
} catch {
if (popup) popup.location.replace(BUSINESS_DASHBOARD_URL);
else window.open(BUSINESS_DASHBOARD_URL, "_blank", "noopener,noreferrer");
} finally {
setHandoffBusy(false);
setOpen(false);
}
}
if (isLoading) { if (isLoading) {
return <span className="h-5 w-16 animate-pulse rounded bg-neutral-100" />; return <span className="h-5 w-16 animate-pulse rounded bg-neutral-100" />;
} }
@@ -18,14 +65,12 @@ export default function ProfileMenu() {
if (!user) { if (!user) {
return ( return (
<a <a
href={loginUrl} href={loginUrl || CUSTOMER_PORTAL_URL}
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex" className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
aria-label="ورود"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5"> <UserIcon />
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" /> <span className="hidden sm:inline">ورود</span>
<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> </a>
); );
} }
@@ -33,33 +78,51 @@ export default function ProfileMenu() {
return ( return (
<div className="relative" ref={ref}> <div className="relative" ref={ref}>
<button <button
type="button"
onClick={() => setOpen((v) => !v)} 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" className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
aria-expanded={open}
aria-haspopup="menu"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5"> <UserIcon />
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" /> <span className="hidden sm:inline">{userDisplayName(user)} عزیز</span>
<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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
fill="none"
className={`hidden h-2.5 w-2.5 transition-transform sm:block ${open ? "rotate-180" : ""}`}
aria-hidden
>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg> </svg>
{user.firstName} عزیز
</button> </button>
{open && ( {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"> <div
role="menu"
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 <a
role="menuitem"
href={`${CUSTOMER_PORTAL_URL}/profile`} 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" className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
onClick={() => setOpen(false)}
> >
پروفایل من پروفایل من
</a> </a>
{isAdmin && ( {isAdmin ? (
<a <a
href={`${CUSTOMER_PORTAL_URL}/admin`} role="menuitem"
href={BUSINESS_DASHBOARD_URL}
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary" className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
onClick={openBusinessDashboard}
> >
مدیریت وبسایت {handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
</a> </a>
)} ) : null}
<button <button
type="button"
role="menuitem"
onClick={() => { onClick={() => {
logout(); logout();
setOpen(false); setOpen(false);
@@ -69,7 +132,7 @@ export default function ProfileMenu() {
خروج خروج
</button> </button>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
+11 -51
View File
@@ -1,53 +1,13 @@
import Image from "next/image"; import ProductCard from "@/components/ProductCard";
import Link from "next/link"; import type { StoreVariant } from "@/lib/meshkee";
import type { Product } from "@/lib/meshkee"; import { storeVariantToCard } from "@/lib/store";
import { slugify } from "@/lib/slug";
function formatToman(value?: number | null) { export default function RemoteProductCard({
if (value === null || value === undefined) return null; variant,
return `${value.toLocaleString("fa-IR")} تومان`; layout = "carousel",
} }: {
variant: StoreVariant;
export default function RemoteProductCard({ product }: { product: Product }) { layout?: "carousel" | "grid";
const href = `/products/${product.id}/${slugify(product.nameFa || product.title)}`; }) {
const price = formatToman(product.store?.minPrice); return <ProductCard {...storeVariantToCard(variant)} layout={layout} />;
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>
);
} }
+73
View File
@@ -0,0 +1,73 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { parseAspectRatio } from "@/lib/static-images";
type SlotMediaProps = {
src: string;
alt?: string;
href?: string;
fallbackSrc?: string;
aspectRatio?: string;
className?: string;
imgClassName?: string;
fill?: boolean;
};
function wrap(href: string | undefined, children: ReactNode, className?: string) {
if (!href) return <div className={className}>{children}</div>;
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} className={className} rel="noopener noreferrer">
{children}
</a>
);
}
export function SlotMedia({
src,
alt = "",
href,
fallbackSrc,
aspectRatio,
className = "",
imgClassName = "absolute inset-0 h-full w-full object-cover",
fill = false,
}: SlotMediaProps) {
const image = (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
className={imgClassName}
onError={(event) => {
if (fallbackSrc && event.currentTarget.src !== fallbackSrc) {
event.currentTarget.src = fallbackSrc;
}
}}
/>
);
if (fill) {
return wrap(href, image, `absolute inset-0 ${className}`.trim());
}
const ratio = parseAspectRatio(aspectRatio);
return wrap(
href,
<span
className="relative block h-full w-full overflow-hidden"
style={ratio ? { aspectRatio: ratio } : undefined}
>
{image}
</span>,
className,
);
}
+168 -62
View File
@@ -7,11 +7,21 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
type ReactNode,
} from "react"; } from "react";
import { meshkee, type AuthUser, CUSTOMER_PORTAL_URL } from "./meshkee"; import {
CUSTOMER_PORTAL_URL,
const ACCESS_TOKEN_KEY = "meshkee_access_token"; meshkee,
const REFRESH_TOKEN_KEY = "meshkee_refresh_token"; type AuthUser,
} from "./meshkee";
import {
clearSharedAuthCookies,
readSharedAccessToken,
readSharedRefreshToken,
SHARED_ACCESS_TOKEN_KEY,
SHARED_REFRESH_TOKEN_KEY,
writeSharedAuthCookies,
} from "./shared-auth-cookie";
type AuthContextValue = { type AuthContextValue = {
user: AuthUser | null; user: AuthUser | null;
@@ -19,81 +29,175 @@ type AuthContextValue = {
isLoading: boolean; isLoading: boolean;
isAdmin: boolean; isAdmin: boolean;
loginUrl: string; loginUrl: string;
businessId: string;
setSession: (accessToken: string, refreshToken: string) => Promise<void>; setSession: (accessToken: string, refreshToken: string) => Promise<void>;
logout: () => void; logout: () => void;
getAccessToken: () => string | null;
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
function getLoginUrl() { function readLegacyToken(key: string) {
if (typeof window === "undefined") return CUSTOMER_PORTAL_URL; try {
const returnUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent( return localStorage.getItem(key);
window.location.pathname } catch {
)}`; return null;
return `${CUSTOMER_PORTAL_URL}/login?returnUrl=${encodeURIComponent(returnUrl)}`; }
} }
export function AuthProvider({ children }: { children: React.ReactNode }) { function writeLegacyToken(key: string, value: string | null) {
try {
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch {
// ignore
}
}
function normalizeUser(raw: Partial<AuthUser> & { id?: string | number } | null | undefined): AuthUser | null {
if (!raw || raw.id == null) return null;
return {
id: String(raw.id),
firstName: raw.firstName ?? "",
lastName: raw.lastName ?? "",
cellNumber: raw.cellNumber ?? "",
email: raw.email ?? null,
role: raw.role,
roles: Array.isArray(raw.roles) ? raw.roles : undefined,
isAdmin: Boolean(raw.isAdmin),
isSuperAdmin: Boolean(raw.isSuperAdmin),
businesses: Array.isArray(raw.businesses)
? raw.businesses.map((business) => ({
id: String(business.id),
name: business.name,
slug: business.slug,
isOwner: Boolean(business.isOwner),
teamRole: business.teamRole ?? null,
}))
: undefined,
};
}
export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string) {
if (!user || !businessId) return false;
if (user.isSuperAdmin || user.isAdmin || user.role === "admin" || user.role === "owner") return true;
if (user.roles?.includes("super_admin")) return true;
return Boolean(
user.businesses?.some(
(business) =>
String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
),
);
}
export function userDisplayName(user: AuthUser | null | undefined) {
if (!user) return "کاربر";
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
return full || user.cellNumber || user.email || "کاربر";
}
function getLoginUrl() {
if (typeof window === "undefined") return `${CUSTOMER_PORTAL_URL}/login`;
const redirect = window.location.pathname || "/";
return `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(redirect)}`;
}
export function AuthProvider({
businessId,
children,
}: {
businessId: string;
children: ReactNode;
}) {
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const loadUser = useCallback(async (token: string) => { const getAccessToken = useCallback(() => {
try { return (
const { user } = await meshkee.me(token); readSharedAccessToken() ||
setUser(user); readLegacyToken(SHARED_ACCESS_TOKEN_KEY) ||
setAccessToken(token); readLegacyToken("meshkee_access_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 clearSession = useCallback(() => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY); writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, null);
if (token) { writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, null);
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time session bootstrap on mount writeLegacyToken("meshkee_access_token", null);
loadUser(token).finally(() => setIsLoading(false)); writeLegacyToken("meshkee_refresh_token", null);
} else { clearSharedAuthCookies();
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); setUser(null);
setAccessToken(null); setAccessToken(null);
}, []); }, []);
const isAdmin = const loadUser = useCallback(
!!user && (user.role === "admin" || user.role === "owner" || user.isAdmin === true); async (token: string) => {
try {
const data = await meshkee.me(token);
const next = normalizeUser(data.user ?? (data as unknown as AuthUser));
if (!next) throw new Error("invalid user");
setUser(next);
setAccessToken(token);
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, token);
writeSharedAuthCookies(token, readSharedRefreshToken() ?? undefined);
} catch {
const refreshToken =
readSharedRefreshToken() ||
readLegacyToken(SHARED_REFRESH_TOKEN_KEY) ||
readLegacyToken("meshkee_refresh_token");
if (refreshToken) {
try {
const res = await meshkee.refresh(refreshToken);
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, res.accessToken);
writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, res.refreshToken);
writeSharedAuthCookies(res.accessToken, res.refreshToken);
const next = normalizeUser(res.user);
setUser(next);
setAccessToken(res.accessToken);
return;
} catch {
// fall through
}
}
clearSession();
}
},
[clearSession],
);
useEffect(() => {
let cancelled = false;
const token = getAccessToken();
if (!token) {
setIsLoading(false);
return;
}
loadUser(token).finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [getAccessToken, loadUser]);
const setSession = useCallback(
async (newAccessToken: string, newRefreshToken: string) => {
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, newAccessToken);
writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, newRefreshToken);
writeSharedAuthCookies(newAccessToken, newRefreshToken);
setIsLoading(true);
await loadUser(newAccessToken);
setIsLoading(false);
},
[loadUser],
);
const logout = useCallback(() => {
clearSession();
}, [clearSession]);
const isAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
@@ -102,10 +206,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
isLoading, isLoading,
isAdmin, isAdmin,
loginUrl: getLoginUrl(), loginUrl: getLoginUrl(),
businessId,
setSession, setSession,
logout, logout,
getAccessToken,
}), }),
[user, accessToken, isLoading, isAdmin, setSession, logout] [user, accessToken, isLoading, isAdmin, businessId, setSession, logout, getAccessToken],
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+88 -63
View File
@@ -7,92 +7,117 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
type ReactNode,
} from "react"; } from "react";
import { meshkee, type Cart } from "./meshkee"; import {
import { useAuth } from "./auth-context"; getContinueCheckoutUrl,
readGuestCart,
writeGuestCart,
type GuestCartItem,
} from "./guest-cart";
type CartContextValue = { type CartContextValue = {
cart: Cart | null; items: GuestCartItem[];
itemCount: number; itemCount: number;
total: number;
isLoading: boolean; isLoading: boolean;
refresh: () => Promise<void>; addItem: (item: Omit<GuestCartItem, "quantity"> & { quantity?: number }) => void;
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>; updateItem: (id: string, quantity: number) => void;
updateItem: (itemId: string, quantity: number) => Promise<void>; removeItem: (id: string) => void;
removeItem: (itemId: string) => Promise<void>; clear: () => void;
continueUrl: string;
}; };
const CartContext = createContext<CartContextValue | null>(null); const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ export function CartProvider({ children }: { children: ReactNode }) {
businessId, const [items, setItems] = useState<GuestCartItem[]>([]);
children, const [isLoading, setIsLoading] = useState(true);
}: {
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(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- refetch cart when auth state changes setItems(readGuestCart());
refresh(); setIsLoading(false);
}, [refresh]); }, []);
const persist = useCallback((next: GuestCartItem[]) => {
setItems(next);
writeGuestCart(next);
}, []);
const addItem = useCallback( const addItem = useCallback(
async (storeItemVariantId: string, quantity = 1) => { (item: Omit<GuestCartItem, "quantity"> & { quantity?: number }) => {
if (!accessToken) return; const quantity = Math.max(1, item.quantity ?? 1);
const { cart } = await meshkee.addCartItem(businessId, accessToken, { setItems((prev) => {
storeItemVariantId, const existing = prev.find((line) => line.id === item.id);
quantity, const next = existing
? prev.map((line) =>
line.id === item.id ? { ...line, quantity: line.quantity + quantity } : line,
)
: [
...prev,
{
id: item.id,
name: item.name,
slug: item.slug,
price: item.price,
originalPrice: item.originalPrice,
image: item.image,
quantity,
},
];
writeGuestCart(next);
return next;
}); });
setCart(cart);
}, },
[accessToken, businessId] [],
); );
const updateItem = useCallback( const updateItem = useCallback((id: string, quantity: number) => {
async (itemId: string, quantity: number) => { setItems((prev) => {
if (!accessToken) return; const next =
const { cart } = await meshkee.updateCartItem(businessId, accessToken, itemId, quantity); quantity <= 0
setCart(cart); ? prev.filter((line) => line.id !== id)
}, : prev.map((line) => (line.id === id ? { ...line, quantity } : line));
[accessToken, businessId] writeGuestCart(next);
); return next;
});
}, []);
const removeItem = useCallback( const removeItem = useCallback((id: string) => {
async (itemId: string) => { setItems((prev) => {
if (!accessToken) return; const next = prev.filter((line) => line.id !== id);
const { cart } = await meshkee.removeCartItem(businessId, accessToken, itemId); writeGuestCart(next);
setCart(cart); return next;
}, });
[accessToken, businessId] }, []);
);
const clear = useCallback(() => persist([]), [persist]);
const itemCount = useMemo( const itemCount = useMemo(
() => cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0, () => items.reduce((sum, item) => sum + item.quantity, 0),
[cart] [items],
); );
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items],
);
const continueUrl = useMemo(() => getContinueCheckoutUrl(items), [items]);
const value = useMemo( const value = useMemo(
() => ({ cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem }), () => ({
[cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem] items,
itemCount,
total,
isLoading,
addItem,
updateItem,
removeItem,
clear,
continueUrl,
}),
[items, itemCount, total, isLoading, addItem, updateItem, removeItem, clear, continueUrl],
); );
return <CartContext.Provider value={value}>{children}</CartContext.Provider>; return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+31
View File
@@ -0,0 +1,31 @@
import type { Category } from "@/lib/meshkee";
import { slugify } from "@/lib/slug";
export type CategoryNode = Category & {
children: Category[];
};
export function categoryHref(category: Pick<Category, "id" | "name" | "nameFa" | "slug">) {
const slug = category.slug || slugify(category.nameFa || category.name);
return `/products/category/${category.id}/${slug}`;
}
export function buildCategoryTree(categories: Category[]): CategoryNode[] {
const active = categories.filter((category) => category.isActive !== false);
const childrenByParent = new Map<string, Category[]>();
for (const category of active) {
if (!category.parentId) continue;
const key = String(category.parentId);
const list = childrenByParent.get(key) ?? [];
list.push(category);
childrenByParent.set(key, list);
}
return active
.filter((category) => !category.parentId)
.map((category) => ({
...category,
children: childrenByParent.get(String(category.id)) ?? [],
}));
}
+90
View File
@@ -0,0 +1,90 @@
import { WEBSITE_DOMAIN } from "@/lib/meshkee";
import { readSharedAccessToken } from "@/lib/shared-auth-cookie";
export const GUEST_CART_KEY = "meshkee-guest-cart";
export type GuestCartItem = {
id: string;
name: string;
slug: string;
price: number;
originalPrice?: number;
image?: string;
quantity: number;
};
function cookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
const host = window.location.hostname.toLowerCase();
if (host === "localhost" || host === "127.0.0.1") return undefined;
const parts = host.split(".").filter(Boolean);
if (parts.length < 2) return undefined;
return `.${parts.slice(-2).join(".")}`;
}
function writeGuestCartCookie(items: GuestCartItem[]) {
if (typeof document === "undefined") return;
const domain = cookieDomain();
const encoded = encodeURIComponent(JSON.stringify(items));
if (encoded.length > 3500) {
let clear = `${GUEST_CART_KEY}=; path=/; max-age=0; SameSite=Lax`;
if (domain) clear += `; domain=${domain}`;
document.cookie = clear;
return;
}
const maxAge = 60 * 60 * 24 * 30;
let cookie = `${GUEST_CART_KEY}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`;
if (domain) cookie += `; domain=${domain}`;
if (window.location.protocol === "https:") cookie += "; Secure";
document.cookie = cookie;
}
export function readGuestCart(): GuestCartItem[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(GUEST_CART_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.map((item) => ({
id: String(item.id),
name: String(item.name || ""),
slug: String(item.slug || ""),
price: Number(item.price) || 0,
originalPrice:
item.originalPrice === undefined || item.originalPrice === null
? undefined
: Number(item.originalPrice),
image: item.image ? String(item.image) : undefined,
quantity: Math.max(1, Number(item.quantity) || 1),
}))
.filter((item) => item.id);
} catch {
return [];
}
}
export function writeGuestCart(items: GuestCartItem[]) {
if (typeof window === "undefined") return;
localStorage.setItem(GUEST_CART_KEY, JSON.stringify(items));
writeGuestCartCookie(items);
}
export function encodeGuestCart(items: GuestCartItem[]) {
const json = JSON.stringify(items);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function getContinueCheckoutUrl(items: GuestCartItem[]) {
const encoded = encodeGuestCart(items);
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
const loggedIn = Boolean(readSharedAccessToken());
if (loggedIn) {
return `https://customer.${WEBSITE_DOMAIN}${checkoutPath}`;
}
return `https://customer.${WEBSITE_DOMAIN}/login?redirect=${encodeURIComponent(checkoutPath)}`;
}
+38
View File
@@ -0,0 +1,38 @@
import { API_BASE } from "@/lib/meshkee";
export function getBearerToken(request: Request) {
const header = request.headers.get("authorization") || request.headers.get("Authorization");
if (!header) return null;
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
return match?.[1]?.trim() || null;
}
export function jsonResponse(data: unknown, status = 200) {
return Response.json(data, { status });
}
export function unauthorizedResponse() {
return jsonResponse({ error: "Unauthorized" }, 401);
}
export async function meshkeeFetch(
path: string,
init: RequestInit & { token?: string } = {},
) {
const { token, ...rest } = init;
const headers = new Headers(rest.headers);
if (!headers.has("Content-Type") && rest.body) {
headers.set("Content-Type", "application/json");
}
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${API_BASE}${path}`, {
...rest,
headers,
cache: "no-store",
});
const isJson = res.headers.get("content-type")?.includes("application/json");
const data = isJson ? await res.json().catch(() => null) : null;
return { ok: res.ok, status: res.status, data };
}
+89 -31
View File
@@ -1,6 +1,8 @@
export const API_BASE = "https://api.meshkee.com/api/v1"; export const API_BASE = "https://api.meshkee.com/api/v1";
export const WEBSITE_DOMAIN = "dcorya.com"; export const WEBSITE_DOMAIN = "dcorya.com";
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`; export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
export const BUSINESS_DASHBOARD_URL = `https://business.${WEBSITE_DOMAIN}`;
export const DEFAULT_FAVICON = "/images/logo.png";
export type Tenant = { export type Tenant = {
id: string; id: string;
@@ -148,18 +150,66 @@ export type Comment = {
createdAt: string; createdAt: string;
}; };
export type StoreItemVariant = { export type StoreVariant = {
id: string; id: string;
price: number; storeItemId?: string;
stockQuantity: number | null; productId?: string | null;
productTitle?: string | null;
productNameFa?: string | null;
productImage?: string | null;
productTotalStock?: number | null;
label?: string | null;
price?: number | null;
discountedPrice?: number | null;
stockQuantity?: number | null;
unlimited?: boolean; unlimited?: boolean;
isFestival?: boolean;
isUsed?: boolean;
sortOrder?: number | null;
selections?: { variationId?: string; variationName?: string; optionId?: string; value?: string }[];
attributes?: Record<string, string>; attributes?: Record<string, string>;
}; };
/** @deprecated Prefer StoreVariant — kept for older call sites */
export type StoreItemVariant = StoreVariant;
export type StoreItem = { export type StoreItem = {
id: string; id: string;
productId: string; productId: string;
variants: StoreItemVariant[]; isActive?: boolean;
variants: StoreVariant[];
};
export type StoreItemListResponse = {
items: StoreVariant[];
total: number;
page: number;
pageSize: number;
};
export type StoreSpecialItem = {
id: string;
productId?: string | null;
productTitle?: string | null;
productNameFa?: string | null;
productImage?: string | null;
isActive?: boolean;
sortOrder?: number | null;
variants?: StoreVariant[];
};
export type StoreSpecial = {
id: string;
key: string;
title: string;
sortOrder?: number;
isActive?: boolean;
items: StoreSpecialItem[];
};
export type StoreSpecialsResponse = {
source: "product" | "store_item" | null;
items: StoreSpecial[];
}; };
export type CartItem = { export type CartItem = {
@@ -177,6 +227,14 @@ export type Cart = {
total?: number; total?: number;
}; };
export type AuthBusinessMembership = {
id: string;
name: string;
slug: string;
isOwner: boolean;
teamRole: string | null;
};
export type AuthUser = { export type AuthUser = {
id: string; id: string;
firstName: string; firstName: string;
@@ -184,7 +242,10 @@ export type AuthUser = {
cellNumber: string; cellNumber: string;
email?: string | null; email?: string | null;
role?: string; role?: string;
roles?: string[];
isAdmin?: boolean; isAdmin?: boolean;
isSuperAdmin?: boolean;
businesses?: AuthBusinessMembership[];
}; };
export type AuthResponse = { export type AuthResponse = {
@@ -281,6 +342,30 @@ export const meshkee = {
{ next: { revalidate: 30 } } as RequestInit { next: { revalidate: 30 } } as RequestInit
), ),
getStoreItems: (params: {
page?: number;
pageSize?: number;
categoryId?: string;
productId?: string;
name?: string;
inStock?: boolean;
} = {}) =>
request<StoreItemListResponse>(
`/tenants/${WEBSITE_DOMAIN}/store-items${qs(params)}`,
{ next: { revalidate: 60 } } as RequestInit
),
getStoreVariantById: (variantId: string) =>
request<{ variant: StoreVariant }>(
`/tenants/${WEBSITE_DOMAIN}/store-items/${variantId}`,
{ next: { revalidate: 30 } } as RequestInit
),
getStoreSpecials: () =>
request<StoreSpecialsResponse>(`/tenants/${WEBSITE_DOMAIN}/store-specials`, {
next: { revalidate: 60 },
} as RequestInit),
getCategories: (entityType: "product" = "product") => getCategories: (entityType: "product" = "product") =>
request<{ items: Category[] }>( request<{ items: Category[] }>(
`/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`, `/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`,
@@ -359,33 +444,6 @@ export const meshkee = {
method: "POST", method: "POST",
body: JSON.stringify({ refreshToken }), 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 }; export { ApiError };
+2 -2
View File
@@ -12,9 +12,9 @@ export default function Providers({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<AuthProvider> <AuthProvider businessId={businessId}>
<ToastProvider> <ToastProvider>
<CartProvider businessId={businessId}>{children}</CartProvider> <CartProvider>{children}</CartProvider>
</ToastProvider> </ToastProvider>
</AuthProvider> </AuthProvider>
); );
+110
View File
@@ -0,0 +1,110 @@
/** Shared auth cookies so storefront + customer.{domain} share one login. */
export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
const CHUNK_SIZE = 2800;
const MAX_CHUNKS = 24;
function cookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
const host = window.location.hostname.toLowerCase();
if (host === "localhost" || host === "127.0.0.1") return undefined;
const parts = host.split(".").filter(Boolean);
if (parts.length < 2) return undefined;
return `.${parts.slice(-2).join(".")}`;
}
function readRawCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const prefix = `${name}=`;
const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
if (!match) return null;
return match.slice(prefix.length);
}
function writeRawCookie(name: string, value: string | null) {
if (typeof document === "undefined") return;
const domain = cookieDomain();
if (value == null) {
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
if (domain) cookie += `; domain=${domain}`;
document.cookie = cookie;
return;
}
const maxAge = 60 * 60 * 24 * 30;
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
if (domain) cookie += `; domain=${domain}`;
if (window.location.protocol === "https:") cookie += "; Secure";
document.cookie = cookie;
}
function clearChunkedCookie(name: string) {
writeRawCookie(name, null);
const n = Number(readRawCookie(`${name}_n`) || "0");
writeRawCookie(`${name}_n`, null);
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
writeRawCookie(`${name}_${i}`, null);
}
}
function writeCookie(name: string, value: string | null) {
clearChunkedCookie(name);
if (value == null || value === "") return;
const encoded = encodeURIComponent(value);
if (encoded.length <= 3500) {
writeRawCookie(name, encoded);
return;
}
const chunks: string[] = [];
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
chunks.push(encoded.slice(i, i + CHUNK_SIZE));
}
writeRawCookie(`${name}_n`, String(chunks.length));
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
}
function readCookie(name: string): string | null {
const n = Number(readRawCookie(`${name}_n`) || "0");
if (n > 0) {
let encoded = "";
for (let i = 0; i < n; i++) {
const part = readRawCookie(`${name}_${i}`);
if (part == null) return null;
encoded += part;
}
try {
return decodeURIComponent(encoded);
} catch {
return null;
}
}
const raw = readRawCookie(name);
if (raw == null) return null;
try {
return decodeURIComponent(raw);
} catch {
return null;
}
}
export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
}
export function clearSharedAuthCookies() {
writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
}
export function readSharedAccessToken(): string | null {
return readCookie(SHARED_ACCESS_TOKEN_KEY);
}
export function readSharedRefreshToken(): string | null {
return readCookie(SHARED_REFRESH_TOKEN_KEY);
}
+124
View File
@@ -0,0 +1,124 @@
import type { ProductCardProps } from "@/components/ProductCard";
import { meshkee, type StoreSpecial, type StoreSpecialItem, type StoreVariant } from "@/lib/meshkee";
import { storeVariantToCard } from "@/lib/store";
import { slugify } from "@/lib/slug";
export const HOMEPAGE_SPECIAL_KEYS = [
"new-collection",
"special-sale",
"our-handmades",
] as const;
export type HomepageSpecialKey = (typeof HOMEPAGE_SPECIAL_KEYS)[number];
export const SPECIAL_META: Record<
HomepageSpecialKey,
{ eyebrow: string; fallbackTitle: string; muted?: boolean }
> = {
"new-collection": {
eyebrow: "NEW ARRIVALS",
fallbackTitle: "جدیدترین پیشنهادهای دکوریا",
},
"special-sale": {
eyebrow: "SPECIAL SALE",
fallbackTitle: "فروش ویژه",
},
"our-handmades": {
eyebrow: "HANDCRAFTED",
fallbackTitle: "صنایع دستی و تزئینی دکوریا",
muted: true,
},
};
const FALLBACK_PRODUCTS: Record<HomepageSpecialKey, ProductCardProps[]> = {
"new-collection": [
{ image: "/images/product-1.jpg", title: "ساعت ایستاده CL001", price: "۲۹٬۷۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-2.jpg", title: "میز کنسول سه درب نیم گرد نقاشی برنزخور B084", price: "۴۹٬۳۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-3.jpg", title: "میز کنسول دودرب نقاشی برنزخور B003", price: "۴۷٬۵۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-4.jpg", title: "میز کنسول سه درب نقاشی برنزخور B096", price: "۴۹٬۵۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-5.jpg", title: "آینه کنسول تک درب نقاشی برنزخور B020", price: "۴۳٬۵۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-6.jpg", title: "میز کنسول تک درب نقاشی برنزخور B075", price: "۳۵٬۵۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-7.jpg", title: "میز کنسول ویترین نقاشی برنزخور B083", price: "۴۶٬۹۰۰٬۰۰۰ تومان", href: "/products" },
{ image: "/images/product-8.jpg", title: "میز کنسول خمره ای وسط کشو نقاشی برنزخور B087", price: "۴۹٬۷۰۰٬۰۰۰ تومان", href: "/products" },
],
"special-sale": [
{ image: "/images/product-3.jpg", title: "میز کنسول دودرب نقاشی برنزخور B003", href: "/products" },
{ image: "/images/product-5.jpg", title: "آینه کنسول تک درب نقاشی برنزخور B020", href: "/products" },
{ image: "/images/product-1.jpg", title: "ساعت ایستاده CL001", href: "/products" },
{ image: "/images/product-7.jpg", title: "میز کنسول ویترین نقاشی برنزخور B083", href: "/products" },
],
"our-handmades": [
{ image: "/images/decor-1.jpg", title: "ساعت و شمعدان دو پسر برنز طلایی", href: "/products" },
{ image: "/images/decor-2.jpg", title: "ساعت و شمعدان زن و مرد برنز طلایی آنتیک", href: "/products" },
{ image: "/images/decor-3.jpg", title: "ساعت و شمعدان زن و مرد برنز طلایی", href: "/products" },
{ image: "/images/decor-4.jpg", title: "ساعت و شمعدان ریسه ای برنز آنتیک", href: "/products" },
{ image: "/images/decor-5.jpg", title: "ست ساعت و شمعدان پرنسس ۰۱۱", href: "/products" },
{ image: "/images/decor-6.jpg", title: "ست شمعدان و ساعت سنگ و برنز ۰۱۰", href: "/products" },
],
};
export function specialItemToCard(item: StoreSpecialItem): ProductCardProps | null {
const variant = item.variants?.[0];
if (variant) {
return storeVariantToCard({
...variant,
productId: item.productId ?? variant.productId,
productTitle: item.productTitle ?? variant.productTitle,
productNameFa: item.productNameFa ?? variant.productNameFa,
productImage: item.productImage ?? variant.productImage,
});
}
const title = item.productNameFa?.trim() || item.productTitle?.trim();
const image = item.productImage?.trim();
if (!title || !image) return null;
const productId = item.productId ? String(item.productId) : null;
return {
href: productId ? `/products/${productId}/${slugify(title)}` : "/products",
image,
title,
price: null,
inStock: false,
};
}
export async function fetchStoreSpecials(): Promise<StoreSpecial[]> {
try {
const data = await meshkee.getStoreSpecials();
return (data.items ?? []).filter((item) => item.isActive !== false);
} catch {
return [];
}
}
export function pickSpecial(specials: StoreSpecial[], key: string) {
return specials.find((item) => item.key === key);
}
export async function resolveSpecialCarousel(key: HomepageSpecialKey, specials: StoreSpecial[]) {
const meta = SPECIAL_META[key];
const special = pickSpecial(specials, key);
const fromApi = (special?.items ?? [])
.map(specialItemToCard)
.filter((item): item is ProductCardProps => Boolean(item));
let products = fromApi;
if (products.length === 0 && key === "new-collection") {
const { items } = await meshkee.getStoreItems({ pageSize: 8 }).catch(() => ({ items: [] as StoreVariant[] }));
products = items.map(storeVariantToCard);
}
if (products.length === 0) {
products = FALLBACK_PRODUCTS[key];
}
return {
key,
id: key,
eyebrow: meta.eyebrow,
title: special?.title?.trim() || meta.fallbackTitle,
muted: meta.muted,
products,
};
}
+200
View File
@@ -0,0 +1,200 @@
import { API_BASE, WEBSITE_DOMAIN } from "@/lib/meshkee";
export type SlotKind = "single" | "list";
export type SlotImage = {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number | null;
};
export type StaticImageSlotCatalogItem = {
key: string;
label: string;
kind: SlotKind;
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
export type StaticImageSlot = StaticImageSlotCatalogItem & {
images: SlotImage[];
};
export type SlotMediaItem = {
id: string;
image: string;
href?: string;
title?: string;
titleEn?: string;
text?: string;
fallbackSrc?: string;
};
/**
* Slot catalog published at GET /meshkee/static-image-slots.
* Ratios match the live homepage layout. If a section’s CSS size or
* aspect ratio changes, update this catalog in the same change — Meshkee
* reads it as the static-image source of truth.
*/
export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
{
key: "slider",
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "1920:1100",
itemCount: null,
recommendedWidth: 1920,
},
{
key: "categories",
label: "Featured categories",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 160,
},
{
key: "two-banner-below-slider",
label: "Two banners below products",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: 2,
recommendedWidth: 900,
},
{
key: "second-categories",
label: "Console / decorative categories",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 320,
},
{
key: "about-us-image",
label: "About us image",
kind: "single",
pageKey: "home",
aspectRatio: "4:3",
itemCount: 1,
recommendedWidth: 960,
},
];
export function catalogSlot(key: string) {
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
}
export function parseAspectRatio(ratio?: string | null) {
if (!ratio) return undefined;
const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
if (!match) return undefined;
return `${match[1]} / ${match[2]}`;
}
export function pickSlot(slots: StaticImageSlot[], key: string) {
return slots.find((slot) => slot.key === key);
}
function normalizeImages(images: SlotImage[] | undefined) {
return [...(images ?? [])]
.filter((image) => Boolean(image?.url))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
if (!slot) return [];
const images = normalizeImages(slot.images);
if (slot.kind === "single") return images.slice(0, 1);
return images;
}
function toMedia(image: SlotImage, fallback?: SlotMediaItem): SlotMediaItem {
return {
id: String(image.id),
image: image.url,
href: image.linkUrl?.trim() || fallback?.href,
title: image.titleFa?.trim() || fallback?.title,
titleEn: image.titleEn?.trim() || fallback?.titleEn,
text: image.subtext?.trim() || fallback?.text,
fallbackSrc: fallback?.image || fallback?.fallbackSrc,
};
}
export function overlaySlotImages(
slot: StaticImageSlot | undefined,
fallbacks: SlotMediaItem[],
key: string,
): { items: SlotMediaItem[]; aspectRatio?: string } {
const catalog = catalogSlot(key);
const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
const kind = slot?.kind ?? catalog?.kind ?? "list";
const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
if (kind === "single") {
const image = apiImages[0];
const fallback = fallbacks[0];
return {
aspectRatio,
items: image ? [toMedia(image, fallback)] : fallbacks.slice(0, 1),
};
}
if (apiImages.length === 0) {
return {
aspectRatio,
items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
};
}
if (itemCount) {
return {
aspectRatio,
items: Array.from({ length: itemCount }, (_, index) => {
const image = apiImages[index];
const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
return image ? toMedia(image, fallback) : fallback;
}).filter(Boolean),
};
}
return {
aspectRatio,
items: apiImages.map((image, index) => toMedia(image, fallbacks[index])),
};
}
function readItems(payload: unknown): StaticImageSlot[] {
if (Array.isArray(payload)) return payload as StaticImageSlot[];
if (payload && typeof payload === "object") {
const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
return record.items ?? record.slots ?? [];
}
return [];
}
export async function fetchStaticImageSlots(pageKey = "home"): Promise<StaticImageSlot[]> {
const url = `${API_BASE}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
try {
const response = await fetch(url, {
next: { revalidate: 60 },
});
if (!response.ok) return [];
return readItems(await response.json());
} catch {
return [];
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { ProductCardProps } from "@/components/ProductCard";
import type { StoreVariant } from "@/lib/meshkee";
import { slugify } from "@/lib/slug";
export function formatToman(value?: number | null) {
if (value === null || value === undefined) return null;
return `${value.toLocaleString("fa-IR")} تومان`;
}
export function variantInStock(variant: Pick<StoreVariant, "stockQuantity" | "unlimited">) {
return Boolean(variant.unlimited || variant.stockQuantity == null || variant.stockQuantity > 0);
}
export function variantSellingPrice(variant: Pick<StoreVariant, "price" | "discountedPrice">) {
return variant.discountedPrice ?? variant.price ?? null;
}
export function variantTitle(variant: StoreVariant) {
return (
variant.productNameFa?.trim() ||
variant.productTitle?.trim() ||
variant.label?.trim() ||
"محصول"
);
}
export function variantHref(variant: StoreVariant) {
const title = variantTitle(variant);
const productId = variant.productId ? String(variant.productId) : null;
if (!productId) return "/products";
return `/products/${productId}/${slugify(title)}`;
}
export function storeVariantToCard(variant: StoreVariant): ProductCardProps {
const price = formatToman(variantSellingPrice(variant));
return {
href: variantHref(variant),
image: variant.productImage || "/images/product-1.jpg",
title: variantTitle(variant),
price,
inStock: variantInStock(variant),
};
}