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:
co-authored by
Cursor
parent
a60e84a2b2
commit
9e2dc2c61d
+96
@@ -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.
|
||||
@@ -5,6 +5,10 @@ const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.meshkee.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "c804387.parspack.net",
|
||||
|
||||
Generated
-114
@@ -631,9 +631,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -650,9 +647,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -669,9 +663,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -688,9 +679,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -707,9 +695,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -726,9 +711,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -745,9 +727,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -764,9 +743,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -783,9 +759,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -808,9 +781,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -833,9 +803,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -858,9 +825,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -883,9 +847,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -908,9 +869,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -933,9 +891,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -958,9 +913,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1228,9 +1180,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1247,9 +1196,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1266,9 +1212,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1285,9 +1228,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1526,9 +1466,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1546,9 +1483,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1566,9 +1500,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1586,9 +1517,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2139,9 +2067,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2156,9 +2081,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2173,9 +2095,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2190,9 +2109,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2207,9 +2123,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2224,9 +2137,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2241,9 +2151,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2258,9 +2165,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2275,9 +2179,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2292,9 +2193,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4980,9 +4878,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5004,9 +4899,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5028,9 +4920,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5052,9 +4941,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import CartPageClient from "./CartPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "سبد خرید | فروشگاه اینترنتی دکوریا",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function CartPage() {
|
||||
return <CartPageClient />;
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee";
|
||||
import { DEFAULT_FAVICON, meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee";
|
||||
import Providers from "@/lib/providers";
|
||||
|
||||
const iranyekan = localFont({
|
||||
@@ -26,7 +26,7 @@ const montserrat = localFont({
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 description =
|
||||
"زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را با دکوریا تجربه کنید.";
|
||||
@@ -35,14 +35,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
metadataBase: new URL(`https://${WEBSITE_DOMAIN}`),
|
||||
title: { default: title, template: `%s` },
|
||||
description,
|
||||
icons: [{ url: iconUrl }],
|
||||
icons: [{ url: iconUrl, type: "image/png" }],
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
siteName: title,
|
||||
locale: "fa_IR",
|
||||
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
@@ -1,26 +1,142 @@
|
||||
import Header from "@/components/Header";
|
||||
import Hero from "@/components/Hero";
|
||||
import ProductCategories from "@/components/ProductCategories";
|
||||
import LatestProducts from "@/components/LatestProducts";
|
||||
import ProductCarousel from "@/components/ProductCarousel";
|
||||
import BannerRow from "@/components/BannerRow";
|
||||
import ConsoleCategories from "@/components/ConsoleCategories";
|
||||
import AboutDcorya from "@/components/AboutDcorya";
|
||||
import DecorativeProducts from "@/components/DecorativeProducts";
|
||||
import ContactTeaser from "@/components/ContactTeaser";
|
||||
import Footer from "@/components/Footer";
|
||||
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 (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<ProductCategories />
|
||||
<LatestProducts />
|
||||
<BannerRow />
|
||||
<ConsoleCategories />
|
||||
<AboutDcorya />
|
||||
<DecorativeProducts />
|
||||
<Hero slides={slider.items} aspectRatio={slider.aspectRatio} />
|
||||
<ProductCategories items={categories.items} aspectRatio={categories.aspectRatio} />
|
||||
<ProductCarousel
|
||||
id={newCollection.id}
|
||||
eyebrow={newCollection.eyebrow}
|
||||
title={newCollection.title}
|
||||
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 />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -46,12 +46,18 @@ export default async function ProductDetailPage({
|
||||
const data = await meshkee.getProductById(id).catch(() => null);
|
||||
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.getComments("product", product.id).catch(() => ({ items: [] })),
|
||||
meshkee.getStoreItemByProduct(product.id).catch(() => ({ storeItem: null })),
|
||||
meshkee
|
||||
.getStoreItems({
|
||||
pageSize: 8,
|
||||
categoryId: product.categoryId || undefined,
|
||||
})
|
||||
.catch(() => ({ items: [] })),
|
||||
]);
|
||||
|
||||
const galleryImages = [
|
||||
@@ -59,7 +65,10 @@ export default async function ProductDetailPage({
|
||||
...product.images.map((img) => img.url),
|
||||
].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 (
|
||||
<>
|
||||
@@ -94,7 +103,7 @@ export default async function ProductDetailPage({
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<AddToCartBox variant={variant} />
|
||||
<AddToCartBox variants={variants} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,12 +118,12 @@ export default async function ProductDetailPage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relatedProducts.length > 0 && (
|
||||
{related.length > 0 && (
|
||||
<div className="mt-16">
|
||||
<h2 className="mb-8 text-xl font-bold text-neutral-800">محصولات مرتبط</h2>
|
||||
<Carousel>
|
||||
{relatedProducts.map((p) => (
|
||||
<RemoteProductCard key={p.id} product={p} />
|
||||
{related.map((variant) => (
|
||||
<RemoteProductCard key={variant.id} variant={variant} />
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
@@ -41,8 +41,8 @@ export default async function CategoryPage({
|
||||
const { category } = categoryData;
|
||||
const title = category.nameFa || category.name;
|
||||
|
||||
const productsRes = await meshkee.getProducts({ page, pageSize, categoryId: id });
|
||||
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize));
|
||||
const storeRes = await meshkee.getStoreItems({ page, pageSize, categoryId: id });
|
||||
const totalPages = Math.max(1, Math.ceil(storeRes.total / storeRes.pageSize));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,15 +63,13 @@ export default async function CategoryPage({
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{productsRes.items.map((product) => (
|
||||
<div key={product.id} className="[&>a]:w-full">
|
||||
<RemoteProductCard product={product} />
|
||||
</div>
|
||||
{storeRes.items.map((variant) => (
|
||||
<RemoteProductCard key={variant.id} variant={variant} layout="grid" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ export default async function ProductsPage({
|
||||
const page = Number(params.page) || 1;
|
||||
const pageSize = 12;
|
||||
|
||||
const [productsRes, categoriesRes] = await Promise.all([
|
||||
meshkee.getProducts({ page, pageSize, categoryId: params.categoryId, name: params.q }),
|
||||
const [storeRes, categoriesRes] = await Promise.all([
|
||||
meshkee.getStoreItems({ page, pageSize, categoryId: params.categoryId, name: params.q }),
|
||||
meshkee.getCategories("product").catch(() => ({ items: [] })),
|
||||
]);
|
||||
|
||||
const topCategories = categoriesRes.items.filter((c) => !c.parentId).slice(0, 10);
|
||||
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize));
|
||||
const totalPages = Math.max(1, Math.ceil(storeRes.total / storeRes.pageSize));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -60,13 +60,13 @@ export default async function ProductsPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{productsRes.items.length === 0 ? (
|
||||
{storeRes.items.length === 0 ? (
|
||||
<p className="py-20 text-center text-neutral-500">محصولی یافت نشد.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{productsRes.items.map((product) => (
|
||||
<RemoteProductCardWrapper key={product.id} product={product} />
|
||||
{storeRes.items.map((variant) => (
|
||||
<RemoteProductCard key={variant.id} variant={variant} layout="grid" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default async function ProductsPage({
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<Link
|
||||
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 ${
|
||||
p === page
|
||||
? "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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
const stats = [
|
||||
{ value: "۹۹٪", label: "کیفیت محصول" },
|
||||
@@ -7,22 +8,38 @@ const stats = [
|
||||
{ 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 (
|
||||
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
||||
<div className="relative order-2 aspect-4/3 overflow-hidden rounded-2xl lg:order-1">
|
||||
<Image
|
||||
src="/images/product-2.jpg"
|
||||
alt="محصولات چوبی دکوریا"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover"
|
||||
<div
|
||||
className={`relative order-2 overflow-hidden rounded-2xl lg:order-1 ${
|
||||
aspectRatio ? "" : "aspect-4/3"
|
||||
}`}
|
||||
>
|
||||
<SlotMedia
|
||||
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 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
|
||||
</p>
|
||||
<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">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<p className="text-2xl font-bold text-primary sm:text-3xl" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||
<p
|
||||
className="text-2xl font-bold text-primary sm:text-3xl"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-neutral-500 sm:text-sm">{stat.label}</p>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCart } from "@/lib/cart-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) {
|
||||
if (value === null || value === undefined) return null;
|
||||
return `${value.toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
|
||||
export default function AddToCartBox({ variant }: { variant: StoreItemVariant | null }) {
|
||||
const { user, loginUrl } = useAuth();
|
||||
export default function AddToCartBox({ variants }: { variants: StoreVariant[] }) {
|
||||
const { addItem } = useCart();
|
||||
const { showToast } = useToast();
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const inStock = variant ? variant.unlimited || (variant.stockQuantity ?? 0) > 0 : false;
|
||||
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 (
|
||||
<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 (
|
||||
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center">
|
||||
<p className="mb-1 text-lg font-extrabold text-neutral-400" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||
{formatToman(variant.price)}
|
||||
<p
|
||||
className="mb-1 text-lg font-extrabold text-neutral-400"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{formatToman(variantSellingPrice(first)) || "تماس بگیرید"}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-neutral-500">ناموجود</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!user) {
|
||||
window.location.href = loginUrl;
|
||||
const handleAdd = () => {
|
||||
if (!active) {
|
||||
showToast("لطفاً یک گزینه را انتخاب کنید", "error");
|
||||
return;
|
||||
}
|
||||
const price = variantSellingPrice(active);
|
||||
if (price == null) {
|
||||
showToast("قیمت این محصول مشخص نیست. لطفاً تماس بگیرید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
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("محصول به سبد خرید اضافه شد");
|
||||
} catch {
|
||||
showToast("افزودن به سبد خرید با خطا مواجه شد", "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayPrice = active ? variantSellingPrice(active) : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5">
|
||||
<p
|
||||
className="mb-4 text-2xl font-extrabold text-primary"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{formatToman(variant.price)}
|
||||
</p>
|
||||
{displayPrice != null ? (
|
||||
<p
|
||||
className="mb-4 text-2xl font-extrabold text-primary"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{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-3 rounded-lg border border-black/10 bg-white px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
||||
>
|
||||
@@ -75,6 +122,7 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-medium">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||
|
||||
@@ -1,43 +1,66 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
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 (
|
||||
<section className="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-4 sm:px-6 md:grid-cols-2 lg:px-8">
|
||||
<Link
|
||||
href="#"
|
||||
className="group relative block overflow-hidden rounded-2xl bg-white shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src="/images/banner-1.png"
|
||||
alt="فروش ویژه دکوریا"
|
||||
width={900}
|
||||
height={484}
|
||||
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72"
|
||||
/>
|
||||
</Link>
|
||||
{items.map((banner) => {
|
||||
const hasOverlay = Boolean(banner.title || banner.titleEn || banner.text);
|
||||
const media = (
|
||||
<div
|
||||
className={`group relative block overflow-hidden rounded-2xl shadow-sm ${
|
||||
hasOverlay ? "" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<SlotMedia
|
||||
src={banner.image}
|
||||
href={undefined}
|
||||
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
|
||||
href="#"
|
||||
className="group relative block overflow-hidden rounded-2xl shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src="/images/console-cat-4.jpg"
|
||||
alt="کلکسیون میز کنسول"
|
||||
width={900}
|
||||
height={484}
|
||||
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72"
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/40 text-center text-white">
|
||||
<p className="text-xs tracking-[0.3em]" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||
NEW COLLECTION
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold">کلکسیون میز کنسول</h3>
|
||||
<span className="mt-2 rounded-full border border-white/70 px-5 py-2 text-sm font-medium transition-colors group-hover:bg-white group-hover:text-neutral-900">
|
||||
مشاهده محصولات
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
if (!banner.href) {
|
||||
return <div key={banner.id}>{media}</div>;
|
||||
}
|
||||
|
||||
if (banner.href.startsWith("/") || banner.href.startsWith("#")) {
|
||||
return (
|
||||
<a key={banner.id} href={banner.href} className="block">
|
||||
{media}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a key={banner.id} href={banner.href} className="block" rel="noopener noreferrer">
|
||||
{media}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useClickOutside } from "@/lib/use-click-outside";
|
||||
|
||||
function formatToman(value?: number) {
|
||||
@@ -13,17 +11,15 @@ function formatToman(value?: number) {
|
||||
}
|
||||
|
||||
export default function CartPopover() {
|
||||
const { user, loginUrl } = useAuth();
|
||||
const { cart, itemCount } = useCart();
|
||||
const { items, itemCount, total, updateItem, removeItem, continueUrl } = useCart();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
|
||||
const items = cart?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="سبد خرید"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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="17.5" cy="20.5" r="1.3" fill="currentColor" />
|
||||
</svg>
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-2 -left-2 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white">
|
||||
{itemCount > 0 ? (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</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">
|
||||
{!user ? (
|
||||
<div className="py-4 text-center">
|
||||
<p className="mb-4 text-sm text-neutral-500">
|
||||
برای مشاهده سبد خرید ابتدا وارد حساب کاربری خود شوید.
|
||||
</p>
|
||||
<a
|
||||
href={loginUrl}
|
||||
className="inline-block rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
ورود به حساب کاربری
|
||||
</a>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
{items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-500">سبد خرید شما خالی است.</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -68,35 +52,64 @@ export default function CartPopover() {
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-neutral-100">
|
||||
{item.image && (
|
||||
<Image src={item.image} alt={item.title ?? ""} fill className="object-cover" />
|
||||
)}
|
||||
{item.image ? (
|
||||
<Image src={item.image} alt={item.name} fill className="object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-neutral-800">
|
||||
{item.title ?? "کالا"}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">تعداد: {item.quantity}</p>
|
||||
<p className="truncate text-sm font-medium text-neutral-800">{item.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
{formatToman(item.unitPrice) && (
|
||||
<p className="shrink-0 text-xs font-bold text-primary">
|
||||
{formatToman(item.unitPrice)}
|
||||
</p>
|
||||
)}
|
||||
</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)}
|
||||
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>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
const items = [
|
||||
{ title: "سه تیکه", icon: "/images/console-cat-1.png" },
|
||||
{ title: "انواع میز پذیرایی", icon: "/images/console-cat-2.png" },
|
||||
{ title: "قلمکاری روی مس", icon: "/images/console-cat-3.png" },
|
||||
{ title: "انواع شمعــــدان", icon: "/images/console-cat-4.jpg" },
|
||||
{ title: "میز کنسول", icon: "/images/console-cat-5.png" },
|
||||
{ title: "میز تی وی", icon: "/images/console-cat-6.png" },
|
||||
];
|
||||
type ConsoleCategoriesProps = {
|
||||
items: SlotMediaItem[];
|
||||
aspectRatio?: string;
|
||||
};
|
||||
|
||||
export default function ConsoleCategories() {
|
||||
export default function ConsoleCategories({ items, aspectRatio }: ConsoleCategoriesProps) {
|
||||
return (
|
||||
<section className="bg-neutral-50 py-16">
|
||||
<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 className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href="#"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-black/5 bg-white p-5 text-center shadow-sm transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50">
|
||||
<Image
|
||||
src={item.icon}
|
||||
alt={item.title}
|
||||
fill
|
||||
sizes="80px"
|
||||
className="object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-neutral-700">{item.title}</h3>
|
||||
</Link>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50">
|
||||
<SlotMedia
|
||||
fill
|
||||
src={item.image}
|
||||
fallbackSrc={item.fallbackSrc}
|
||||
alt={item.title || item.titleEn || ""}
|
||||
aspectRatio={aspectRatio}
|
||||
imgClassName="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
{item.title ? (
|
||||
<h3 className="text-sm font-medium text-neutral-700">{item.title}</h3>
|
||||
) : null}
|
||||
{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>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,22 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import CartPopover from "./CartPopover";
|
||||
import ProfileMenu from "./ProfileMenu";
|
||||
import ProductsMegaMenu from "./ProductsMegaMenu";
|
||||
import { buildCategoryTree } from "@/lib/categories";
|
||||
import { meshkee } from "@/lib/meshkee";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/", label: "صفحه اصلی" },
|
||||
{ href: "/products", label: "محصولات" },
|
||||
{ href: "/blog", label: "بلاگ" },
|
||||
{ href: "/satisfaction", 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 (
|
||||
<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">
|
||||
<Link href="/" className="shrink-0">
|
||||
<Image
|
||||
@@ -27,6 +31,13 @@ export default function Header() {
|
||||
</Link>
|
||||
|
||||
<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) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
|
||||
+86
-59
@@ -1,53 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
const slides = [
|
||||
{ src: "/images/slide-1.jpg", alt: "کریستال و ظروف دکوراتیو دکوریا" },
|
||||
{ src: "/images/slide-2.jpg", alt: "فروش اقساطی بدون سود محصولات دکوریا" },
|
||||
{ src: "/images/slide-3.jpg", alt: "قلمکاری مس، هنر اصیل ایرانی دکوریا" },
|
||||
{ src: "/images/slide-4.jpg", alt: "میز کنسول چوبی معرقکاری دکوریا" },
|
||||
];
|
||||
const DEFAULT_TITLE = "ارسال به سراسر ایران و کشورهای همسایه";
|
||||
const DEFAULT_TITLE_EN = "Shipping Across Iran and Neighboring Countries";
|
||||
|
||||
export default function Hero() {
|
||||
type HeroProps = {
|
||||
slides: SlotMediaItem[];
|
||||
aspectRatio?: string;
|
||||
};
|
||||
|
||||
export default function Hero({ slides = [] }: HeroProps) {
|
||||
const [active, setActive] = useState(0);
|
||||
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) => {
|
||||
setActive((index + slides.length) % slides.length);
|
||||
}, []);
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
setActive((index + count) % count);
|
||||
},
|
||||
[count],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (count <= 1) return;
|
||||
timerRef.current = setInterval(() => {
|
||||
setActive((prev) => (prev + 1) % slides.length);
|
||||
setActive((prev) => (prev + 1) % count);
|
||||
}, 5500);
|
||||
return () => {
|
||||
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 (
|
||||
<section className="relative h-[calc(100dvh-4rem)] min-h-[420px] w-full overflow-hidden">
|
||||
{slides.map((slide, i) => (
|
||||
{safeSlides.map((slide, i) => (
|
||||
<div
|
||||
key={slide.src}
|
||||
key={slide.id}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||
i === active ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={slide.src}
|
||||
alt={slide.alt}
|
||||
<SlotMedia
|
||||
fill
|
||||
priority={i === 0}
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
src={slide.image}
|
||||
href={slide.href}
|
||||
fallbackSrc={slide.fallbackSrc}
|
||||
alt={slide.title || slide.titleEn || "اسلاید دکوریا"}
|
||||
imgClassName="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-start px-4 pb-20 sm:px-10 sm:pb-24">
|
||||
<div 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
|
||||
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"
|
||||
@@ -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"
|
||||
style={{ animationDelay: "0.25s" }}
|
||||
>
|
||||
ارسال به سراسر ایران و کشورهای همسایه
|
||||
{titleFa}
|
||||
</h1>
|
||||
<p
|
||||
className="animate-fade-in-up mt-2 text-xs tracking-wide text-neutral-500 sm:text-sm"
|
||||
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.4s" }}
|
||||
>
|
||||
Shipping Across Iran and Neighboring Countries
|
||||
</p>
|
||||
{titleEn ? (
|
||||
<p
|
||||
className="animate-fade-in-up mt-2 text-xs tracking-wide text-neutral-500 sm:text-sm"
|
||||
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.4s" }}
|
||||
>
|
||||
{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>
|
||||
|
||||
<button
|
||||
aria-label="اسلاید قبلی"
|
||||
onClick={() => goTo(active - 1)}
|
||||
className="absolute top-1/2 right-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:right-8"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="اسلاید بعدی"
|
||||
onClick={() => goTo(active + 1)}
|
||||
className="absolute top-1/2 left-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:left-8"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-10 flex gap-2 sm:left-10">
|
||||
{slides.map((slide, i) => (
|
||||
{count > 1 ? (
|
||||
<>
|
||||
<button
|
||||
key={slide.src}
|
||||
aria-label={`اسلاید ${i + 1}`}
|
||||
onClick={() => goTo(i)}
|
||||
className={`h-2.5 rounded-full transition-all ${
|
||||
i === active ? "w-7 bg-white" : "w-2.5 bg-white/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,78 @@
|
||||
import Image from "next/image";
|
||||
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({
|
||||
href = "#",
|
||||
image,
|
||||
title,
|
||||
price,
|
||||
}: {
|
||||
image: string;
|
||||
title: string;
|
||||
price?: string;
|
||||
}) {
|
||||
inStock = true,
|
||||
layout = "carousel",
|
||||
}: ProductCardProps) {
|
||||
const widthClass =
|
||||
layout === "grid"
|
||||
? "w-full"
|
||||
: "w-[80vw] shrink-0 snap-start sm:w-[300px]";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="#"
|
||||
className="group w-[80vw] shrink-0 snap-start overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl sm:w-[300px]"
|
||||
href={href}
|
||||
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
|
||||
src={image}
|
||||
alt={title}
|
||||
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"
|
||||
/>
|
||||
{!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 className="p-5 text-center">
|
||||
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
||||
{title}
|
||||
</h3>
|
||||
{price && (
|
||||
{price ? (
|
||||
<p
|
||||
className="mt-3 text-lg font-extrabold text-primary"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{price}
|
||||
</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>
|
||||
</Link>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
const categories = [
|
||||
{ title: "هنرهای دستی", subtitle: "ARTS AND CRAFTS", icon: "/images/cat-crafts.png", href: "#" },
|
||||
{ title: "دکوری و تزئینی", subtitle: "DECORATION", icon: "/images/cat-decoration.png", href: "#" },
|
||||
{ title: "آشپز خانه", subtitle: "KITCHEN", icon: "/images/cat-kitchen.png", href: "#" },
|
||||
{ title: "محصولات چوبی", subtitle: "WOOD PRODUCTS", icon: "/images/cat-wood.png", href: "#" },
|
||||
];
|
||||
type ProductCategoriesProps = {
|
||||
items: SlotMediaItem[];
|
||||
aspectRatio?: string;
|
||||
};
|
||||
|
||||
export default function ProductCategories() {
|
||||
export default function ProductCategories({ items, aspectRatio }: ProductCategoriesProps) {
|
||||
return (
|
||||
<section
|
||||
className="relative bg-cover bg-center py-16"
|
||||
@@ -27,26 +25,59 @@ export default function ProductCategories() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.title}
|
||||
href={cat.href}
|
||||
className="group flex flex-col items-center gap-4 rounded-2xl bg-white/80 p-6 text-center shadow-sm backdrop-blur-sm transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center">
|
||||
<Image src={cat.icon} alt={cat.title} width={64} height={64} className="h-16 w-16 object-contain transition-transform group-hover:scale-110" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-neutral-800">{cat.title}</h3>
|
||||
<p
|
||||
className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{cat.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{items.map((cat) => {
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex h-20 w-20 items-center justify-center overflow-hidden">
|
||||
<SlotMedia
|
||||
src={cat.image}
|
||||
fallbackSrc={cat.fallbackSrc}
|
||||
alt={cat.title || cat.titleEn || ""}
|
||||
aspectRatio={aspectRatio}
|
||||
className="h-16 w-16"
|
||||
imgClassName="absolute inset-0 h-full w-full object-contain transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{cat.title ? <h3 className="font-bold text-neutral-800">{cat.title}</h3> : null}
|
||||
{cat.titleEn ? (
|
||||
<p
|
||||
className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400"
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
import { useAuth, userDisplayName } from "@/lib/auth-context";
|
||||
import { BUSINESS_DASHBOARD_URL, CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
|
||||
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() {
|
||||
const { user, isLoading, isAdmin, loginUrl, logout } = useAuth();
|
||||
const { user, isLoading, isAdmin, loginUrl, logout, getAccessToken } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [handoffBusy, setHandoffBusy] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
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) {
|
||||
return <span className="h-5 w-16 animate-pulse rounded bg-neutral-100" />;
|
||||
}
|
||||
@@ -18,14 +65,12 @@ export default function ProfileMenu() {
|
||||
if (!user) {
|
||||
return (
|
||||
<a
|
||||
href={loginUrl}
|
||||
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
|
||||
href={loginUrl || CUSTOMER_PORTAL_URL}
|
||||
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">
|
||||
<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>
|
||||
ورود
|
||||
<UserIcon />
|
||||
<span className="hidden sm:inline">ورود</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -33,33 +78,51 @@ export default function ProfileMenu() {
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
|
||||
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">
|
||||
<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" />
|
||||
<UserIcon />
|
||||
<span className="hidden sm:inline">{userDisplayName(user)} عزیز</span>
|
||||
<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>
|
||||
{user.firstName} عزیز
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-50 mt-3 w-48 overflow-hidden rounded-xl border border-black/5 bg-white py-2 text-right shadow-2xl">
|
||||
{open ? (
|
||||
<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
|
||||
role="menuitem"
|
||||
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"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
پروفایل من
|
||||
</a>
|
||||
{isAdmin && (
|
||||
{isAdmin ? (
|
||||
<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"
|
||||
onClick={openBusinessDashboard}
|
||||
>
|
||||
مدیریت وبسایت
|
||||
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||
</a>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
logout();
|
||||
setOpen(false);
|
||||
@@ -69,7 +132,7 @@ export default function ProfileMenu() {
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,13 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Product } from "@/lib/meshkee";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import type { StoreVariant } from "@/lib/meshkee";
|
||||
import { storeVariantToCard } from "@/lib/store";
|
||||
|
||||
function formatToman(value?: number | null) {
|
||||
if (value === null || value === undefined) return null;
|
||||
return `${value.toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
|
||||
export default function RemoteProductCard({ product }: { product: Product }) {
|
||||
const href = `/products/${product.id}/${slugify(product.nameFa || product.title)}`;
|
||||
const price = formatToman(product.store?.minPrice);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group w-[80vw] shrink-0 snap-start overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl sm:w-[300px]"
|
||||
>
|
||||
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50">
|
||||
{product.thumbnailUrl ? (
|
||||
<Image
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.nameFa || product.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 80vw, 300px"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-neutral-300">بدون تصویر</div>
|
||||
)}
|
||||
{product.store && !product.store.inStock && (
|
||||
<span className="absolute right-3 top-3 rounded-full bg-neutral-800/80 px-3 py-1 text-xs font-medium text-white">
|
||||
ناموجود
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 text-center">
|
||||
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
||||
{product.nameFa || product.title}
|
||||
</h3>
|
||||
{price && (
|
||||
<p
|
||||
className="mt-3 text-lg font-extrabold text-primary"
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
{price}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
export default function RemoteProductCard({
|
||||
variant,
|
||||
layout = "carousel",
|
||||
}: {
|
||||
variant: StoreVariant;
|
||||
layout?: "carousel" | "grid";
|
||||
}) {
|
||||
return <ProductCard {...storeVariantToCard(variant)} layout={layout} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,11 +7,21 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { meshkee, type AuthUser, CUSTOMER_PORTAL_URL } from "./meshkee";
|
||||
|
||||
const ACCESS_TOKEN_KEY = "meshkee_access_token";
|
||||
const REFRESH_TOKEN_KEY = "meshkee_refresh_token";
|
||||
import {
|
||||
CUSTOMER_PORTAL_URL,
|
||||
meshkee,
|
||||
type AuthUser,
|
||||
} from "./meshkee";
|
||||
import {
|
||||
clearSharedAuthCookies,
|
||||
readSharedAccessToken,
|
||||
readSharedRefreshToken,
|
||||
SHARED_ACCESS_TOKEN_KEY,
|
||||
SHARED_REFRESH_TOKEN_KEY,
|
||||
writeSharedAuthCookies,
|
||||
} from "./shared-auth-cookie";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
@@ -19,81 +29,175 @@ type AuthContextValue = {
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
loginUrl: string;
|
||||
businessId: string;
|
||||
setSession: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
getAccessToken: () => string | null;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function getLoginUrl() {
|
||||
if (typeof window === "undefined") return CUSTOMER_PORTAL_URL;
|
||||
const returnUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(
|
||||
window.location.pathname
|
||||
)}`;
|
||||
return `${CUSTOMER_PORTAL_URL}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
function readLegacyToken(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadUser = useCallback(async (token: string) => {
|
||||
try {
|
||||
const { user } = await meshkee.me(token);
|
||||
setUser(user);
|
||||
setAccessToken(token);
|
||||
} catch {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const res = await meshkee.refresh(refreshToken);
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, res.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, res.refreshToken);
|
||||
setUser(res.user);
|
||||
setAccessToken(res.accessToken);
|
||||
return;
|
||||
} catch {
|
||||
// fall through to logout
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}
|
||||
const getAccessToken = useCallback(() => {
|
||||
return (
|
||||
readSharedAccessToken() ||
|
||||
readLegacyToken(SHARED_ACCESS_TOKEN_KEY) ||
|
||||
readLegacyToken("meshkee_access_token")
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (token) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time session bootstrap on mount
|
||||
loadUser(token).finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadUser]);
|
||||
|
||||
const setSession = useCallback(
|
||||
async (newAccessToken: string, newRefreshToken: string) => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
setIsLoading(true);
|
||||
await loadUser(newAccessToken);
|
||||
setIsLoading(false);
|
||||
},
|
||||
[loadUser]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
const clearSession = useCallback(() => {
|
||||
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, null);
|
||||
writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, null);
|
||||
writeLegacyToken("meshkee_access_token", null);
|
||||
writeLegacyToken("meshkee_refresh_token", null);
|
||||
clearSharedAuthCookies();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}, []);
|
||||
|
||||
const isAdmin =
|
||||
!!user && (user.role === "admin" || user.role === "owner" || user.isAdmin === true);
|
||||
const loadUser = useCallback(
|
||||
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(
|
||||
() => ({
|
||||
@@ -102,10 +206,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
isLoading,
|
||||
isAdmin,
|
||||
loginUrl: getLoginUrl(),
|
||||
businessId,
|
||||
setSession,
|
||||
logout,
|
||||
getAccessToken,
|
||||
}),
|
||||
[user, accessToken, isLoading, isAdmin, setSession, logout]
|
||||
[user, accessToken, isLoading, isAdmin, businessId, setSession, logout, getAccessToken],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
+88
-63
@@ -7,92 +7,117 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { meshkee, type Cart } from "./meshkee";
|
||||
import { useAuth } from "./auth-context";
|
||||
import {
|
||||
getContinueCheckoutUrl,
|
||||
readGuestCart,
|
||||
writeGuestCart,
|
||||
type GuestCartItem,
|
||||
} from "./guest-cart";
|
||||
|
||||
type CartContextValue = {
|
||||
cart: Cart | null;
|
||||
items: GuestCartItem[];
|
||||
itemCount: number;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
|
||||
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
addItem: (item: Omit<GuestCartItem, "quantity"> & { quantity?: number }) => void;
|
||||
updateItem: (id: string, quantity: number) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clear: () => void;
|
||||
continueUrl: string;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({
|
||||
businessId,
|
||||
children,
|
||||
}: {
|
||||
businessId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { accessToken } = useAuth();
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
setCart(null);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { cart } = await meshkee.getCart(businessId, accessToken);
|
||||
setCart(cart);
|
||||
} catch {
|
||||
setCart(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [accessToken, businessId]);
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<GuestCartItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- refetch cart when auth state changes
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
setItems(readGuestCart());
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((next: GuestCartItem[]) => {
|
||||
setItems(next);
|
||||
writeGuestCart(next);
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(
|
||||
async (storeItemVariantId: string, quantity = 1) => {
|
||||
if (!accessToken) return;
|
||||
const { cart } = await meshkee.addCartItem(businessId, accessToken, {
|
||||
storeItemVariantId,
|
||||
quantity,
|
||||
(item: Omit<GuestCartItem, "quantity"> & { quantity?: number }) => {
|
||||
const quantity = Math.max(1, item.quantity ?? 1);
|
||||
setItems((prev) => {
|
||||
const existing = prev.find((line) => line.id === item.id);
|
||||
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(
|
||||
async (itemId: string, quantity: number) => {
|
||||
if (!accessToken) return;
|
||||
const { cart } = await meshkee.updateCartItem(businessId, accessToken, itemId, quantity);
|
||||
setCart(cart);
|
||||
},
|
||||
[accessToken, businessId]
|
||||
);
|
||||
const updateItem = useCallback((id: string, quantity: number) => {
|
||||
setItems((prev) => {
|
||||
const next =
|
||||
quantity <= 0
|
||||
? prev.filter((line) => line.id !== id)
|
||||
: prev.map((line) => (line.id === id ? { ...line, quantity } : line));
|
||||
writeGuestCart(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!accessToken) return;
|
||||
const { cart } = await meshkee.removeCartItem(businessId, accessToken, itemId);
|
||||
setCart(cart);
|
||||
},
|
||||
[accessToken, businessId]
|
||||
);
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((prev) => {
|
||||
const next = prev.filter((line) => line.id !== id);
|
||||
writeGuestCart(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => persist([]), [persist]);
|
||||
|
||||
const itemCount = useMemo(
|
||||
() => cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0,
|
||||
[cart]
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const total = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const continueUrl = useMemo(() => getContinueCheckoutUrl(items), [items]);
|
||||
|
||||
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>;
|
||||
|
||||
@@ -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)) ?? [],
|
||||
}));
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,8 @@
|
||||
export const API_BASE = "https://api.meshkee.com/api/v1";
|
||||
export const WEBSITE_DOMAIN = "dcorya.com";
|
||||
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
|
||||
export const BUSINESS_DASHBOARD_URL = `https://business.${WEBSITE_DOMAIN}`;
|
||||
export const DEFAULT_FAVICON = "/images/logo.png";
|
||||
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
@@ -148,18 +150,66 @@ export type Comment = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type StoreItemVariant = {
|
||||
export type StoreVariant = {
|
||||
id: string;
|
||||
price: number;
|
||||
stockQuantity: number | null;
|
||||
storeItemId?: string;
|
||||
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;
|
||||
isFestival?: boolean;
|
||||
isUsed?: boolean;
|
||||
sortOrder?: number | null;
|
||||
selections?: { variationId?: string; variationName?: string; optionId?: string; value?: string }[];
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
/** @deprecated Prefer StoreVariant — kept for older call sites */
|
||||
export type StoreItemVariant = StoreVariant;
|
||||
|
||||
export type StoreItem = {
|
||||
id: 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 = {
|
||||
@@ -177,6 +227,14 @@ export type Cart = {
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type AuthBusinessMembership = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOwner: boolean;
|
||||
teamRole: string | null;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
@@ -184,7 +242,10 @@ export type AuthUser = {
|
||||
cellNumber: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
roles?: string[];
|
||||
isAdmin?: boolean;
|
||||
isSuperAdmin?: boolean;
|
||||
businesses?: AuthBusinessMembership[];
|
||||
};
|
||||
|
||||
export type AuthResponse = {
|
||||
@@ -281,6 +342,30 @@ export const meshkee = {
|
||||
{ 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") =>
|
||||
request<{ items: Category[] }>(
|
||||
`/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`,
|
||||
@@ -359,33 +444,6 @@ export const meshkee = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}),
|
||||
|
||||
// --- Cart (requires businessId + bearer token) ---
|
||||
getCart: (businessId: string, token: string) =>
|
||||
request<{ cart: Cart }>(`/businesses/${businessId}/cart`, { token, cache: "no-store" }),
|
||||
|
||||
addCartItem: (
|
||||
businessId: string,
|
||||
token: string,
|
||||
body: { storeItemVariantId: string; quantity?: number }
|
||||
) =>
|
||||
request<{ message: string; cart: Cart }>(`/businesses/${businessId}/cart/items`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
updateCartItem: (businessId: string, token: string, itemId: string, quantity: number) =>
|
||||
request<{ message: string; cart: Cart }>(
|
||||
`/businesses/${businessId}/cart/items/${itemId}`,
|
||||
{ method: "PATCH", token, body: JSON.stringify({ quantity }) }
|
||||
),
|
||||
|
||||
removeCartItem: (businessId: string, token: string, itemId: string) =>
|
||||
request<{ message: string; cart: Cart }>(
|
||||
`/businesses/${businessId}/cart/items/${itemId}`,
|
||||
{ method: "DELETE", token }
|
||||
),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -12,9 +12,9 @@ export default function Providers({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthProvider businessId={businessId}>
|
||||
<ToastProvider>
|
||||
<CartProvider businessId={businessId}>{children}</CartProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user