Create Glassmond storefront with Meshkee integration

Build the RTL Next.js experience with live catalog, journal, customer interactions, and SEO-ready canonical routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:08:00 +03:30
co-authored by Cursor
commit 98fde02322
66 changed files with 8280 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# dependencies
/node_modules
# Next.js
/.next
/out
# local files
.DS_Store
*.log
# TypeScript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -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 -->
+103
View File
@@ -0,0 +1,103 @@
# Glassmond project context
## Goal
Persian RTL Next.js recreation of the public Glassmond website. Current scope is:
- Homepage (`/`)
- Products (`/products`) and canonical product details (`/products/{id}/{nameFaSlug}`)
- Product categories (`/products/category/{id}/{nameFaSlug}`)
- Blog (`/blog`) and canonical article details (`/blog/{id}/{titleSlug}`)
- About (`/about`)
- Contact (`/contact`)
- Features (`/features`)
The visual source of truth is `https://www.glassmond.com`. Public copy, logo, product imagery, banners and Iran Yekan font were downloaded from the source site. The footer's final black credit row follows the compact treatment used by `https://brb-store.com`.
## Stack
- Next.js App Router
- TypeScript
- Plain responsive CSS in `app/globals.css`
- Lucide React icons
- Local Iran Yekan font files
## Important architecture
- Shared layout: `app/layout.tsx`
- Navigation: `components/header.tsx`
- Footer: `components/footer.tsx`
- Global intersection-based reveal controller: `components/motion-observer.tsx`
- Responsive five-card product carousel: `components/product-carousel.tsx`
- Typed Meshkee API client and canonical URL helpers: `lib/meshkee.ts`
- Build-time static route discovery: `scripts/generate-sitemap-config.mjs`
- Generated Meshkee sitemap payload: `data/generated-sitemap-pages.ts`
- Product gallery/lightbox: `components/product-gallery.tsx`
- Auth-aware account UI: `components/auth-provider.tsx` and `components/user-menu.tsx`
- Shared authenticated comment UI: `components/entity-comments.tsx`
- Reusable headings, feature grid, video CTA and inner-page hero: `components/sections.tsx`
- Static feature and contact presentation data: `data/site.ts`
- Public source-site assets: `public/images` and `public/fonts`
Products and blogs now come from the Meshkee Website API. The homepage, listing pages, details, approved comments and contact form use the live `glassmond.com` tenant. Local files remain as visual fallbacks only.
The contact form validates in the browser and submits through `app/api/contact/route.ts` to Meshkee's public contact-submission endpoint.
## Meshkee backend
- API base: `https://api.meshkee.com/api/v1`
- Apex tenant domain: `glassmond.com`
- Resolved business ID: `22`
- Public content always uses `/tenants/glassmond.com/...`
- Customer portal: `https://customer.glassmond.com`
- Business/admin portal: `https://business.glassmond.com`
- The account provider recognizes Meshkee bearer tokens from the common browser token keys and validates them with `GET /auth/me`. Logged-in users see `{firstName} عزیز`, profile/logout actions, and the management link when their API role is administrative.
- Comment submission is UI-gated to authenticated users. Approved comments remain publicly readable.
- CMS HTML is sanitized server-side with `sanitize-html` before rendering.
- Remote CMS media is optimized through Next Image and restricted in `next.config.ts` to the tenant's ParsPack media path.
- Meshkee discovery manifests are exposed at `/meshkee/static-image-slots` and `/meshkee/sitemap-config.json`.
## SEO architecture
- `npm run build` executes `npm run seo:generate` first. The generator scans `app/**/page.*`, includes only public static pages, and excludes dynamic segments plus auth, cart, checkout, admin, API and Meshkee utility routes.
- Do not add `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts` or `app/robots.ts`. Production nginx proxies the sitemap index, sitemap parts and robots file from the Meshkee API.
- Every public static route has a unique title, description, explicit canonical URL and Open Graph metadata.
- Product/blog search and category-filter query variants are `noindex,follow`; paginated lists have self-referencing canonicals and page-specific metadata.
- Product, product-category and blog detail pages derive metadata and canonical URLs from current CMS fields. The API ID resolves content; the Persian slug is SEO-only and stale slugs redirect.
- All crawlable pages render one H1. Section structure uses H2/H3, including a screen-reader H2 for compact feature sections.
- Content images and gallery thumbnails use descriptive Persian alt text or CMS titles.
- Sanitized CMS body images are allowed only with safe attributes, receive a meaningful title-based fallback alt, and load lazily.
## Design system
- Direction/language: RTL / Persian (`fa`)
- Strict monochrome palette: black accents on light surfaces and white accents on dark/image surfaces; no red
- Main dark: `#1b1b1b`
- Soft dark: `#3b3b3b`
- Warm surface: `#f6f1f0`
- Cream surface: `#fef8f4`
- Font: Iran Yekan
- English section labels use the source site's Montserrat Alternates Light font for a geometric Glassmond-like character. Labels include NEW PRODUCTS, OUR STORY, WHY GLASSMOND, JOURNAL, ABOUT GLASSMOND, OUR SERVICES and contact labels.
- About and contact inner-page heroes use matching English labels. The About page includes a monochrome experience/innovation/support statistics band.
- Square edges, editorial spacing, black/white surfaces and dark feature/footer sections
- Typography is intentionally light and compact. Header links use light weight; only the current route uses regular weight with a thin white underline. The header contains no red.
- Body copy uses light weight while section headings and key labels use bold weight to create hierarchy without color.
- Motion uses staggered hero/page-hero text entry, a header drop-in, one-time scroll reveals for sections/cards and a staggered product carousel transition. It is disabled when `prefers-reduced-motion` is enabled.
## Source contact data
- Mobile/WhatsApp: `0930 129 3902`
- Factory: Qom, Samen industrial town, Alley 23
- Instagram and WhatsApp URLs are stored in `data/site.ts`
## Notes for future work
- Canonical detail links use API IDs; the Persian slug is SEO-only and stale slugs redirect to the current canonical path.
- Canonical product-category pages resolve through `GET /categories/by-id/{id}` and list products by `categoryId`.
- Product list and blog list support server-rendered search, category filtering and pagination.
- Product details include a hover zoom, lightbox gallery, description/comments tabs and related products.
- If the customer portal changes its token handoff/storage convention, update only `components/auth-provider.tsx`.
- The API currently returns no sellable variants or prices for the imported products, so product details use contact/WhatsApp ordering rather than cart actions.
- The portfolio module currently has no published items. Add `/portfolios` and `/portfolios/{id}/{titleFaSlug}` before publishing portfolio content so future sitemap entries resolve.
- The Features page combines source-site feature claims with detailed anti-fog, lighting, touch-control and 12V/IP64 explanations.
- Update this file whenever architecture, routes, backend integration, assets, or project conventions materially change.
+19
View File
@@ -0,0 +1,19 @@
Domain: glassmond.com
Documentation for backend is:https://api.meshkee.com/docs/website
Login or user icon should go to customer.{domainName}.com
When logged in, show user name + عزیز
Show a drop down for it with this item >> پروفایل من - خروج and if admin مدیریت وبسایت
Create products page and details >> connect to products
Details page:
- use light box gallery
- zoom image on hover
- has two tabs in bottom, description and comments. Comment is for authenticated user if not show a login link button or box
Create blog page and details >> connect to blog
update context md for better use in other Ai and systems - update it on every prompt (If required)
+18
View File
@@ -0,0 +1,18 @@
Wire this page to clinicsamen.com, static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- 4 people image > 4 slot
- our-services-icon > 4 slot
- two-banner-bg > 2 slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
+18
View File
@@ -0,0 +1,18 @@
### Sitemap + robots.txt
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
- Live sitemap is an index: /sitemap.xml → /sitemap-main.xml + /sitemap-products.xml.
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
- Category: /products/category/{id}/{nameFaSlug} → GET /tenants/{domain}/categories/by-id/{id} (then list products with categoryId)
- Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id}
- Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id}
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
- After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages).
### On-page SEO (every public page)
- Unique <title> and meta description on every crawlable page
- Exactly one H1; H2/H3 for real sections
- All content images must have meaningful alt (CMS title when available)
- Prefer CMS fields for titles/descriptions; never leave Next defaults
+18
View File
@@ -0,0 +1,18 @@
Clone this website glassmond.com
Download and use banners,images and fonts from main website, icon and color Pallete from main website
Logo from main website either
Pick data from current website - all data is valid
Lang:fa
Directions: rtl
Use next js
Color palette like website
Clone only homepage, about us, contact us
we will read products from backend later
Footer Botton row (meshkee link) should be like https://brb-store.com/
update context md for better use in other Ai and systems - update it on every prompt (If required)
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+116
View File
@@ -0,0 +1,116 @@
import type { Metadata } from "next";
import Image from "next/image";
import { Award, CheckCircle2, Lightbulb, Users } from "lucide-react";
import { Features, PageHero, SectionHeading, VideoBanner } from "@/components/sections";
export const metadata: Metadata = {
title: "درباره ما",
description: "با داستان شکل‌گیری، اختراع‌ها، کیفیت تولید و خدمات آینه هوشمند گلسموند آشنا شوید.",
alternates: { canonical: "/about" },
openGraph: {
title: "درباره گلسموند",
description: "داستان شکل‌گیری گلسموند و اختراع آینه هوشمند ضد بخار را بخوانید.",
url: "/about",
images: ["/images/about/gallery-1.jpg"],
},
};
export default function AboutPage() {
return (
<>
<PageHero englishTitle="ABOUT US" title="درباره ما بیشتر بدانید" />
<section className="about-intro">
<div className="container about-grid">
<div className="about-gallery">
<div className="gallery-main">
<Image src="/images/about/gallery-1.jpg" alt="آینه هوشمند گلسموند در فضای مدرن" fill sizes="40vw" />
</div>
<div className="gallery-side">
<Image src="/images/about/gallery-3.jpg" alt="طراحی آینه گلسموند" fill sizes="22vw" />
</div>
<div className="about-year">
<strong>۱۳۹۳</strong>
<span>سال آغاز فعالیت</span>
</div>
</div>
<div className="about-copy">
<SectionHeading
eyebrow=""
englishTitle="ABOUT GLASSMOND"
title="درباره گلسموند"
align="start"
/>
<p>
شرکت فن‌آوران صنعت گلسمند با برند «گلسموند» در سال ۱۳۹۳ شروع به کار
کرد. بنیان‌گذار این شرکت، مهندس مهدی عرب‌پور، در سال ۱۳۹۷ برای اولین
بار موفق به ثبت اختراع و پتنت موقت آمریکا در زمینه تولید آینه‌های
هوشمند ضد بخار شد.
</p>
<p>
ویژگی‌های انحصاری این آینه‌ها نتیجه طراحی و توسعه داخلی است و محصول
امروز به‌صورت کاملا صنعتی در مجموعه گلسموند تولید می‌شود.
</p>
<div className="about-highlights">
<div><Award /><span><b>ثبت اختراع</b> فناوری انحصاری ضد بخار</span></div>
<div><Users /><span><b>خدمات گسترده</b> نمایندگی در ایران و عراق</span></div>
<div><Lightbulb /><span><b>تولید هوشمند</b> قطعات به‌روز و ایمن</span></div>
</div>
</div>
</div>
</section>
<section className="about-stats" aria-label="آمار مجموعه گلسموند">
<div className="container about-stats-grid">
<article>
<span>EXPERIENCE</span>
<strong>+۱۰</strong>
<p>سال تجربه و نوآوری</p>
</article>
<article>
<span>INNOVATION</span>
<strong>۱</strong>
<p>اختراع ثبت‌شده</p>
</article>
<article>
<span>SUPPORT</span>
<strong>۱۰</strong>
<p>سال خدمات پس از فروش</p>
</article>
</div>
</section>
<section className="why-section">
<div className="container why-grid">
<div>
<SectionHeading
eyebrow=""
englishTitle="OUR SERVICES"
title="چرا گلسموند؟"
align="start"
/>
<p>
طراحی و اختراع انحصاری، رعایت استانداردهای جهانی در طراحی و تولید،
تضمین کیفیت در ضد بخار و ضد آب بودن و قیمت‌گذاری متناسب با کیفیت،
پایه‌های اعتماد مشتریان به گلسموند هستند.
</p>
<ul className="check-list">
<li><CheckCircle2 /> طراحی و اختراع انحصاری محصول</li>
<li><CheckCircle2 /> رعایت استانداردهای جهانی و ایمنی</li>
<li><CheckCircle2 /> تضمین کیفیت و قیمت</li>
<li><CheckCircle2 /> یک سال گارانتی و ده سال خدمات پس از فروش</li>
<li><CheckCircle2 /> استفاده از مواد اولیه درجه یک</li>
</ul>
</div>
<div className="why-image">
<Image src="/images/about/gallery-4.jpg" alt="کیفیت آینه گلسموند" fill sizes="45vw" />
</div>
</div>
</section>
<Features compact />
<VideoBanner />
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import {
MESHKEE_API_BASE,
WEBSITE_DOMAIN,
} from "@/lib/meshkee";
export async function POST(request: Request) {
const body = (await request.json()) as {
title?: string;
name?: string;
email?: string;
cellNumber?: string;
text?: string;
};
if (!body.title?.trim() || !body.name?.trim() || !body.text?.trim()) {
return NextResponse.json(
{ message: "نام، موضوع و متن پیام الزامی است." },
{ status: 400 },
);
}
const response = await fetch(
`${MESHKEE_API_BASE}/tenants/${WEBSITE_DOMAIN}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: body.title.trim(),
name: body.name.trim(),
email: body.email?.trim() || undefined,
cellNumber: body.cellNumber?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
},
);
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { CalendarDays, UserRound } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { CommentsPanel } from "@/components/entity-comments";
import {
blogUrl,
formatPersianDate,
getBlogById,
getBlogs,
getComments,
plainText,
sanitizeCmsContent,
slugifyFa,
} from "@/lib/meshkee";
type BlogDetailProps = {
params: Promise<{ id: string; slug: string }>;
};
export async function generateStaticParams() {
const data = await getBlogs({ pageSize: 50 }).catch(() => ({ items: [] }));
return data.items.map((blog) => ({ id: blog.id, slug: slugifyFa(blog.title) }));
}
export async function generateMetadata({ params }: BlogDetailProps): Promise<Metadata> {
const { id } = await params;
const data = await getBlogById(id).catch(() => null);
if (!data) return { title: "مقاله یافت نشد", robots: { index: false } };
const { blog } = data;
const description =
plainText(blog.abstract || blog.mainTextHtml).slice(0, 155) ||
`مطالعه مقاله ${blog.title} در مجله گلسموند.`;
const canonical = blogUrl(blog);
return {
title: blog.title,
description,
alternates: { canonical },
openGraph: {
title: blog.title,
description,
type: "article",
publishedTime: blog.publishedAt,
url: canonical,
images: blog.titleImageUrl ? [blog.titleImageUrl] : [],
},
};
}
export default async function BlogDetailPage({ params }: BlogDetailProps) {
const { id, slug } = await params;
const data = await getBlogById(id).catch(() => null);
if (!data) notFound();
const { blog } = data;
const canonical = blogUrl(blog);
const expectedSlug = canonical.split("/").at(-1) || "";
let currentSlug = slug;
try {
currentSlug = decodeURIComponent(slug);
} catch {
currentSlug = slug;
}
if (currentSlug !== expectedSlug) redirect(encodeURI(canonical));
const comments = await getComments("blog", blog.id);
const content = sanitizeCmsContent(blog.mainTextHtml, blog.title);
const authorName = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
return (
<article className="blog-detail">
<header className="blog-detail-header">
<div className="container">
<nav className="detail-breadcrumbs light" aria-label="مسیر صفحه">
<Link href="/">صفحه اصلی</Link>
<span>/</span>
<Link href="/blog">مجله</Link>
<span>/</span>
<b>{blog.title}</b>
</nav>
<span className="page-hero-label">GLASSMOND JOURNAL</span>
<h1>{blog.title}</h1>
<div className="blog-meta">
<span><CalendarDays />{formatPersianDate(blog.publishedAt)}</span>
{authorName && <span><UserRound />{authorName}</span>}
</div>
</div>
</header>
<div className="container blog-detail-image">
<Image
src={blog.titleImageUrl || "/images/articles/decorative-mirrors.jpg"}
alt={blog.title}
fill
sizes="(max-width: 900px) 100vw, 1100px"
priority
/>
</div>
<div className="container blog-content-wrap">
{blog.abstract && <p className="blog-lead">{blog.abstract}</p>}
<div className="cms-content" dangerouslySetInnerHTML={{ __html: content }} />
</div>
<section className="blog-comments">
<div className="container">
<div className="section-heading align-start">
<span className="english-title">COMMENTS</span>
<h2>دیدگاه‌ها</h2>
</div>
<CommentsPanel entityType="blog" entityId={blog.id} comments={comments.items} />
</div>
</section>
</article>
);
}
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Search } from "lucide-react";
import { ApiBlogCard } from "@/components/api-blog-card";
import { PageHero } from "@/components/sections";
import { getBlogs, getCategories } from "@/lib/meshkee";
type BlogSearchParams = {
page?: string;
q?: string;
category?: string;
};
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<BlogSearchParams>;
}): Promise<Metadata> {
const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const q = (params.q || "").trim();
const category = (params.category || "").trim();
const baseDescription =
"مقالات گلسموند درباره آینه هوشمند، آینه دکوراتیو، نورپردازی و کاربرد آینه در دکوراسیون داخلی.";
if (q || category) {
const title = q ? `نتایج جست‌وجوی «${q}»` : "مقالات دسته‌بندی انتخاب‌شده";
return {
title,
description: `${title} در مجله تخصصی گلسموند.`,
alternates: { canonical: "/blog" },
robots: { index: false, follow: true },
};
}
const canonical = page > 1 ? `/blog?page=${page}` : "/blog";
const title = page > 1 ? `مجله گلسموند – صفحه ${page}` : "مجله گلسموند";
return {
title,
description: page > 1 ? `${baseDescription} صفحه ${page}.` : baseDescription,
alternates: { canonical },
openGraph: {
title,
description: "مقالات تخصصی درباره آینه هوشمند، نورپردازی و دکوراسیون داخلی.",
url: canonical,
images: ["/images/articles/decorative-mirrors.jpg"],
},
};
}
function pageHref(page: number, q: string, category: string) {
const query = new URLSearchParams();
if (page > 1) query.set("page", String(page));
if (q) query.set("q", q);
if (category) query.set("category", category);
const value = query.toString();
return value ? `/blog?${value}` : "/blog";
}
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<BlogSearchParams>;
}) {
const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const q = (params.q || "").trim();
const category = (params.category || "").trim();
const [blogs, categories] = await Promise.all([
getBlogs({ page, pageSize: 9, title: q, categoryId: category }).catch(() => ({
items: [],
total: 0,
page,
pageSize: 9,
})),
getCategories("blog").catch(() => ({ items: [] })),
]);
const totalPages = Math.max(1, Math.ceil(blogs.total / blogs.pageSize));
return (
<>
<PageHero englishTitle="JOURNAL" title="مجله گلسموند" />
<section className="blog-list-section">
<div className="container">
<form className="catalog-filters blog-filters" action="/blog" method="get">
<label>
<span>جست‌وجوی مقاله</span>
<div>
<input type="search" name="q" defaultValue={q} placeholder="عنوان مقاله..." />
<Search />
</div>
</label>
<label>
<span>دسته‌بندی</span>
<select name="category" defaultValue={category}>
<option value="">همه مقالات</option>
{categories.items.map((item) => (
<option key={item.id} value={item.id}>{item.nameFa || item.name}</option>
))}
</select>
</label>
<button className="primary-button" type="submit">جست‌وجو</button>
</form>
{blogs.items.length > 0 ? (
<div className="blog-list-grid">
{blogs.items.map((blog) => <ApiBlogCard key={blog.id} blog={blog} />)}
</div>
) : (
<div className="empty-state">
<h2>مقاله‌ای پیدا نشد</h2>
<p>عبارت جست‌وجو را تغییر دهید یا فیلترها را حذف کنید.</p>
<Link href="/blog">نمایش همه مقالات</Link>
</div>
)}
{totalPages > 1 && (
<nav className="pagination" aria-label="صفحه‌بندی مقالات">
{page > 1 && (
<Link href={pageHref(page - 1, q, category)}><ArrowRight />قبلی</Link>
)}
<span>صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")}</span>
{page < totalPages && (
<Link href={pageHref(page + 1, q, category)}>بعدی<ArrowLeft /></Link>
)}
</nav>
)}
</div>
</section>
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Camera, Clock3, MapPin, MessageCircle, Phone } from "lucide-react";
import { ContactForm } from "@/components/contact-form";
import { PageHero, SectionHeading } from "@/components/sections";
import { contact } from "@/data/site";
export const metadata: Metadata = {
title: "تماس با ما",
description: "شماره تماس، آدرس کارخانه و فرم ارسال پیام مستقیم به تیم آینه هوشمند گلسموند.",
alternates: { canonical: "/contact" },
openGraph: {
title: "تماس با گلسموند",
description: "راه‌های ارتباط مستقیم با تیم فروش و پشتیبانی گلسموند.",
url: "/contact",
images: ["/images/hero.jpg"],
},
};
export default function ContactPage() {
return (
<>
<PageHero englishTitle="CONTACT US" title="با ما تماس بگیرید" />
<section className="contact-section">
<div className="container">
<SectionHeading
eyebrow=""
englishTitle="CONTACT INFO"
title="اطلاعات تماس با ما"
description="هفت روز هفته می‌توانید از راه‌های زیر با تیم گلسموند در ارتباط باشید."
/>
<div className="contact-cards">
<article>
<span><Phone /></span>
<h3>شماره تماس</h3>
<a href={contact.mobileHref}>{contact.mobile}</a>
</article>
<article>
<span><MapPin /></span>
<h3>آدرس کارخانه</h3>
<p>{contact.address}</p>
</article>
<article>
<span><Clock3 /></span>
<h3>زمان پاسخ‌گویی</h3>
<p>هفت روز هفته</p>
</article>
</div>
<div className="contact-main">
<div className="form-panel">
<SectionHeading
eyebrow=""
englishTitle="SEND A MESSAGE"
title="پیامی برای ما ارسال کنید"
description="فرم را تکمیل کنید تا پیام شما مستقیم برای تیم گلسموند ثبت شود."
align="start"
/>
<ContactForm />
</div>
<aside className="contact-aside">
<h3>در شبکه‌های اجتماعی همراه ما باشید</h3>
<p>نمونه محصولات تازه، خبرها و پروژه‌های اجراشده گلسموند را ببینید.</p>
<a href={contact.instagram} target="_blank" rel="noreferrer">
<Camera />
اینستاگرام گلسموند
</a>
<a href={contact.whatsapp} target="_blank" rel="noreferrer">
<MessageCircle />
گفت‌وگو در واتساپ
</a>
</aside>
</div>
</div>
</section>
<section className="map-section" aria-label="موقعیت گلسموند روی نقشه">
<iframe
src="https://www.google.com/maps/embed?pb=!1m14!1m8!1m3!1d3282.943383616102!2d50.85739!3d34.630871!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3f93bb7deb55b5fb%3A0xc59744e996908549!2z2YXYrNiq2YXYuSDYqtis2KfYsduMINmF2LPaqdmI2YbbjCDZgdix2K_ZiNiz!5e0!3m2!1sfa!2s!4v1704776476571!5m2!1sfa!2s"
width="100%"
height="480"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="موقعیت کارخانه گلسموند"
/>
</section>
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import Image from "next/image";
import { Droplets, MousePointerClick, ShieldCheck, SunMedium } from "lucide-react";
import { Features, PageHero, SectionHeading, VideoBanner } from "@/components/sections";
export const metadata: Metadata = {
title: "ویژگی‌ها",
description:
"ویژگی‌های آینه هوشمند گلسموند؛ فناوری ضد بخار، نور استاندارد، کنترل لمسی، ایمنی ۱۲ ولت و استاندارد IP64.",
alternates: { canonical: "/features" },
openGraph: {
title: "ویژگی‌های آینه هوشمند گلسموند",
description:
"فناوری ضد بخار، نور استاندارد، کنترل لمسی و ایمنی ۱۲ ولت را بیشتر بشناسید.",
url: "/features",
images: ["/images/about/gallery-2.jpg"],
},
};
const technologies = [
{
icon: Droplets,
english: "ANTI-FOG",
title: "فناوری ضد بخار",
description:
"گرمایش یکنواخت سطح آینه، دیدی شفاف و بدون بخار را در فضای مرطوب حمام حفظ می‌کند.",
},
{
icon: SunMedium,
english: "SMART LIGHT",
title: "نورپردازی استاندارد",
description:
"نور یکدست و کاربردی برای اصلاح، آرایش و استفاده روزمره، بدون ایجاد سایه‌های مزاحم.",
},
{
icon: MousePointerClick,
english: "TOUCH CONTROL",
title: "کنترل لمسی",
description:
"کلیدهای لمسی هوشمند با دسترسی سریع و ظاهری مینیمال، هماهنگ با طراحی یکپارچه آینه.",
},
{
icon: ShieldCheck,
english: "SAFE POWER",
title: "ایمنی ۱۲ ولت",
description:
"استفاده از برق ۱۲ ولت و استاندارد IP64 برای کاهش مصرف انرژی و افزایش ایمنی در محیط مرطوب.",
},
];
export default function FeaturesPage() {
return (
<>
<PageHero englishTitle="FEATURES" title="ویژگی‌های آینه هوشمند گلسموند" />
<section className="features-intro">
<div className="container features-intro-grid">
<div className="features-intro-image">
<Image
src="/images/about/gallery-2.jpg"
alt="جزئیات آینه هوشمند گلسموند"
fill
sizes="(max-width: 860px) 100vw, 50vw"
priority
/>
<span>GLASSMOND</span>
</div>
<div className="features-intro-copy">
<SectionHeading
eyebrow=""
englishTitle="SMART TECHNOLOGY"
title="هوشمندی در تمام جزئیات"
align="start"
/>
<p>
آینه‌های گلسموند با تمرکز بر ایمنی، دوام و تجربه استفاده روزمره
طراحی شده‌اند. فناوری ضد بخار، نور استاندارد و کنترل لمسی در ساختاری
یکپارچه قرار گرفته‌اند تا محصول در عین زیبایی، کاملا کاربردی باشد.
</p>
<div className="feature-specs">
<div>
<strong>IP64</strong>
<span>مقاوم در برابر نفوذ آب و بخار</span>
</div>
<div>
<strong>۱۲V</strong>
<span>ایمن و کم‌مصرف</span>
</div>
<div>
<strong>۱۰</strong>
<span>سال خدمات پس از فروش</span>
</div>
</div>
</div>
</div>
</section>
<Features />
<section className="technology-section">
<div className="container">
<SectionHeading
eyebrow=""
englishTitle="CORE FEATURES"
title="تکنولوژی برای آسایش بیشتر"
description="هر قابلیت با هدف استفاده ساده‌تر، ایمن‌تر و ماندگارتر طراحی شده است."
/>
<div className="technology-grid">
{technologies.map((item, index) => {
const Icon = item.icon;
return (
<article key={item.english}>
<span className="technology-number">{String(index + 1).padStart(2, "0")}</span>
<Icon />
<small>{item.english}</small>
<h3>{item.title}</h3>
<p>{item.description}</p>
</article>
);
})}
</div>
</div>
</section>
<VideoBanner />
</>
);
}
+3371
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { AuthProvider } from "@/components/auth-provider";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { MotionObserver } from "@/components/motion-observer";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://glassmond.com"),
title: {
default: "آینه هوشمند گلسموند",
template: "%s | گلسموند",
},
description:
"تولیدکننده آینه‌های هوشمند، ضد بخار و آرایشی با استاندارد IP64، گارانتی یکساله و ده سال خدمات پس از فروش.",
icons: {
icon: "/images/logo.png",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fa" dir="rtl">
<body>
<AuthProvider>
<MotionObserver />
<Header />
<main>{children}</main>
<Footer />
</AuthProvider>
</body>
</html>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { generatedStaticPages } from "@/data/generated-sitemap-pages";
export function GET() {
return NextResponse.json({
baseUrl: "https://glassmond.com",
staticPages: generatedStaticPages,
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
export function GET() {
return NextResponse.json({
slots: [
{
key: "home-hero",
label: "Homepage hero",
kind: "single",
pageKey: "home",
aspectRatio: "16:9",
itemCount: 1,
recommendedWidth: 1920,
},
{
key: "home-video-cover",
label: "Homepage video cover",
kind: "single",
pageKey: "home",
aspectRatio: "16:7",
itemCount: 1,
recommendedWidth: 1600,
},
{
key: "inner-page-hero",
label: "Inner page hero",
kind: "single",
pageKey: "global",
aspectRatio: "16:7",
itemCount: 1,
recommendedWidth: 1920,
},
{
key: "about-gallery",
label: "About gallery",
kind: "list",
pageKey: "about",
aspectRatio: "1:1",
itemCount: 4,
recommendedWidth: 1200,
},
],
});
}
+156
View File
@@ -0,0 +1,156 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, ArrowUpLeft, CheckCircle2 } from "lucide-react";
import { ProductCarousel } from "@/components/product-carousel";
import { Features, SectionHeading, VideoBanner } from "@/components/sections";
import {
blogUrl,
formatPersianDate,
getBlogs,
getProducts,
} from "@/lib/meshkee";
export const metadata: Metadata = {
title: "آینه هوشمند ضد بخار",
description:
"گلسموند، تولیدکننده آینه هوشمند ضد بخار با نور حرفه‌ای، استاندارد IP64، یک سال گارانتی و ده سال خدمات پس از فروش.",
alternates: { canonical: "/" },
openGraph: {
title: "آینه هوشمند ضد بخار گلسموند",
description:
"آینه‌های هوشمند گلسموند؛ تلفیق طراحی مدرن، نور حرفه‌ای و فناوری ضد بخار.",
url: "/",
type: "website",
locale: "fa_IR",
images: ["/images/hero.jpg"],
},
};
export default async function HomePage() {
const [productData, blogData] = await Promise.all([
getProducts({ pageSize: 12 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
getBlogs({ pageSize: 4 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 4 })),
]);
return (
<>
<section className="home-hero">
<div className="container hero-content">
<span className="hero-kicker">اولین آینه ضد بخار در ایران</span>
<h1>
زیبایی را <strong>هوشمندانه</strong> ببینید
</h1>
<p>
آینه‌های هوشمند گلسموند؛ تلفیقی از طراحی مدرن، نور حرفه‌ای و فناوری
ضد بخار برای تجربه‌ای متفاوت.
</p>
<div className="hero-actions">
<Link href="/products" className="primary-button">
مشاهده محصولات
<ArrowLeft />
</Link>
<Link href="/about" className="text-button">
درباره گلسموند
<ArrowUpLeft />
</Link>
</div>
</div>
<div className="hero-index" aria-hidden="true">
<span>01</span>
<i />
<small>GLASSMOND</small>
</div>
<a href="#products" className="scroll-hint" aria-label="رفتن به محصولات">
<span>اسکرول کنید</span>
<i />
</a>
</section>
<section id="products" className="products-section">
<div className="container">
<SectionHeading
eyebrow=""
englishTitle="NEW PRODUCTS"
title="محصولات جدید ما"
description="اولین آینه ضد بخار در ایران"
/>
{productData.items.length > 0 ? (
<ProductCarousel products={productData.items} />
) : (
<p className="empty-state">در حال حاضر محصولی برای نمایش وجود ندارد.</p>
)}
</div>
</section>
<section className="resume-section">
<div className="container resume-grid">
<div>
<SectionHeading
eyebrow=""
englishTitle="OUR STORY"
title="یک دهه نوآوری در صنعت آینه هوشمند"
align="start"
/>
<p className="resume-text">
صادرات به کشورهای خاورمیانه، همکاری با بزرگ‌ترین شرکت واگن‌سازی و
لوکوموتیوسازی خاورمیانه (پارس مپنا) و ایجاد نمایندگی‌های معتبر و
فعال در ایران و مناطق شمال کشور عراق، بخشی از مسیر گلسموند است.
</p>
<ul className="check-list">
<li><CheckCircle2 /> ثبت اختراع آینه هوشمند ضد بخار</li>
<li><CheckCircle2 /> تولید صنعتی با استاندارد IP64</li>
<li><CheckCircle2 /> یک سال گارانتی و ده سال خدمات</li>
</ul>
</div>
<div className="resume-image">
<Image src="/images/about/gallery-2.jpg" alt="آینه هوشمند گلسموند" fill sizes="50vw" />
<div className="experience-badge">
<strong>+۱۰</strong>
<span>سال تجربه و نوآوری</span>
</div>
</div>
</div>
</section>
<Features />
<VideoBanner />
<section id="articles" className="articles-section">
<div className="container">
<SectionHeading
eyebrow=""
englishTitle="JOURNAL"
title="جدیدترین مقالات"
description="مطالب کاربردی درباره آینه، طراحی داخلی و دکوراسیون مدرن"
/>
<div className="articles-grid">
{blogData.items.map((article) => (
<article className="article-card" key={article.id}>
<div className="article-image">
<Link href={blogUrl(article)}>
<Image
src={article.titleImageUrl || "/images/articles/decorative-mirrors.jpg"}
alt={article.title}
fill
sizes="(max-width: 800px) 100vw, 25vw"
/>
</Link>
</div>
<div className="article-copy">
<span>{formatPersianDate(article.publishedAt)}</span>
<h3><Link href={blogUrl(article)}>{article.title}</Link></h3>
<p>{article.abstract}</p>
<Link href={blogUrl(article)} aria-label={`مطالعه ${article.title}`}>
ادامه مطلب
<ArrowLeft />
</Link>
</div>
</article>
))}
</div>
</div>
</section>
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
import type { Metadata } from "next";
import Link from "next/link";
import { MessageCircle, Phone, ShieldCheck } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { ApiProductCard } from "@/components/api-product-card";
import { ProductTabs } from "@/components/entity-comments";
import { ProductGallery } from "@/components/product-gallery";
import {
getComments,
getProductById,
getProducts,
plainText,
productImage,
productUrl,
sanitizeCmsContent,
slugifyFa,
} from "@/lib/meshkee";
type ProductPageProps = {
params: Promise<{ id: string; slug: string }>;
};
export async function generateStaticParams() {
const data = await getProducts({ pageSize: 50 }).catch(() => ({ items: [] }));
return data.items.map((product) => ({
id: product.id,
slug: slugifyFa(product.nameFa || product.title),
}));
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { id } = await params;
const data = await getProductById(id).catch(() => null);
if (!data) return { title: "محصول یافت نشد", robots: { index: false } };
const product = data.product;
const title = product.nameFa || product.title;
const description =
plainText(product.summary || product.descriptionHtml).slice(0, 155) ||
`مشخصات و تصاویر ${title} از محصولات گلسموند.`;
const image = productImage(product);
const canonical = productUrl(product);
return {
title,
description,
alternates: { canonical },
openGraph: {
title,
description,
url: canonical,
images: image ? [image] : [],
},
};
}
export default async function ProductDetailPage({ params }: ProductPageProps) {
const { id, slug } = await params;
const data = await getProductById(id).catch(() => null);
if (!data) notFound();
const { product, relatedProducts = [] } = data;
const canonical = productUrl(product);
const expectedSlug = canonical.split("/").at(-1) || "";
let currentSlug = slug;
try {
currentSlug = decodeURIComponent(slug);
} catch {
currentSlug = slug;
}
if (currentSlug !== expectedSlug) redirect(encodeURI(canonical));
const title = product.nameFa || product.title;
const mainImage = productImage(product);
const images = Array.from(
new Set([mainImage, ...(product.images || []).map((item) => item.url)].filter(Boolean)),
) as string[];
const comments = await getComments("product", product.id);
const categoryTitle = product.categoryNameFa || product.categoryName || "آینه";
const categoryHref = product.categoryId
? `/products/category/${product.categoryId}/${slugifyFa(categoryTitle)}`
: null;
const descriptionHtml = sanitizeCmsContent(
product.descriptionHtml || product.summary || "اطلاعات تکمیلی این محصول به‌زودی درج می‌شود.",
title,
);
return (
<>
<section className="product-detail-top">
<div className="container">
<nav className="detail-breadcrumbs" aria-label="مسیر صفحه">
<Link href="/">صفحه اصلی</Link>
<span>/</span>
<Link href="/products">محصولات</Link>
<span>/</span>
<b>{title}</b>
</nav>
<div className="product-detail-grid">
<ProductGallery images={images.length ? images : ["/images/hero.jpg"]} alt={title} />
<div className="product-detail-copy">
<span className="english-title">PRODUCT DETAILS</span>
<p className="product-category">{product.categoryNameFa || "آینه هوشمند"}</p>
<h1>{title}</h1>
{product.summary && <p className="product-summary">{product.summary}</p>}
<dl className="product-facts">
<div>
<dt>دسته‌بندی</dt>
<dd>
{categoryHref ? <Link href={categoryHref}>{categoryTitle}</Link> : categoryTitle}
</dd>
</div>
<div>
<dt>وضعیت سفارش</dt>
<dd>{product.store?.inStock ? "موجود" : "تماس بگیرید"}</dd>
</div>
<div>
<dt>خدمات</dt>
<dd>یک سال گارانتی و ده سال پشتیبانی</dd>
</div>
</dl>
<div className="product-assurance">
<ShieldCheck />
<p>تولید مطابق استاندارد IP64 با برق ایمن ۱۲ ولت</p>
</div>
<div className="product-detail-actions">
<a className="primary-button" href="tel:+989301293902">
<Phone />
تماس برای سفارش
</a>
<a
className="outline-dark-button"
href="https://wa.me/989301293902"
target="_blank"
rel="noreferrer"
>
<MessageCircle />
واتساپ
</a>
</div>
</div>
</div>
</div>
</section>
<section className="product-information">
<div className="container">
<ProductTabs
descriptionHtml={descriptionHtml}
entityId={product.id}
comments={comments.items}
/>
</div>
</section>
{relatedProducts.length > 0 && (
<section className="related-section">
<div className="container">
<div className="section-heading">
<span className="english-title">RELATED PRODUCTS</span>
<h2>محصولات مرتبط</h2>
</div>
<div className="catalog-grid">
{relatedProducts.slice(0, 3).map((item) => (
<ApiProductCard key={item.id} product={item} headingLevel={3} />
))}
</div>
</div>
</section>
)}
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { ApiProductCard } from "@/components/api-product-card";
import { PageHero } from "@/components/sections";
import {
getCategories,
getCategoryById,
getProducts,
productCategoryUrl,
slugifyFa,
} from "@/lib/meshkee";
type ProductCategoryPageProps = {
params: Promise<{ id: string; slug: string }>;
};
export async function generateStaticParams() {
const data = await getCategories("product").catch(() => ({ items: [] }));
return data.items.map((category) => ({
id: category.id,
slug: slugifyFa(category.nameFa || category.name),
}));
}
export async function generateMetadata({
params,
}: ProductCategoryPageProps): Promise<Metadata> {
const { id } = await params;
const data = await getCategoryById(id).catch(() => null);
if (!data) return { title: "دسته‌بندی یافت نشد", robots: { index: false } };
const title = data.category.nameFa || data.category.name;
const canonical = productCategoryUrl(data.category);
const description =
data.category.description ||
`مشاهده محصولات دسته ${title} گلسموند، شامل آینه‌های هوشمند با طراحی مدرن و فناوری ضد بخار.`;
return {
title: `${title} | محصولات`,
description,
alternates: { canonical },
openGraph: {
title: `${title} | محصولات گلسموند`,
description,
url: canonical,
},
};
}
export default async function ProductCategoryPage({
params,
}: ProductCategoryPageProps) {
const { id, slug } = await params;
const categoryData = await getCategoryById(id).catch(() => null);
if (!categoryData) notFound();
const { category } = categoryData;
const canonical = productCategoryUrl(category);
const expectedSlug = canonical.split("/").at(-1) || "";
let currentSlug = slug;
try {
currentSlug = decodeURIComponent(slug);
} catch {
currentSlug = slug;
}
if (currentSlug !== expectedSlug) redirect(encodeURI(canonical));
const products = await getProducts({ categoryId: category.id, pageSize: 24 }).catch(() => ({
items: [],
total: 0,
page: 1,
pageSize: 24,
}));
const title = category.nameFa || category.name;
return (
<>
<PageHero englishTitle="PRODUCT CATEGORY" title={title} />
<section className="catalog-section category-catalog">
<div className="container">
<header className="category-intro">
<span className="english-title">GLASSMOND COLLECTION</span>
<h2>محصولات {title}</h2>
<p>
{category.description ||
`مجموعه محصولات ${title} گلسموند با تمرکز بر کیفیت ساخت، ایمنی و طراحی ماندگار.`}
</p>
</header>
{products.items.length > 0 ? (
<div className="catalog-grid">
{products.items.map((product) => (
<ApiProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="empty-state">
<h2>محصولی در این دسته ثبت نشده است</h2>
<p>برای مشاهده محصولات جدید دوباره به این صفحه سر بزنید.</p>
</div>
)}
</div>
</section>
</>
);
}
+165
View File
@@ -0,0 +1,165 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Search } from "lucide-react";
import { ApiProductCard } from "@/components/api-product-card";
import { PageHero } from "@/components/sections";
import {
getCategories,
getCategoryById,
getProducts,
productCategoryUrl,
} from "@/lib/meshkee";
type ProductsSearchParams = {
page?: string;
q?: string;
category?: string;
};
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<ProductsSearchParams>;
}): Promise<Metadata> {
const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const q = (params.q || "").trim();
const categoryId = (params.category || "").trim();
const baseDescription =
"مشاهده محصولات آینه هوشمند، ضد بخار و آرایشی گلسموند با تصاویر، مشخصات و جزئیات کامل.";
if (q || categoryId) {
const category = categoryId
? await getCategoryById(categoryId).then((data) => data.category).catch(() => null)
: null;
const label = q
? `نتایج جست‌وجوی «${q}»`
: `محصولات ${category?.nameFa || category?.name || "دسته‌بندی انتخاب‌شده"}`;
const canonical = category && !q ? productCategoryUrl(category) : "/products";
return {
title: label,
description: `${label} در مجموعه محصولات آینه هوشمند گلسموند.`,
alternates: { canonical },
robots: { index: false, follow: true },
};
}
const canonical = page > 1 ? `/products?page=${page}` : "/products";
const title = page > 1 ? `محصولات آینه هوشمند – صفحه ${page}` : "محصولات آینه هوشمند";
return {
title,
description: page > 1 ? `${baseDescription} صفحه ${page}.` : baseDescription,
alternates: { canonical },
openGraph: {
title: "محصولات آینه هوشمند گلسموند",
description: "مجموعه آینه‌های هوشمند، ضد بخار و آرایشی گلسموند را مشاهده کنید.",
url: canonical,
images: ["/images/hero.jpg"],
},
};
}
function pageHref(page: number, q: string, category: string) {
const query = new URLSearchParams();
if (page > 1) query.set("page", String(page));
if (q) query.set("q", q);
if (category) query.set("category", category);
const value = query.toString();
return value ? `/products?${value}` : "/products";
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<ProductsSearchParams>;
}) {
const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const q = (params.q || "").trim();
const category = (params.category || "").trim();
const [products, categories] = await Promise.all([
getProducts({ page, pageSize: 12, name: q, categoryId: category }).catch(() => ({
items: [],
total: 0,
page,
pageSize: 12,
})),
getCategories("product").catch(() => ({ items: [] })),
]);
const totalPages = Math.max(1, Math.ceil(products.total / products.pageSize));
return (
<>
<PageHero englishTitle="PRODUCTS" title="محصولات گلسموند" />
<section className="catalog-section">
<div className="container">
<form className="catalog-filters" action="/products" method="get">
<label>
<span>جست‌وجوی محصول</span>
<div>
<input
type="search"
name="q"
defaultValue={q}
placeholder="نام محصول را وارد کنید..."
/>
<Search />
</div>
</label>
<label>
<span>دسته‌بندی</span>
<select name="category" defaultValue={category}>
<option value="">همه محصولات</option>
{categories.items.map((item) => (
<option key={item.id} value={item.id}>{item.nameFa || item.name}</option>
))}
</select>
</label>
<button className="primary-button" type="submit">اعمال فیلتر</button>
</form>
<div className="catalog-summary">
<p><strong>{products.total.toLocaleString("fa-IR")}</strong> محصول</p>
{(q || category) && <Link href="/products">حذف فیلترها</Link>}
</div>
{products.items.length > 0 ? (
<div className="catalog-grid">
{products.items.map((product) => (
<ApiProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="empty-state">
<h2>محصولی پیدا نشد</h2>
<p>عبارت جست‌وجو یا دسته‌بندی را تغییر دهید.</p>
</div>
)}
{totalPages > 1 && (
<nav className="pagination" aria-label="صفحه‌بندی محصولات">
{page > 1 && (
<Link href={pageHref(page - 1, q, category)}>
<ArrowRight />
قبلی
</Link>
)}
<span>صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")}</span>
{page < totalPages && (
<Link href={pageHref(page + 1, q, category)}>
بعدی
<ArrowLeft />
</Link>
)}
</nav>
)}
</div>
</section>
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import {
blogUrl,
formatPersianDate,
type Blog,
} from "@/lib/meshkee";
export function ApiBlogCard({ blog }: { blog: Blog }) {
const href = blogUrl(blog);
return (
<article className="blog-list-card">
<Link className="blog-list-image" href={href}>
<Image
src={blog.titleImageUrl || "/images/articles/decorative-mirrors.jpg"}
alt={blog.title}
fill
sizes="(max-width: 680px) 100vw, (max-width: 1050px) 50vw, 33vw"
/>
</Link>
<div className="blog-list-copy">
<span>{formatPersianDate(blog.publishedAt)}</span>
<h2><Link href={href}>{blog.title}</Link></h2>
<p>{blog.abstract}</p>
<Link href={href}>
مطالعه مقاله
<ArrowLeft />
</Link>
</div>
</article>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import {
productImage,
productUrl,
slugifyFa,
type Product,
} from "@/lib/meshkee";
export function ApiProductCard({
product,
headingLevel = 2,
}: {
product: Product;
headingLevel?: 2 | 3;
}) {
const title = product.nameFa || product.title;
const href = productUrl(product);
const Heading = headingLevel === 3 ? "h3" : "h2";
const categoryTitle = product.categoryNameFa || product.categoryName || "آینه هوشمند";
const categoryHref = product.categoryId
? `/products/category/${product.categoryId}/${slugifyFa(categoryTitle)}`
: null;
return (
<article className="catalog-product-card">
<Link className="catalog-product-image" href={href}>
<Image
src={productImage(product) || "/images/hero.jpg"}
alt={title}
fill
sizes="(max-width: 620px) 100vw, (max-width: 1000px) 50vw, 33vw"
/>
</Link>
<div className="catalog-product-copy">
<span>
{categoryHref ? <Link href={categoryHref}>{categoryTitle}</Link> : categoryTitle}
</span>
<Heading><Link href={href}>{title}</Link></Heading>
<div>
<p>{product.store?.inStock ? "موجود" : "برای سفارش تماس بگیرید"}</p>
<Link href={href} aria-label={`مشاهده ${title}`}>
جزئیات
<ArrowLeft />
</Link>
</div>
</div>
</article>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { MESHKEE_API_BASE } from "@/lib/meshkee";
export type AuthUser = {
id?: string | number;
firstName?: string;
lastName?: string;
name?: string;
cellNumber?: string;
email?: string;
role?: string;
roles?: string[];
isAdmin?: boolean;
};
type AuthContextValue = {
user: AuthUser | null;
token: string | null;
loading: boolean;
isAdmin: boolean;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue>({
user: null,
token: null,
loading: true,
isAdmin: false,
logout: () => undefined,
});
const tokenKeys = [
"accessToken",
"meshkeeAccessToken",
"meshkee_access_token",
"meshkee-token",
];
function readToken() {
try {
for (const key of tokenKeys) {
const value = window.localStorage.getItem(key);
if (value) return value;
}
const authValue = window.localStorage.getItem("meshkee-auth");
if (authValue) {
const auth = JSON.parse(authValue) as { accessToken?: string };
if (auth.accessToken) return auth.accessToken;
}
const cookies = document.cookie.split(";").map((item) => item.trim());
for (const key of tokenKeys) {
const prefix = `${key}=`;
const cookie = cookies.find((item) => item.startsWith(prefix));
if (cookie) return decodeURIComponent(cookie.slice(prefix.length));
}
} catch {
return null;
}
return null;
}
function clearTokens() {
try {
tokenKeys.forEach((key) => {
window.localStorage.removeItem(key);
document.cookie = `${key}=; Max-Age=0; path=/; domain=.glassmond.com`;
document.cookie = `${key}=; Max-Age=0; path=/`;
});
window.localStorage.removeItem("meshkee-auth");
} catch {
// External customer portal remains the source of truth for authentication.
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const accessToken = readToken();
if (!accessToken) {
setLoading(false);
return;
}
setToken(accessToken);
fetch(`${MESHKEE_API_BASE}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(async (response) => {
if (!response.ok) throw new Error("Unauthorized");
return response.json() as Promise<{ user: AuthUser }>;
})
.then((data) => setUser(data.user))
.catch(() => {
clearTokens();
setToken(null);
})
.finally(() => setLoading(false));
}, []);
const logout = useCallback(() => {
clearTokens();
setUser(null);
setToken(null);
}, []);
const isAdmin = useMemo(() => {
const roles = [user?.role, ...(user?.roles ?? [])]
.filter(Boolean)
.map((role) => String(role).toLowerCase());
return Boolean(
user?.isAdmin ||
roles.some((role) => ["admin", "owner", "superadmin", "business_admin"].includes(role)),
);
}, [user]);
return (
<AuthContext.Provider value={{ user, token, loading, isAdmin, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { FormEvent, useState } from "react";
import { ArrowLeft } from "lucide-react";
export function ContactForm() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [sending, setSending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formElement = event.currentTarget;
const form = new FormData(formElement);
const name = String(form.get("name") ?? "").trim();
const phone = String(form.get("phone") ?? "").trim();
const subject = String(form.get("subject") ?? "").trim();
const message = String(form.get("message") ?? "").trim();
if (!name || !phone || !subject || !message) {
setError("لطفا نام، شماره تماس، موضوع و متن پیام را کامل کنید.");
return;
}
setError("");
setSuccess("");
setSending(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: subject,
name,
cellNumber: phone,
text: message,
}),
});
if (!response.ok) throw new Error("Contact submission failed");
formElement.reset();
setSuccess("پیام شما با موفقیت ثبت شد. همکاران ما با شما تماس می‌گیرند.");
} catch {
setError("ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.");
} finally {
setSending(false);
}
}
return (
<form className="contact-form" onSubmit={submit}>
<div className="form-row">
<label>
<span>نام و نام خانوادگی</span>
<input type="text" name="name" placeholder="نام شما" autoComplete="name" />
</label>
<label>
<span>شماره تماس</span>
<input type="tel" name="phone" placeholder="۰۹۱۲ ۱۲۳ ۴۵۶۷" inputMode="tel" autoComplete="tel" />
</label>
</div>
<label>
<span>موضوع</span>
<input type="text" name="subject" placeholder="موضوع پیام" />
</label>
<label>
<span>پیام شما</span>
<textarea name="message" rows={6} placeholder="پیام خود را اینجا بنویسید..." />
</label>
{error && <p className="form-error" role="alert">{error}</p>}
{success && <p className="form-success" role="status">{success}</p>}
<button type="submit" className="primary-button" disabled={sending}>
{sending ? "در حال ارسال..." : "ارسال پیام"}
<ArrowLeft />
</button>
</form>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { FormEvent, useState } from "react";
import { MessageSquare, Send, UserRound } from "lucide-react";
import { useAuth } from "@/components/auth-provider";
import {
CUSTOMER_URL,
MESHKEE_API_BASE,
WEBSITE_DOMAIN,
type PublicComment,
} from "@/lib/meshkee";
export function CommentsPanel({
entityType,
entityId,
comments,
}: {
entityType: "product" | "blog";
entityId: string;
comments: PublicComment[];
}) {
const { user, loading } = useAuth();
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user) return;
const form = event.currentTarget;
const data = new FormData(form);
const text = String(data.get("text") ?? "").trim();
if (!text) return;
setStatus("sending");
try {
const authorName =
[user.firstName, user.lastName].filter(Boolean).join(" ") ||
user.name ||
"کاربر گلسموند";
const response = await fetch(
`${MESHKEE_API_BASE}/tenants/${WEBSITE_DOMAIN}/comments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entityType,
entityId,
authorName,
authorEmail: user.email || undefined,
text,
}),
},
);
if (!response.ok) throw new Error("Comment failed");
setStatus("sent");
form.reset();
} catch {
setStatus("error");
}
}
return (
<div className="comments-panel">
<div className="comments-list">
{comments.length > 0 ? (
comments.map((comment) => (
<article key={comment.id}>
<span><UserRound /></span>
<div>
<h3>{comment.authorName}</h3>
<p>{comment.text}</p>
</div>
</article>
))
) : (
<div className="comments-empty">
<MessageSquare />
<p>هنوز دیدگاهی ثبت نشده است. اولین نفر باشید.</p>
</div>
)}
</div>
{!loading && (
user ? (
<form className="comment-form" onSubmit={submit}>
<label htmlFor={`comment-${entityId}`}>دیدگاه شما</label>
<textarea
id={`comment-${entityId}`}
name="text"
rows={5}
placeholder="نظر خود را بنویسید..."
required
/>
<button className="primary-button" type="submit" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال..." : "ثبت دیدگاه"}
<Send />
</button>
{status === "sent" && <p className="comment-success">دیدگاه شما ثبت شد و پس از تأیید نمایش داده می‌شود.</p>}
{status === "error" && <p className="form-error">ارسال دیدگاه انجام نشد. دوباره تلاش کنید.</p>}
</form>
) : (
<div className="comment-login-box">
<UserRound />
<div>
<h3>برای ثبت دیدگاه وارد شوید</h3>
<p>ثبت نظر برای کاربران واردشده امکان‌پذیر است.</p>
</div>
<a className="primary-button" href={CUSTOMER_URL}>ورود به حساب</a>
</div>
)
)}
</div>
);
}
export function ProductTabs({
descriptionHtml,
entityId,
comments,
}: {
descriptionHtml: string;
entityId: string;
comments: PublicComment[];
}) {
const [tab, setTab] = useState<"description" | "comments">("description");
return (
<section className="entity-tabs">
<div className="tab-buttons" role="tablist" aria-label="اطلاعات محصول">
<button
type="button"
role="tab"
aria-selected={tab === "description"}
className={tab === "description" ? "active" : ""}
onClick={() => setTab("description")}
>
توضیحات
</button>
<button
type="button"
role="tab"
aria-selected={tab === "comments"}
className={tab === "comments" ? "active" : ""}
onClick={() => setTab("comments")}
>
دیدگاه‌ها
<span>{comments.length.toLocaleString("fa-IR")}</span>
</button>
</div>
<div className="tab-content">
{tab === "description" ? (
<div
className="cms-content"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<CommentsPanel entityType="product" entityId={entityId} comments={comments} />
)}
</div>
</section>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowUpLeft, Camera, MapPin, MessageCircle, Phone, Send } from "lucide-react";
import { contact } from "@/data/site";
export function Footer() {
return (
<footer id="site-footer" className="footer">
<div className="container footer-grid">
<div className="footer-about">
<Image src="/images/logo.png" alt="گلسموند" width={174} height={72} />
<p>
آینه هوشمند گلسموند؛ اولین تولیدکننده آینه‌های هوشمند ضد بخار با طراحی
انحصاری، استانداردهای جهانی و خدمات پس از فروش مطمئن.
</p>
<div className="socials">
<a href={contact.instagram} target="_blank" rel="noreferrer" aria-label="اینستاگرام">
<Camera />
</a>
<a href="/" aria-label="تلگرام">
<Send />
</a>
<a href={contact.whatsapp} target="_blank" rel="noreferrer" aria-label="واتساپ">
<MessageCircle />
</a>
</div>
</div>
<div>
<h3>دسترسی سریع</h3>
<ul className="footer-links">
<li><Link href="/">صفحه اصلی</Link></li>
<li><Link href="/products">محصولات</Link></li>
<li><Link href="/blog">مقالات</Link></li>
<li><Link href="/features">ویژگی‌ها</Link></li>
<li><Link href="/about">درباره ما</Link></li>
<li><Link href="/contact">تماس با ما</Link></li>
</ul>
</div>
<div>
<h3>ارتباط با ما</h3>
<ul className="footer-contact">
<li>
<MapPin />
<span>{contact.address}</span>
</li>
<li>
<Phone />
<a href={contact.mobileHref}>{contact.mobile}</a>
</li>
</ul>
<Link href="/contact" className="footer-cta">
ارسال پیام
<ArrowUpLeft />
</Link>
</div>
</div>
<div className="footer-bottom">
<div className="container footer-bottom-inner">
<span>تمام حقوق برای گلسموند محفوظ است.</span>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit"
>
<span className="meshkee-credit-frame" aria-hidden="true">
<i className="line-l" />
<i className="line-t" />
<i className="line-r" />
<i className="line-b" />
</span>
<Image
src="/images/meshkee-logo-white.png"
alt="Meshkee"
width={42}
height={42}
className="meshkee-credit-logo"
/>
<span className="meshkee-credit-text">
<span>Designed And Developed By</span>
<strong>Meshkee E-Commerce Team</strong>
</span>
</a>
</div>
</div>
</footer>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Menu, Phone, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { contact } from "@/data/site";
import { UserMenu } from "@/components/user-menu";
const nav = [
{ href: "/", label: "صفحه اصلی" },
{ href: "/products", label: "محصولات" },
{ href: "/blog", label: "مقالات" },
{ href: "/features", label: "ویژگی‌ها" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 30);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => setOpen(false), [pathname]);
return (
<header className={`site-header ${scrolled ? "is-scrolled" : ""}`}>
<div className="container nav-wrap">
<Link href="/" className="brand" aria-label="گلسموند - صفحه اصلی">
<Image src="/images/logo.png" alt="گلسموند" width={164} height={68} priority />
</Link>
<nav className="desktop-nav" aria-label="ناوبری اصلی">
{nav.map((item) => {
const active =
item.href === "/"
? pathname === "/"
: !item.href.includes("#") && pathname === item.href;
return (
<Link key={item.href} href={item.href} className={active ? "active" : ""}>
{item.label}
</Link>
);
})}
</nav>
<a className="nav-phone" href={contact.mobileHref} aria-label="تماس با گلسموند">
<Phone size={17} />
<span>{contact.mobile}</span>
</a>
<UserMenu />
<button
className="menu-button"
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={open ? "بستن منو" : "باز کردن منو"}
>
{open ? <X /> : <Menu />}
</button>
</div>
<div className={`mobile-panel ${open ? "open" : ""}`}>
<nav className="container" aria-label="ناوبری موبایل">
{nav.map((item) => {
const active =
item.href === "/"
? pathname === "/"
: !item.href.includes("#") && pathname === item.href;
return (
<Link key={item.href} href={item.href} className={active ? "active" : ""}>
{item.label}
</Link>
);
})}
<a href={contact.mobileHref} className="mobile-call">
<Phone size={18} />
تماس با گلسموند
</a>
</nav>
</div>
</header>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
const revealSelectors = [
".section-heading",
".product-carousel",
".resume-grid > *",
".features-intro-grid > *",
".feature-card",
".technology-grid > *",
".video-content > *",
".article-card",
".about-grid > *",
".about-stats-grid > *",
".why-grid > *",
".contact-cards article",
".contact-main > *",
".map-section",
".catalog-product-card",
".category-intro",
".blog-list-card",
".product-detail-grid > *",
".entity-tabs",
".blog-content-wrap",
".comments-list article",
".footer-grid > *",
];
export function MotionObserver() {
const pathname = usePathname();
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const root = document.documentElement;
const elements: HTMLElement[] = [];
revealSelectors.forEach((selector) => {
document.querySelectorAll<HTMLElement>(selector).forEach((element, index) => {
element.dataset.reveal = "";
element.style.setProperty("--reveal-delay", `${Math.min(index, 5) * 70}ms`);
elements.push(element);
});
});
root.classList.add("motion-ready");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{
threshold: 0.12,
rootMargin: "0px 0px -55px",
},
);
elements.forEach((element) => observer.observe(element));
return () => {
observer.disconnect();
root.classList.remove("motion-ready");
elements.forEach((element) => {
delete element.dataset.reveal;
element.classList.remove("is-visible");
element.style.removeProperty("--reveal-delay");
});
};
}, [pathname]);
return null;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Phone } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
productImage,
productUrl,
type Product,
} from "@/lib/meshkee";
function getPageSize() {
if (typeof window === "undefined") return 5;
if (window.innerWidth < 620) return 1;
if (window.innerWidth < 900) return 2;
return 5;
}
export function ProductCarousel({ products }: { products: Product[] }) {
const [pageSize, setPageSize] = useState(5);
const [page, setPage] = useState(0);
useEffect(() => {
const update = () => {
setPageSize(getPageSize());
setPage(0);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
const pages = Math.max(1, products.length - pageSize + 1);
const visibleProducts = useMemo(() => {
const start = page;
return products.slice(start, start + pageSize);
}, [page, pageSize]);
return (
<div className="product-carousel">
<div
key={`${page}-${pageSize}`}
className="source-products-grid"
style={{ "--visible-products": pageSize } as React.CSSProperties}
aria-live="polite"
>
{visibleProducts.map((product) => (
<article className="source-product-card" key={product.id}>
<div className="source-product-image">
<Link href={productUrl(product)} aria-label={product.nameFa || product.title}>
<Image
src={productImage(product) || "/images/hero.jpg"}
alt={product.nameFa || product.title}
fill
sizes="(max-width: 620px) 100vw, (max-width: 900px) 50vw, (max-width: 1200px) 33vw, 20vw"
/>
</Link>
</div>
<div className="source-product-copy">
<div>
<h3>
<Link href={productUrl(product)}>{product.nameFa || product.title}</Link>
</h3>
<a href="tel:+989301293902">تماس بگیرید</a>
</div>
<a
className="product-phone"
href="tel:+989301293902"
aria-label={`تماس برای ${product.title}`}
>
<Phone />
</a>
</div>
</article>
))}
</div>
{pages > 1 && (
<div className="carousel-dots" aria-label="صفحات محصولات">
{Array.from({ length: pages }, (_, index) => (
<button
key={index}
type="button"
className={index === page ? "active" : ""}
onClick={() => setPage(index)}
aria-label={`صفحه ${index + 1}`}
aria-current={index === page ? "true" : undefined}
/>
))}
</div>
)}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import { ChevronLeft, ChevronRight, Expand, X } from "lucide-react";
import { useEffect, useState, type MouseEvent } from "react";
export function ProductGallery({
images,
alt,
}: {
images: string[];
alt: string;
}) {
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
const [origin, setOrigin] = useState("50% 50%");
const previous = () => setActive((value) => (value - 1 + images.length) % images.length);
const next = () => setActive((value) => (value + 1) % images.length);
useEffect(() => {
if (!lightbox) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setLightbox(false);
if (event.key === "ArrowLeft") next();
if (event.key === "ArrowRight") previous();
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [lightbox, images.length]);
function updateZoomOrigin(event: MouseEvent<HTMLButtonElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 100;
const y = ((event.clientY - bounds.top) / bounds.height) * 100;
setOrigin(`${x}% ${y}%`);
}
if (images.length === 0) return null;
return (
<>
<div className="product-gallery">
<button
className="product-gallery-main"
type="button"
onClick={() => setLightbox(true)}
onMouseMove={updateZoomOrigin}
onMouseLeave={() => setOrigin("50% 50%")}
aria-label="نمایش تصویر بزرگ"
>
<Image
src={images[active]}
alt={`${alt} - تصویر ${active + 1}`}
fill
loading="eager"
sizes="(max-width: 900px) 100vw, 55vw"
style={{ transformOrigin: origin }}
/>
<span><Expand /></span>
</button>
{images.length > 1 && (
<div className="gallery-thumbnails">
{images.map((image, index) => (
<button
key={`${image}-${index}`}
type="button"
className={index === active ? "active" : ""}
onClick={() => setActive(index)}
aria-label={`تصویر ${index + 1}`}
>
<Image
src={image}
alt={`${alt} - تصویر کوچک ${index + 1}`}
fill
sizes="90px"
/>
</button>
))}
</div>
)}
</div>
{lightbox && (
<div className="lightbox" role="dialog" aria-modal="true" aria-label={`گالری ${alt}`}>
<button className="lightbox-close" type="button" onClick={() => setLightbox(false)} aria-label="بستن">
<X />
</button>
{images.length > 1 && (
<>
<button className="lightbox-prev" type="button" onClick={previous} aria-label="تصویر قبلی">
<ChevronRight />
</button>
<button className="lightbox-next" type="button" onClick={next} aria-label="تصویر بعدی">
<ChevronLeft />
</button>
</>
)}
<div className="lightbox-image">
<Image src={images[active]} alt={`${alt} - تصویر بزرگ`} fill sizes="100vw" />
</div>
<span className="lightbox-count">{active + 1} / {images.length}</span>
</div>
)}
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import Link from "next/link";
import { ArrowLeft, Play } from "lucide-react";
import { features } from "@/data/site";
export function SectionHeading({
eyebrow,
englishTitle,
title,
description,
align = "center",
}: {
eyebrow: string;
englishTitle?: string;
title: string;
description?: string;
align?: "center" | "start";
}) {
return (
<div className={`section-heading ${align === "start" ? "align-start" : ""}`}>
{eyebrow && <span className="eyebrow">{eyebrow}</span>}
{englishTitle && <span className="english-title">{englishTitle}</span>}
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
);
}
export function Features({ compact = false }: { compact?: boolean }) {
return (
<section id="features" className={`features-section ${compact ? "compact" : ""}`}>
<div className="container">
{!compact && (
<SectionHeading
eyebrow=""
englishTitle="WHY GLASSMOND"
title="چرا گلسموند را انتخاب کنیم؟"
description="کیفیت، ایمنی و طراحی هوشمند؛ سه اصل همیشگی ما در ساخت هر آینه"
/>
)}
{compact && <h2 className="sr-only">ویژگی‌های آینه هوشمند گلسموند</h2>}
<div className="features-grid">
{features.map((feature) => {
const Icon = feature.icon;
return (
<article className="feature-card" key={feature.number}>
<span className="feature-number">{feature.number}</span>
<Icon className="feature-icon" />
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</article>
);
})}
</div>
</div>
</section>
);
}
export function VideoBanner() {
return (
<section className="video-banner">
<div className="container video-content">
<button className="play-button" type="button" aria-label="پخش ویدیوی معرفی گلسموند">
<Play />
</button>
<div>
<p>آینه گلسموند، حاصل تلاشی سخت برای نتیجه‌ای ماندگار</p>
<h2>آینه هوشمند گلسموند را بهتر بشناسید</h2>
</div>
<Link href="/about" className="outline-button">
داستان ما
<ArrowLeft />
</Link>
</div>
</section>
);
}
export function PageHero({
englishTitle,
title,
}: {
englishTitle: string;
title: string;
}) {
return (
<section className="page-hero">
<div className="container">
<span className="page-hero-label">{englishTitle}</span>
<h1>{title}</h1>
<div className="breadcrumbs">
<Link href="/">صفحه اصلی</Link>
<b>/</b>
<span>{title}</span>
</div>
</div>
</section>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { ChevronDown, LayoutDashboard, LogOut, UserRound } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/components/auth-provider";
import { BUSINESS_URL, CUSTOMER_URL } from "@/lib/meshkee";
export function UserMenu() {
const { user, loading, isAdmin, logout } = useAuth();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
if (loading || !user) {
return (
<a className="login-link" href={CUSTOMER_URL} aria-label="ورود به حساب کاربری">
<UserRound />
</a>
);
}
const displayName = user.firstName || user.name || user.cellNumber || "کاربر";
return (
<div className="user-menu" ref={rootRef}>
<button
type="button"
className="user-menu-trigger"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<UserRound />
<span>{displayName} عزیز</span>
<ChevronDown className={open ? "rotated" : ""} />
</button>
{open && (
<div className="user-dropdown">
<a href={`${CUSTOMER_URL}/profile`}>
<UserRound />
پروفایل من
</a>
{isAdmin && (
<a href={BUSINESS_URL}>
<LayoutDashboard />
مدیریت وب‌سایت
</a>
)}
<button type="button" onClick={logout}>
<LogOut />
خروج
</button>
</div>
)}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
// Generated by scripts/generate-sitemap-config.mjs. Do not edit manually.
export const generatedStaticPages = [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"changefreq": "monthly",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contact",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/features",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/products",
"changefreq": "daily",
"priority": 0.9
}
] as const;
+106
View File
@@ -0,0 +1,106 @@
import {
BadgeCheck,
Brush,
Gauge,
ShieldCheck,
Sparkles,
Zap,
type LucideIcon,
} from "lucide-react";
export type Product = {
id: number;
title: string;
image: string;
};
export type Feature = {
number: string;
title: string;
description: string;
icon: LucideIcon;
};
export const products: Product[] = [
{ id: 15411, title: "آینه ضد بخار مستطیلی افقی - مدل HLW", image: "/images/products/hlw-horizontal.jpg" },
{ id: 15410, title: "آینه ضد بخار مستطیلی عمودی - مدل VLW", image: "/images/products/vlw-vertical.jpg" },
{ id: 15409, title: "آینه ضد بخار بیضی - مدل SLW", image: "/images/products/slw-oval.jpg" },
{ id: 15408, title: "آینه آرایشی مستطیلی عمودی - مدل VLW", image: "/images/products/vlw-makeup.jpg" },
{ id: 15407, title: "آینه آرایشی بیضی - مدل SLW", image: "/images/products/slw-makeup.jpg" },
{ id: 15406, title: "آینه ضد بخار دایره ای - مدل CLW", image: "/images/products/clw-defogger.jpg" },
{ id: 15405, title: "آینه آرایشی دایره ای - مدل CLW", image: "/images/products/clw-makeup.jpg" },
];
export const features: Feature[] = [
{
number: "01",
title: "گارانتی و خدمات پس از فروش",
description: "گارانتی یکساله و ده سال خدمات پس از فروش و همچنین بسته‌بندی اصولی برای ارسال محصول",
icon: ShieldCheck,
},
{
number: "02",
title: "دارا بودن استانداردهای جهانی",
description: "دارای استاندارد IP64 و عدم نفوذپذیری آب و بخار، برای بالا بردن عمر مفید قطعات الکترونیکی",
icon: BadgeCheck,
},
{
number: "03",
title: "کیفیت بالا",
description: "تضمین کیفیت جهانی در ضد بخار بودن و ضد آب بودن محصولات گلسموند",
icon: Sparkles,
},
{
number: "04",
title: "دارای کلیدهای لمسی",
description: "استفاده از کلید لمسی هوشمند و به‌روزترین قطعات الکتریکی و آی‌سی‌های دنیا",
icon: Gauge,
},
{
number: "05",
title: "مصرف پایین انرژی",
description: "کاربری برق ۱۲ ولت برای ایمنی بیشتر، جلوگیری از برق‌گرفتگی و مصرف پایین انرژی",
icon: Zap,
},
{
number: "06",
title: "مناسب برای آرایش",
description: "طراحی استاندارد، ضد بخار و نور مناسب در حمام برای اصلاح و میکاپ",
icon: Brush,
},
];
export const articles = [
{
title: "آشنایی با انواع آینه تزئینی",
image: "/images/articles/decorative-mirrors.jpg",
excerpt:
"چه خانه مجللی داشته باشید و چه در خانه‌ای کوچک ساکن باشید، قطعا یک آینه در خانه خود خواهید داشت. آینه‌ها همگام با پیشرفت تکنولوژی، جذاب‌تر و کاربردی‌تر شده‌اند.",
},
{
title: "آشنایی با کاربرد آینه در دکوراسیون",
image: "/images/articles/mirror-in-decor.jpg",
excerpt:
"کاربرد آینه در دکوراسیون داخلی را نمی‌توان نادیده گرفت؛ آینه‌ها نقش مهمی در فضا دارند و زیبایی را با خود به ارمغان می‌آورند.",
},
{
title: "کاربرد آینه در دکوراسیون داخلی خانه",
image: "/images/articles/interior-mirror.jpg",
excerpt:
"طراحی دکوراسیون در هر دوره سبک ویژه‌ای داشته، اما استفاده از آینه در دوره‌های مختلف همواره رواج داشته است.",
},
{
title: "آینه دکوراتیو چیست؟",
image: "/images/articles/decorative-definition.jpg",
excerpt:
"آینه‌کاری به معنای برش آینه بزرگ به قطعات کوچک‌تر برای ایجاد شکل‌ها و طرح‌های متنوع است؛ هنری با قدمتی طولانی در ایران.",
},
];
export const contact = {
mobile: "۰۹۳۰ ۱۲۹ ۳۹۰۲",
mobileHref: "tel:+989301293902",
address: "قم، شهرک ثامن، کوچه ۲۳",
instagram: "https://www.instagram.com/glassmond?igsh=MmVlMjlkMTBhMg%3D%3D&utm_source=qr",
whatsapp: "https://wa.me/qr/EYP2DZXAQQYJJ1",
};
+297
View File
@@ -0,0 +1,297 @@
import sanitizeHtml from "sanitize-html";
export const MESHKEE_API_BASE = "https://api.meshkee.com/api/v1";
export const WEBSITE_DOMAIN = "glassmond.com";
export const CUSTOMER_URL = `https://customer.${WEBSITE_DOMAIN}`;
export const BUSINESS_URL = `https://business.${WEBSITE_DOMAIN}`;
export const BUSINESS_ID = "22";
export type Tenant = {
id: number | string;
name: string;
nameFa: string;
domain: string;
logoUrl: string | null;
faviconUrl: string | null;
specialProductsSource: "product" | "store_item";
enabledModules: string[];
};
export type MediaImage = {
mediaId?: string;
url: string;
};
export type Product = {
id: string;
title: string;
nameFa: string;
summary: string;
descriptionHtml: string;
slug: string;
categoryId: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
thumbnailUrl: string | null;
image?: string;
thumbnail?: string;
images: MediaImage[];
commentCount: number;
createdAt: string;
updatedAt: string;
store?: {
variantCount: number;
minPrice: number | null;
maxPrice: number | null;
inStock: boolean;
};
};
export type Blog = {
id: string;
title: string;
slug: string;
type: "news" | "article" | "blog";
abstract: string;
mainTextHtml: string;
categoryId: string | null;
categoryName?: string | null;
author?: {
firstName?: string;
lastName?: string;
} | null;
titleImageUrl: string | null;
commentCount: number;
publishedAt: string;
createdAt: string;
};
export type Category = {
id: string;
name: string;
nameFa: string;
slug: string;
parentId: string | null;
description: string | null;
};
export type PublicComment = {
id: string;
authorName: string;
text: string;
createdAt: string;
};
type ListResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
async function apiFetch<T>(
path: string,
options: RequestInit & { revalidate?: number } = {},
): Promise<T> {
const { revalidate = 300, ...requestOptions } = options;
const response = await fetch(`${MESHKEE_API_BASE}${path}`, {
...requestOptions,
headers: {
Accept: "application/json",
...requestOptions.headers,
},
next: { revalidate },
});
if (!response.ok) {
throw new Error(`Meshkee API ${response.status}: ${path}`);
}
return response.json() as Promise<T>;
}
export function tenantPath(path = "") {
return `/tenants/${WEBSITE_DOMAIN}${path}`;
}
export async function getTenant() {
return apiFetch<Tenant>(tenantPath(), { revalidate: 3600 });
}
export async function getProducts({
page = 1,
pageSize = 12,
name,
categoryId,
}: {
page?: number;
pageSize?: number;
name?: string;
categoryId?: string;
} = {}) {
const query = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
});
if (name) query.set("name", name);
if (categoryId) query.set("categoryId", categoryId);
return apiFetch<ListResponse<Product>>(`${tenantPath("/products")}?${query}`);
}
export async function getProductById(productId: string) {
return apiFetch<{ product: Product; relatedProducts: Product[] }>(
tenantPath(`/products/by-id/${encodeURIComponent(productId)}`),
);
}
export async function getProductTechnicalInfo(productId: string) {
return apiFetch<{ form?: unknown; values?: unknown }>(
tenantPath(`/products/by-id/${encodeURIComponent(productId)}/technical-info`),
).catch(() => null);
}
export async function getBlogs({
page = 1,
pageSize = 12,
title,
categoryId,
}: {
page?: number;
pageSize?: number;
title?: string;
categoryId?: string;
} = {}) {
const query = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
});
if (title) query.set("title", title);
if (categoryId) query.set("categoryId", categoryId);
return apiFetch<ListResponse<Blog>>(`${tenantPath("/blogs")}?${query}`);
}
export async function getBlogById(blogId: string) {
return apiFetch<{ blog: Blog }>(
tenantPath(`/blogs/by-id/${encodeURIComponent(blogId)}`),
);
}
export async function getCategories(entityType: "product" | "blog") {
return apiFetch<{ items: Category[] }>(
`${tenantPath("/categories")}?entityType=${entityType}`,
{ revalidate: 1800 },
);
}
export async function getCategoryById(
categoryId: string,
entityType: "product" | "blog" = "product",
) {
return apiFetch<{ category: Category }>(
`${tenantPath(`/categories/by-id/${encodeURIComponent(categoryId)}`)}?entityType=${entityType}`,
{ revalidate: 1800 },
);
}
export async function getComments(
entityType: "product" | "blog",
entityId: string,
) {
const query = new URLSearchParams({ entityType, entityId });
return apiFetch<{ items: PublicComment[] }>(
`${tenantPath("/comments")}?${query}`,
).catch(() => ({ items: [] }));
}
export function slugifyFa(value: string) {
return value
.normalize("NFKC")
.trim()
.replace(/[\u200c\u200f\u202a-\u202e]/g, " ")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
}
export function productUrl(product: Pick<Product, "id" | "nameFa" | "title">) {
return `/products/${product.id}/${slugifyFa(product.nameFa || product.title)}`;
}
export function productCategoryUrl(
category: Pick<Category, "id" | "nameFa" | "name">,
) {
return `/products/category/${category.id}/${slugifyFa(category.nameFa || category.name)}`;
}
export function blogUrl(blog: Pick<Blog, "id" | "title">) {
return `/blog/${blog.id}/${slugifyFa(blog.title)}`;
}
export function productImage(product: Product) {
return product.thumbnailUrl || product.image || product.thumbnail || null;
}
export function sanitizeCmsContent(
content: string,
imageAlt = "تصویر مرتبط با محتوای گلسموند",
) {
const cleaned = sanitizeHtml(content || "", {
allowedTags: [
"p",
"br",
"strong",
"b",
"em",
"i",
"ul",
"ol",
"li",
"h2",
"h3",
"h4",
"blockquote",
"a",
"img",
],
allowedAttributes: {
a: ["href", "target", "rel"],
img: ["src", "alt", "width", "height", "loading"],
},
allowedSchemes: ["http", "https", "mailto", "tel"],
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer",
}),
img: (tagName, attribs) => ({
tagName,
attribs: {
src: attribs.src || "",
alt: attribs.alt?.trim() || imageAlt,
loading: "lazy",
...(attribs.width ? { width: attribs.width } : {}),
...(attribs.height ? { height: attribs.height } : {}),
},
}),
},
});
return cleaned.includes("<") ? cleaned : cleaned.replace(/\n/g, "<br />");
}
export function plainText(content: string) {
return sanitizeHtml(content || "", { allowedTags: [], allowedAttributes: {} })
.replace(/\s+/g, " ")
.trim();
}
export function formatPersianDate(value: string) {
try {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
} catch {
return "";
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "c804387.parspack.net",
pathname: "/c804387/meshkee/businesses/22/media/**",
},
],
},
};
export default nextConfig;
+1674
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "glassmond",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "npm run seo:generate",
"build": "next build",
"start": "next start",
"lint": "tsc --noEmit",
"seo:generate": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"lucide-react": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest",
"sanitize-html": "^2.17.7"
},
"devDependencies": {
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"@types/sanitize-html": "^2.16.1",
"typescript": "latest"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

+85
View File
@@ -0,0 +1,85 @@
import { readdir, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const appDirectory = join(root, "app");
const outputFile = join(root, "data", "generated-sitemap-pages.ts");
const pageFilePattern = /^page\.(?:js|jsx|ts|tsx)$/;
const excludedSegments = new Set([
"admin",
"api",
"auth",
"cart",
"checkout",
"meshkee",
]);
const routeSettings = {
"/": { changefreq: "daily", priority: 1 },
"/products": { changefreq: "daily", priority: 0.9 },
"/blog": { changefreq: "weekly", priority: 0.8 },
"/features": { changefreq: "monthly", priority: 0.7 },
"/about": { changefreq: "monthly", priority: 0.6 },
"/contact": { changefreq: "monthly", priority: 0.5 },
};
function toRoute(directory) {
const route = relative(appDirectory, directory)
.split("/")
.filter((segment) => segment && !segment.startsWith("("))
.join("/");
return route ? `/${route}` : "/";
}
function isPublicStaticRoute(route) {
const segments = route.split("/").filter(Boolean);
return (
!segments.some(
(segment) =>
segment.startsWith("[") ||
segment.startsWith("_") ||
excludedSegments.has(segment),
)
);
}
async function collectPages(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const route = toRoute(directory);
const pages = [];
if (
isPublicStaticRoute(route) &&
entries.some((entry) => entry.isFile() && pageFilePattern.test(entry.name))
) {
pages.push(route);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const childRoute = toRoute(join(directory, entry.name));
if (!isPublicStaticRoute(childRoute)) continue;
pages.push(...(await collectPages(join(directory, entry.name))));
}
return pages;
}
const routes = [...new Set(await collectPages(appDirectory))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b, "en");
});
const pages = routes.map((path) => ({
path,
...(routeSettings[path] ?? { changefreq: "monthly", priority: 0.5 }),
}));
const source = `// Generated by scripts/generate-sitemap-config.mjs. Do not edit manually.
export const generatedStaticPages = ${JSON.stringify(pages, null, 2)} as const;
`;
await writeFile(outputFile, source, "utf8");
console.log(`Generated ${pages.length} public sitemap routes in ${relative(root, outputFile)}.`);
+41
View File
@@ -0,0 +1,41 @@
{
"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"
]
}