Initial commit: Hoomat storefront with Meshkee API and static-image slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Keep Meshkee static-image slot catalog aspect ratios in sync with UI
|
||||
globs: lib/meshkee/static-slots.ts,app/meshkee/static-image-slots/**,app/page.tsx,app/about-us/**,app/components/hero-slider.tsx,app/components/slot-media.tsx,app/components/product-carousel.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Static image slots
|
||||
|
||||
When homepage/about (or other) UI image sizes or aspect ratios change during development, update `STATIC_IMAGE_SLOT_CATALOG` in `lib/meshkee/static-slots.ts` (and thus `GET /meshkee/static-image-slots`) so Meshkee’s static-image reader stays accurate.
|
||||
|
||||
- Prefer API `slot.aspectRatio` / catalog values; do not invent upload or CMS write APIs.
|
||||
- Empty or failed slot fetches must keep local placeholders.
|
||||
- List slots use all `slot.images`; single slots use the first image with a `url`.
|
||||
- Wrap with `<a>` when `linkUrl` is present; map `titleFa` / `titleEn` / `subtext` to the existing UI copy fields.
|
||||
@@ -0,0 +1,8 @@
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
dist
|
||||
.env*
|
||||
*.log
|
||||
coverage
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,109 @@
|
||||
# Hoomat Store Context
|
||||
|
||||
## Current Prompt
|
||||
|
||||
Source prompt: `https://api.meshkee.com/api/v1/public/ai-prompts/hoomat-store/images-placeholder/28488577c9163eaa8fdb8e86`
|
||||
|
||||
Goal: wire homepage/about static-image slots from Meshkee (`GET /tenants/hoomatstore.com/website/static-images`) and publish the slot catalog at `/meshkee/static-image-slots`. Empty/fail → keep local placeholders.
|
||||
|
||||
Previous prompts:
|
||||
- Backend: `…/backend/114f70b804d97953f91ad26a` (API auth/cart/store/blog/contact)
|
||||
- Basic create: `…/basic-create/d3ca3d1229f0e5c8e532b073` (Persian RTL flat-design clone)
|
||||
|
||||
## Meshkee Backend
|
||||
|
||||
- Docs: `https://api.meshkee.com/docs/website`
|
||||
- OpenAPI: `https://api.meshkee.com/docs/website/openapi.json`
|
||||
- API base: `https://api.meshkee.com/api/v1`
|
||||
- Apex domain: `hoomatstore.com` (business id currently `35`)
|
||||
- Customer app: `https://customer.hoomatstore.com`
|
||||
- Business admin: `https://business.hoomatstore.com`
|
||||
- Tenant resolve: `GET /tenants/hoomatstore.com`
|
||||
- Public content: `/tenants/hoomatstore.com/...`
|
||||
- Cart/orders: `/businesses/{businessId}/...` with Bearer JWT
|
||||
- Auth token keys (localStorage): `meshkee_customer_access_token`, `meshkee_customer_refresh_token`
|
||||
- Guest cart key: `meshkee-guest-cart`
|
||||
- Cart continue: authenticated users go to `customer.hoomatstore.com/checkout/cart`; guests go to customer login with `redirect=/checkout/cart` and optional `#guestCart=` payload (Meshkee customer dashboard handoff)
|
||||
|
||||
## Static image slots
|
||||
|
||||
Catalog source: `lib/meshkee/static-slots.ts` → `GET /meshkee/static-image-slots`
|
||||
|
||||
| key | kind | pageKey | count | UI |
|
||||
|-----|------|---------|-------|----|
|
||||
| `slider` | list | home | n | homepage hero |
|
||||
| `categories-icon` | list | home | 6 | quick category icons |
|
||||
| `special-sale-banner` | single | home | 1 | sale promo beside product carousel |
|
||||
| `product-features-image` | single | home | 1 | features sofa |
|
||||
| `contactus-image` | single | home | 1 | homepage contact guarantee panel |
|
||||
| `two-banner-row` | list | home | 2 | dual promo banners |
|
||||
| `aboutus-image` | single | home | 1 | homepage about panel |
|
||||
| `three-banner-row` | list | home | 3 | triple promo banners |
|
||||
| `about-us-image` | list | about | 2 | about page panels |
|
||||
|
||||
Rules: use `slot.images` (all if list, first if single); `url` → img/bg; `linkUrl` → wrap link; map `titleFa` / `titleEn` / `subtext` to UI; if UI aspect/size changes, update the catalog. Cursor rule: `.cursor/rules/static-image-slots.mdc`.
|
||||
|
||||
## Source Website Data
|
||||
|
||||
- Main site: `https://www.hoomatstore.com/`
|
||||
- About page: `https://www.hoomatstore.com/about-us`
|
||||
- Contact page: `https://www.hoomatstore.com/contactus`
|
||||
- Footer inspiration for bottom Meshkee row: `https://safeteb.com/`
|
||||
|
||||
## Design Requirements
|
||||
|
||||
- Language: Persian (`fa`)
|
||||
- Direction: RTL
|
||||
- Framework: Next.js app router
|
||||
- Style: flat design, furniture-store feel, responsive layout
|
||||
- Header: fixed with blurry/translucent background; stronger glass after scroll
|
||||
- Header now includes user menu, cart icon/badge + drawer, and products mega menu (1st + 2nd category levels)
|
||||
- Palette:
|
||||
- Primary: `#00234B`
|
||||
- Secondary: `#CC9966`
|
||||
- Tertiary: `#883F3F`
|
||||
- Fonts: Iran Sans (FA), Josefin Sans (EN titles), Montserrat fallback
|
||||
- Logo local fallback: `public/assets/hoomat-logo.png`
|
||||
- Favicon: from `GET /tenants/{domain}/website/favicon` when available; otherwise local logo
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `/` homepage (static-image slots + local fallbacks; product carousels still sample)
|
||||
- `/about-us` (about-us-image ×2 + local fallbacks)
|
||||
- `/contactus` (contact form posts to Meshkee contact-submissions)
|
||||
- `/store` store-items listing
|
||||
- `/store/[variantId]/[[...slug]]` store item detail
|
||||
- `/blog` blog listing
|
||||
- `/blog/[id]/[slug]` blog detail
|
||||
- `/meshkee/static-image-slots` slot catalog JSON for Meshkee dashboard
|
||||
|
||||
## Auth / Cart UX
|
||||
|
||||
- Logged out user icon → customer login
|
||||
- Logged in: `{firstName} عزیز` with dropdown: پروفایل من, خروج, and مدیریت وبسایت (admins only, handoff to business app)
|
||||
- Cart badge shows total quantity; drawer continue follows Meshkee customer checkout flow above
|
||||
|
||||
## Key Code Locations
|
||||
|
||||
- API client: `lib/meshkee/api.ts` (`getStaticImages`, `getStaticImageSlot`)
|
||||
- Slot catalog/helpers: `lib/meshkee/static-slots.ts`
|
||||
- Slot UI helpers: `app/components/slot-media.tsx`
|
||||
- Config/domain: `lib/meshkee/config.ts`
|
||||
- Auth/cart/toast providers: `app/providers/*`
|
||||
- Header + cart drawer + mega menu: `app/components/header.tsx` and related components
|
||||
- Shared static homepage content still in `lib/site-data.ts`
|
||||
- Global styles: `app/globals.css`
|
||||
|
||||
## Data Notes
|
||||
|
||||
- Store-items for this tenant may be empty until inventory is published; blogs/categories are live.
|
||||
- Static-images for this tenant may be empty until CMS slots are filled; UI keeps local placeholders.
|
||||
- Product description/gallery/technical/comments for a store variant come from the linked catalog product (`productId`).
|
||||
- Homepage carousels still use local sample products until a later prompt wires them to API.
|
||||
|
||||
## Maintenance Notes
|
||||
|
||||
- Update this file on every Meshkee prompt.
|
||||
- Prefer Meshkee Website API docs over inventing CMS/admin endpoints.
|
||||
- Keep RTL/FA patterns and existing visual language when adding pages.
|
||||
- When UI image size/aspect changes, update `STATIC_IMAGE_SLOT_CATALOG` so `/meshkee/static-image-slots` stays correct.
|
||||
@@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import { SlotLinkedImage } from "@/app/components/slot-media";
|
||||
import { getStaticImageSlot, getStaticImages } from "@/lib/meshkee/api";
|
||||
import { slotImages } from "@/lib/meshkee/static-slots";
|
||||
import type { StaticImageItem, StaticImageSlot } from "@/lib/meshkee/types";
|
||||
import { aboutCopy, features, site } from "@/lib/site-data";
|
||||
|
||||
export const metadata = {
|
||||
title: "درباره ما | داستان فروشگاه اینترنتی مبلمان هومت",
|
||||
description: "با داستان، دیدگاه و خدمات مبلمان هومت بیشتر آشنا شوید.",
|
||||
};
|
||||
|
||||
async function loadAboutImages(): Promise<StaticImageItem[]> {
|
||||
const aboutSlots = await getStaticImages("about").catch(
|
||||
() => [] as StaticImageSlot[],
|
||||
);
|
||||
const fromPage = slotImages(aboutSlots, "about-us-image");
|
||||
if (fromPage.length) return fromPage;
|
||||
|
||||
const slot = await getStaticImageSlot("about-us-image");
|
||||
return slot ? slotImages([slot], "about-us-image") : [];
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const aboutImages = await loadAboutImages();
|
||||
const primaryImage = aboutImages[0];
|
||||
const secondaryImage = aboutImages[1];
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<section className="page-hero">
|
||||
<p className="eyebrow">{aboutCopy.eyebrow}</p>
|
||||
<h1>{aboutCopy.title}</h1>
|
||||
<p>{aboutCopy.quote}</p>
|
||||
</section>
|
||||
|
||||
<section className="content-grid">
|
||||
<article className="contact-card">
|
||||
<p className="eyebrow">Story</p>
|
||||
<h2>{aboutCopy.storyTitle}</h2>
|
||||
<p>{aboutCopy.story}</p>
|
||||
<p>
|
||||
هومت با تکیه بر تولید مستقیم، تلاش میکند تجربه خرید مبلمان را برای
|
||||
خانوادهها سادهتر کند؛ از انتخاب محصول تا مشاوره، سفارش و تحویل.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="contact-card">
|
||||
<p className="eyebrow">Vision</p>
|
||||
<h2>{aboutCopy.visionTitle}</h2>
|
||||
<p>{aboutCopy.vision}</p>
|
||||
<ul className="contact-list">
|
||||
{features.map((feature) => (
|
||||
<li key={feature.title}>
|
||||
{feature.title} - {feature.subtitle}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="primary-btn" href="/contactus">
|
||||
تماس با هومت
|
||||
</Link>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="about-panel">
|
||||
<SlotLinkedImage
|
||||
image={primaryImage}
|
||||
fallbackSrc={site.heroImages[2]}
|
||||
alt="نمایشگاه و مبلمان هومت"
|
||||
width={720}
|
||||
height={620}
|
||||
/>
|
||||
<div className="about-copy">
|
||||
<p className="eyebrow">ONLINE FURNITURE SHOP</p>
|
||||
<h2>فروشگاه آنلاین خرید مبلمان منزل با تنوع بالا</h2>
|
||||
<p>
|
||||
مجموعه هومت محصولات اصلی مورد نیاز دکوراسیون منزل را پوشش میدهد:
|
||||
مبلمان راحتی، کلاسیک، مینیمال، مبل تختخوابشو، میز ناهارخوری،
|
||||
صندلی، سرویس خواب و میزهای کاربردی.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="about-panel about-panel-secondary">
|
||||
<SlotLinkedImage
|
||||
image={secondaryImage}
|
||||
fallbackSrc={site.heroImages[0]}
|
||||
alt="فضای نمایشگاهی هومت"
|
||||
width={720}
|
||||
height={620}
|
||||
/>
|
||||
<div className="about-copy">
|
||||
<p className="eyebrow">SHOWROOM</p>
|
||||
<h2>از انتخاب مدل تا چیدمان نهایی</h2>
|
||||
<p>
|
||||
در نمایشگاه و کارخانه هومت میتوانید پارچه، رنگ و مدل را از نزدیک
|
||||
بررسی کنید و با راهنمایی مشاوران، ترکیبی هماهنگ برای منزل خود بسازید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBlogById } from "@/lib/meshkee/api";
|
||||
import { formatDate } from "@/lib/meshkee/format";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const post = await getBlogById(id);
|
||||
return {
|
||||
title: `${post.title} | مبلمان هومت`,
|
||||
description: post.abstract || post.title,
|
||||
};
|
||||
} catch {
|
||||
return { title: "مقاله | مبلمان هومت" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
let post;
|
||||
try {
|
||||
post = await getBlogById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<article className="blog-detail">
|
||||
<section className="page-hero">
|
||||
<p className="eyebrow">{post.categoryName || "Article"}</p>
|
||||
<h1>{post.title}</h1>
|
||||
<time>{formatDate(post.publishedAt || post.createdAt)}</time>
|
||||
</section>
|
||||
|
||||
{post.titleImageUrl ? (
|
||||
<div className="blog-detail-cover">
|
||||
<Image
|
||||
src={post.titleImageUrl}
|
||||
alt={post.title}
|
||||
width={1200}
|
||||
height={720}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{post.abstract ? <p className="blog-abstract">{post.abstract}</p> : null}
|
||||
|
||||
{post.mainTextHtml ? (
|
||||
<div
|
||||
className="rich-html blog-content"
|
||||
dangerouslySetInnerHTML={{ __html: post.mainTextHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="empty-note">متن مقاله در دسترس نیست.</p>
|
||||
)}
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { listBlogs } from "@/lib/meshkee/api";
|
||||
import { formatDate } from "@/lib/meshkee/format";
|
||||
import { blogHref } from "@/lib/meshkee/urls";
|
||||
|
||||
type SearchParams = Promise<{ page?: string }>;
|
||||
|
||||
export const metadata = {
|
||||
title: "مجله دکوراسیون | مبلمان هومت",
|
||||
description: "مقالات و راهنمای خرید مبلمان و دکوراسیون از مبلمان هومت",
|
||||
};
|
||||
|
||||
export default async function BlogPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
const data = await listBlogs({ page, pageSize: 12 });
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<section className="page-hero">
|
||||
<p className="eyebrow">Blog</p>
|
||||
<h1>مجله دکوراســـــــــیون</h1>
|
||||
<p>راهنماها و مقالات تخصصی برای انتخاب بهتر مبلمان و چیدمان خانه.</p>
|
||||
</section>
|
||||
|
||||
<section className="section blog-grid-section">
|
||||
{data.items.length === 0 ? (
|
||||
<p className="empty-note">مقالهای برای نمایش وجود ندارد.</p>
|
||||
) : (
|
||||
<div className="blog-grid">
|
||||
{data.items.map((post) => (
|
||||
<Link key={post.id} href={blogHref(post.id, post.title)} className="blog-card">
|
||||
<div className="blog-card-media">
|
||||
{post.titleImageUrl ? (
|
||||
<Image
|
||||
src={post.titleImageUrl}
|
||||
alt={post.title}
|
||||
width={640}
|
||||
height={420}
|
||||
/>
|
||||
) : (
|
||||
<div className="store-card-fallback" />
|
||||
)}
|
||||
</div>
|
||||
<div className="blog-card-body">
|
||||
<time>{formatDate(post.publishedAt || post.createdAt)}</time>
|
||||
<h2>{post.title}</h2>
|
||||
{post.abstract ? <p>{post.abstract}</p> : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.total > data.pageSize ? (
|
||||
<div className="pager">
|
||||
{page > 1 ? <Link href={`/blog?page=${page - 1}`}>قبلی</Link> : null}
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{page}
|
||||
</span>
|
||||
{page * data.pageSize < data.total ? (
|
||||
<Link href={`/blog?page=${page + 1}`}>بعدی</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Article = {
|
||||
title: string;
|
||||
date: string;
|
||||
image: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export function ArticleCarousel({
|
||||
articles,
|
||||
ariaLabel,
|
||||
}: {
|
||||
articles: Article[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollCarousel = (direction: "next" | "previous") => {
|
||||
const amount = carouselRef.current
|
||||
? carouselRef.current.clientWidth * 0.7
|
||||
: 320;
|
||||
carouselRef.current?.scrollBy({
|
||||
left: direction === "next" ? -amount : amount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-carousel-shell article-carousel-shell">
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-left"
|
||||
type="button"
|
||||
aria-label="مقالات بعدی"
|
||||
onClick={() => scrollCarousel("next")}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div ref={carouselRef} className="carousel article-carousel" aria-label={ariaLabel}>
|
||||
{articles.map((article) => (
|
||||
<Link
|
||||
className="article-card"
|
||||
key={article.href || article.title}
|
||||
href={article.href || "/blog"}
|
||||
aria-label={article.title}
|
||||
>
|
||||
<div className="article-media">
|
||||
<Image
|
||||
src={article.image}
|
||||
alt={article.title}
|
||||
fill
|
||||
sizes="(max-width: 960px) 50vw, 28vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="article-body">
|
||||
<time className="article-date" dateTime={article.date}>
|
||||
{article.date}
|
||||
</time>
|
||||
<h3>{article.title}</h3>
|
||||
<p>ادامه</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-right"
|
||||
type="button"
|
||||
aria-label="مقالات قبلی"
|
||||
onClick={() => scrollCarousel("previous")}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCart } from "@/app/providers/cart-provider";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { formatPrice } from "@/lib/meshkee/format";
|
||||
import { customerContinueCheckoutUrl } from "@/lib/meshkee/urls";
|
||||
|
||||
export function CartDrawer() {
|
||||
const { cart, open, setOpen, updateQuantity, removeItem, guestItems, loading } =
|
||||
useCart();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const continueUrl = customerContinueCheckoutUrl(
|
||||
isAuthenticated,
|
||||
isAuthenticated ? [] : guestItems,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="cart-drawer-root">
|
||||
<button
|
||||
type="button"
|
||||
className="cart-drawer-backdrop"
|
||||
aria-label="بستن سبد خرید"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<aside className="cart-drawer" role="dialog" aria-modal="true" aria-label="سبد خرید">
|
||||
<div className="cart-drawer-head">
|
||||
<div>
|
||||
<h2>سبد خرید</h2>
|
||||
<p>
|
||||
{cart.items.length
|
||||
? `${new Intl.NumberFormat("fa-IR").format(
|
||||
cart.items.reduce((sum, line) => sum + line.quantity, 0),
|
||||
)} کالا`
|
||||
: "سبد خرید خالی است"}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="icon-btn" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer-body">
|
||||
{cart.items.length === 0 ? (
|
||||
<p className="empty-note">هنوز محصولی به سبد اضافه نشده است.</p>
|
||||
) : (
|
||||
cart.items.map((item) => (
|
||||
<article key={item.id} className="cart-line">
|
||||
<div className="cart-line-media">
|
||||
{item.image ? (
|
||||
<Image src={item.image} alt={item.name} width={72} height={72} />
|
||||
) : (
|
||||
<div className="cart-line-fallback" />
|
||||
)}
|
||||
</div>
|
||||
<div className="cart-line-info">
|
||||
<h3>{item.name}</h3>
|
||||
{item.optionLabel ? <p>{item.optionLabel}</p> : null}
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(item.price)}
|
||||
</strong>
|
||||
<div className="cart-line-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateQuantity(item.id, item.quantity - 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateQuantity(item.id, item.quantity + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-btn"
|
||||
onClick={() => void removeItem(item.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer-foot">
|
||||
<div className="cart-total">
|
||||
<span>جمع</span>
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(cart.total)}
|
||||
</strong>
|
||||
</div>
|
||||
<a className="primary-btn cart-continue" href={continueUrl}>
|
||||
ادامه
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { submitContact } from "@/lib/meshkee/api";
|
||||
import { useToast } from "@/app/providers/toast-provider";
|
||||
|
||||
export function ContactForm() {
|
||||
const { showToast } = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [cellNumber, setCellNumber] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [successText, setSuccessText] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSuccessText(null);
|
||||
try {
|
||||
await submitContact({
|
||||
title: "تماس از وبسایت هومت",
|
||||
name: name.trim(),
|
||||
cellNumber: cellNumber.trim() || undefined,
|
||||
text: text.trim(),
|
||||
});
|
||||
setName("");
|
||||
setCellNumber("");
|
||||
setText("");
|
||||
setSuccessText("پیام شما با موفقیت ارسال شد. به زودی با شما تماس میگیریم.");
|
||||
showToast("پیام با موفقیت ارسال شد");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "ارسال پیام ناموفق بود");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={(event) => void onSubmit(event)}>
|
||||
<input
|
||||
required
|
||||
aria-label="نام و نام خانوادگی"
|
||||
placeholder="نام و نام خانوادگی"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
aria-label="شماره تماس"
|
||||
placeholder="شماره تماس"
|
||||
className="ltr-number"
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
value={cellNumber}
|
||||
onChange={(event) => setCellNumber(event.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
aria-label="پیام"
|
||||
placeholder="پیام شما"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
<button className="primary-btn" type="submit" disabled={submitting}>
|
||||
{submitting ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
{successText ? <p className="form-success">{successText}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { navItems, site } from "@/lib/site-data";
|
||||
|
||||
const addressBlocks = [
|
||||
{
|
||||
title: "دفتر مرکزی",
|
||||
text: "تهران _ شهرک غرب _ مجتمع تجاری پلاتین _ طبقه چهارم _ مبلمان هومت",
|
||||
},
|
||||
{
|
||||
title: "نماینده فروش",
|
||||
text: "قم _ بازار مبل گنجوی _ بلوار نظامی گنجوی _ بین کوی 5 و 7 _ مبلمان هومت",
|
||||
},
|
||||
{
|
||||
title: "کارخانه",
|
||||
text: "قم _ بلوار حضرت معصومه _ کوی 35 _ جنب قالیشویی امیرکبیر _ کارخانه مبلمان هومت",
|
||||
},
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container footer-main">
|
||||
<div className="footer-brand">
|
||||
<Link href="/" className="footer-logo" aria-label={site.name}>
|
||||
<Image
|
||||
src={site.logo}
|
||||
alt={site.name}
|
||||
width={220}
|
||||
height={40}
|
||||
className="footer-logo-img"
|
||||
/>
|
||||
</Link>
|
||||
<p>{site.description}</p>
|
||||
<a className="footer-phone" href={`tel:${site.phone.replaceAll("-", "")}`}>
|
||||
<span className="footer-phone-icon" aria-hidden="true">
|
||||
☎
|
||||
</span>
|
||||
<span>
|
||||
تماس با فروشگاه
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</strong>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="footer-col">
|
||||
<h4>پیوندها</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/">صفحه اصلی</Link>
|
||||
</li>
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="footer-col footer-addresses">
|
||||
<h4>نشانی ما</h4>
|
||||
<ul>
|
||||
{addressBlocks.map((item) => (
|
||||
<li key={item.title}>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom">
|
||||
<div className="container footer-bottom-bar">
|
||||
<div className="footer-bottom-site">{site.name}</div>
|
||||
<a
|
||||
href="https://meshkee.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
dir="ltr"
|
||||
className="meshkee-credit"
|
||||
>
|
||||
<span className="meshkee-credit__frame" aria-hidden="true">
|
||||
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||
</span>
|
||||
<Image
|
||||
src="/assets/meshkee-logo-white.png"
|
||||
alt="Meshkee"
|
||||
className="meshkee-credit__logo"
|
||||
width={42}
|
||||
height={42}
|
||||
/>
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CartDrawer } from "@/app/components/cart-drawer";
|
||||
import { ProductsMegaMenu } from "@/app/components/products-mega-menu";
|
||||
import { UserMenu } from "@/app/components/user-menu";
|
||||
import { useCart } from "@/app/providers/cart-provider";
|
||||
import { site } from "@/lib/site-data";
|
||||
import type { Category } from "@/lib/meshkee/types";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "درباره ما", href: "/about-us" },
|
||||
{ label: "تماس با ما", href: "/contactus" },
|
||||
{ label: "مجله دکوراسیون", href: "/blog" },
|
||||
];
|
||||
|
||||
export function Header({ categories }: { categories: Category[] }) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const { itemCount, setOpen } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeader = () => setIsScrolled(window.scrollY > 12);
|
||||
updateHeader();
|
||||
window.addEventListener("scroll", updateHeader, { passive: true });
|
||||
return () => window.removeEventListener("scroll", updateHeader);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={`header ${isScrolled ? "is-scrolled" : ""}`}>
|
||||
<div className="container header-inner">
|
||||
<Link className="brand" href="/" aria-label={site.name}>
|
||||
<Image src={site.logo} alt={site.name} width={291} height={50} priority />
|
||||
</Link>
|
||||
<nav className="nav" aria-label="ناوبری اصلی">
|
||||
<ProductsMegaMenu categories={categories} />
|
||||
{navLinks.map((item) => (
|
||||
<Link key={item.href} href={item.href}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="nav-icons">
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn cart-trigger"
|
||||
aria-label="سبد خرید"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<CartIcon />
|
||||
{itemCount > 0 ? (
|
||||
<span className="cart-badge ltr-number" dir="ltr" lang="en">
|
||||
{itemCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</nav>
|
||||
<div className="header-actions">
|
||||
<a
|
||||
className="phone-pill"
|
||||
href={`tel:${site.phone.replaceAll("-", "")}`}
|
||||
aria-label={`شماره تماس ${site.phone}`}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<CartDrawer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CartIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6.4 8.25h11.2l-.72 10.1a1.6 1.6 0 0 1-1.6 1.5H8.72a1.6 1.6 0 0 1-1.6-1.5L6.4 8.25Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.1 8.25V7a2.9 2.9 0 0 1 5.8 0v1.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8.2 3.8h2.3l1.1 3.3-1.5 1.1a11.4 11.4 0 0 0 4.7 4.7l1.1-1.5 3.3 1.1v2.3a1.8 1.8 0 0 1-1.8 1.8A13.8 13.8 0 0 1 3.8 5.6 1.8 1.8 0 0 1 5.6 3.8Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { heroSlides as fallbackSlides, site } from "@/lib/site-data";
|
||||
|
||||
const SLIDE_INTERVAL = 5200;
|
||||
|
||||
export type HeroSlide = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
linkUrl?: string | null;
|
||||
};
|
||||
|
||||
export function HeroSlider({ slides }: { slides?: HeroSlide[] }) {
|
||||
const items = slides?.length ? slides : fallbackSlides;
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const activeSlide = items[activeIndex] ?? items[0];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused || items.length <= 1) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setActiveIndex((current) => (current + 1) % items.length);
|
||||
}, SLIDE_INTERVAL);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isPaused, items.length]);
|
||||
|
||||
if (!activeSlide) return null;
|
||||
|
||||
const media = (
|
||||
<div
|
||||
key={activeSlide.image}
|
||||
className="hero-image-layer"
|
||||
style={{ backgroundImage: `url(${activeSlide.image})` }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<div
|
||||
className="hero-card"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
{activeSlide.linkUrl ? (
|
||||
<a href={activeSlide.linkUrl} className="hero-image-link" aria-label={activeSlide.title}>
|
||||
{media}
|
||||
</a>
|
||||
) : (
|
||||
media
|
||||
)}
|
||||
<div key={`${activeIndex}-${activeSlide.title}`} className="hero-content">
|
||||
<p className="eyebrow sequence-item sequence-eyebrow">{activeSlide.eyebrow}</p>
|
||||
<h1 className="sequence-item sequence-title">{activeSlide.title}</h1>
|
||||
<p className="sequence-item sequence-copy">{activeSlide.description}</p>
|
||||
<div className="hero-actions sequence-item sequence-actions">
|
||||
<Link className="primary-btn" href={activeSlide.linkUrl || "/store"}>
|
||||
مشاهده محصولات
|
||||
</Link>
|
||||
<Link className="hero-phone-link" href={`tel:${site.phone.replaceAll("-", "")}`}>
|
||||
<span className="hero-phone-icon" aria-hidden="true">
|
||||
☎
|
||||
</span>
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-dots" aria-label="انتخاب اسلاید">
|
||||
{items.map((slide, index) => (
|
||||
<button
|
||||
key={`${slide.image}-${index}`}
|
||||
className={index === activeIndex ? "active" : ""}
|
||||
type="button"
|
||||
aria-label={`اسلاید ${index + 1}`}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Product = {
|
||||
title: string;
|
||||
enName: string;
|
||||
price: string;
|
||||
image: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type SaleBanner = {
|
||||
image?: string | null;
|
||||
linkUrl?: string | null;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
function ProductPrice({ price }: { price: string }) {
|
||||
const trimmed = price.trim();
|
||||
const match = trimmed.match(/^([\d٬,.\s]+)\s*(تومان)$/);
|
||||
|
||||
if (match) {
|
||||
return (
|
||||
<p className="price price-with-amount" dir="ltr">
|
||||
<span className="price-amount ltr-number" lang="en">
|
||||
{match[1].trim()}
|
||||
</span>
|
||||
<span className="price-unit">تومان</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="price price-note">{trimmed}</p>;
|
||||
}
|
||||
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<Link
|
||||
href={product.href || "/store"}
|
||||
className="product-card"
|
||||
aria-label={product.title}
|
||||
>
|
||||
<Image src={product.image} alt={product.title} width={560} height={560} />
|
||||
<div className="product-body">
|
||||
<p className="product-en-name">{product.enName}</p>
|
||||
<h3>{product.title}</h3>
|
||||
<ProductPrice price={product.price} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SaleBannerCard({ banner }: { banner?: SaleBanner | null }) {
|
||||
const style = banner?.image
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(136, 63, 63, 0.55), rgba(0, 35, 75, 0.5)), url(${banner.image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined;
|
||||
const className = "sale sale-promo-card";
|
||||
const label = banner?.ariaLabel || "فروش ویژه";
|
||||
|
||||
if (banner?.linkUrl) {
|
||||
return (
|
||||
<a href={banner.linkUrl} className={className} style={style} aria-label={label} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="/store" className={className} style={style} aria-label={label} />
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCarousel({
|
||||
products,
|
||||
ariaLabel,
|
||||
withSaleBanner = false,
|
||||
saleBanner,
|
||||
}: {
|
||||
products: Product[];
|
||||
ariaLabel: string;
|
||||
withSaleBanner?: boolean;
|
||||
saleBanner?: SaleBanner | null;
|
||||
}) {
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollCarousel = (direction: "next" | "previous") => {
|
||||
const amount = 300;
|
||||
carouselRef.current?.scrollBy({
|
||||
left: direction === "next" ? -amount : amount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-carousel-shell">
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-left"
|
||||
type="button"
|
||||
aria-label="محصولات بعدی"
|
||||
onClick={() => scrollCarousel("next")}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className={`carousel ${withSaleBanner ? "sale-carousel" : ""}`}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{withSaleBanner ? <SaleBannerCard banner={saleBanner} /> : null}
|
||||
{products.map((product) => (
|
||||
<ProductCard key={`${ariaLabel}-${product.title}`} product={product} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="carousel-chevron carousel-chevron-right"
|
||||
type="button"
|
||||
aria-label="محصولات قبلی"
|
||||
onClick={() => scrollCarousel("previous")}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { buildCategoryTree, categoryLabel } from "@/lib/meshkee/format";
|
||||
import type { Category } from "@/lib/meshkee/types";
|
||||
import { storeCategoryHref } from "@/lib/meshkee/urls";
|
||||
|
||||
export function ProductsMegaMenu({ categories }: { categories: Category[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tree = buildCategoryTree(categories).filter(
|
||||
(node) => node.children.length > 0 || !node.parentId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mega-menu ${open ? "is-open" : ""}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<Link
|
||||
href="/store"
|
||||
className="mega-menu-trigger"
|
||||
onFocus={() => setOpen(true)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>محصولات</span>
|
||||
<svg
|
||||
className="mega-menu-chevron"
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.5 5.75 8 10.25l4.5-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
{open ? (
|
||||
<div className="mega-menu-panel" role="menu">
|
||||
{tree.length === 0 ? (
|
||||
<p className="empty-note">دستهبندیای برای نمایش وجود ندارد.</p>
|
||||
) : (
|
||||
<div className="mega-menu-grid">
|
||||
{tree.map((parent) => (
|
||||
<div key={parent.id} className="mega-menu-column">
|
||||
<Link
|
||||
href={storeCategoryHref(parent.id, categoryLabel(parent))}
|
||||
className="mega-menu-parent"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{categoryLabel(parent)}
|
||||
</Link>
|
||||
{parent.children.length > 0 ? (
|
||||
<ul className="mega-menu-children">
|
||||
{parent.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
href={storeCategoryHref(child.id, categoryLabel(child))}
|
||||
className="mega-menu-child"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{categoryLabel(child)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Header } from "@/app/components/header";
|
||||
import { Footer } from "@/app/components/footer";
|
||||
import { AppProviders } from "@/app/providers";
|
||||
import type { Category } from "@/lib/meshkee/types";
|
||||
|
||||
export function SiteShell({
|
||||
categories,
|
||||
children,
|
||||
}: {
|
||||
categories: Category[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppProviders>
|
||||
<div className="site-shell">
|
||||
<Header categories={categories} />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { StaticImageItem } from "@/lib/meshkee/types";
|
||||
import {
|
||||
imageSubtext,
|
||||
imageTitleEn,
|
||||
imageTitleFa,
|
||||
} from "@/lib/meshkee/static-slots";
|
||||
|
||||
export function SlotBanner({
|
||||
image,
|
||||
fallbackEyebrow,
|
||||
fallbackTitle,
|
||||
fallbackText,
|
||||
className = "banner",
|
||||
}: {
|
||||
image?: StaticImageItem | null;
|
||||
fallbackEyebrow?: string;
|
||||
fallbackTitle: string;
|
||||
fallbackText: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const titleFa = imageTitleFa(image) || fallbackTitle;
|
||||
const titleEn = imageTitleEn(image) || fallbackEyebrow || "";
|
||||
const subtext = imageSubtext(image) || fallbackText;
|
||||
const style = image?.url
|
||||
? {
|
||||
backgroundImage: `linear-gradient(135deg, rgba(0, 35, 75, 0.72), rgba(136, 63, 63, 0.62)), url(${image.url})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{titleEn ? <p className="eyebrow">{titleEn}</p> : null}
|
||||
<h3>{titleFa}</h3>
|
||||
<p>{subtext}</p>
|
||||
</>
|
||||
);
|
||||
|
||||
if (image?.linkUrl) {
|
||||
return (
|
||||
<a href={image.linkUrl} className={className} style={style}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlotLinkedImage({
|
||||
image,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
}: {
|
||||
image?: StaticImageItem | null;
|
||||
fallbackSrc: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const src = image?.url || fallbackSrc;
|
||||
const resolvedAlt = imageTitleFa(image) || alt;
|
||||
const node = (
|
||||
<Image
|
||||
src={src}
|
||||
alt={resolvedAlt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
if (image?.linkUrl) {
|
||||
return (
|
||||
<a href={image.linkUrl} aria-label={resolvedAlt}>
|
||||
{node}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCart } from "@/app/providers/cart-provider";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { useToast } from "@/app/providers/toast-provider";
|
||||
import { submitComment } from "@/lib/meshkee/api";
|
||||
import {
|
||||
formatPrice,
|
||||
storeItemName,
|
||||
storeItemPrice,
|
||||
} from "@/lib/meshkee/format";
|
||||
import type {
|
||||
Comment,
|
||||
Product,
|
||||
StoreItemVariant,
|
||||
TechnicalInfo,
|
||||
} from "@/lib/meshkee/types";
|
||||
import { customerLoginUrl, storeItemHref } from "@/lib/meshkee/urls";
|
||||
|
||||
type TabKey = "description" | "technical" | "comments";
|
||||
|
||||
export function StoreDetailClient({
|
||||
variant,
|
||||
product,
|
||||
technical,
|
||||
comments: initialComments,
|
||||
similar,
|
||||
}: {
|
||||
variant: StoreItemVariant;
|
||||
product: Product;
|
||||
technical: TechnicalInfo;
|
||||
comments: Comment[];
|
||||
similar: StoreItemVariant[];
|
||||
}) {
|
||||
const name = storeItemName(variant);
|
||||
const price = storeItemPrice(variant);
|
||||
const images = useMemo(() => {
|
||||
const list = (product.images ?? [])
|
||||
.map((image) => image.url)
|
||||
.filter(Boolean);
|
||||
const fallback = variant.productImage || product.thumbnailUrl || product.image;
|
||||
if (fallback && !list.includes(fallback)) list.unshift(fallback);
|
||||
return list;
|
||||
}, [product, variant]);
|
||||
|
||||
const [activeImage, setActiveImage] = useState(images[0] || "");
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [tab, setTab] = useState<TabKey>("description");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { addItem } = useCart();
|
||||
const { isAuthenticated, user, getAccessToken } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const techRows = useMemo(() => {
|
||||
const fields = technical.form?.fields ?? [];
|
||||
if (!fields.length || !technical.values?.length) return [];
|
||||
return [...fields]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((field) => {
|
||||
const value = technical.values.find((entry) => entry.fieldId === field.id);
|
||||
if (!value) return null;
|
||||
let display = value.textValue ?? "";
|
||||
if (value.optionId) {
|
||||
display =
|
||||
field.options?.find((option) => option.id === value.optionId)?.labelFa ||
|
||||
field.options?.find((option) => option.id === value.optionId)?.label ||
|
||||
display;
|
||||
}
|
||||
if (value.optionIds?.length) {
|
||||
display = value.optionIds
|
||||
.map(
|
||||
(id) =>
|
||||
field.options?.find((option) => option.id === id)?.labelFa ||
|
||||
field.options?.find((option) => option.id === id)?.label ||
|
||||
id,
|
||||
)
|
||||
.join("، ");
|
||||
}
|
||||
if (!display) return null;
|
||||
return {
|
||||
label: field.labelFa || field.label || field.key || "ویژگی",
|
||||
value: display,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
}, [technical]);
|
||||
|
||||
async function handleAddToCart() {
|
||||
await addItem({
|
||||
storeItemVariantId: variant.id,
|
||||
name,
|
||||
price,
|
||||
quantity,
|
||||
image: activeImage || variant.productImage,
|
||||
slug: product.slug || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCommentSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!isAuthenticated || !user) return;
|
||||
const authorName =
|
||||
[user.firstName, user.lastName].filter(Boolean).join(" ").trim() || "کاربر";
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitComment(
|
||||
{
|
||||
entityType: "product",
|
||||
entityId: product.id,
|
||||
authorName,
|
||||
authorEmail: user.email || undefined,
|
||||
text: commentText.trim(),
|
||||
},
|
||||
getAccessToken(),
|
||||
);
|
||||
setComments((prev) => [
|
||||
{
|
||||
id: `local-${Date.now()}`,
|
||||
authorName,
|
||||
text: commentText.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setCommentText("");
|
||||
showToast("نظر شما ارسال شد و پس از تایید نمایش داده میشود");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "ارسال نظر ناموفق بود");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="store-detail">
|
||||
<div className="store-detail-gallery">
|
||||
<button
|
||||
type="button"
|
||||
className="store-main-image zoomable"
|
||||
onClick={() => activeImage && setLightboxOpen(true)}
|
||||
>
|
||||
{activeImage ? (
|
||||
<Image src={activeImage} alt={name} width={900} height={900} priority />
|
||||
) : (
|
||||
<div className="store-card-fallback large" />
|
||||
)}
|
||||
</button>
|
||||
{images.length > 1 ? (
|
||||
<div className="store-thumbs">
|
||||
{images.map((image) => (
|
||||
<button
|
||||
key={image}
|
||||
type="button"
|
||||
className={image === activeImage ? "is-active" : ""}
|
||||
onClick={() => setActiveImage(image)}
|
||||
>
|
||||
<Image src={image} alt={name} width={120} height={120} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="store-detail-info">
|
||||
<p className="eyebrow">{variant.label || product.categoryNameFa || "Store Item"}</p>
|
||||
<h1>{name}</h1>
|
||||
<strong className="store-detail-price ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(price)}
|
||||
</strong>
|
||||
{variant.selections?.length ? (
|
||||
<ul className="store-selections">
|
||||
{variant.selections.map((selection, index) => (
|
||||
<li key={`${selection.optionId}-${index}`}>
|
||||
{selection.variationName}: {selection.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="store-buy-row">
|
||||
<label>
|
||||
تعداد
|
||||
<input
|
||||
className="ltr-number"
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(event) =>
|
||||
setQuantity(Math.max(1, Number(event.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="primary-btn" onClick={() => void handleAddToCart()}>
|
||||
افزودن به سبد خرید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-detail-tabs">
|
||||
<div className="tab-list" role="tablist">
|
||||
{(
|
||||
[
|
||||
["description", "توضیحات"],
|
||||
["technical", "اطلاعات فنی"],
|
||||
["comments", "نظرات"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={tab === key ? "is-active" : ""}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tab-panel">
|
||||
{tab === "description" ? (
|
||||
product.descriptionHtml ? (
|
||||
<div
|
||||
className="rich-html"
|
||||
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="empty-note">توضیحاتی ثبت نشده است.</p>
|
||||
)
|
||||
) : null}
|
||||
{tab === "technical" ? (
|
||||
techRows.length ? (
|
||||
<table className="tech-table">
|
||||
<tbody>
|
||||
{techRows.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<th>{row.label}</th>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="empty-note">اطلاعات فنی موجود نیست.</p>
|
||||
)
|
||||
) : null}
|
||||
{tab === "comments" ? (
|
||||
<div className="comments-block">
|
||||
{isAuthenticated ? (
|
||||
<form className="form" onSubmit={(event) => void handleCommentSubmit(event)}>
|
||||
<textarea
|
||||
required
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="نظر خود را بنویسید"
|
||||
/>
|
||||
<button className="primary-btn" type="submit" disabled={submitting}>
|
||||
ارسال نظر
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="login-box">
|
||||
<p>برای ثبت نظر وارد حساب کاربری شوید.</p>
|
||||
<a className="primary-btn" href={customerLoginUrl(`/store/${variant.id}`)}>
|
||||
ورود / ثبتنام
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="comments-list">
|
||||
{comments.length === 0 ? (
|
||||
<p className="empty-note">هنوز نظری ثبت نشده است.</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<article key={comment.id}>
|
||||
<h3>{comment.authorName || "کاربر"}</h3>
|
||||
<p>{comment.text}</p>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{similar.length > 0 ? (
|
||||
<section className="section similar-section">
|
||||
<h2>محصولات مشابه</h2>
|
||||
<div className="store-grid">
|
||||
{similar.map((item) => {
|
||||
const itemName = storeItemName(item);
|
||||
return (
|
||||
<Link key={item.id} href={storeItemHref(item.id, itemName)} className="store-card">
|
||||
<div className="store-card-media">
|
||||
{item.productImage ? (
|
||||
<Image src={item.productImage} alt={itemName} width={320} height={320} />
|
||||
) : (
|
||||
<div className="store-card-fallback" />
|
||||
)}
|
||||
</div>
|
||||
<div className="store-card-body">
|
||||
<h3>{itemName}</h3>
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(storeItemPrice(item))}
|
||||
</strong>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{lightboxOpen && activeImage ? (
|
||||
<div className="lightbox" role="dialog" aria-modal="true">
|
||||
<button
|
||||
type="button"
|
||||
className="lightbox-backdrop"
|
||||
aria-label="بستن"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
/>
|
||||
<div className="lightbox-content">
|
||||
<Image src={activeImage} alt={name} width={1200} height={1200} />
|
||||
<button type="button" className="icon-btn" onClick={() => setLightboxOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { createAuthHandoff } from "@/lib/meshkee/api";
|
||||
import { displayName } from "@/lib/meshkee/format";
|
||||
import {
|
||||
businessDashboardUrl,
|
||||
customerLoginUrl,
|
||||
customerProfileUrl,
|
||||
} from "@/lib/meshkee/urls";
|
||||
|
||||
export function UserMenu() {
|
||||
const { user, isAuthenticated, isWebsiteAdmin, logout, getAccessToken } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [handingOff, setHandingOff] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<a
|
||||
className="header-icon-btn"
|
||||
href={customerLoginUrl("/")}
|
||||
aria-label="ورود"
|
||||
title="ورود"
|
||||
>
|
||||
<UserIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const name = displayName(user);
|
||||
|
||||
async function openBusinessAdmin(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.button !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const target = businessDashboardUrl();
|
||||
const popup = window.open("about:blank", "_blank");
|
||||
setHandingOff(true);
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const { ticket } = await createAuthHandoff(token || "");
|
||||
const url = new URL(target);
|
||||
if (ticket) url.searchParams.set("handoff", 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(target);
|
||||
else window.open(target, "_blank", "noopener,noreferrer");
|
||||
} finally {
|
||||
setHandingOff(false);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-menu" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`${name} عزیز`}
|
||||
title={`${name} عزیز`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<UserIcon />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="user-menu-dropdown" role="menu">
|
||||
<p className="user-menu-greeting">{name} عزیز</p>
|
||||
<a role="menuitem" href={customerProfileUrl()} onClick={() => setOpen(false)}>
|
||||
پروفایل من
|
||||
</a>
|
||||
{isWebsiteAdmin ? (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={businessDashboardUrl()}
|
||||
onClick={(event) => void openBusinessAdmin(event)}
|
||||
>
|
||||
{handingOff ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
logout();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M5.75 18.5c1.55-2.45 3.7-3.7 6.25-3.7s4.7 1.25 6.25 3.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ContactForm } from "@/app/components/contact-form";
|
||||
import { site } from "@/lib/site-data";
|
||||
|
||||
export const metadata = {
|
||||
title: "تماس با ما | مبلمان هومت",
|
||||
description: "راههای ارتباط با مبلمان هومت، شماره تماسها، ساعات پاسخگویی و آدرس شعب.",
|
||||
};
|
||||
|
||||
const branches = [
|
||||
{
|
||||
index: "۰۱",
|
||||
city: "Tehran",
|
||||
cityFa: "تهران",
|
||||
title: "شعبه مرکزی",
|
||||
lines: ["شهرک غرب", "مجتمع تجاری پلاتین، طبقه چهارم", "مبلمان هومت"],
|
||||
mapQuery: "تهران شهرک غرب مجتمع تجاری پلاتین مبلمان هومت",
|
||||
},
|
||||
{
|
||||
index: "۰۲",
|
||||
city: "Qom",
|
||||
cityFa: "قم",
|
||||
title: "نماینده فروش قم",
|
||||
lines: ["بازار مبل گنجوی", "بلوار نظامی گنجوی، بین کوی ۵ و ۷", "مبلمان هومت"],
|
||||
mapQuery: "قم بازار مبل گنجوی بلوار نظامی گنجوی مبلمان هومت",
|
||||
},
|
||||
{
|
||||
index: "۰۳",
|
||||
city: "Qom",
|
||||
cityFa: "قم",
|
||||
title: "کارخانه شعبه قم",
|
||||
lines: ["بلوار حضرت معصومه، کوی ۳۵", "جنب قالیشویی امیرکبیر", "کارخانه مبلمان هومت"],
|
||||
mapQuery: "قم بلوار حضرت معصومه کوی 35 کارخانه مبلمان هومت",
|
||||
},
|
||||
];
|
||||
|
||||
function MapPinIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 21s6.5-5.2 6.5-10.2A6.5 6.5 0 0 0 12 4a6.5 6.5 0 0 0-6.5 6.8C5.5 15.8 12 21 12 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10.6" r="2.2" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<main className="container">
|
||||
<section className="page-hero">
|
||||
<p className="eyebrow">Contact Us</p>
|
||||
<h1>با مــــــــــا در ارتبـــــــــاط باشید</h1>
|
||||
<p>
|
||||
جهت انتخاب پارچه میتوانید با شماره تماسهای زیر در واتساپ یا تلگرام
|
||||
با هومت در تماس باشید.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="content-grid">
|
||||
<article className="contact-card">
|
||||
<p className="eyebrow">Contact Hoomat</p>
|
||||
<h2>ساعات پاسخگویی: 10 صبح الی 20 شب</h2>
|
||||
<ul className="contact-list">
|
||||
<li>
|
||||
واتساپ / تلگرام:{" "}
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.whatsapp} _ {site.mobile}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
شماره تماس:{" "}
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</span>
|
||||
</li>
|
||||
<li>شعبه مرکزی: مجتمع تجاری پلاتین، طبقه چهارم، مبلمان هومت</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="contact-card">
|
||||
<p className="eyebrow">Have a Question?</p>
|
||||
<h2>با ما درمیان بگذارید ...</h2>
|
||||
<ContactForm />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="section locations-section">
|
||||
<div className="locations-header">
|
||||
<p className="eyebrow">Our Locations</p>
|
||||
<h2>شعب و نمایندگیها</h2>
|
||||
<p>از نمایشگاه تهران تا کارخانه قم، نزدیکترین نقطه هومت را پیدا کنید.</p>
|
||||
</div>
|
||||
<div className="locations-grid">
|
||||
{branches.map((branch) => (
|
||||
<article className="location-card" key={branch.title}>
|
||||
<div className="location-card-top">
|
||||
<span className="location-index" aria-hidden="true">
|
||||
{branch.index}
|
||||
</span>
|
||||
<span className="location-pin">
|
||||
<MapPinIcon />
|
||||
</span>
|
||||
</div>
|
||||
<p className="location-city">
|
||||
<span className="location-city-en">{branch.city}</span>
|
||||
<span>{branch.cityFa}</span>
|
||||
</p>
|
||||
<h3>{branch.title}</h3>
|
||||
<ul className="location-lines">
|
||||
{branch.lines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
className="location-map-link"
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(branch.mapQuery)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
مسیریابی در نقشه
|
||||
<span aria-hidden="true">‹</span>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { SiteShell } from "@/app/components/site-shell";
|
||||
import { getCategories, getFavicon } from "@/lib/meshkee/api";
|
||||
import { site } from "@/lib/site-data";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
let icon = site.logo;
|
||||
try {
|
||||
const favicon = await getFavicon();
|
||||
if (favicon.faviconUrl) icon = favicon.faviconUrl;
|
||||
} catch {
|
||||
// keep default local logo
|
||||
}
|
||||
|
||||
return {
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
icons: [{ url: icon }],
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const categories = await getCategories("product").catch(() => []);
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body>
|
||||
<SiteShell categories={categories}>{children}</SiteShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/meshkee/static-slots";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ slots: STATIC_IMAGE_SLOT_CATALOG });
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { HeroSlider, type HeroSlide } from "@/app/components/hero-slider";
|
||||
import { ProductCarousel } from "@/app/components/product-carousel";
|
||||
import { ArticleCarousel } from "@/app/components/article-carousel";
|
||||
import { SlotBanner, SlotLinkedImage } from "@/app/components/slot-media";
|
||||
import { getStaticImages, listBlogs } from "@/lib/meshkee/api";
|
||||
import { formatDate } from "@/lib/meshkee/format";
|
||||
import {
|
||||
firstSlotImage,
|
||||
imageSubtext,
|
||||
imageTitleEn,
|
||||
imageTitleFa,
|
||||
slotImages,
|
||||
} from "@/lib/meshkee/static-slots";
|
||||
import type { StaticImageItem, StaticImageSlot } from "@/lib/meshkee/types";
|
||||
import { blogHref } from "@/lib/meshkee/urls";
|
||||
import {
|
||||
aboutCopy,
|
||||
articles as fallbackArticles,
|
||||
categories,
|
||||
categoryQuickLinks,
|
||||
diningProducts,
|
||||
features,
|
||||
heroSlides as fallbackHeroSlides,
|
||||
products,
|
||||
site,
|
||||
} from "@/lib/site-data";
|
||||
|
||||
function SectionTitle({
|
||||
eyebrow,
|
||||
title,
|
||||
href,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-title">
|
||||
<div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{href ? (
|
||||
<Link className="section-title-chevron" href={href} aria-label="مشاهده بیشتر">
|
||||
<span aria-hidden="true">‹</span>
|
||||
<em>MORE</em>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickCategoryIcon({ index }: { index: number }) {
|
||||
const shapes = [
|
||||
"M5 18h14M7 18v-5a4 4 0 0 1 4-4h2a4 4 0 0 1 4 4v5M8 18v2m8-2v2",
|
||||
"M8 20v-7a4 4 0 0 1 8 0v7M7 13h10M9 20h6",
|
||||
"M6 18h12M8 18v-8h8v8M10 10v8m4-8v8",
|
||||
"M5 18h14M7 18V9h10v9M9 12h6",
|
||||
"M5 12h14M8 12v8m8-8v8M7 8h10",
|
||||
"M5 18V9h14v9M5 14h14M8 18v2m8-2v2",
|
||||
];
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={shapes[index % shapes.length]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function buildHeroSlides(images: StaticImageItem[]): HeroSlide[] {
|
||||
if (!images.length) return fallbackHeroSlides;
|
||||
|
||||
return images.map((image, index) => {
|
||||
const fallback = fallbackHeroSlides[index % fallbackHeroSlides.length];
|
||||
return {
|
||||
eyebrow: imageTitleEn(image) || fallback.eyebrow,
|
||||
title: imageTitleFa(image) || fallback.title,
|
||||
description: imageSubtext(image) || fallback.description,
|
||||
image: image.url || fallback.image,
|
||||
linkUrl: image.linkUrl,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeQuickCategories(icons: StaticImageItem[]) {
|
||||
return categoryQuickLinks.map((item, index) => {
|
||||
const image = icons[index];
|
||||
return {
|
||||
title: imageTitleFa(image) || item.title,
|
||||
iconImage: image?.url || item.iconImage,
|
||||
href: image?.linkUrl || "#products",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const twoBannerFallbacks = [
|
||||
{
|
||||
eyebrow: "MINIMAL SOFA",
|
||||
title: "خرید مبل مینیمال مدرن",
|
||||
text: "طراحی ساده، دستههای چوبی و نشیمن راحت برای خانههای مدرن.",
|
||||
},
|
||||
{
|
||||
eyebrow: "INSTALLMENT",
|
||||
title: "خرید مبل اقساطی",
|
||||
text: "شرایط ویژه برای خرید انواع مبلمان با تحویل و مشاوره فروش.",
|
||||
},
|
||||
];
|
||||
|
||||
const threeBannerFallbacks = [
|
||||
{
|
||||
title: "مبل راحتی مدرن",
|
||||
text: "انتخابی مناسب برای چیدمان صمیمی و کاربردی فضای نشیمن.",
|
||||
},
|
||||
{
|
||||
title: "سرویس خواب",
|
||||
text: "مدلهای مدرن و کلاسیک برای تکمیل دکوراسیون اتاق خواب.",
|
||||
},
|
||||
{
|
||||
title: "میز جلو مبلی و عسلی",
|
||||
text: "هماهنگ با سبک مبلمان و قابل سفارش برای خانههای ایرانی.",
|
||||
},
|
||||
];
|
||||
|
||||
export default async function Home() {
|
||||
const [homeSlots, blogItems] = await Promise.all([
|
||||
getStaticImages("home").catch(() => [] as StaticImageSlot[]),
|
||||
listBlogs({ page: 1, pageSize: 8 })
|
||||
.then((data) =>
|
||||
data.items.map((post) => ({
|
||||
title: post.title,
|
||||
date: formatDate(post.publishedAt || post.createdAt),
|
||||
image: post.titleImageUrl || "/assets/sample/spacejoy-c0JoR_-2x3E-unsplash.jpg",
|
||||
href: blogHref(post.id, post.title),
|
||||
})),
|
||||
)
|
||||
.catch(() =>
|
||||
fallbackArticles.map((article) => ({
|
||||
...article,
|
||||
href: "/blog",
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
const heroSlides = buildHeroSlides(slotImages(homeSlots, "slider"));
|
||||
const quickCategories = mergeQuickCategories(slotImages(homeSlots, "categories-icon"));
|
||||
const saleBannerImage = firstSlotImage(homeSlots, "special-sale-banner");
|
||||
const featuresImage = firstSlotImage(homeSlots, "product-features-image");
|
||||
const contactImage = firstSlotImage(homeSlots, "contactus-image");
|
||||
const aboutHomeImage = firstSlotImage(homeSlots, "aboutus-image");
|
||||
const twoBanners = slotImages(homeSlots, "two-banner-row");
|
||||
const threeBanners = slotImages(homeSlots, "three-banner-row");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<HeroSlider slides={heroSlides} />
|
||||
|
||||
<section className="quick-categories-section">
|
||||
<div className="container">
|
||||
<div className="quick-category-frame">
|
||||
{quickCategories.map((item, index) => (
|
||||
<a className="quick-category-item" href={item.href} key={`${item.title}-${index}`}>
|
||||
<span className="quick-category-icon">
|
||||
{item.iconImage ? (
|
||||
<Image src={item.iconImage} alt="" width={96} height={96} aria-hidden="true" />
|
||||
) : (
|
||||
<QuickCategoryIcon index={index} />
|
||||
)}
|
||||
</span>
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="grid category-grid">
|
||||
{categories.map((category) => (
|
||||
<article className="category-card" key={category.title}>
|
||||
<Image src={category.image} alt={category.title} width={520} height={460} />
|
||||
<div>
|
||||
<h3>{category.title}</h3>
|
||||
<p>{category.subtitle}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<ProductCarousel
|
||||
products={products}
|
||||
ariaLabel="کاروسل فروش ویژه"
|
||||
withSaleBanner
|
||||
saleBanner={{
|
||||
image: saleBannerImage?.url,
|
||||
linkUrl: saleBannerImage?.linkUrl,
|
||||
ariaLabel: imageTitleFa(saleBannerImage) || "فروش ویژه",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="feature-showcase">
|
||||
<svg className="feature-connectors" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<path d="M55 45 C64 31 75 25 88 17" />
|
||||
<path d="M45 45 C35 28 25 24 12 17" />
|
||||
<path d="M58 56 C70 70 78 81 90 86" />
|
||||
<path d="M42 56 C31 73 22 80 10 86" />
|
||||
</svg>
|
||||
<div className="feature-sofa">
|
||||
<SlotLinkedImage
|
||||
image={featuresImage}
|
||||
fallbackSrc="/assets/sample/sofa-real-v2.png"
|
||||
alt="مبل راحتی هومت"
|
||||
width={900}
|
||||
height={420}
|
||||
/>
|
||||
</div>
|
||||
{features.map((feature, index) => (
|
||||
<article className={`feature-card feature-card-${index + 1}`} key={feature.title}>
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.subtitle}</p>
|
||||
<small className="feature-subtext">{feature.subtext}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" id="products">
|
||||
<div className="container">
|
||||
<SectionTitle eyebrow="Newest sofa" title="جدیدترین مبل" href="/store" />
|
||||
<ProductCarousel products={products} ariaLabel="کاروسل مبل راحتی" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container contact-row">
|
||||
<div className="contact-card">
|
||||
<p className="eyebrow">Contact Us</p>
|
||||
<h2>برای انتخاب پارچه و سفارش با ما در ارتباط باشید</h2>
|
||||
<p>
|
||||
مشاوران هومت هر روز از ساعت 10 صبح الی 20 شب آماده پاسخگویی و
|
||||
راهنمایی برای انتخاب مدل، رنگ، پارچه و شرایط خرید هستند.
|
||||
</p>
|
||||
<ul className="contact-list">
|
||||
<li>
|
||||
واتساپ / تلگرام:{" "}
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.whatsapp} _ {site.mobile}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
شماره تماس:{" "}
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{site.phone}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Link className="primary-btn" href="/contactus">
|
||||
ارتباط با مبلمان هومت
|
||||
</Link>
|
||||
</div>
|
||||
<SlotBanner
|
||||
image={contactImage}
|
||||
className="banner contact-guarantee"
|
||||
fallbackEyebrow="GUARANTEE"
|
||||
fallbackTitle="ضمانت نامه کتبی مبلمان"
|
||||
fallbackText="خرید با اطمینان، ارسال مبل راحتی و کلاسیک و پشتیبانی پس از فروش."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<SectionTitle eyebrow="Dining Set Table" title="کاروسل میز ناهارخوری" />
|
||||
<ProductCarousel products={diningProducts} ariaLabel="کاروسل میز ناهارخوری" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container grid banner-grid">
|
||||
{twoBannerFallbacks.map((fallback, index) => (
|
||||
<SlotBanner
|
||||
key={fallback.title}
|
||||
image={twoBanners[index]}
|
||||
fallbackEyebrow={fallback.eyebrow}
|
||||
fallbackTitle={fallback.title}
|
||||
fallbackText={fallback.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container about-panel">
|
||||
<SlotLinkedImage
|
||||
image={aboutHomeImage}
|
||||
fallbackSrc={site.heroImages[1]}
|
||||
alt="درباره مبلمان هومت"
|
||||
width={720}
|
||||
height={620}
|
||||
/>
|
||||
<div className="about-copy">
|
||||
<p className="eyebrow">{aboutCopy.eyebrow}</p>
|
||||
<h2>درباره هومت</h2>
|
||||
<p>{aboutCopy.story}</p>
|
||||
<Link className="primary-btn" href="/about-us">
|
||||
بیشتر بدانید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container grid banner-grid three">
|
||||
{threeBannerFallbacks.map((fallback, index) => (
|
||||
<SlotBanner
|
||||
key={fallback.title}
|
||||
image={threeBanners[index]}
|
||||
fallbackTitle={fallback.title}
|
||||
fallbackText={fallback.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" id="articles">
|
||||
<div className="container">
|
||||
<SectionTitle eyebrow="Latest Articles" title="آخــریـن مــقـالــات" href="/blog" />
|
||||
<ArticleCarousel articles={blogItems} ariaLabel="کاروسل مقالات" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
getMe,
|
||||
getTenant,
|
||||
refreshTokens,
|
||||
} from "@/lib/meshkee/api";
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
REFRESH_TOKEN_KEY,
|
||||
} from "@/lib/meshkee/config";
|
||||
import type { AuthUser } from "@/lib/meshkee/types";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
ready: boolean;
|
||||
businessId: string | null;
|
||||
isWebsiteAdmin: boolean;
|
||||
getAccessToken: () => string | null;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function readToken(key: string) {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
function writeTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [businessId, setBusinessId] = useState<string | null>(null);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const getAccessToken = useCallback(() => readToken(ACCESS_TOKEN_KEY), []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
let accessToken = readToken(ACCESS_TOKEN_KEY);
|
||||
const refreshToken = readToken(REFRESH_TOKEN_KEY);
|
||||
if (!accessToken && !refreshToken) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!accessToken && refreshToken) {
|
||||
const tokens = await refreshTokens(refreshToken);
|
||||
writeTokens(tokens.accessToken, tokens.refreshToken);
|
||||
accessToken = tokens.accessToken;
|
||||
}
|
||||
if (!accessToken) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
const me = await getMe(accessToken);
|
||||
setUser(me);
|
||||
} catch {
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const tokens = await refreshTokens(refreshToken);
|
||||
writeTokens(tokens.accessToken, tokens.refreshToken);
|
||||
const me = await getMe(tokens.accessToken);
|
||||
setUser(me);
|
||||
return;
|
||||
} catch {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
try {
|
||||
const tenant = await getTenant();
|
||||
if (!cancelled) setBusinessId(String(tenant.id));
|
||||
} catch {
|
||||
if (!cancelled) setBusinessId(null);
|
||||
}
|
||||
await refreshUser();
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})().catch(() => {
|
||||
// swallow unexpected bootstrap rejections (e.g. aborted HMR)
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshUser]);
|
||||
|
||||
const isWebsiteAdmin = useMemo(() => {
|
||||
if (!user || !businessId) return false;
|
||||
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
|
||||
return Boolean(
|
||||
user.businesses?.some(
|
||||
(biz) =>
|
||||
String(biz.id) === String(businessId) &&
|
||||
(biz.isOwner || Boolean(biz.teamRole)),
|
||||
),
|
||||
);
|
||||
}, [user, businessId]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isAuthenticated: Boolean(user),
|
||||
ready,
|
||||
businessId,
|
||||
isWebsiteAdmin,
|
||||
getAccessToken,
|
||||
logout,
|
||||
refreshUser,
|
||||
}),
|
||||
[
|
||||
user,
|
||||
ready,
|
||||
businessId,
|
||||
isWebsiteAdmin,
|
||||
getAccessToken,
|
||||
logout,
|
||||
refreshUser,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
addCartItem,
|
||||
getCart,
|
||||
removeCartItem,
|
||||
updateCartItem,
|
||||
} from "@/lib/meshkee/api";
|
||||
import {
|
||||
cartFromGuest,
|
||||
emptyCart,
|
||||
normalizeCartResponse,
|
||||
} from "@/lib/meshkee/cart";
|
||||
import {
|
||||
clearGuestCart,
|
||||
readGuestCart,
|
||||
writeGuestCart,
|
||||
} from "@/lib/meshkee/guest-cart";
|
||||
import type { CartState, GuestCartLine } from "@/lib/meshkee/types";
|
||||
import { useAuth } from "@/app/providers/auth-provider";
|
||||
import { useToast } from "@/app/providers/toast-provider";
|
||||
|
||||
type CartContextValue = {
|
||||
cart: CartState;
|
||||
itemCount: number;
|
||||
ready: boolean;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
refreshCart: () => Promise<void>;
|
||||
addItem: (item: {
|
||||
storeItemVariantId: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity?: number;
|
||||
image?: string | null;
|
||||
slug?: string;
|
||||
}) => Promise<void>;
|
||||
updateQuantity: (itemId: string, quantity: number) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
guestItems: GuestCartLine[];
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const { ready: authReady, isAuthenticated, businessId, getAccessToken } =
|
||||
useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [cart, setCart] = useState<CartState>(emptyCart());
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [guestItems, setGuestItems] = useState<GuestCartLine[]>([]);
|
||||
|
||||
const refreshCart = useCallback(async () => {
|
||||
if (!authReady) return;
|
||||
const token = getAccessToken();
|
||||
if (isAuthenticated && token && businessId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const localGuest = readGuestCart();
|
||||
if (localGuest.length > 0) {
|
||||
for (const line of localGuest) {
|
||||
await addCartItem(businessId, token, {
|
||||
storeItemVariantId: line.id,
|
||||
quantity: line.quantity,
|
||||
});
|
||||
}
|
||||
clearGuestCart();
|
||||
setGuestItems([]);
|
||||
}
|
||||
const payload = await getCart(businessId, token);
|
||||
setCart(normalizeCartResponse(payload));
|
||||
} catch {
|
||||
setCart(emptyCart());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setReady(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const guest = readGuestCart();
|
||||
setGuestItems(guest);
|
||||
setCart(cartFromGuest(guest));
|
||||
setReady(true);
|
||||
}, [authReady, businessId, getAccessToken, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCart().catch(() => {
|
||||
setCart(emptyCart());
|
||||
setReady(true);
|
||||
});
|
||||
}, [refreshCart]);
|
||||
|
||||
const addItem = useCallback(
|
||||
async (item: {
|
||||
storeItemVariantId: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity?: number;
|
||||
image?: string | null;
|
||||
slug?: string;
|
||||
}) => {
|
||||
const quantity = Math.max(1, Math.floor(item.quantity ?? 1));
|
||||
const token = getAccessToken();
|
||||
|
||||
if (isAuthenticated && token && businessId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await addCartItem(businessId, token, {
|
||||
storeItemVariantId: item.storeItemVariantId,
|
||||
quantity,
|
||||
});
|
||||
setCart(normalizeCartResponse(payload));
|
||||
showToast("به سبد خرید اضافه شد");
|
||||
setOpen(true);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "افزودن به سبد خرید ناموفق بود",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = readGuestCart();
|
||||
const existing = current.find((line) => line.id === item.storeItemVariantId);
|
||||
const next = existing
|
||||
? current.map((line) =>
|
||||
line.id === item.storeItemVariantId
|
||||
? { ...line, quantity: line.quantity + quantity }
|
||||
: line,
|
||||
)
|
||||
: [
|
||||
...current,
|
||||
{
|
||||
id: item.storeItemVariantId,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
image: item.image ?? null,
|
||||
slug: item.slug,
|
||||
quantity,
|
||||
},
|
||||
];
|
||||
writeGuestCart(next);
|
||||
setGuestItems(next);
|
||||
setCart(cartFromGuest(next));
|
||||
showToast("به سبد خرید اضافه شد");
|
||||
setOpen(true);
|
||||
},
|
||||
[businessId, getAccessToken, isAuthenticated, showToast],
|
||||
);
|
||||
|
||||
const updateQuantity = useCallback(
|
||||
async (itemId: string, quantity: number) => {
|
||||
const token = getAccessToken();
|
||||
if (isAuthenticated && token && businessId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload =
|
||||
quantity < 1
|
||||
? await removeCartItem(businessId, token, itemId)
|
||||
: await updateCartItem(businessId, token, itemId, quantity);
|
||||
setCart(normalizeCartResponse(payload));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = readGuestCart();
|
||||
const next =
|
||||
quantity < 1
|
||||
? current.filter((line) => line.id !== itemId)
|
||||
: current.map((line) =>
|
||||
line.id === itemId ? { ...line, quantity } : line,
|
||||
);
|
||||
writeGuestCart(next);
|
||||
setGuestItems(next);
|
||||
setCart(cartFromGuest(next));
|
||||
},
|
||||
[businessId, getAccessToken, isAuthenticated],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
await updateQuantity(itemId, 0);
|
||||
},
|
||||
[updateQuantity],
|
||||
);
|
||||
|
||||
const itemCount = useMemo(
|
||||
() => cart.items.reduce((sum, line) => sum + line.quantity, 0),
|
||||
[cart.items],
|
||||
);
|
||||
|
||||
const value = useMemo<CartContextValue>(
|
||||
() => ({
|
||||
cart,
|
||||
itemCount,
|
||||
ready,
|
||||
loading,
|
||||
open,
|
||||
setOpen,
|
||||
refreshCart,
|
||||
addItem,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
guestItems,
|
||||
}),
|
||||
[
|
||||
cart,
|
||||
itemCount,
|
||||
ready,
|
||||
loading,
|
||||
open,
|
||||
refreshCart,
|
||||
addItem,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
guestItems,
|
||||
],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { AuthProvider } from "@/app/providers/auth-provider";
|
||||
import { CartProvider } from "@/app/providers/cart-provider";
|
||||
import { ToastProvider } from "@/app/providers/toast-provider";
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type ToastContextValue = {
|
||||
message: string | null;
|
||||
showToast: (message: string) => void;
|
||||
clearToast: () => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const showToast = useCallback((next: string) => {
|
||||
setMessage(next);
|
||||
}, []);
|
||||
|
||||
const clearToast = useCallback(() => setMessage(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
const timer = window.setTimeout(() => setMessage(null), 3200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ message, showToast, clearToast }),
|
||||
[message, showToast, clearToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{message ? (
|
||||
<div className="toast" role="status" aria-live="polite">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { StoreDetailClient } from "@/app/components/store-detail-client";
|
||||
import {
|
||||
getProductById,
|
||||
getProductTechnicalInfo,
|
||||
getStoreItemVariant,
|
||||
listComments,
|
||||
listStoreItems,
|
||||
} from "@/lib/meshkee/api";
|
||||
import { storeItemName } from "@/lib/meshkee/format";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ variantId: string; slug?: string[] }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { variantId } = await params;
|
||||
try {
|
||||
const variant = await getStoreItemVariant(variantId);
|
||||
const name = storeItemName(variant);
|
||||
return {
|
||||
title: `${name} | مبلمان هومت`,
|
||||
description: `خرید ${name} از فروشگاه مبلمان هومت`,
|
||||
};
|
||||
} catch {
|
||||
return { title: "محصول | مبلمان هومت" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function StoreDetailPage({ params }: PageProps) {
|
||||
const { variantId } = await params;
|
||||
|
||||
let variant;
|
||||
try {
|
||||
variant = await getStoreItemVariant(variantId);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [{ product }, technical, comments] = await Promise.all([
|
||||
getProductById(variant.productId),
|
||||
getProductTechnicalInfo(variant.productId).catch(() => ({
|
||||
form: null,
|
||||
values: [],
|
||||
})),
|
||||
listComments({ entityType: "product", entityId: variant.productId }).catch(
|
||||
() => [],
|
||||
),
|
||||
]);
|
||||
|
||||
const similarResponse = await listStoreItems({
|
||||
page: 1,
|
||||
pageSize: 8,
|
||||
categoryId: product.categoryId || undefined,
|
||||
}).catch(() => ({ items: [] as Awaited<ReturnType<typeof listStoreItems>>["items"] }));
|
||||
|
||||
const similar = similarResponse.items
|
||||
.filter((item) => item.id !== variant.id)
|
||||
.slice(0, 4);
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<StoreDetailClient
|
||||
variant={variant}
|
||||
product={product}
|
||||
technical={technical}
|
||||
comments={comments}
|
||||
similar={similar}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { listStoreItems } from "@/lib/meshkee/api";
|
||||
import {
|
||||
formatPrice,
|
||||
storeItemName,
|
||||
storeItemPrice,
|
||||
} from "@/lib/meshkee/format";
|
||||
import { storeItemHref } from "@/lib/meshkee/urls";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
categoryId?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
|
||||
export const metadata = {
|
||||
title: "فروشگاه | مبلمان هومت",
|
||||
description: "لیست محصولات قابل خرید فروشگاه مبلمان هومت",
|
||||
};
|
||||
|
||||
export default async function StorePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
const data = await listStoreItems({
|
||||
page,
|
||||
pageSize: 24,
|
||||
categoryId: params.categoryId,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<section className="page-hero">
|
||||
<p className="eyebrow">Store</p>
|
||||
<h1>فروشــــــــگاه هومــــــــت</h1>
|
||||
<p>محصولات قابل سفارش را مشاهده کنید و به سبد خرید اضافه کنید.</p>
|
||||
</section>
|
||||
|
||||
<section className="section store-grid-section">
|
||||
{data.items.length === 0 ? (
|
||||
<p className="empty-note">در حال حاضر محصول فروشگاهی فعالی وجود ندارد.</p>
|
||||
) : (
|
||||
<div className="store-grid">
|
||||
{data.items.map((item) => {
|
||||
const name = storeItemName(item);
|
||||
const href = storeItemHref(item.id, name);
|
||||
const price = storeItemPrice(item);
|
||||
return (
|
||||
<Link key={item.id} href={href} className="store-card">
|
||||
<div className="store-card-media">
|
||||
{item.productImage ? (
|
||||
<Image
|
||||
src={item.productImage}
|
||||
alt={name}
|
||||
width={480}
|
||||
height={480}
|
||||
/>
|
||||
) : (
|
||||
<div className="store-card-fallback" />
|
||||
)}
|
||||
</div>
|
||||
<div className="store-card-body">
|
||||
{item.label ? <p className="eyebrow">{item.label}</p> : null}
|
||||
<h2>{name}</h2>
|
||||
<strong className="ltr-number" dir="ltr" lang="en">
|
||||
{formatPrice(price)}
|
||||
</strong>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.total > data.pageSize ? (
|
||||
<div className="pager">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={`/store?page=${page - 1}${
|
||||
params.categoryId
|
||||
? `&categoryId=${encodeURIComponent(params.categoryId)}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
قبلی
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="ltr-number" dir="ltr" lang="en">
|
||||
{page}
|
||||
</span>
|
||||
{page * data.pageSize < data.total ? (
|
||||
<Link
|
||||
href={`/store?page=${page + 1}${
|
||||
params.categoryId
|
||||
? `&categoryId=${encodeURIComponent(params.categoryId)}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
بعدی
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||
]);
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 529 KiB |
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
MESHKEE_API_BASE,
|
||||
MESHKEE_DOMAIN,
|
||||
} from "@/lib/meshkee/config";
|
||||
import type {
|
||||
AuthUser,
|
||||
BlogPost,
|
||||
Category,
|
||||
Comment,
|
||||
FaviconInfo,
|
||||
Paginated,
|
||||
Product,
|
||||
StaticImageSlot,
|
||||
StoreItemVariant,
|
||||
TechnicalInfo,
|
||||
Tenant,
|
||||
} from "@/lib/meshkee/types";
|
||||
|
||||
export class MeshkeeApiError extends Error {
|
||||
status: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(message: string, status: number, payload?: unknown) {
|
||||
super(message);
|
||||
this.name = "MeshkeeApiError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
type FetchOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
token?: string | null;
|
||||
signal?: AbortSignal;
|
||||
cache?: RequestCache;
|
||||
next?: NextFetchRequestConfig;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (options.token) {
|
||||
headers.Authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
};
|
||||
|
||||
// `next` / `cache` are server-only fetch extensions
|
||||
if (typeof window === "undefined") {
|
||||
if (options.cache) init.cache = options.cache;
|
||||
if (options.next) {
|
||||
(init as RequestInit & { next?: NextFetchRequestConfig }).next = options.next;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${MESHKEE_API_BASE}${path}`, init);
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const record =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const errorValue = record?.error;
|
||||
const messageValue = record?.message;
|
||||
const message =
|
||||
(typeof errorValue === "string" && errorValue) ||
|
||||
(typeof messageValue === "string" && messageValue) ||
|
||||
"خطا در ارتباط با سرور";
|
||||
throw new MeshkeeApiError(message, response.status, payload);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function tenantPath(suffix = "") {
|
||||
return `/tenants/${encodeURIComponent(MESHKEE_DOMAIN)}${suffix}`;
|
||||
}
|
||||
|
||||
export async function getTenant(): Promise<Tenant> {
|
||||
return request<Tenant>(tenantPath(), { next: { revalidate: 300 } });
|
||||
}
|
||||
|
||||
export async function getFavicon(): Promise<FaviconInfo> {
|
||||
return request<FaviconInfo>(tenantPath("/website/favicon"), {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStaticImages(
|
||||
pageKey?: string,
|
||||
): Promise<StaticImageSlot[]> {
|
||||
const query = pageKey ? `?pageKey=${encodeURIComponent(pageKey)}` : "";
|
||||
const data = await request<{ items: StaticImageSlot[] }>(
|
||||
`${tenantPath("/website/static-images")}${query}`,
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function getStaticImageSlot(
|
||||
key: string,
|
||||
): Promise<StaticImageSlot | null> {
|
||||
try {
|
||||
return await request<StaticImageSlot>(
|
||||
tenantPath(`/website/static-images/${encodeURIComponent(key)}`),
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategories(
|
||||
entityType: "product" | "blog" = "product",
|
||||
): Promise<Category[]> {
|
||||
const data = await request<{ items: Category[] }>(
|
||||
`${tenantPath("/categories")}?entityType=${entityType}`,
|
||||
{ next: { revalidate: 120 } },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function listStoreItems(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryId?: string;
|
||||
name?: string;
|
||||
productId?: string;
|
||||
} = {}): Promise<Paginated<StoreItemVariant>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set("page", String(params.page));
|
||||
if (params.pageSize) query.set("pageSize", String(params.pageSize));
|
||||
if (params.categoryId) query.set("categoryId", params.categoryId);
|
||||
if (params.name) query.set("name", params.name);
|
||||
if (params.productId) query.set("productId", params.productId);
|
||||
const qs = query.toString();
|
||||
return request<Paginated<StoreItemVariant>>(
|
||||
`${tenantPath("/store-items")}${qs ? `?${qs}` : ""}`,
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStoreItemVariant(
|
||||
variantId: string,
|
||||
): Promise<StoreItemVariant> {
|
||||
const data = await request<{ variant: StoreItemVariant }>(
|
||||
tenantPath(`/store-items/${encodeURIComponent(variantId)}`),
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
return data.variant;
|
||||
}
|
||||
|
||||
export async function getProductById(productId: string): Promise<{
|
||||
product: Product;
|
||||
relatedProducts: Product[];
|
||||
}> {
|
||||
const data = await request<{
|
||||
product: Product;
|
||||
relatedProducts?: Product[] | null;
|
||||
}>(tenantPath(`/products/by-id/${encodeURIComponent(productId)}`), {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
return {
|
||||
product: data.product,
|
||||
relatedProducts: data.relatedProducts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProductTechnicalInfo(
|
||||
productId: string,
|
||||
): Promise<TechnicalInfo> {
|
||||
return request<TechnicalInfo>(
|
||||
tenantPath(`/products/by-id/${encodeURIComponent(productId)}/technical-info`),
|
||||
{ next: { revalidate: 120 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listBlogs(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}): Promise<Paginated<BlogPost>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set("page", String(params.page));
|
||||
if (params.pageSize) query.set("pageSize", String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
return request<Paginated<BlogPost>>(
|
||||
`${tenantPath("/blogs")}${qs ? `?${qs}` : ""}`,
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBlogById(blogId: string): Promise<BlogPost> {
|
||||
const data = await request<{ blog?: BlogPost } & BlogPost>(
|
||||
tenantPath(`/blogs/by-id/${encodeURIComponent(blogId)}`),
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
return data.blog ?? data;
|
||||
}
|
||||
|
||||
export async function listComments(params: {
|
||||
entityType: "product" | "blog";
|
||||
entityId: string;
|
||||
}): Promise<Comment[]> {
|
||||
const query = new URLSearchParams({
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
});
|
||||
const data = await request<{ items: Comment[] }>(
|
||||
`${tenantPath("/comments")}?${query.toString()}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function submitComment(
|
||||
body: {
|
||||
entityType: "product" | "blog";
|
||||
entityId: string;
|
||||
authorName: string;
|
||||
authorEmail?: string;
|
||||
text: string;
|
||||
},
|
||||
token?: string | null,
|
||||
) {
|
||||
return request(tenantPath("/comments"), {
|
||||
method: "POST",
|
||||
body,
|
||||
token,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitContact(body: {
|
||||
title: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
text: string;
|
||||
}) {
|
||||
return request(tenantPath("/contact-submissions"), {
|
||||
method: "POST",
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMe(token: string, signal?: AbortSignal): Promise<AuthUser> {
|
||||
const data = await request<{ user?: AuthUser } & AuthUser>("/auth/me", {
|
||||
token,
|
||||
signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
return data.user ?? data;
|
||||
}
|
||||
|
||||
export async function refreshTokens(refreshToken: string) {
|
||||
return request<{ accessToken: string; refreshToken: string }>("/auth/refresh", {
|
||||
method: "POST",
|
||||
body: { refreshToken },
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAuthHandoff(token: string) {
|
||||
return request<{ ticket?: string }>("/auth/handoff", {
|
||||
method: "POST",
|
||||
token,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCart(businessId: string, token: string) {
|
||||
return request(`/businesses/${encodeURIComponent(businessId)}/cart`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCartItem(
|
||||
businessId: string,
|
||||
token: string,
|
||||
body: { storeItemVariantId: string; quantity: number },
|
||||
) {
|
||||
return request(`/businesses/${encodeURIComponent(businessId)}/cart/items`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCartItem(
|
||||
businessId: string,
|
||||
token: string,
|
||||
itemId: string,
|
||||
quantity: number,
|
||||
) {
|
||||
return request(
|
||||
`/businesses/${encodeURIComponent(businessId)}/cart/items/${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: { quantity },
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeCartItem(
|
||||
businessId: string,
|
||||
token: string,
|
||||
itemId: string,
|
||||
) {
|
||||
return request(
|
||||
`/businesses/${encodeURIComponent(businessId)}/cart/items/${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
token,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { CartLine, CartState } from "@/lib/meshkee/types";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && !Number.isNaN(Number(value))) {
|
||||
return Number(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function emptyCart(): CartState {
|
||||
return { items: [], subtotal: 0, total: 0 };
|
||||
}
|
||||
|
||||
export function cartFromGuest(
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
price: number;
|
||||
image?: string | null;
|
||||
quantity: number;
|
||||
}>,
|
||||
): CartState {
|
||||
const lines: CartLine[] = items.map((item) => ({
|
||||
id: item.id,
|
||||
storeItemVariantId: item.id,
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
image: item.image ?? null,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0);
|
||||
return { items: lines, subtotal, total: subtotal };
|
||||
}
|
||||
|
||||
export function normalizeCartResponse(payload: unknown): CartState {
|
||||
const root = asRecord(payload);
|
||||
const cart = asRecord(root.cart ?? root);
|
||||
const source = Array.isArray(cart.items)
|
||||
? cart.items
|
||||
: Array.isArray(root.items)
|
||||
? root.items
|
||||
: [];
|
||||
|
||||
const items: CartLine[] = source.map((raw) => {
|
||||
const item = asRecord(raw);
|
||||
const product = asRecord(item.product);
|
||||
const variant = asRecord(item.variant);
|
||||
const storeItem = asRecord(item.storeItem);
|
||||
const quantity = asNumber(item.quantity) ?? 1;
|
||||
const price =
|
||||
asNumber(item.unitPrice) ??
|
||||
asNumber(item.price) ??
|
||||
asNumber(item.discountedPrice) ??
|
||||
asNumber(variant.discountedPrice) ??
|
||||
asNumber(variant.price) ??
|
||||
asNumber(storeItem.discountedPrice) ??
|
||||
asNumber(storeItem.price) ??
|
||||
0;
|
||||
const selections = Array.isArray(item.selections)
|
||||
? item.selections
|
||||
: Array.isArray(variant.selections)
|
||||
? variant.selections
|
||||
: [];
|
||||
const optionLabel = selections
|
||||
.map((entry) => {
|
||||
const row = asRecord(entry);
|
||||
return asString(row.label || row.optionLabel || row.value);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
return {
|
||||
id: asString(item.id || item.itemId),
|
||||
storeItemVariantId: asString(
|
||||
item.storeItemVariantId || variant.id || storeItem.id || item.variantId,
|
||||
),
|
||||
name: asString(
|
||||
item.productTitle ||
|
||||
item.productNameFa ||
|
||||
product.nameFa ||
|
||||
product.name ||
|
||||
product.title ||
|
||||
storeItem.productTitle ||
|
||||
storeItem.productNameFa ||
|
||||
"محصول",
|
||||
),
|
||||
slug: asString(product.slug || item.productSlug || storeItem.productSlug) || undefined,
|
||||
image:
|
||||
asString(
|
||||
item.productImage ||
|
||||
item.image ||
|
||||
product.image ||
|
||||
product.thumbnailUrl ||
|
||||
storeItem.productImage,
|
||||
) || null,
|
||||
price,
|
||||
quantity,
|
||||
optionLabel: optionLabel || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotal =
|
||||
asNumber(cart.subtotal) ?? items.reduce((sum, line) => sum + line.price * line.quantity, 0);
|
||||
const total = asNumber(cart.total) ?? subtotal;
|
||||
return { items, subtotal, total };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const MESHKEE_DOMAIN = "hoomatstore.com";
|
||||
export const MESHKEE_API_BASE = "https://api.meshkee.com/api/v1";
|
||||
export const CUSTOMER_APP_URL = `https://customer.${MESHKEE_DOMAIN}`;
|
||||
export const BUSINESS_APP_URL = `https://business.${MESHKEE_DOMAIN}`;
|
||||
|
||||
export const ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
|
||||
export const REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
|
||||
export const GUEST_CART_KEY = "meshkee-guest-cart";
|
||||
export const GUEST_CART_URL_PARAM = "guestCart";
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Category, CategoryTreeNode, GuestCartLine } from "@/lib/meshkee/types";
|
||||
|
||||
export function slugifyFa(value: string | null | undefined): string {
|
||||
const raw = (value ?? "").trim().toLowerCase();
|
||||
if (!raw) return "item";
|
||||
return raw
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\u0600-\u06FFa-z0-9-]+/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "item";
|
||||
}
|
||||
|
||||
export function formatPrice(value: number | null | undefined): string {
|
||||
const amount = Number(value ?? 0);
|
||||
return `${new Intl.NumberFormat("fa-IR").format(amount)} تومان`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayName(user: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}): string {
|
||||
const first = user.firstName?.trim();
|
||||
if (first) return first;
|
||||
const last = user.lastName?.trim();
|
||||
if (last) return last;
|
||||
return "کاربر";
|
||||
}
|
||||
|
||||
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const map = new Map<string, CategoryTreeNode>();
|
||||
for (const category of categories) {
|
||||
if (category.isActive === false) continue;
|
||||
map.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const node of map.values()) {
|
||||
if (node.parentId && map.has(node.parentId)) {
|
||||
map.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: CategoryTreeNode[]) => {
|
||||
nodes.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
nodes.forEach((node) => sortNodes(node.children));
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function categoryLabel(category: Category): string {
|
||||
return category.nameFa?.trim() || category.name;
|
||||
}
|
||||
|
||||
export function storeItemName(item: {
|
||||
productNameFa?: string | null;
|
||||
productTitle?: string | null;
|
||||
label?: string | null;
|
||||
}): string {
|
||||
return item.productNameFa?.trim() || item.productTitle?.trim() || "محصول";
|
||||
}
|
||||
|
||||
export function storeItemPrice(item: {
|
||||
price: number;
|
||||
discountedPrice?: number | null;
|
||||
}): number {
|
||||
return item.discountedPrice != null && item.discountedPrice > 0
|
||||
? item.discountedPrice
|
||||
: item.price;
|
||||
}
|
||||
|
||||
export function normalizeGuestLines(items: GuestCartLine[]): GuestCartLine[] {
|
||||
const result: GuestCartLine[] = [];
|
||||
for (const item of items) {
|
||||
const id = (item.id || "").trim();
|
||||
if (!id) continue;
|
||||
result.push({
|
||||
id,
|
||||
name: item.name || "محصول",
|
||||
slug: item.slug,
|
||||
price: Number(item.price) || 0,
|
||||
image: item.image ?? null,
|
||||
quantity: Math.max(1, Math.floor(item.quantity) || 1),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
GUEST_CART_KEY,
|
||||
GUEST_CART_URL_PARAM,
|
||||
} from "@/lib/meshkee/config";
|
||||
import { normalizeGuestLines } from "@/lib/meshkee/format";
|
||||
import type { GuestCartLine } from "@/lib/meshkee/types";
|
||||
|
||||
function parseLines(raw: string | null): GuestCartLine[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? normalizeGuestLines(parsed) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function readGuestCart(): GuestCartLine[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
return parseLines(localStorage.getItem(GUEST_CART_KEY));
|
||||
}
|
||||
|
||||
export function writeGuestCart(items: GuestCartLine[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
const normalized = normalizeGuestLines(items);
|
||||
localStorage.setItem(GUEST_CART_KEY, JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
export function clearGuestCart() {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(GUEST_CART_KEY);
|
||||
}
|
||||
|
||||
export function encodeGuestCartForUrl(items: GuestCartLine[]): string {
|
||||
const payload = normalizeGuestLines(items).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
...(item.slug ? { slug: item.slug } : {}),
|
||||
price: item.price,
|
||||
image: item.image ?? null,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
return btoa(unescape(encodeURIComponent(JSON.stringify(payload))))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export { GUEST_CART_URL_PARAM };
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { StaticImageItem, StaticImageSlot } from "@/lib/meshkee/types";
|
||||
|
||||
/**
|
||||
* Slot catalog for Meshkee dashboard import.
|
||||
* If UI image size/aspect changes during development, update these values.
|
||||
*/
|
||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
{
|
||||
key: "slider",
|
||||
label: "Homepage slider",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "16:9",
|
||||
itemCount: null,
|
||||
recommendedWidth: 1440,
|
||||
},
|
||||
{
|
||||
key: "categories-icon",
|
||||
label: "Quick category icons",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: 6,
|
||||
recommendedWidth: 192,
|
||||
},
|
||||
{
|
||||
key: "special-sale-banner",
|
||||
label: "Special sale banner",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "3:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 520,
|
||||
},
|
||||
{
|
||||
key: "product-features-image",
|
||||
label: "Product features sofa image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 900,
|
||||
},
|
||||
{
|
||||
key: "contactus-image",
|
||||
label: "Homepage contact guarantee image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "4:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "two-banner-row",
|
||||
label: "Two banner row",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: "aboutus-image",
|
||||
label: "Homepage about image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "6:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "three-banner-row",
|
||||
label: "Three banner row",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: "about-us-image",
|
||||
label: "About page images",
|
||||
kind: "list" as const,
|
||||
pageKey: "about",
|
||||
aspectRatio: "6:5",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
];
|
||||
|
||||
export function findSlot(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageSlot | undefined {
|
||||
return slots.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
export function slotImages(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageItem[] {
|
||||
const slot = findSlot(slots, key);
|
||||
if (!slot?.images?.length) return [];
|
||||
if (slot.kind === "single") {
|
||||
return slot.images[0]?.url ? [slot.images[0]] : [];
|
||||
}
|
||||
return slot.images.filter((image) => Boolean(image?.url));
|
||||
}
|
||||
|
||||
export function firstSlotImage(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageItem | null {
|
||||
return slotImages(slots, key)[0] ?? null;
|
||||
}
|
||||
|
||||
export function imageTitleFa(image: StaticImageItem | null | undefined): string {
|
||||
return image?.titleFa?.trim() || image?.title?.trim() || "";
|
||||
}
|
||||
|
||||
export function imageTitleEn(image: StaticImageItem | null | undefined): string {
|
||||
return image?.titleEn?.trim() || "";
|
||||
}
|
||||
|
||||
export function imageSubtext(image: StaticImageItem | null | undefined): string {
|
||||
return image?.subtext?.trim() || "";
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
slug?: string;
|
||||
domain: string;
|
||||
logoUrl?: string | null;
|
||||
logoDarkUrl?: string | null;
|
||||
faviconUrl?: string | null;
|
||||
specialProductsSource?: "product" | "store_item";
|
||||
};
|
||||
|
||||
export type FaviconInfo = {
|
||||
faviconUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
logoDarkUrl: string | null;
|
||||
hasDedicatedFavicon: boolean;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
slug: string;
|
||||
entityType?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export type CategoryTreeNode = Category & { children: CategoryTreeNode[] };
|
||||
|
||||
export type StoreItemVariant = {
|
||||
id: string;
|
||||
storeItemId?: string;
|
||||
productId: string;
|
||||
productTitle?: string | null;
|
||||
productNameFa?: string | null;
|
||||
productImage?: string | null;
|
||||
productTotalStock?: number | null;
|
||||
selections?: Array<{
|
||||
variationId?: string;
|
||||
variationName?: string;
|
||||
optionId?: string;
|
||||
value?: string;
|
||||
}>;
|
||||
label?: string | null;
|
||||
price: number;
|
||||
discountedPrice?: number | null;
|
||||
stockQuantity?: number | null;
|
||||
isFestival?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type ProductImage = {
|
||||
mediaId?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
nameFa?: string | null;
|
||||
summary?: string | null;
|
||||
descriptionHtml?: string | null;
|
||||
slug?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
categoryNameFa?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
image?: string | null;
|
||||
images?: ProductImage[];
|
||||
commentCount?: number;
|
||||
};
|
||||
|
||||
export type TechnicalInfo = {
|
||||
form: {
|
||||
fields: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
labelFa?: string;
|
||||
key?: string;
|
||||
type?: string;
|
||||
sortOrder?: number;
|
||||
options?: Array<{ id: string; label?: string; labelFa?: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
values: Array<{
|
||||
fieldId: string;
|
||||
textValue?: string | null;
|
||||
optionId?: string | null;
|
||||
optionIds?: string[] | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type BlogPost = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type?: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
commentCount?: number;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
id: string;
|
||||
authorName?: string;
|
||||
text: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
cellNumber?: string | null;
|
||||
email?: string | null;
|
||||
roles?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
businesses?: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
isOwner?: boolean;
|
||||
teamRole?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CartLine = {
|
||||
id: string;
|
||||
storeItemVariantId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
image?: string | null;
|
||||
price: number;
|
||||
quantity: number;
|
||||
optionLabel?: string;
|
||||
};
|
||||
|
||||
export type CartState = {
|
||||
items: CartLine[];
|
||||
subtotal: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type GuestCartLine = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
price: number;
|
||||
image?: string | null;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type StaticImageItem = {
|
||||
id?: string;
|
||||
url: string;
|
||||
title?: string | null;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
subtext?: string | null;
|
||||
linkUrl?: string | null;
|
||||
};
|
||||
|
||||
export type StaticImageSlot = {
|
||||
key: string;
|
||||
pageKey?: string;
|
||||
kind: "single" | "list";
|
||||
label?: string;
|
||||
aspectRatio?: string;
|
||||
itemCount?: number | null;
|
||||
recommendedWidth?: number | null;
|
||||
images: StaticImageItem[];
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BUSINESS_APP_URL,
|
||||
CUSTOMER_APP_URL,
|
||||
} from "@/lib/meshkee/config";
|
||||
import {
|
||||
encodeGuestCartForUrl,
|
||||
GUEST_CART_URL_PARAM,
|
||||
} from "@/lib/meshkee/guest-cart";
|
||||
import { slugifyFa } from "@/lib/meshkee/format";
|
||||
import type { GuestCartLine } from "@/lib/meshkee/types";
|
||||
|
||||
export function customerLoginUrl(redirectPath = "/"): string {
|
||||
const url = new URL("/login", CUSTOMER_APP_URL);
|
||||
url.searchParams.set("redirect", redirectPath.startsWith("/") ? redirectPath : `/${redirectPath}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function customerProfileUrl(): string {
|
||||
return `${CUSTOMER_APP_URL}/`;
|
||||
}
|
||||
|
||||
export function businessDashboardUrl(): string {
|
||||
return `${BUSINESS_APP_URL}/`;
|
||||
}
|
||||
|
||||
export function customerContinueCheckoutUrl(
|
||||
isAuthenticated: boolean,
|
||||
guestItems: GuestCartLine[] = [],
|
||||
): string {
|
||||
if (isAuthenticated) {
|
||||
const url = new URL("/checkout/cart", CUSTOMER_APP_URL);
|
||||
if (guestItems.length > 0) {
|
||||
url.hash = `${GUEST_CART_URL_PARAM}=${encodeGuestCartForUrl(guestItems)}`;
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
let redirect = "/checkout/cart";
|
||||
if (guestItems.length > 0) {
|
||||
redirect = `${redirect}#${GUEST_CART_URL_PARAM}=${encodeGuestCartForUrl(guestItems)}`;
|
||||
}
|
||||
return customerLoginUrl(redirect);
|
||||
}
|
||||
|
||||
export function storeItemHref(variantId: string, name?: string | null): string {
|
||||
return `/store/${encodeURIComponent(variantId)}/${slugifyFa(name)}`;
|
||||
}
|
||||
|
||||
export function storeCategoryHref(categoryId: string, name?: string | null): string {
|
||||
return `/store?categoryId=${encodeURIComponent(categoryId)}&name=${encodeURIComponent(slugifyFa(name))}`;
|
||||
}
|
||||
|
||||
export function blogHref(id: string, title?: string | null): string {
|
||||
return `/blog/${encodeURIComponent(id)}/${slugifyFa(title)}`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
export const site = {
|
||||
name: "مبلمان هومت",
|
||||
title: "فروشگاه اینترنتی مبلمان و دکوراسیون | مبلمان هومت",
|
||||
description: "خرید آنلاین مبلمان راحتی، مدرن و کلاسیک با قیمتهای ویژه و کیفیت عالی",
|
||||
phone: "0992-3964060",
|
||||
mobile: "0902-0462518",
|
||||
whatsapp: "09223964060",
|
||||
palette: {
|
||||
primary: "#00234B",
|
||||
secondary: "#CC9966",
|
||||
tertiary: "#883F3F",
|
||||
cream: "#F8F3EC",
|
||||
ink: "#12151C",
|
||||
},
|
||||
logo: "/assets/hoomat-logo.png",
|
||||
heroImages: [
|
||||
"/assets/sample/spacejoy-c0JoR_-2x3E-unsplash.jpg",
|
||||
"/assets/sample/premium_photo-1682259448839-2ea3bc7f9e73.avif",
|
||||
"/assets/sample/charlesdeluvio-CdTH3UwWlR4-unsplash.jpg",
|
||||
],
|
||||
addresses: [
|
||||
"دفتر مرکزی: تهران _ شهرک غرب _ مجتمع تجاری پلاتین _ طبقه چهارم _ مبلمان هومت",
|
||||
"نماینده فروش: قم _ بازار مبل گنجوی _ بلوار نظامی گنجوی _ بین کوی 5 و 7 _ مبلمان هومت",
|
||||
"کارخانه: قم _ بلوار حضرت معصومه _ کوی 35 _ جنب قالیشویی امیرکبیر _ کارخانه مبلمان هومت",
|
||||
],
|
||||
};
|
||||
|
||||
export const navItems = [
|
||||
{ label: "محصولات", href: "/store" },
|
||||
{ label: "درباره ما", href: "/about-us" },
|
||||
{ label: "تماس با ما", href: "/contactus" },
|
||||
{ label: "مجله دکوراسیون", href: "/blog" },
|
||||
];
|
||||
|
||||
export const heroSlides = [
|
||||
{
|
||||
eyebrow: "ONLINE FURNITURE SHOP",
|
||||
title: "خرید اینترنتی مبلمان و دکوراسیون منزل",
|
||||
description:
|
||||
"تولید کننده انواع مبلمان راحتی و کلاسیک با امکان خرید آنلاین، مشاوره مستقیم و قیمتهای ویژه برای خانههای امروزی.",
|
||||
image: site.heroImages[0],
|
||||
},
|
||||
{
|
||||
eyebrow: "FURNITURE PRODUCER",
|
||||
title: "تولید کننده انواع مبلمان راحتی و کلاسیک",
|
||||
description:
|
||||
"مدلهای مینیمال، مدرن و کلاسیک را با راهنمایی مستقیم انتخاب کنید و متناسب با چیدمان خانه سفارش دهید.",
|
||||
image: site.heroImages[1],
|
||||
},
|
||||
{
|
||||
eyebrow: "SPECIAL SALE",
|
||||
title: "جشنواره مبلمان با قیمتهای ویژه",
|
||||
description:
|
||||
"برای انتخاب پارچه، رنگ و مدل دلخواه با مشاوران هومت در تماس باشید و خریدی مطمئنتر تجربه کنید.",
|
||||
image: site.heroImages[2],
|
||||
},
|
||||
];
|
||||
|
||||
export const categories = [
|
||||
{
|
||||
title: "مبل راحتی",
|
||||
subtitle: "Sofa Set",
|
||||
image: "/assets/sample/products/sam-moghadam-kvmdsTrGOBM-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "مبل کلاسیک",
|
||||
subtitle: "Royal Sofa Set",
|
||||
image: "/assets/sample/products/scopic-ltd-NLlWwR4d3qU-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "سرویس خواب",
|
||||
subtitle: "Bed Set",
|
||||
image: "/assets/sample/jean-philippe-delberghe-feijc-nqWKM-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "میز ناهارخوری",
|
||||
subtitle: "Dinning Set Table",
|
||||
image: "/assets/sample/lucas-kepner-o_-snE5cDTI-unsplash.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
export const categoryQuickLinks = [
|
||||
{ title: "مبل راحتی", icon: "sofa", iconImage: "/assets/sample/categories/sofa.png" },
|
||||
{ title: "مبل کلاسیک", icon: "chair", iconImage: "/assets/sample/categories/classic.png" },
|
||||
{ title: "مبل مینیمال", icon: "minimal", iconImage: "/assets/sample/categories/console.png" },
|
||||
{ title: "مبل تخت خواب شو", icon: "sleep", iconImage: "/assets/sample/categories/chair.png" },
|
||||
{ title: "میز ناهارخوری", icon: "table", iconImage: "/assets/sample/categories/dining-table.png" },
|
||||
{ title: "سرویس خواب", icon: "bed", iconImage: "/assets/sample/categories/bed.png" },
|
||||
];
|
||||
|
||||
export const features = [
|
||||
{
|
||||
title: "تولید کننده انواع مبلمان",
|
||||
subtitle: "FURNITURE PRODUCER",
|
||||
subtext: "خرید مستقیم از مجموعه هومت",
|
||||
},
|
||||
{
|
||||
title: "فروشگاه اینترنتی مبلمان",
|
||||
subtitle: "ONLINE FURNITURE SHOP",
|
||||
subtext: "انتخاب ساده و سفارش آنلاین",
|
||||
},
|
||||
{
|
||||
title: "ضمانت نامه کتبی مبلمان",
|
||||
subtitle: "GUARANTEE",
|
||||
subtext: "اطمینان بیشتر هنگام خرید",
|
||||
},
|
||||
{
|
||||
title: "ارسال مبل راحتی و کلاسیک",
|
||||
subtitle: "DELIVERY",
|
||||
subtext: "هماهنگی ارسال تا محل شما",
|
||||
},
|
||||
];
|
||||
|
||||
export const products = [
|
||||
{
|
||||
title: "مبل راحتی مینیمال 7 نفره دسته چوبی مدل سون",
|
||||
enName: "MINIMAL SOFA SEVEN",
|
||||
price: "85,900,000 تومان",
|
||||
badge: "جدیدترین مبل",
|
||||
image: "/assets/sample/products/ambo-ampeng-KEGGJPKxcSc-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "مبل راحتی مدرن 8 نفره مدل النا",
|
||||
enName: "ELENA SOFA FURNITURE",
|
||||
price: "209,250,000 تومان",
|
||||
badge: "Newest sofa",
|
||||
image: "/assets/sample/products/kevin-shek-kcxZ-YUZ8VE-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "مبل راحتی مینیمال مدل سارینا 8 نفره",
|
||||
enName: "SARINA SOFA FURNITURE",
|
||||
price: "تماس بگیرید",
|
||||
badge: "فروش ویژه",
|
||||
image: "/assets/sample/products/premium_photo-1682410454254-809412bf04bd.avif",
|
||||
},
|
||||
{
|
||||
title: "مبل راحتی مینیمال جدید مدل نوید 8 نفره",
|
||||
enName: "NAVID MINIMAL SOFA",
|
||||
price: "212,500,000 تومان",
|
||||
badge: "special sale",
|
||||
image: "/assets/sample/products/premium_photo-1683120739323-0a920bdb15a4.avif",
|
||||
},
|
||||
];
|
||||
|
||||
export const diningProducts = [
|
||||
{
|
||||
title: "ست میز ناهارخوری کلاسیک",
|
||||
enName: "CLASSIC DINING SET",
|
||||
price: "تماس بگیرید",
|
||||
image: "/assets/sample/products/sam-moghadam-xej3GOtAQ-o-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "ست میز ناهارخوری مدرن",
|
||||
enName: "MODERN DINING SET",
|
||||
price: "تماس بگیرید",
|
||||
image: "/assets/sample/premium_photo-1670950411934-e21abf39191e.avif",
|
||||
},
|
||||
{
|
||||
title: "صندلی ناهارخوری",
|
||||
enName: "DINING CHAIR",
|
||||
price: "تماس بگیرید",
|
||||
image: "/assets/sample/premium_photo-1682410455752-1007e8cea553.avif",
|
||||
},
|
||||
];
|
||||
|
||||
export const articles = [
|
||||
{
|
||||
title: "راهنمای خرید مبل راحتی با قیمت مناسب ارزشمند تر است تا تعمیر کردن مبل؟",
|
||||
date: "۱۲ مرداد ۱۴۰۴",
|
||||
image: "/assets/sample/spacejoy-c0JoR_-2x3E-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "خرید مبل اقساطی: خرید انواع مبلمان با شرایط ویژه",
|
||||
date: "۵ مرداد ۱۴۰۴",
|
||||
image: "/assets/sample/charlesdeluvio-CKbBn7ZhmyM-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "بازار مبل قم کجاست؟",
|
||||
date: "۲۸ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/charlesdeluvio-CdTH3UwWlR4-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "خرید مبل راحتی آنلاین | راهنمای جامع انتخاب بهترین مدلها",
|
||||
date: "۲۰ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/premium_photo-1682259448839-2ea3bc7f9e73.avif",
|
||||
},
|
||||
{
|
||||
title: "نکات کلیدی برای ست کردن مبلمان راحتی و میز ناهارخوری",
|
||||
date: "۱۴ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/lucas-kepner-o_-snE5cDTI-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "رنگ سال مبل ۱۴۰۴: ایدههایی برای دکوراسیون داخلی",
|
||||
date: "۸ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/jean-philippe-delberghe-feijc-nqWKM-unsplash.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
export const aboutCopy = {
|
||||
eyebrow: "About Us",
|
||||
title: "با مبلمان هومت بیشتر آشنا شویم",
|
||||
quote: "زیبایی را لمس کنید",
|
||||
storyTitle: "داستان مبلمان هومت",
|
||||
story:
|
||||
"مبلمان هومت فروشگاه آنلاین خرید مبلمان منزل با تنوع بالا و قیمت ویژه است. تمرکز ما تولید و عرضه انواع مبلمان راحتی، کلاسیک، مینیمال، سرویس خواب و میز ناهارخوری با کیفیت قابل اعتماد و تجربه خرید ساده است.",
|
||||
visionTitle: "دیدگاه ما",
|
||||
vision:
|
||||
"هدف هومت ساختن فضایی گرمتر برای خانههای ایرانی است؛ جایی که انتخاب مبلمان با اطلاعات کامل، قیمت شفاف، مشاوره مستقیم و پشتیبانی واقعی همراه باشد.",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
||||
{ protocol: "https", hostname: "meshkee-templates.sas.amin.parminstorage.ir" },
|
||||
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
|
||||
{ protocol: "https", hostname: "c804387.parspack.net" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "hoomat-store",
|
||||
"version": "1.0.0",
|
||||
"description": "Persian RTL Next.js homepage clone for Hoomat furniture store.",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"next": "^16.3.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.3.4",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 529 KiB |
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||