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>
@@ -0,0 +1,14 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# Next.js
|
||||
/.next
|
||||
/out
|
||||
|
||||
# local files
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 723 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 474 KiB |
@@ -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)}.`);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||