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",
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "cdn.meshkee.com",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "c804387.parspack.net",
|
hostname: "c804387.parspack.net",
|
||||||
|
|||||||
Generated
-114
@@ -631,9 +631,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -650,9 +647,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -669,9 +663,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -688,9 +679,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -707,9 +695,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -726,9 +711,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -745,9 +727,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -764,9 +743,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -783,9 +759,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -808,9 +781,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -833,9 +803,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -858,9 +825,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -883,9 +847,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -908,9 +869,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -933,9 +891,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -958,9 +913,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1228,9 +1180,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1247,9 +1196,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1266,9 +1212,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1285,9 +1228,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1526,9 +1466,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1546,9 +1483,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1566,9 +1500,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1586,9 +1517,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2139,9 +2067,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2156,9 +2081,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2173,9 +2095,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2190,9 +2109,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2207,9 +2123,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2224,9 +2137,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2241,9 +2151,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2258,9 +2165,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2275,9 +2179,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2292,9 +2193,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4980,9 +4878,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5004,9 +4899,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5028,9 +4920,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5052,9 +4941,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -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 { redirect } from "next/navigation";
|
||||||
import CartPageClient from "./CartPageClient";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "سبد خرید | فروشگاه اینترنتی دکوریا",
|
|
||||||
robots: { index: false, follow: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CartPage() {
|
export default function CartPage() {
|
||||||
return <CartPageClient />;
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee";
|
import { DEFAULT_FAVICON, meshkee, WEBSITE_DOMAIN } from "@/lib/meshkee";
|
||||||
import Providers from "@/lib/providers";
|
import Providers from "@/lib/providers";
|
||||||
|
|
||||||
const iranyekan = localFont({
|
const iranyekan = localFont({
|
||||||
@@ -26,7 +26,7 @@ const montserrat = localFont({
|
|||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const favicon = await meshkee.getFavicon();
|
const favicon = await meshkee.getFavicon();
|
||||||
const iconUrl = favicon?.faviconUrl || favicon?.logoUrl || "/images/logo.png";
|
const iconUrl = favicon?.faviconUrl || favicon?.logoUrl || DEFAULT_FAVICON;
|
||||||
const title = "فروشگاه اینترنتی دکوریا";
|
const title = "فروشگاه اینترنتی دکوریا";
|
||||||
const description =
|
const description =
|
||||||
"زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را با دکوریا تجربه کنید.";
|
"زیباترین دکوراتیو چوبی، کریستال، برنز و صنایع دستی نفیس. لذت خرید اینترنتی را با دکوریا تجربه کنید.";
|
||||||
@@ -35,14 +35,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
metadataBase: new URL(`https://${WEBSITE_DOMAIN}`),
|
metadataBase: new URL(`https://${WEBSITE_DOMAIN}`),
|
||||||
title: { default: title, template: `%s` },
|
title: { default: title, template: `%s` },
|
||||||
description,
|
description,
|
||||||
icons: [{ url: iconUrl }],
|
icons: [{ url: iconUrl, type: "image/png" }],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
siteName: title,
|
siteName: title,
|
||||||
locale: "fa_IR",
|
locale: "fa_IR",
|
||||||
type: "website",
|
type: "website",
|
||||||
images: [{ url: favicon?.logoUrl || "/images/logo.png" }],
|
images: [{ url: favicon?.logoUrl || DEFAULT_FAVICON }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Cache-Control": "public, max-age=60",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
|
||||||
|
}
|
||||||
+126
-10
@@ -1,26 +1,142 @@
|
|||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Hero from "@/components/Hero";
|
import Hero from "@/components/Hero";
|
||||||
import ProductCategories from "@/components/ProductCategories";
|
import ProductCategories from "@/components/ProductCategories";
|
||||||
import LatestProducts from "@/components/LatestProducts";
|
import ProductCarousel from "@/components/ProductCarousel";
|
||||||
import BannerRow from "@/components/BannerRow";
|
import BannerRow from "@/components/BannerRow";
|
||||||
import ConsoleCategories from "@/components/ConsoleCategories";
|
import ConsoleCategories from "@/components/ConsoleCategories";
|
||||||
import AboutDcorya from "@/components/AboutDcorya";
|
import AboutDcorya from "@/components/AboutDcorya";
|
||||||
import DecorativeProducts from "@/components/DecorativeProducts";
|
|
||||||
import ContactTeaser from "@/components/ContactTeaser";
|
import ContactTeaser from "@/components/ContactTeaser";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
|
import {
|
||||||
|
fetchStaticImageSlots,
|
||||||
|
overlaySlotImages,
|
||||||
|
pickSlot,
|
||||||
|
type SlotMediaItem,
|
||||||
|
} from "@/lib/static-images";
|
||||||
|
import { fetchStoreSpecials, resolveSpecialCarousel } from "@/lib/specials";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
const sliderFallbacks: SlotMediaItem[] = [
|
||||||
|
{ id: "slide-1", image: "/images/slide-1.jpg" },
|
||||||
|
{ id: "slide-2", image: "/images/slide-2.jpg" },
|
||||||
|
{ id: "slide-3", image: "/images/slide-3.jpg" },
|
||||||
|
{ id: "slide-4", image: "/images/slide-4.jpg" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryFallbacks: SlotMediaItem[] = [
|
||||||
|
{
|
||||||
|
id: "cat-crafts",
|
||||||
|
image: "/images/cat-crafts.png",
|
||||||
|
href: "#",
|
||||||
|
title: "هنرهای دستی",
|
||||||
|
titleEn: "ARTS AND CRAFTS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat-decoration",
|
||||||
|
image: "/images/cat-decoration.png",
|
||||||
|
href: "#",
|
||||||
|
title: "دکوری و تزئینی",
|
||||||
|
titleEn: "DECORATION",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat-kitchen",
|
||||||
|
image: "/images/cat-kitchen.png",
|
||||||
|
href: "#",
|
||||||
|
title: "آشپز خانه",
|
||||||
|
titleEn: "KITCHEN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat-wood",
|
||||||
|
image: "/images/cat-wood.png",
|
||||||
|
href: "#",
|
||||||
|
title: "محصولات چوبی",
|
||||||
|
titleEn: "WOOD PRODUCTS",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bannerFallbacks: SlotMediaItem[] = [
|
||||||
|
{ id: "banner-1", image: "/images/banner-1.png", href: "#" },
|
||||||
|
{
|
||||||
|
id: "banner-2",
|
||||||
|
image: "/images/console-cat-4.jpg",
|
||||||
|
href: "#",
|
||||||
|
title: "کلکسیون میز کنسول",
|
||||||
|
titleEn: "NEW COLLECTION",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondCategoryFallbacks: SlotMediaItem[] = [
|
||||||
|
{ id: "console-1", image: "/images/console-cat-1.png", href: "#", title: "سه تیکه" },
|
||||||
|
{ id: "console-2", image: "/images/console-cat-2.png", href: "#", title: "انواع میز پذیرایی" },
|
||||||
|
{ id: "console-3", image: "/images/console-cat-3.png", href: "#", title: "قلمکاری روی مس" },
|
||||||
|
{ id: "console-4", image: "/images/console-cat-4.jpg", href: "#", title: "انواع شمعــــدان" },
|
||||||
|
{ id: "console-5", image: "/images/console-cat-5.png", href: "#", title: "میز کنسول" },
|
||||||
|
{ id: "console-6", image: "/images/console-cat-6.png", href: "#", title: "میز تی وی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aboutFallback: SlotMediaItem[] = [
|
||||||
|
{ id: "about", image: "/images/product-2.jpg", title: "محصولات چوبی دکوریا" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const [slots, specials] = await Promise.all([
|
||||||
|
fetchStaticImageSlots("home"),
|
||||||
|
fetchStoreSpecials(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [newCollection, specialSale, handmades] = await Promise.all([
|
||||||
|
resolveSpecialCarousel("new-collection", specials),
|
||||||
|
resolveSpecialCarousel("special-sale", specials),
|
||||||
|
resolveSpecialCarousel("our-handmades", specials),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const slider = overlaySlotImages(pickSlot(slots, "slider"), sliderFallbacks, "slider");
|
||||||
|
const categories = overlaySlotImages(pickSlot(slots, "categories"), categoryFallbacks, "categories");
|
||||||
|
const banners = overlaySlotImages(
|
||||||
|
pickSlot(slots, "two-banner-below-slider"),
|
||||||
|
bannerFallbacks,
|
||||||
|
"two-banner-below-slider",
|
||||||
|
);
|
||||||
|
const secondCategories = overlaySlotImages(
|
||||||
|
pickSlot(slots, "second-categories"),
|
||||||
|
secondCategoryFallbacks,
|
||||||
|
"second-categories",
|
||||||
|
);
|
||||||
|
const about = overlaySlotImages(
|
||||||
|
pickSlot(slots, "about-us-image"),
|
||||||
|
aboutFallback,
|
||||||
|
"about-us-image",
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero slides={slider.items} aspectRatio={slider.aspectRatio} />
|
||||||
<ProductCategories />
|
<ProductCategories items={categories.items} aspectRatio={categories.aspectRatio} />
|
||||||
<LatestProducts />
|
<ProductCarousel
|
||||||
<BannerRow />
|
id={newCollection.id}
|
||||||
<ConsoleCategories />
|
eyebrow={newCollection.eyebrow}
|
||||||
<AboutDcorya />
|
title={newCollection.title}
|
||||||
<DecorativeProducts />
|
products={newCollection.products}
|
||||||
|
/>
|
||||||
|
<BannerRow items={banners.items} aspectRatio={banners.aspectRatio} />
|
||||||
|
<ConsoleCategories items={secondCategories.items} aspectRatio={secondCategories.aspectRatio} />
|
||||||
|
<ProductCarousel
|
||||||
|
id={specialSale.id}
|
||||||
|
eyebrow={specialSale.eyebrow}
|
||||||
|
title={specialSale.title}
|
||||||
|
products={specialSale.products}
|
||||||
|
/>
|
||||||
|
<AboutDcorya image={about.items[0]} aspectRatio={about.aspectRatio} />
|
||||||
|
<ProductCarousel
|
||||||
|
id={handmades.id}
|
||||||
|
eyebrow={handmades.eyebrow}
|
||||||
|
title={handmades.title}
|
||||||
|
products={handmades.products}
|
||||||
|
muted={handmades.muted}
|
||||||
|
/>
|
||||||
<ContactTeaser />
|
<ContactTeaser />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -46,12 +46,18 @@ export default async function ProductDetailPage({
|
|||||||
const data = await meshkee.getProductById(id).catch(() => null);
|
const data = await meshkee.getProductById(id).catch(() => null);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
const { product, relatedProducts } = data;
|
const { product } = data;
|
||||||
|
|
||||||
const [technicalInfo, commentsRes, storeItemRes] = await Promise.all([
|
const [technicalInfo, commentsRes, storeItemRes, relatedStore] = await Promise.all([
|
||||||
meshkee.getProductTechnicalInfo(product.slug).catch(() => null),
|
meshkee.getProductTechnicalInfo(product.slug).catch(() => null),
|
||||||
meshkee.getComments("product", product.id).catch(() => ({ items: [] })),
|
meshkee.getComments("product", product.id).catch(() => ({ items: [] })),
|
||||||
meshkee.getStoreItemByProduct(product.id).catch(() => ({ storeItem: null })),
|
meshkee.getStoreItemByProduct(product.id).catch(() => ({ storeItem: null })),
|
||||||
|
meshkee
|
||||||
|
.getStoreItems({
|
||||||
|
pageSize: 8,
|
||||||
|
categoryId: product.categoryId || undefined,
|
||||||
|
})
|
||||||
|
.catch(() => ({ items: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const galleryImages = [
|
const galleryImages = [
|
||||||
@@ -59,7 +65,10 @@ export default async function ProductDetailPage({
|
|||||||
...product.images.map((img) => img.url),
|
...product.images.map((img) => img.url),
|
||||||
].filter((url, i, arr) => arr.indexOf(url) === i);
|
].filter((url, i, arr) => arr.indexOf(url) === i);
|
||||||
|
|
||||||
const variant = storeItemRes.storeItem?.variants?.[0] ?? null;
|
const variants = storeItemRes.storeItem?.variants ?? [];
|
||||||
|
const related = relatedStore.items.filter(
|
||||||
|
(item) => String(item.productId) !== String(product.id),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -94,7 +103,7 @@ export default async function ProductDetailPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<AddToCartBox variant={variant} />
|
<AddToCartBox variants={variants} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,12 +118,12 @@ export default async function ProductDetailPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{relatedProducts.length > 0 && (
|
{related.length > 0 && (
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<h2 className="mb-8 text-xl font-bold text-neutral-800">محصولات مرتبط</h2>
|
<h2 className="mb-8 text-xl font-bold text-neutral-800">محصولات مرتبط</h2>
|
||||||
<Carousel>
|
<Carousel>
|
||||||
{relatedProducts.map((p) => (
|
{related.map((variant) => (
|
||||||
<RemoteProductCard key={p.id} product={p} />
|
<RemoteProductCard key={variant.id} variant={variant} />
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default async function CategoryPage({
|
|||||||
const { category } = categoryData;
|
const { category } = categoryData;
|
||||||
const title = category.nameFa || category.name;
|
const title = category.nameFa || category.name;
|
||||||
|
|
||||||
const productsRes = await meshkee.getProducts({ page, pageSize, categoryId: id });
|
const storeRes = await meshkee.getStoreItems({ page, pageSize, categoryId: id });
|
||||||
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize));
|
const totalPages = Math.max(1, Math.ceil(storeRes.total / storeRes.pageSize));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -63,15 +63,13 @@ export default async function CategoryPage({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
<section className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
||||||
{productsRes.items.length === 0 ? (
|
{storeRes.items.length === 0 ? (
|
||||||
<p className="py-20 text-center text-neutral-500">محصولی در این دستهبندی یافت نشد.</p>
|
<p className="py-20 text-center text-neutral-500">محصولی در این دستهبندی یافت نشد.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{productsRes.items.map((product) => (
|
{storeRes.items.map((variant) => (
|
||||||
<div key={product.id} className="[&>a]:w-full">
|
<RemoteProductCard key={variant.id} variant={variant} layout="grid" />
|
||||||
<RemoteProductCard product={product} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ export default async function ProductsPage({
|
|||||||
const page = Number(params.page) || 1;
|
const page = Number(params.page) || 1;
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
|
|
||||||
const [productsRes, categoriesRes] = await Promise.all([
|
const [storeRes, categoriesRes] = await Promise.all([
|
||||||
meshkee.getProducts({ page, pageSize, categoryId: params.categoryId, name: params.q }),
|
meshkee.getStoreItems({ page, pageSize, categoryId: params.categoryId, name: params.q }),
|
||||||
meshkee.getCategories("product").catch(() => ({ items: [] })),
|
meshkee.getCategories("product").catch(() => ({ items: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const topCategories = categoriesRes.items.filter((c) => !c.parentId).slice(0, 10);
|
const topCategories = categoriesRes.items.filter((c) => !c.parentId).slice(0, 10);
|
||||||
const totalPages = Math.max(1, Math.ceil(productsRes.total / productsRes.pageSize));
|
const totalPages = Math.max(1, Math.ceil(storeRes.total / storeRes.pageSize));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -60,13 +60,13 @@ export default async function ProductsPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{productsRes.items.length === 0 ? (
|
{storeRes.items.length === 0 ? (
|
||||||
<p className="py-20 text-center text-neutral-500">محصولی یافت نشد.</p>
|
<p className="py-20 text-center text-neutral-500">محصولی یافت نشد.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{productsRes.items.map((product) => (
|
{storeRes.items.map((variant) => (
|
||||||
<RemoteProductCardWrapper key={product.id} product={product} />
|
<RemoteProductCard key={variant.id} variant={variant} layout="grid" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export default async function ProductsPage({
|
|||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p}
|
key={p}
|
||||||
href={`/products?page=${p}${params.categoryId ? `&categoryId=${params.categoryId}` : ""}`}
|
href={`/products?page=${p}${params.categoryId ? `&categoryId=${params.categoryId}` : ""}${params.q ? `&q=${encodeURIComponent(params.q)}` : ""}`}
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
||||||
p === page
|
p === page
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-white"
|
||||||
@@ -95,11 +95,3 @@ export default async function ProductsPage({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RemoteProductCardWrapper({ product }: { product: Parameters<typeof RemoteProductCard>[0]["product"] }) {
|
|
||||||
return (
|
|
||||||
<div className="[&>a]:w-full">
|
|
||||||
<RemoteProductCard product={product} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
|
import type { SlotMediaItem } from "@/lib/static-images";
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ value: "۹۹٪", label: "کیفیت محصول" },
|
{ value: "۹۹٪", label: "کیفیت محصول" },
|
||||||
@@ -7,22 +8,38 @@ const stats = [
|
|||||||
{ value: "۹۰٪", label: "رضایت مشتری" },
|
{ value: "۹۰٪", label: "رضایت مشتری" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AboutDcorya() {
|
type AboutDcoryaProps = {
|
||||||
|
image?: SlotMediaItem;
|
||||||
|
aspectRatio?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboutDcorya({ image, aspectRatio }: AboutDcoryaProps) {
|
||||||
|
const src = image?.image ?? "/images/product-2.jpg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
||||||
<div className="relative order-2 aspect-4/3 overflow-hidden rounded-2xl lg:order-1">
|
<div
|
||||||
<Image
|
className={`relative order-2 overflow-hidden rounded-2xl lg:order-1 ${
|
||||||
src="/images/product-2.jpg"
|
aspectRatio ? "" : "aspect-4/3"
|
||||||
alt="محصولات چوبی دکوریا"
|
}`}
|
||||||
fill
|
>
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
<SlotMedia
|
||||||
className="object-cover"
|
src={src}
|
||||||
|
href={image?.href}
|
||||||
|
fallbackSrc={image?.fallbackSrc ?? "/images/product-2.jpg"}
|
||||||
|
alt={image?.title || "محصولات چوبی دکوریا"}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
className="block w-full"
|
||||||
|
imgClassName="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="order-1 lg:order-2">
|
<div className="order-1 lg:order-2">
|
||||||
<p className="text-sm font-semibold tracking-widest text-primary" style={{ fontFamily: "var(--font-montserrat)" }}>
|
<p
|
||||||
|
className="text-sm font-semibold tracking-widest text-primary"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
ABOUT DCORYA
|
ABOUT DCORYA
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">فروشگاه آنلاین دکوریا</h2>
|
<h2 className="mt-2 text-2xl font-bold sm:text-3xl">فروشگاه آنلاین دکوریا</h2>
|
||||||
@@ -44,7 +61,10 @@ export default function AboutDcorya() {
|
|||||||
<div className="mt-10 grid grid-cols-3 gap-4 border-t border-black/5 pt-8">
|
<div className="mt-10 grid grid-cols-3 gap-4 border-t border-black/5 pt-8">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="text-center">
|
<div key={stat.label} className="text-center">
|
||||||
<p className="text-2xl font-bold text-primary sm:text-3xl" style={{ fontFamily: "var(--font-montserrat)" }}>
|
<p
|
||||||
|
className="text-2xl font-bold text-primary sm:text-3xl"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs font-medium text-neutral-500 sm:text-sm">{stat.label}</p>
|
<p className="mt-1 text-xs font-medium text-neutral-500 sm:text-sm">{stat.label}</p>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { useCart } from "@/lib/cart-context";
|
||||||
import { useToast } from "@/lib/toast-context";
|
import { useToast } from "@/lib/toast-context";
|
||||||
import type { StoreItemVariant } from "@/lib/meshkee";
|
import type { StoreVariant } from "@/lib/meshkee";
|
||||||
|
import { formatToman, variantInStock, variantSellingPrice, variantTitle } from "@/lib/store";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
|
||||||
function formatToman(value?: number | null) {
|
export default function AddToCartBox({ variants }: { variants: StoreVariant[] }) {
|
||||||
if (value === null || value === undefined) return null;
|
|
||||||
return `${value.toLocaleString("fa-IR")} تومان`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddToCartBox({ variant }: { variant: StoreItemVariant | null }) {
|
|
||||||
const { user, loginUrl } = useAuth();
|
|
||||||
const { addItem } = useCart();
|
const { addItem } = useCart();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const inStock = variant ? variant.unlimited || (variant.stockQuantity ?? 0) > 0 : false;
|
const sellable = useMemo(() => variants.filter(variantInStock), [variants]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!variant) {
|
const active =
|
||||||
|
sellable.find((variant) => variant.id === selectedId) ||
|
||||||
|
(sellable.length === 1 ? sellable[0] : null);
|
||||||
|
|
||||||
|
if (variants.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center text-sm text-neutral-500">
|
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center text-sm text-neutral-500">
|
||||||
این محصول در حال حاضر برای خرید آنلاین موجود نیست. برای اطلاع از قیمت و موجودی با ما تماس
|
این محصول در حال حاضر برای خرید آنلاین موجود نیست. برای اطلاع از قیمت و موجودی با ما تماس
|
||||||
@@ -29,45 +29,92 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inStock) {
|
if (sellable.length === 0) {
|
||||||
|
const first = variants[0];
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center">
|
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5 text-center">
|
||||||
<p className="mb-1 text-lg font-extrabold text-neutral-400" style={{ fontFamily: "var(--font-montserrat)" }}>
|
<p
|
||||||
{formatToman(variant.price)}
|
className="mb-1 text-lg font-extrabold text-neutral-400"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{formatToman(variantSellingPrice(first)) || "تماس بگیرید"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium text-neutral-500">ناموجود</p>
|
<p className="text-sm font-medium text-neutral-500">ناموجود</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = () => {
|
||||||
if (!user) {
|
if (!active) {
|
||||||
window.location.href = loginUrl;
|
showToast("لطفاً یک گزینه را انتخاب کنید", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const price = variantSellingPrice(active);
|
||||||
|
if (price == null) {
|
||||||
|
showToast("قیمت این محصول مشخص نیست. لطفاً تماس بگیرید", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await addItem(variant.id, quantity);
|
const name = variantTitle(active);
|
||||||
|
addItem({
|
||||||
|
id: String(active.id),
|
||||||
|
name,
|
||||||
|
slug: slugify(name),
|
||||||
|
price,
|
||||||
|
originalPrice:
|
||||||
|
active.discountedPrice != null && active.price != null ? active.price : undefined,
|
||||||
|
image: active.productImage || undefined,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
showToast("محصول به سبد خرید اضافه شد");
|
showToast("محصول به سبد خرید اضافه شد");
|
||||||
} catch {
|
|
||||||
showToast("افزودن به سبد خرید با خطا مواجه شد", "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const displayPrice = active ? variantSellingPrice(active) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5">
|
<div className="rounded-xl border border-black/5 bg-neutral-50 p-5">
|
||||||
<p
|
{displayPrice != null ? (
|
||||||
className="mb-4 text-2xl font-extrabold text-primary"
|
<p
|
||||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
className="mb-4 text-2xl font-extrabold text-primary"
|
||||||
>
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
{formatToman(variant.price)}
|
>
|
||||||
</p>
|
{formatToman(displayPrice)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mb-4 text-lg font-bold text-primary">تماس بگیرید</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sellable.length > 1 ? (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-600">انتخاب گزینه</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{sellable.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(variant.id)}
|
||||||
|
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
active?.id === variant.id
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-black/10 bg-white text-neutral-700 hover:border-primary hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{variant.label || variantTitle(variant)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-black/10 bg-white px-2 py-1.5">
|
<div className="flex items-center gap-3 rounded-lg border border-black/10 bg-white px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
||||||
>
|
>
|
||||||
@@ -75,6 +122,7 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
|
|||||||
</button>
|
</button>
|
||||||
<span className="w-6 text-center text-sm font-medium">{quantity}</span>
|
<span className="w-6 text-center text-sm font-medium">{quantity}</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setQuantity((q) => q + 1)}
|
onClick={() => setQuantity((q) => q + 1)}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-600 hover:text-primary"
|
||||||
>
|
>
|
||||||
@@ -83,8 +131,9 @@ export default function AddToCartBox({ variant }: { variant: StoreItemVariant |
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || (sellable.length > 1 && !active)}
|
||||||
className="flex-1 rounded-lg bg-primary py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark disabled:opacity-60"
|
className="flex-1 rounded-lg bg-primary py-3 text-sm font-bold text-white transition-colors hover:bg-primary-dark disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isSubmitting ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
{isSubmitting ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||||
|
|||||||
@@ -1,43 +1,66 @@
|
|||||||
import Image from "next/image";
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
import Link from "next/link";
|
import type { SlotMediaItem } from "@/lib/static-images";
|
||||||
|
|
||||||
export default function BannerRow() {
|
type BannerRowProps = {
|
||||||
|
items: SlotMediaItem[];
|
||||||
|
aspectRatio?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BannerRow({ items, aspectRatio }: BannerRowProps) {
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-4 sm:px-6 md:grid-cols-2 lg:px-8">
|
<section className="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-4 sm:px-6 md:grid-cols-2 lg:px-8">
|
||||||
<Link
|
{items.map((banner) => {
|
||||||
href="#"
|
const hasOverlay = Boolean(banner.title || banner.titleEn || banner.text);
|
||||||
className="group relative block overflow-hidden rounded-2xl bg-white shadow-sm"
|
const media = (
|
||||||
>
|
<div
|
||||||
<Image
|
className={`group relative block overflow-hidden rounded-2xl shadow-sm ${
|
||||||
src="/images/banner-1.png"
|
hasOverlay ? "" : "bg-white"
|
||||||
alt="فروش ویژه دکوریا"
|
}`}
|
||||||
width={900}
|
>
|
||||||
height={484}
|
<SlotMedia
|
||||||
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72"
|
src={banner.image}
|
||||||
/>
|
href={undefined}
|
||||||
</Link>
|
fallbackSrc={banner.fallbackSrc}
|
||||||
|
alt={banner.title || banner.titleEn || ""}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
className={!aspectRatio ? "block h-56 w-full sm:h-72" : "block w-full"}
|
||||||
|
imgClassName="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
{hasOverlay ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/40 text-center text-white">
|
||||||
|
{banner.titleEn ? (
|
||||||
|
<p className="text-xs tracking-[0.3em]" style={{ fontFamily: "var(--font-montserrat)" }}>
|
||||||
|
{banner.titleEn}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{banner.title ? <h3 className="text-2xl font-bold">{banner.title}</h3> : null}
|
||||||
|
{banner.text ? <p className="text-sm">{banner.text}</p> : null}
|
||||||
|
<span className="mt-2 rounded-full border border-white/70 px-5 py-2 text-sm font-medium transition-colors group-hover:bg-white group-hover:text-neutral-900">
|
||||||
|
مشاهده محصولات
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<Link
|
if (!banner.href) {
|
||||||
href="#"
|
return <div key={banner.id}>{media}</div>;
|
||||||
className="group relative block overflow-hidden rounded-2xl shadow-sm"
|
}
|
||||||
>
|
|
||||||
<Image
|
if (banner.href.startsWith("/") || banner.href.startsWith("#")) {
|
||||||
src="/images/console-cat-4.jpg"
|
return (
|
||||||
alt="کلکسیون میز کنسول"
|
<a key={banner.id} href={banner.href} className="block">
|
||||||
width={900}
|
{media}
|
||||||
height={484}
|
</a>
|
||||||
className="h-56 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-72"
|
);
|
||||||
/>
|
}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/40 text-center text-white">
|
|
||||||
<p className="text-xs tracking-[0.3em]" style={{ fontFamily: "var(--font-montserrat)" }}>
|
return (
|
||||||
NEW COLLECTION
|
<a key={banner.id} href={banner.href} className="block" rel="noopener noreferrer">
|
||||||
</p>
|
{media}
|
||||||
<h3 className="text-2xl font-bold">کلکسیون میز کنسول</h3>
|
</a>
|
||||||
<span className="mt-2 rounded-full border border-white/70 px-5 py-2 text-sm font-medium transition-colors group-hover:bg-white group-hover:text-neutral-900">
|
);
|
||||||
مشاهده محصولات
|
})}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { useCart } from "@/lib/cart-context";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
|
||||||
import { useClickOutside } from "@/lib/use-click-outside";
|
import { useClickOutside } from "@/lib/use-click-outside";
|
||||||
|
|
||||||
function formatToman(value?: number) {
|
function formatToman(value?: number) {
|
||||||
@@ -13,17 +11,15 @@ function formatToman(value?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CartPopover() {
|
export default function CartPopover() {
|
||||||
const { user, loginUrl } = useAuth();
|
const { items, itemCount, total, updateItem, removeItem, continueUrl } = useCart();
|
||||||
const { cart, itemCount } = useCart();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useClickOutside(ref, () => setOpen(false));
|
useClickOutside(ref, () => setOpen(false));
|
||||||
|
|
||||||
const items = cart?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
aria-label="سبد خرید"
|
aria-label="سبد خرید"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="relative text-neutral-600 transition-colors hover:text-primary"
|
className="relative text-neutral-600 transition-colors hover:text-primary"
|
||||||
@@ -39,28 +35,16 @@ export default function CartPopover() {
|
|||||||
<circle cx="9.5" cy="20.5" r="1.3" fill="currentColor" />
|
<circle cx="9.5" cy="20.5" r="1.3" fill="currentColor" />
|
||||||
<circle cx="17.5" cy="20.5" r="1.3" fill="currentColor" />
|
<circle cx="17.5" cy="20.5" r="1.3" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 ? (
|
||||||
<span className="absolute -top-2 -left-2 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white">
|
<span className="absolute -top-2 -left-2 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-white">
|
||||||
{itemCount}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open ? (
|
||||||
<div className="absolute left-0 top-full z-50 mt-3 w-80 rounded-2xl border border-black/5 bg-white p-4 text-right shadow-2xl">
|
<div className="absolute left-0 top-full z-50 mt-3 w-80 rounded-2xl border border-black/5 bg-white p-4 text-right shadow-2xl">
|
||||||
{!user ? (
|
{items.length === 0 ? (
|
||||||
<div className="py-4 text-center">
|
|
||||||
<p className="mb-4 text-sm text-neutral-500">
|
|
||||||
برای مشاهده سبد خرید ابتدا وارد حساب کاربری خود شوید.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={loginUrl}
|
|
||||||
className="inline-block rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
|
||||||
>
|
|
||||||
ورود به حساب کاربری
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-neutral-500">سبد خرید شما خالی است.</p>
|
<p className="py-6 text-center text-sm text-neutral-500">سبد خرید شما خالی است.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -68,35 +52,64 @@ export default function CartPopover() {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className="flex items-center gap-3">
|
<div key={item.id} className="flex items-center gap-3">
|
||||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-neutral-100">
|
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-neutral-100">
|
||||||
{item.image && (
|
{item.image ? (
|
||||||
<Image src={item.image} alt={item.title ?? ""} fill className="object-cover" />
|
<Image src={item.image} alt={item.name} fill className="object-cover" />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-neutral-800">
|
<p className="truncate text-sm font-medium text-neutral-800">{item.name}</p>
|
||||||
{item.title ?? "کالا"}
|
<div className="mt-1 flex items-center gap-2">
|
||||||
</p>
|
<button
|
||||||
<p className="text-xs text-neutral-500">تعداد: {item.quantity}</p>
|
type="button"
|
||||||
|
onClick={() => updateItem(item.id, item.quantity - 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-black/10 text-xs text-neutral-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-5 text-center text-xs">{item.quantity}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateItem(item.id, item.quantity + 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-black/10 text-xs text-neutral-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
{formatToman(item.price) ? (
|
||||||
|
<p className="text-xs font-bold text-primary">{formatToman(item.price)}</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
className="text-[10px] text-neutral-400 hover:text-primary"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{formatToman(item.unitPrice) && (
|
|
||||||
<p className="shrink-0 text-xs font-bold text-primary">
|
|
||||||
{formatToman(item.unitPrice)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href="/cart"
|
{formatToman(total) ? (
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-black/5 pt-3 text-sm">
|
||||||
|
<span className="font-medium text-neutral-600">جمع</span>
|
||||||
|
<span className="font-extrabold text-primary">{formatToman(total)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={continueUrl}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="mt-4 block rounded-lg bg-primary py-2.5 text-center text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
className="mt-4 block rounded-lg bg-primary py-2.5 text-center text-sm font-bold text-white transition-colors hover:bg-primary-dark"
|
||||||
>
|
>
|
||||||
مشاهده سبد خرید و ادامه
|
ادامه خرید
|
||||||
</Link>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import Image from "next/image";
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
import Link from "next/link";
|
import type { SlotMediaItem } from "@/lib/static-images";
|
||||||
|
|
||||||
const items = [
|
type ConsoleCategoriesProps = {
|
||||||
{ title: "سه تیکه", icon: "/images/console-cat-1.png" },
|
items: SlotMediaItem[];
|
||||||
{ title: "انواع میز پذیرایی", icon: "/images/console-cat-2.png" },
|
aspectRatio?: string;
|
||||||
{ title: "قلمکاری روی مس", icon: "/images/console-cat-3.png" },
|
};
|
||||||
{ title: "انواع شمعــــدان", icon: "/images/console-cat-4.jpg" },
|
|
||||||
{ title: "میز کنسول", icon: "/images/console-cat-5.png" },
|
|
||||||
{ title: "میز تی وی", icon: "/images/console-cat-6.png" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ConsoleCategories() {
|
export default function ConsoleCategories({ items, aspectRatio }: ConsoleCategoriesProps) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-neutral-50 py-16">
|
<section className="bg-neutral-50 py-16">
|
||||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||||
@@ -24,24 +20,59 @@ export default function ConsoleCategories() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<Link
|
const content = (
|
||||||
key={item.title}
|
<>
|
||||||
href="#"
|
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50">
|
||||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-black/5 bg-white p-5 text-center shadow-sm transition-shadow hover:shadow-lg"
|
<SlotMedia
|
||||||
>
|
fill
|
||||||
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-neutral-50">
|
src={item.image}
|
||||||
<Image
|
fallbackSrc={item.fallbackSrc}
|
||||||
src={item.icon}
|
alt={item.title || item.titleEn || ""}
|
||||||
alt={item.title}
|
aspectRatio={aspectRatio}
|
||||||
fill
|
imgClassName="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-110"
|
||||||
sizes="80px"
|
/>
|
||||||
className="object-cover transition-transform group-hover:scale-110"
|
</div>
|
||||||
/>
|
{item.title ? (
|
||||||
</div>
|
<h3 className="text-sm font-medium text-neutral-700">{item.title}</h3>
|
||||||
<h3 className="text-sm font-medium text-neutral-700">{item.title}</h3>
|
) : null}
|
||||||
</Link>
|
{item.titleEn ? (
|
||||||
))}
|
<p
|
||||||
|
className="text-[10px] tracking-wide text-neutral-400"
|
||||||
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
|
>
|
||||||
|
{item.titleEn}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{item.text ? <p className="text-xs text-neutral-500">{item.text}</p> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const className =
|
||||||
|
"group flex flex-col items-center gap-3 rounded-2xl border border-black/5 bg-white p-5 text-center shadow-sm transition-shadow hover:shadow-lg";
|
||||||
|
|
||||||
|
if (!item.href) {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className={className}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.href.startsWith("/") || item.href.startsWith("#")) {
|
||||||
|
return (
|
||||||
|
<a key={item.id} href={item.href} className={className}>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a key={item.id} href={item.href} className={className} rel="noopener noreferrer">
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 Link from "next/link";
|
||||||
import CartPopover from "./CartPopover";
|
import CartPopover from "./CartPopover";
|
||||||
import ProfileMenu from "./ProfileMenu";
|
import ProfileMenu from "./ProfileMenu";
|
||||||
|
import ProductsMegaMenu from "./ProductsMegaMenu";
|
||||||
|
import { buildCategoryTree } from "@/lib/categories";
|
||||||
|
import { meshkee } from "@/lib/meshkee";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/", label: "صفحه اصلی" },
|
|
||||||
{ href: "/products", label: "محصولات" },
|
|
||||||
{ href: "/blog", label: "بلاگ" },
|
{ href: "/blog", label: "بلاگ" },
|
||||||
{ href: "/satisfaction", label: "مشتریان راضی" },
|
{ href: "/satisfaction", label: "مشتریان راضی" },
|
||||||
{ href: "/purchaseguide", label: "راهنمای خرید" },
|
{ href: "/purchaseguide", label: "راهنمای خرید" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default async function Header() {
|
||||||
|
const categoriesRes = await meshkee.getCategories("product").catch(() => ({ items: [] }));
|
||||||
|
const categories = buildCategoryTree(categoriesRes.items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 h-16 border-b border-black/10 bg-white shadow-[0_2px_10px_rgba(0,0,0,0.06)]">
|
<header className="relative sticky top-0 z-50 h-16 border-b border-black/10 bg-white shadow-[0_2px_10px_rgba(0,0,0,0.06)]">
|
||||||
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-6 px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-6 px-4 sm:px-6 lg:px-8">
|
||||||
<Link href="/" className="shrink-0">
|
<Link href="/" className="shrink-0">
|
||||||
<Image
|
<Image
|
||||||
@@ -27,6 +31,13 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-7 lg:flex">
|
<nav className="hidden items-center gap-7 lg:flex">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
<ProductsMegaMenu categories={categories} />
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.label}
|
key={link.label}
|
||||||
|
|||||||
+86
-59
@@ -1,53 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
|
import type { SlotMediaItem } from "@/lib/static-images";
|
||||||
|
|
||||||
const slides = [
|
const DEFAULT_TITLE = "ارسال به سراسر ایران و کشورهای همسایه";
|
||||||
{ src: "/images/slide-1.jpg", alt: "کریستال و ظروف دکوراتیو دکوریا" },
|
const DEFAULT_TITLE_EN = "Shipping Across Iran and Neighboring Countries";
|
||||||
{ src: "/images/slide-2.jpg", alt: "فروش اقساطی بدون سود محصولات دکوریا" },
|
|
||||||
{ src: "/images/slide-3.jpg", alt: "قلمکاری مس، هنر اصیل ایرانی دکوریا" },
|
|
||||||
{ src: "/images/slide-4.jpg", alt: "میز کنسول چوبی معرقکاری دکوریا" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Hero() {
|
type HeroProps = {
|
||||||
|
slides: SlotMediaItem[];
|
||||||
|
aspectRatio?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Hero({ slides = [] }: HeroProps) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const safeSlides = slides.length > 0 ? slides : [{ id: "fallback", image: "/images/slide-1.jpg" }];
|
||||||
|
const count = safeSlides.length;
|
||||||
|
|
||||||
const goTo = useCallback((index: number) => {
|
const goTo = useCallback(
|
||||||
setActive((index + slides.length) % slides.length);
|
(index: number) => {
|
||||||
}, []);
|
setActive((index + count) % count);
|
||||||
|
},
|
||||||
|
[count],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (count <= 1) return;
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setActive((prev) => (prev + 1) % slides.length);
|
setActive((prev) => (prev + 1) % count);
|
||||||
}, 5500);
|
}, 5500);
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [count]);
|
||||||
|
|
||||||
|
const current = safeSlides[active];
|
||||||
|
const titleFa = current?.title || DEFAULT_TITLE;
|
||||||
|
const titleEn = current?.titleEn || DEFAULT_TITLE_EN;
|
||||||
|
const subtext = current?.text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[calc(100dvh-4rem)] min-h-[420px] w-full overflow-hidden">
|
<section className="relative h-[calc(100dvh-4rem)] min-h-[420px] w-full overflow-hidden">
|
||||||
{slides.map((slide, i) => (
|
{safeSlides.map((slide, i) => (
|
||||||
<div
|
<div
|
||||||
key={slide.src}
|
key={slide.id}
|
||||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||||
i === active ? "opacity-100" : "opacity-0"
|
i === active ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
<SlotMedia
|
||||||
src={slide.src}
|
|
||||||
alt={slide.alt}
|
|
||||||
fill
|
fill
|
||||||
priority={i === 0}
|
src={slide.image}
|
||||||
sizes="100vw"
|
href={slide.href}
|
||||||
className="object-cover"
|
fallbackSrc={slide.fallbackSrc}
|
||||||
|
alt={slide.title || slide.titleEn || "اسلاید دکوریا"}
|
||||||
|
imgClassName="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-start px-4 pb-20 sm:px-10 sm:pb-24">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-start px-4 pb-20 sm:px-10 sm:pb-24">
|
||||||
<div
|
<div
|
||||||
key={active}
|
key={active}
|
||||||
className="max-w-xs rounded-2xl bg-white/95 px-6 py-6 text-right shadow-2xl backdrop-blur-sm sm:max-w-md sm:px-10 sm:py-8"
|
className="max-w-xs rounded-2xl bg-white/95 px-6 py-6 text-right shadow-2xl backdrop-blur-sm sm:max-w-md sm:px-10 sm:py-8"
|
||||||
@@ -62,48 +75,62 @@ export default function Hero() {
|
|||||||
className="animate-fade-in-up text-lg font-bold leading-relaxed text-neutral-800 sm:text-2xl"
|
className="animate-fade-in-up text-lg font-bold leading-relaxed text-neutral-800 sm:text-2xl"
|
||||||
style={{ animationDelay: "0.25s" }}
|
style={{ animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
ارسال به سراسر ایران و کشورهای همسایه
|
{titleFa}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
{titleEn ? (
|
||||||
className="animate-fade-in-up mt-2 text-xs tracking-wide text-neutral-500 sm:text-sm"
|
<p
|
||||||
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.4s" }}
|
className="animate-fade-in-up mt-2 text-xs tracking-wide text-neutral-500 sm:text-sm"
|
||||||
>
|
style={{ fontFamily: "var(--font-montserrat)", animationDelay: "0.4s" }}
|
||||||
Shipping Across Iran and Neighboring Countries
|
>
|
||||||
</p>
|
{titleEn}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{subtext ? (
|
||||||
|
<p
|
||||||
|
className="animate-fade-in-up mt-2 text-xs text-neutral-500 sm:text-sm"
|
||||||
|
style={{ animationDelay: "0.5s" }}
|
||||||
|
>
|
||||||
|
{subtext}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{count > 1 ? (
|
||||||
aria-label="اسلاید قبلی"
|
<>
|
||||||
onClick={() => goTo(active - 1)}
|
|
||||||
className="absolute top-1/2 right-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:right-8"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
|
||||||
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="اسلاید بعدی"
|
|
||||||
onClick={() => goTo(active + 1)}
|
|
||||||
className="absolute top-1/2 left-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:left-8"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
|
||||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="absolute bottom-6 left-6 z-10 flex gap-2 sm:left-10">
|
|
||||||
{slides.map((slide, i) => (
|
|
||||||
<button
|
<button
|
||||||
key={slide.src}
|
aria-label="اسلاید قبلی"
|
||||||
aria-label={`اسلاید ${i + 1}`}
|
onClick={() => goTo(active - 1)}
|
||||||
onClick={() => goTo(i)}
|
className="absolute top-1/2 right-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:right-8"
|
||||||
className={`h-2.5 rounded-full transition-all ${
|
>
|
||||||
i === active ? "w-7 bg-white" : "w-2.5 bg-white/50"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
}`}
|
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
/>
|
</svg>
|
||||||
))}
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
aria-label="اسلاید بعدی"
|
||||||
|
onClick={() => goTo(active + 1)}
|
||||||
|
className="absolute top-1/2 left-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:left-8"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
||||||
|
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 left-6 z-10 flex gap-2 sm:left-10">
|
||||||
|
{safeSlides.map((slide, i) => (
|
||||||
|
<button
|
||||||
|
key={slide.id}
|
||||||
|
aria-label={`اسلاید ${i + 1}`}
|
||||||
|
onClick={() => goTo(i)}
|
||||||
|
className={`h-2.5 rounded-full transition-all ${
|
||||||
|
i === active ? "w-7 bg-white" : "w-2.5 bg-white/50"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export type ProductCardProps = {
|
||||||
|
href?: string;
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
price?: string | null;
|
||||||
|
inStock?: boolean;
|
||||||
|
/** `carousel` keeps fixed snap widths; `grid` fills the parent cell. */
|
||||||
|
layout?: "carousel" | "grid";
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProductCard({
|
export default function ProductCard({
|
||||||
|
href = "#",
|
||||||
image,
|
image,
|
||||||
title,
|
title,
|
||||||
price,
|
price,
|
||||||
}: {
|
inStock = true,
|
||||||
image: string;
|
layout = "carousel",
|
||||||
title: string;
|
}: ProductCardProps) {
|
||||||
price?: string;
|
const widthClass =
|
||||||
}) {
|
layout === "grid"
|
||||||
|
? "w-full"
|
||||||
|
: "w-[80vw] shrink-0 snap-start sm:w-[300px]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
href={href}
|
||||||
className="group w-[80vw] shrink-0 snap-start overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl sm:w-[300px]"
|
className={`group overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl ${widthClass}`}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50">
|
<div className="relative aspect-square overflow-hidden bg-neutral-50">
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 80vw, 300px"
|
sizes={layout === "grid" ? "(max-width: 640px) 100vw, 33vw" : "(max-width: 640px) 80vw, 300px"}
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
{!inStock ? (
|
||||||
|
<span className="absolute right-3 top-3 rounded-full bg-neutral-800/80 px-3 py-1 text-xs font-medium text-white">
|
||||||
|
ناموجود
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 text-center">
|
<div className="p-5 text-center">
|
||||||
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{price && (
|
{price ? (
|
||||||
<p
|
<p
|
||||||
className="mt-3 text-lg font-extrabold text-primary"
|
className="mt-3 text-lg font-extrabold text-primary"
|
||||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
>
|
>
|
||||||
{price}
|
{price}
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 inline-flex items-center justify-center gap-1.5 text-sm font-bold text-primary">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="h-4 w-4"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.5 4.5h2.2l1.1 3.3-1.4 1.4a12.5 12.5 0 0 0 4.9 4.9l1.4-1.4 3.3 1.1v2.2c0 .9-.7 1.6-1.6 1.6A13.9 13.9 0 0 1 4.5 6.1c0-.9.7-1.6 1.6-1.6Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
تماس بگیرید
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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 { SlotMedia } from "@/components/SlotMedia";
|
||||||
import Link from "next/link";
|
import type { SlotMediaItem } from "@/lib/static-images";
|
||||||
|
|
||||||
const categories = [
|
type ProductCategoriesProps = {
|
||||||
{ title: "هنرهای دستی", subtitle: "ARTS AND CRAFTS", icon: "/images/cat-crafts.png", href: "#" },
|
items: SlotMediaItem[];
|
||||||
{ title: "دکوری و تزئینی", subtitle: "DECORATION", icon: "/images/cat-decoration.png", href: "#" },
|
aspectRatio?: string;
|
||||||
{ title: "آشپز خانه", subtitle: "KITCHEN", icon: "/images/cat-kitchen.png", href: "#" },
|
};
|
||||||
{ title: "محصولات چوبی", subtitle: "WOOD PRODUCTS", icon: "/images/cat-wood.png", href: "#" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ProductCategories() {
|
export default function ProductCategories({ items, aspectRatio }: ProductCategoriesProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="relative bg-cover bg-center py-16"
|
className="relative bg-cover bg-center py-16"
|
||||||
@@ -27,26 +25,59 @@ export default function ProductCategories() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
||||||
{categories.map((cat) => (
|
{items.map((cat) => {
|
||||||
<Link
|
const content = (
|
||||||
key={cat.title}
|
<>
|
||||||
href={cat.href}
|
<div className="flex h-20 w-20 items-center justify-center overflow-hidden">
|
||||||
className="group flex flex-col items-center gap-4 rounded-2xl bg-white/80 p-6 text-center shadow-sm backdrop-blur-sm transition-shadow hover:shadow-lg"
|
<SlotMedia
|
||||||
>
|
src={cat.image}
|
||||||
<div className="flex h-20 w-20 items-center justify-center">
|
fallbackSrc={cat.fallbackSrc}
|
||||||
<Image src={cat.icon} alt={cat.title} width={64} height={64} className="h-16 w-16 object-contain transition-transform group-hover:scale-110" />
|
alt={cat.title || cat.titleEn || ""}
|
||||||
</div>
|
aspectRatio={aspectRatio}
|
||||||
<div>
|
className="h-16 w-16"
|
||||||
<h3 className="font-bold text-neutral-800">{cat.title}</h3>
|
imgClassName="absolute inset-0 h-full w-full object-contain transition-transform group-hover:scale-110"
|
||||||
<p
|
/>
|
||||||
className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400"
|
</div>
|
||||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
<div>
|
||||||
>
|
{cat.title ? <h3 className="font-bold text-neutral-800">{cat.title}</h3> : null}
|
||||||
{cat.subtitle}
|
{cat.titleEn ? (
|
||||||
</p>
|
<p
|
||||||
</div>
|
className="mt-1 text-[10px] tracking-[0.2em] text-neutral-400"
|
||||||
</Link>
|
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||||
))}
|
>
|
||||||
|
{cat.titleEn}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{cat.text ? <p className="mt-1 text-xs text-neutral-500">{cat.text}</p> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const className =
|
||||||
|
"group flex flex-col items-center gap-4 rounded-2xl bg-white/80 p-6 text-center shadow-sm backdrop-blur-sm transition-shadow hover:shadow-lg";
|
||||||
|
|
||||||
|
if (!cat.href) {
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className={className}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cat.href.startsWith("/") || cat.href.startsWith("#")) {
|
||||||
|
return (
|
||||||
|
<a key={cat.id} href={cat.href} className={className}>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a key={cat.id} href={cat.href} className={className} rel="noopener noreferrer">
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState, type MouseEvent } from "react";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth, userDisplayName } from "@/lib/auth-context";
|
||||||
import { CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
|
import { BUSINESS_DASHBOARD_URL, CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
|
||||||
import { useClickOutside } from "@/lib/use-click-outside";
|
import { useClickOutside } from "@/lib/use-click-outside";
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
|
||||||
|
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path
|
||||||
|
d="M5 20c1.2-3.5 4-5.5 7-5.5s5.8 2 7 5.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfileMenu() {
|
export default function ProfileMenu() {
|
||||||
const { user, isLoading, isAdmin, loginUrl, logout } = useAuth();
|
const { user, isLoading, isAdmin, loginUrl, logout, getAccessToken } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [handoffBusy, setHandoffBusy] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useClickOutside(ref, () => setOpen(false));
|
useClickOutside(ref, () => setOpen(false));
|
||||||
|
|
||||||
|
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (handoffBusy) return;
|
||||||
|
|
||||||
|
const popup = window.open("about:blank", "_blank");
|
||||||
|
setHandoffBusy(true);
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const res = await fetch("/api/auth/handoff", {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("handoff failed");
|
||||||
|
const data = (await res.json()) as { ticket?: string };
|
||||||
|
const url = new URL(BUSINESS_DASHBOARD_URL);
|
||||||
|
if (data.ticket) url.searchParams.set("handoff", data.ticket);
|
||||||
|
if (popup) {
|
||||||
|
popup.opener = null;
|
||||||
|
popup.location.replace(url.toString());
|
||||||
|
} else {
|
||||||
|
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (popup) popup.location.replace(BUSINESS_DASHBOARD_URL);
|
||||||
|
else window.open(BUSINESS_DASHBOARD_URL, "_blank", "noopener,noreferrer");
|
||||||
|
} finally {
|
||||||
|
setHandoffBusy(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <span className="h-5 w-16 animate-pulse rounded bg-neutral-100" />;
|
return <span className="h-5 w-16 animate-pulse rounded bg-neutral-100" />;
|
||||||
}
|
}
|
||||||
@@ -18,14 +65,12 @@ export default function ProfileMenu() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={loginUrl}
|
href={loginUrl || CUSTOMER_PORTAL_URL}
|
||||||
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
|
className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
|
||||||
|
aria-label="ورود"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
<UserIcon />
|
||||||
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" />
|
<span className="hidden sm:inline">ورود</span>
|
||||||
<path d="M5 20c1.2-3.5 4-5.5 7-5.5s5.8 2 7 5.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
ورود
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,33 +78,51 @@ export default function ProfileMenu() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
|
className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className="h-5 w-5">
|
<UserIcon />
|
||||||
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.8" />
|
<span className="hidden sm:inline">{userDisplayName(user)} عزیز</span>
|
||||||
<path d="M5 20c1.2-3.5 4-5.5 7-5.5s5.8 2 7 5.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
className={`hidden h-2.5 w-2.5 transition-transform sm:block ${open ? "rotate-180" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
{user.firstName} عزیز
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open ? (
|
||||||
<div className="absolute left-0 top-full z-50 mt-3 w-48 overflow-hidden rounded-xl border border-black/5 bg-white py-2 text-right shadow-2xl">
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute left-0 top-full z-50 mt-3 w-48 overflow-hidden rounded-xl border border-black/5 bg-white py-2 text-right shadow-2xl"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
|
role="menuitem"
|
||||||
href={`${CUSTOMER_PORTAL_URL}/profile`}
|
href={`${CUSTOMER_PORTAL_URL}/profile`}
|
||||||
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
پروفایل من
|
پروفایل من
|
||||||
</a>
|
</a>
|
||||||
{isAdmin && (
|
{isAdmin ? (
|
||||||
<a
|
<a
|
||||||
href={`${CUSTOMER_PORTAL_URL}/admin`}
|
role="menuitem"
|
||||||
|
href={BUSINESS_DASHBOARD_URL}
|
||||||
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
className="block px-4 py-2.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50 hover:text-primary"
|
||||||
|
onClick={openBusinessDashboard}
|
||||||
>
|
>
|
||||||
مدیریت وبسایت
|
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||||
</a>
|
</a>
|
||||||
)}
|
) : null}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
logout();
|
logout();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -69,7 +132,7 @@ export default function ProfileMenu() {
|
|||||||
خروج
|
خروج
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,13 @@
|
|||||||
import Image from "next/image";
|
import ProductCard from "@/components/ProductCard";
|
||||||
import Link from "next/link";
|
import type { StoreVariant } from "@/lib/meshkee";
|
||||||
import type { Product } from "@/lib/meshkee";
|
import { storeVariantToCard } from "@/lib/store";
|
||||||
import { slugify } from "@/lib/slug";
|
|
||||||
|
|
||||||
function formatToman(value?: number | null) {
|
export default function RemoteProductCard({
|
||||||
if (value === null || value === undefined) return null;
|
variant,
|
||||||
return `${value.toLocaleString("fa-IR")} تومان`;
|
layout = "carousel",
|
||||||
}
|
}: {
|
||||||
|
variant: StoreVariant;
|
||||||
export default function RemoteProductCard({ product }: { product: Product }) {
|
layout?: "carousel" | "grid";
|
||||||
const href = `/products/${product.id}/${slugify(product.nameFa || product.title)}`;
|
}) {
|
||||||
const price = formatToman(product.store?.minPrice);
|
return <ProductCard {...storeVariantToCard(variant)} layout={layout} />;
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="group w-[80vw] shrink-0 snap-start overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl sm:w-[300px]"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-4/3 overflow-hidden bg-neutral-50">
|
|
||||||
{product.thumbnailUrl ? (
|
|
||||||
<Image
|
|
||||||
src={product.thumbnailUrl}
|
|
||||||
alt={product.nameFa || product.title}
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 640px) 80vw, 300px"
|
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-neutral-300">بدون تصویر</div>
|
|
||||||
)}
|
|
||||||
{product.store && !product.store.inStock && (
|
|
||||||
<span className="absolute right-3 top-3 rounded-full bg-neutral-800/80 px-3 py-1 text-xs font-medium text-white">
|
|
||||||
ناموجود
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-5 text-center">
|
|
||||||
<h3 className="line-clamp-2 min-h-[2.8em] text-base font-semibold text-neutral-800">
|
|
||||||
{product.nameFa || product.title}
|
|
||||||
</h3>
|
|
||||||
{price && (
|
|
||||||
<p
|
|
||||||
className="mt-3 text-lg font-extrabold text-primary"
|
|
||||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
|
||||||
>
|
|
||||||
{price}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { meshkee, type AuthUser, CUSTOMER_PORTAL_URL } from "./meshkee";
|
import {
|
||||||
|
CUSTOMER_PORTAL_URL,
|
||||||
const ACCESS_TOKEN_KEY = "meshkee_access_token";
|
meshkee,
|
||||||
const REFRESH_TOKEN_KEY = "meshkee_refresh_token";
|
type AuthUser,
|
||||||
|
} from "./meshkee";
|
||||||
|
import {
|
||||||
|
clearSharedAuthCookies,
|
||||||
|
readSharedAccessToken,
|
||||||
|
readSharedRefreshToken,
|
||||||
|
SHARED_ACCESS_TOKEN_KEY,
|
||||||
|
SHARED_REFRESH_TOKEN_KEY,
|
||||||
|
writeSharedAuthCookies,
|
||||||
|
} from "./shared-auth-cookie";
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
@@ -19,81 +29,175 @@ type AuthContextValue = {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
loginUrl: string;
|
loginUrl: string;
|
||||||
|
businessId: string;
|
||||||
setSession: (accessToken: string, refreshToken: string) => Promise<void>;
|
setSession: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
getAccessToken: () => string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
function getLoginUrl() {
|
function readLegacyToken(key: string) {
|
||||||
if (typeof window === "undefined") return CUSTOMER_PORTAL_URL;
|
try {
|
||||||
const returnUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(
|
return localStorage.getItem(key);
|
||||||
window.location.pathname
|
} catch {
|
||||||
)}`;
|
return null;
|
||||||
return `${CUSTOMER_PORTAL_URL}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
function writeLegacyToken(key: string, value: string | null) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, value);
|
||||||
|
else localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUser(raw: Partial<AuthUser> & { id?: string | number } | null | undefined): AuthUser | null {
|
||||||
|
if (!raw || raw.id == null) return null;
|
||||||
|
return {
|
||||||
|
id: String(raw.id),
|
||||||
|
firstName: raw.firstName ?? "",
|
||||||
|
lastName: raw.lastName ?? "",
|
||||||
|
cellNumber: raw.cellNumber ?? "",
|
||||||
|
email: raw.email ?? null,
|
||||||
|
role: raw.role,
|
||||||
|
roles: Array.isArray(raw.roles) ? raw.roles : undefined,
|
||||||
|
isAdmin: Boolean(raw.isAdmin),
|
||||||
|
isSuperAdmin: Boolean(raw.isSuperAdmin),
|
||||||
|
businesses: Array.isArray(raw.businesses)
|
||||||
|
? raw.businesses.map((business) => ({
|
||||||
|
id: String(business.id),
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
isOwner: Boolean(business.isOwner),
|
||||||
|
teamRole: business.teamRole ?? null,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string) {
|
||||||
|
if (!user || !businessId) return false;
|
||||||
|
if (user.isSuperAdmin || user.isAdmin || user.role === "admin" || user.role === "owner") return true;
|
||||||
|
if (user.roles?.includes("super_admin")) return true;
|
||||||
|
return Boolean(
|
||||||
|
user.businesses?.some(
|
||||||
|
(business) =>
|
||||||
|
String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userDisplayName(user: AuthUser | null | undefined) {
|
||||||
|
if (!user) return "کاربر";
|
||||||
|
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return full || user.cellNumber || user.email || "کاربر";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginUrl() {
|
||||||
|
if (typeof window === "undefined") return `${CUSTOMER_PORTAL_URL}/login`;
|
||||||
|
const redirect = window.location.pathname || "/";
|
||||||
|
return `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(redirect)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({
|
||||||
|
businessId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
businessId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const loadUser = useCallback(async (token: string) => {
|
const getAccessToken = useCallback(() => {
|
||||||
try {
|
return (
|
||||||
const { user } = await meshkee.me(token);
|
readSharedAccessToken() ||
|
||||||
setUser(user);
|
readLegacyToken(SHARED_ACCESS_TOKEN_KEY) ||
|
||||||
setAccessToken(token);
|
readLegacyToken("meshkee_access_token")
|
||||||
} catch {
|
);
|
||||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
||||||
if (refreshToken) {
|
|
||||||
try {
|
|
||||||
const res = await meshkee.refresh(refreshToken);
|
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, res.accessToken);
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, res.refreshToken);
|
|
||||||
setUser(res.user);
|
|
||||||
setAccessToken(res.accessToken);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// fall through to logout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
setUser(null);
|
|
||||||
setAccessToken(null);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const clearSession = useCallback(() => {
|
||||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, null);
|
||||||
if (token) {
|
writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, null);
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time session bootstrap on mount
|
writeLegacyToken("meshkee_access_token", null);
|
||||||
loadUser(token).finally(() => setIsLoading(false));
|
writeLegacyToken("meshkee_refresh_token", null);
|
||||||
} else {
|
clearSharedAuthCookies();
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [loadUser]);
|
|
||||||
|
|
||||||
const setSession = useCallback(
|
|
||||||
async (newAccessToken: string, newRefreshToken: string) => {
|
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
|
||||||
setIsLoading(true);
|
|
||||||
await loadUser(newAccessToken);
|
|
||||||
setIsLoading(false);
|
|
||||||
},
|
|
||||||
[loadUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
|
||||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isAdmin =
|
const loadUser = useCallback(
|
||||||
!!user && (user.role === "admin" || user.role === "owner" || user.isAdmin === true);
|
async (token: string) => {
|
||||||
|
try {
|
||||||
|
const data = await meshkee.me(token);
|
||||||
|
const next = normalizeUser(data.user ?? (data as unknown as AuthUser));
|
||||||
|
if (!next) throw new Error("invalid user");
|
||||||
|
setUser(next);
|
||||||
|
setAccessToken(token);
|
||||||
|
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, token);
|
||||||
|
writeSharedAuthCookies(token, readSharedRefreshToken() ?? undefined);
|
||||||
|
} catch {
|
||||||
|
const refreshToken =
|
||||||
|
readSharedRefreshToken() ||
|
||||||
|
readLegacyToken(SHARED_REFRESH_TOKEN_KEY) ||
|
||||||
|
readLegacyToken("meshkee_refresh_token");
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const res = await meshkee.refresh(refreshToken);
|
||||||
|
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, res.accessToken);
|
||||||
|
writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, res.refreshToken);
|
||||||
|
writeSharedAuthCookies(res.accessToken, res.refreshToken);
|
||||||
|
const next = normalizeUser(res.user);
|
||||||
|
setUser(next);
|
||||||
|
setAccessToken(res.accessToken);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadUser(token).finally(() => {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [getAccessToken, loadUser]);
|
||||||
|
|
||||||
|
const setSession = useCallback(
|
||||||
|
async (newAccessToken: string, newRefreshToken: string) => {
|
||||||
|
writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, newAccessToken);
|
||||||
|
writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, newRefreshToken);
|
||||||
|
writeSharedAuthCookies(newAccessToken, newRefreshToken);
|
||||||
|
setIsLoading(true);
|
||||||
|
await loadUser(newAccessToken);
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
[loadUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearSession();
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const isAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -102,10 +206,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
loginUrl: getLoginUrl(),
|
loginUrl: getLoginUrl(),
|
||||||
|
businessId,
|
||||||
setSession,
|
setSession,
|
||||||
logout,
|
logout,
|
||||||
|
getAccessToken,
|
||||||
}),
|
}),
|
||||||
[user, accessToken, isLoading, isAdmin, setSession, logout]
|
[user, accessToken, isLoading, isAdmin, businessId, setSession, logout, getAccessToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
+88
-63
@@ -7,92 +7,117 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { meshkee, type Cart } from "./meshkee";
|
import {
|
||||||
import { useAuth } from "./auth-context";
|
getContinueCheckoutUrl,
|
||||||
|
readGuestCart,
|
||||||
|
writeGuestCart,
|
||||||
|
type GuestCartItem,
|
||||||
|
} from "./guest-cart";
|
||||||
|
|
||||||
type CartContextValue = {
|
type CartContextValue = {
|
||||||
cart: Cart | null;
|
items: GuestCartItem[];
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
total: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<void>;
|
addItem: (item: Omit<GuestCartItem, "quantity"> & { quantity?: number }) => void;
|
||||||
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
|
updateItem: (id: string, quantity: number) => void;
|
||||||
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
removeItem: (id: string) => void;
|
||||||
removeItem: (itemId: string) => Promise<void>;
|
clear: () => void;
|
||||||
|
continueUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CartContext = createContext<CartContextValue | null>(null);
|
const CartContext = createContext<CartContextValue | null>(null);
|
||||||
|
|
||||||
export function CartProvider({
|
export function CartProvider({ children }: { children: ReactNode }) {
|
||||||
businessId,
|
const [items, setItems] = useState<GuestCartItem[]>([]);
|
||||||
children,
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
}: {
|
|
||||||
businessId: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { accessToken } = useAuth();
|
|
||||||
const [cart, setCart] = useState<Cart | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!accessToken) {
|
|
||||||
setCart(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const { cart } = await meshkee.getCart(businessId, accessToken);
|
|
||||||
setCart(cart);
|
|
||||||
} catch {
|
|
||||||
setCart(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [accessToken, businessId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- refetch cart when auth state changes
|
setItems(readGuestCart());
|
||||||
refresh();
|
setIsLoading(false);
|
||||||
}, [refresh]);
|
}, []);
|
||||||
|
|
||||||
|
const persist = useCallback((next: GuestCartItem[]) => {
|
||||||
|
setItems(next);
|
||||||
|
writeGuestCart(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addItem = useCallback(
|
const addItem = useCallback(
|
||||||
async (storeItemVariantId: string, quantity = 1) => {
|
(item: Omit<GuestCartItem, "quantity"> & { quantity?: number }) => {
|
||||||
if (!accessToken) return;
|
const quantity = Math.max(1, item.quantity ?? 1);
|
||||||
const { cart } = await meshkee.addCartItem(businessId, accessToken, {
|
setItems((prev) => {
|
||||||
storeItemVariantId,
|
const existing = prev.find((line) => line.id === item.id);
|
||||||
quantity,
|
const next = existing
|
||||||
|
? prev.map((line) =>
|
||||||
|
line.id === item.id ? { ...line, quantity: line.quantity + quantity } : line,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
price: item.price,
|
||||||
|
originalPrice: item.originalPrice,
|
||||||
|
image: item.image,
|
||||||
|
quantity,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
writeGuestCart(next);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
setCart(cart);
|
|
||||||
},
|
},
|
||||||
[accessToken, businessId]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateItem = useCallback(
|
const updateItem = useCallback((id: string, quantity: number) => {
|
||||||
async (itemId: string, quantity: number) => {
|
setItems((prev) => {
|
||||||
if (!accessToken) return;
|
const next =
|
||||||
const { cart } = await meshkee.updateCartItem(businessId, accessToken, itemId, quantity);
|
quantity <= 0
|
||||||
setCart(cart);
|
? prev.filter((line) => line.id !== id)
|
||||||
},
|
: prev.map((line) => (line.id === id ? { ...line, quantity } : line));
|
||||||
[accessToken, businessId]
|
writeGuestCart(next);
|
||||||
);
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback((id: string) => {
|
||||||
async (itemId: string) => {
|
setItems((prev) => {
|
||||||
if (!accessToken) return;
|
const next = prev.filter((line) => line.id !== id);
|
||||||
const { cart } = await meshkee.removeCartItem(businessId, accessToken, itemId);
|
writeGuestCart(next);
|
||||||
setCart(cart);
|
return next;
|
||||||
},
|
});
|
||||||
[accessToken, businessId]
|
}, []);
|
||||||
);
|
|
||||||
|
const clear = useCallback(() => persist([]), [persist]);
|
||||||
|
|
||||||
const itemCount = useMemo(
|
const itemCount = useMemo(
|
||||||
() => cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0,
|
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||||
[cart]
|
[items],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const total = useMemo(
|
||||||
|
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const continueUrl = useMemo(() => getContinueCheckoutUrl(items), [items]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem }),
|
() => ({
|
||||||
[cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem]
|
items,
|
||||||
|
itemCount,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
removeItem,
|
||||||
|
clear,
|
||||||
|
continueUrl,
|
||||||
|
}),
|
||||||
|
[items, itemCount, total, isLoading, addItem, updateItem, removeItem, clear, continueUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||||
|
|||||||
@@ -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 API_BASE = "https://api.meshkee.com/api/v1";
|
||||||
export const WEBSITE_DOMAIN = "dcorya.com";
|
export const WEBSITE_DOMAIN = "dcorya.com";
|
||||||
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
|
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
|
||||||
|
export const BUSINESS_DASHBOARD_URL = `https://business.${WEBSITE_DOMAIN}`;
|
||||||
|
export const DEFAULT_FAVICON = "/images/logo.png";
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -148,18 +150,66 @@ export type Comment = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StoreItemVariant = {
|
export type StoreVariant = {
|
||||||
id: string;
|
id: string;
|
||||||
price: number;
|
storeItemId?: string;
|
||||||
stockQuantity: number | null;
|
productId?: string | null;
|
||||||
|
productTitle?: string | null;
|
||||||
|
productNameFa?: string | null;
|
||||||
|
productImage?: string | null;
|
||||||
|
productTotalStock?: number | null;
|
||||||
|
label?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
discountedPrice?: number | null;
|
||||||
|
stockQuantity?: number | null;
|
||||||
unlimited?: boolean;
|
unlimited?: boolean;
|
||||||
|
isFestival?: boolean;
|
||||||
|
isUsed?: boolean;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
selections?: { variationId?: string; variationName?: string; optionId?: string; value?: string }[];
|
||||||
attributes?: Record<string, string>;
|
attributes?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated Prefer StoreVariant — kept for older call sites */
|
||||||
|
export type StoreItemVariant = StoreVariant;
|
||||||
|
|
||||||
export type StoreItem = {
|
export type StoreItem = {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
variants: StoreItemVariant[];
|
isActive?: boolean;
|
||||||
|
variants: StoreVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreItemListResponse = {
|
||||||
|
items: StoreVariant[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSpecialItem = {
|
||||||
|
id: string;
|
||||||
|
productId?: string | null;
|
||||||
|
productTitle?: string | null;
|
||||||
|
productNameFa?: string | null;
|
||||||
|
productImage?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
variants?: StoreVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSpecial = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
items: StoreSpecialItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSpecialsResponse = {
|
||||||
|
source: "product" | "store_item" | null;
|
||||||
|
items: StoreSpecial[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CartItem = {
|
export type CartItem = {
|
||||||
@@ -177,6 +227,14 @@ export type Cart = {
|
|||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthBusinessMembership = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
teamRole: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -184,7 +242,10 @@ export type AuthUser = {
|
|||||||
cellNumber: string;
|
cellNumber: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
roles?: string[];
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
|
businesses?: AuthBusinessMembership[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthResponse = {
|
export type AuthResponse = {
|
||||||
@@ -281,6 +342,30 @@ export const meshkee = {
|
|||||||
{ next: { revalidate: 30 } } as RequestInit
|
{ next: { revalidate: 30 } } as RequestInit
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getStoreItems: (params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
productId?: string;
|
||||||
|
name?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
} = {}) =>
|
||||||
|
request<StoreItemListResponse>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/store-items${qs(params)}`,
|
||||||
|
{ next: { revalidate: 60 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getStoreVariantById: (variantId: string) =>
|
||||||
|
request<{ variant: StoreVariant }>(
|
||||||
|
`/tenants/${WEBSITE_DOMAIN}/store-items/${variantId}`,
|
||||||
|
{ next: { revalidate: 30 } } as RequestInit
|
||||||
|
),
|
||||||
|
|
||||||
|
getStoreSpecials: () =>
|
||||||
|
request<StoreSpecialsResponse>(`/tenants/${WEBSITE_DOMAIN}/store-specials`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
} as RequestInit),
|
||||||
|
|
||||||
getCategories: (entityType: "product" = "product") =>
|
getCategories: (entityType: "product" = "product") =>
|
||||||
request<{ items: Category[] }>(
|
request<{ items: Category[] }>(
|
||||||
`/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`,
|
`/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`,
|
||||||
@@ -359,33 +444,6 @@ export const meshkee = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ refreshToken }),
|
body: JSON.stringify({ refreshToken }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// --- Cart (requires businessId + bearer token) ---
|
|
||||||
getCart: (businessId: string, token: string) =>
|
|
||||||
request<{ cart: Cart }>(`/businesses/${businessId}/cart`, { token, cache: "no-store" }),
|
|
||||||
|
|
||||||
addCartItem: (
|
|
||||||
businessId: string,
|
|
||||||
token: string,
|
|
||||||
body: { storeItemVariantId: string; quantity?: number }
|
|
||||||
) =>
|
|
||||||
request<{ message: string; cart: Cart }>(`/businesses/${businessId}/cart/items`, {
|
|
||||||
method: "POST",
|
|
||||||
token,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateCartItem: (businessId: string, token: string, itemId: string, quantity: number) =>
|
|
||||||
request<{ message: string; cart: Cart }>(
|
|
||||||
`/businesses/${businessId}/cart/items/${itemId}`,
|
|
||||||
{ method: "PATCH", token, body: JSON.stringify({ quantity }) }
|
|
||||||
),
|
|
||||||
|
|
||||||
removeCartItem: (businessId: string, token: string, itemId: string) =>
|
|
||||||
request<{ message: string; cart: Cart }>(
|
|
||||||
`/businesses/${businessId}/cart/items/${itemId}`,
|
|
||||||
{ method: "DELETE", token }
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ApiError };
|
export { ApiError };
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export default function Providers({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider businessId={businessId}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<CartProvider businessId={businessId}>{children}</CartProvider>
|
<CartProvider>{children}</CartProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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