Initial commit: Hoomat storefront with Meshkee API and static-image slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 15:00:41 +03:30
co-authored by Cursor
commit cfabf3e327
95 changed files with 12651 additions and 0 deletions
+107
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+93
View File
@@ -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;
}
+339
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+2267
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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>
);
}
+8
View File
@@ -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 });
}
+344
View File
@@ -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>
);
}
+169
View File
@@ -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;
}
+243
View File
@@ -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;
}
+15
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}