Create Glassmond storefront with Meshkee integration

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:08:00 +03:30
co-authored by Cursor
commit 98fde02322
66 changed files with 8280 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import {
blogUrl,
formatPersianDate,
type Blog,
} from "@/lib/meshkee";
export function ApiBlogCard({ blog }: { blog: Blog }) {
const href = blogUrl(blog);
return (
<article className="blog-list-card">
<Link className="blog-list-image" href={href}>
<Image
src={blog.titleImageUrl || "/images/articles/decorative-mirrors.jpg"}
alt={blog.title}
fill
sizes="(max-width: 680px) 100vw, (max-width: 1050px) 50vw, 33vw"
/>
</Link>
<div className="blog-list-copy">
<span>{formatPersianDate(blog.publishedAt)}</span>
<h2><Link href={href}>{blog.title}</Link></h2>
<p>{blog.abstract}</p>
<Link href={href}>
مطالعه مقاله
<ArrowLeft />
</Link>
</div>
</article>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import {
productImage,
productUrl,
slugifyFa,
type Product,
} from "@/lib/meshkee";
export function ApiProductCard({
product,
headingLevel = 2,
}: {
product: Product;
headingLevel?: 2 | 3;
}) {
const title = product.nameFa || product.title;
const href = productUrl(product);
const Heading = headingLevel === 3 ? "h3" : "h2";
const categoryTitle = product.categoryNameFa || product.categoryName || "آینه هوشمند";
const categoryHref = product.categoryId
? `/products/category/${product.categoryId}/${slugifyFa(categoryTitle)}`
: null;
return (
<article className="catalog-product-card">
<Link className="catalog-product-image" href={href}>
<Image
src={productImage(product) || "/images/hero.jpg"}
alt={title}
fill
sizes="(max-width: 620px) 100vw, (max-width: 1000px) 50vw, 33vw"
/>
</Link>
<div className="catalog-product-copy">
<span>
{categoryHref ? <Link href={categoryHref}>{categoryTitle}</Link> : categoryTitle}
</span>
<Heading><Link href={href}>{title}</Link></Heading>
<div>
<p>{product.store?.inStock ? "موجود" : "برای سفارش تماس بگیرید"}</p>
<Link href={href} aria-label={`مشاهده ${title}`}>
جزئیات
<ArrowLeft />
</Link>
</div>
</div>
</article>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { MESHKEE_API_BASE } from "@/lib/meshkee";
export type AuthUser = {
id?: string | number;
firstName?: string;
lastName?: string;
name?: string;
cellNumber?: string;
email?: string;
role?: string;
roles?: string[];
isAdmin?: boolean;
};
type AuthContextValue = {
user: AuthUser | null;
token: string | null;
loading: boolean;
isAdmin: boolean;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue>({
user: null,
token: null,
loading: true,
isAdmin: false,
logout: () => undefined,
});
const tokenKeys = [
"accessToken",
"meshkeeAccessToken",
"meshkee_access_token",
"meshkee-token",
];
function readToken() {
try {
for (const key of tokenKeys) {
const value = window.localStorage.getItem(key);
if (value) return value;
}
const authValue = window.localStorage.getItem("meshkee-auth");
if (authValue) {
const auth = JSON.parse(authValue) as { accessToken?: string };
if (auth.accessToken) return auth.accessToken;
}
const cookies = document.cookie.split(";").map((item) => item.trim());
for (const key of tokenKeys) {
const prefix = `${key}=`;
const cookie = cookies.find((item) => item.startsWith(prefix));
if (cookie) return decodeURIComponent(cookie.slice(prefix.length));
}
} catch {
return null;
}
return null;
}
function clearTokens() {
try {
tokenKeys.forEach((key) => {
window.localStorage.removeItem(key);
document.cookie = `${key}=; Max-Age=0; path=/; domain=.glassmond.com`;
document.cookie = `${key}=; Max-Age=0; path=/`;
});
window.localStorage.removeItem("meshkee-auth");
} catch {
// External customer portal remains the source of truth for authentication.
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const accessToken = readToken();
if (!accessToken) {
setLoading(false);
return;
}
setToken(accessToken);
fetch(`${MESHKEE_API_BASE}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(async (response) => {
if (!response.ok) throw new Error("Unauthorized");
return response.json() as Promise<{ user: AuthUser }>;
})
.then((data) => setUser(data.user))
.catch(() => {
clearTokens();
setToken(null);
})
.finally(() => setLoading(false));
}, []);
const logout = useCallback(() => {
clearTokens();
setUser(null);
setToken(null);
}, []);
const isAdmin = useMemo(() => {
const roles = [user?.role, ...(user?.roles ?? [])]
.filter(Boolean)
.map((role) => String(role).toLowerCase());
return Boolean(
user?.isAdmin ||
roles.some((role) => ["admin", "owner", "superadmin", "business_admin"].includes(role)),
);
}, [user]);
return (
<AuthContext.Provider value={{ user, token, loading, isAdmin, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { FormEvent, useState } from "react";
import { ArrowLeft } from "lucide-react";
export function ContactForm() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [sending, setSending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formElement = event.currentTarget;
const form = new FormData(formElement);
const name = String(form.get("name") ?? "").trim();
const phone = String(form.get("phone") ?? "").trim();
const subject = String(form.get("subject") ?? "").trim();
const message = String(form.get("message") ?? "").trim();
if (!name || !phone || !subject || !message) {
setError("لطفا نام، شماره تماس، موضوع و متن پیام را کامل کنید.");
return;
}
setError("");
setSuccess("");
setSending(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: subject,
name,
cellNumber: phone,
text: message,
}),
});
if (!response.ok) throw new Error("Contact submission failed");
formElement.reset();
setSuccess("پیام شما با موفقیت ثبت شد. همکاران ما با شما تماس می‌گیرند.");
} catch {
setError("ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.");
} finally {
setSending(false);
}
}
return (
<form className="contact-form" onSubmit={submit}>
<div className="form-row">
<label>
<span>نام و نام خانوادگی</span>
<input type="text" name="name" placeholder="نام شما" autoComplete="name" />
</label>
<label>
<span>شماره تماس</span>
<input type="tel" name="phone" placeholder="۰۹۱۲ ۱۲۳ ۴۵۶۷" inputMode="tel" autoComplete="tel" />
</label>
</div>
<label>
<span>موضوع</span>
<input type="text" name="subject" placeholder="موضوع پیام" />
</label>
<label>
<span>پیام شما</span>
<textarea name="message" rows={6} placeholder="پیام خود را اینجا بنویسید..." />
</label>
{error && <p className="form-error" role="alert">{error}</p>}
{success && <p className="form-success" role="status">{success}</p>}
<button type="submit" className="primary-button" disabled={sending}>
{sending ? "در حال ارسال..." : "ارسال پیام"}
<ArrowLeft />
</button>
</form>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { FormEvent, useState } from "react";
import { MessageSquare, Send, UserRound } from "lucide-react";
import { useAuth } from "@/components/auth-provider";
import {
CUSTOMER_URL,
MESHKEE_API_BASE,
WEBSITE_DOMAIN,
type PublicComment,
} from "@/lib/meshkee";
export function CommentsPanel({
entityType,
entityId,
comments,
}: {
entityType: "product" | "blog";
entityId: string;
comments: PublicComment[];
}) {
const { user, loading } = useAuth();
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user) return;
const form = event.currentTarget;
const data = new FormData(form);
const text = String(data.get("text") ?? "").trim();
if (!text) return;
setStatus("sending");
try {
const authorName =
[user.firstName, user.lastName].filter(Boolean).join(" ") ||
user.name ||
"کاربر گلسموند";
const response = await fetch(
`${MESHKEE_API_BASE}/tenants/${WEBSITE_DOMAIN}/comments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entityType,
entityId,
authorName,
authorEmail: user.email || undefined,
text,
}),
},
);
if (!response.ok) throw new Error("Comment failed");
setStatus("sent");
form.reset();
} catch {
setStatus("error");
}
}
return (
<div className="comments-panel">
<div className="comments-list">
{comments.length > 0 ? (
comments.map((comment) => (
<article key={comment.id}>
<span><UserRound /></span>
<div>
<h3>{comment.authorName}</h3>
<p>{comment.text}</p>
</div>
</article>
))
) : (
<div className="comments-empty">
<MessageSquare />
<p>هنوز دیدگاهی ثبت نشده است. اولین نفر باشید.</p>
</div>
)}
</div>
{!loading && (
user ? (
<form className="comment-form" onSubmit={submit}>
<label htmlFor={`comment-${entityId}`}>دیدگاه شما</label>
<textarea
id={`comment-${entityId}`}
name="text"
rows={5}
placeholder="نظر خود را بنویسید..."
required
/>
<button className="primary-button" type="submit" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال..." : "ثبت دیدگاه"}
<Send />
</button>
{status === "sent" && <p className="comment-success">دیدگاه شما ثبت شد و پس از تأیید نمایش داده می‌شود.</p>}
{status === "error" && <p className="form-error">ارسال دیدگاه انجام نشد. دوباره تلاش کنید.</p>}
</form>
) : (
<div className="comment-login-box">
<UserRound />
<div>
<h3>برای ثبت دیدگاه وارد شوید</h3>
<p>ثبت نظر برای کاربران واردشده امکان‌پذیر است.</p>
</div>
<a className="primary-button" href={CUSTOMER_URL}>ورود به حساب</a>
</div>
)
)}
</div>
);
}
export function ProductTabs({
descriptionHtml,
entityId,
comments,
}: {
descriptionHtml: string;
entityId: string;
comments: PublicComment[];
}) {
const [tab, setTab] = useState<"description" | "comments">("description");
return (
<section className="entity-tabs">
<div className="tab-buttons" role="tablist" aria-label="اطلاعات محصول">
<button
type="button"
role="tab"
aria-selected={tab === "description"}
className={tab === "description" ? "active" : ""}
onClick={() => setTab("description")}
>
توضیحات
</button>
<button
type="button"
role="tab"
aria-selected={tab === "comments"}
className={tab === "comments" ? "active" : ""}
onClick={() => setTab("comments")}
>
دیدگاه‌ها
<span>{comments.length.toLocaleString("fa-IR")}</span>
</button>
</div>
<div className="tab-content">
{tab === "description" ? (
<div
className="cms-content"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<CommentsPanel entityType="product" entityId={entityId} comments={comments} />
)}
</div>
</section>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowUpLeft, Camera, MapPin, MessageCircle, Phone, Send } from "lucide-react";
import { contact } from "@/data/site";
export function Footer() {
return (
<footer id="site-footer" className="footer">
<div className="container footer-grid">
<div className="footer-about">
<Image src="/images/logo.png" alt="گلسموند" width={174} height={72} />
<p>
آینه هوشمند گلسموند؛ اولین تولیدکننده آینه‌های هوشمند ضد بخار با طراحی
انحصاری، استانداردهای جهانی و خدمات پس از فروش مطمئن.
</p>
<div className="socials">
<a href={contact.instagram} target="_blank" rel="noreferrer" aria-label="اینستاگرام">
<Camera />
</a>
<a href="/" aria-label="تلگرام">
<Send />
</a>
<a href={contact.whatsapp} target="_blank" rel="noreferrer" aria-label="واتساپ">
<MessageCircle />
</a>
</div>
</div>
<div>
<h3>دسترسی سریع</h3>
<ul className="footer-links">
<li><Link href="/">صفحه اصلی</Link></li>
<li><Link href="/products">محصولات</Link></li>
<li><Link href="/blog">مقالات</Link></li>
<li><Link href="/features">ویژگی‌ها</Link></li>
<li><Link href="/about">درباره ما</Link></li>
<li><Link href="/contact">تماس با ما</Link></li>
</ul>
</div>
<div>
<h3>ارتباط با ما</h3>
<ul className="footer-contact">
<li>
<MapPin />
<span>{contact.address}</span>
</li>
<li>
<Phone />
<a href={contact.mobileHref}>{contact.mobile}</a>
</li>
</ul>
<Link href="/contact" className="footer-cta">
ارسال پیام
<ArrowUpLeft />
</Link>
</div>
</div>
<div className="footer-bottom">
<div className="container footer-bottom-inner">
<span>تمام حقوق برای گلسموند محفوظ است.</span>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit"
>
<span className="meshkee-credit-frame" aria-hidden="true">
<i className="line-l" />
<i className="line-t" />
<i className="line-r" />
<i className="line-b" />
</span>
<Image
src="/images/meshkee-logo-white.png"
alt="Meshkee"
width={42}
height={42}
className="meshkee-credit-logo"
/>
<span className="meshkee-credit-text">
<span>Designed And Developed By</span>
<strong>Meshkee E-Commerce Team</strong>
</span>
</a>
</div>
</div>
</footer>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Menu, Phone, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { contact } from "@/data/site";
import { UserMenu } from "@/components/user-menu";
const nav = [
{ href: "/", label: "صفحه اصلی" },
{ href: "/products", label: "محصولات" },
{ href: "/blog", label: "مقالات" },
{ href: "/features", label: "ویژگی‌ها" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 30);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => setOpen(false), [pathname]);
return (
<header className={`site-header ${scrolled ? "is-scrolled" : ""}`}>
<div className="container nav-wrap">
<Link href="/" className="brand" aria-label="گلسموند - صفحه اصلی">
<Image src="/images/logo.png" alt="گلسموند" width={164} height={68} priority />
</Link>
<nav className="desktop-nav" aria-label="ناوبری اصلی">
{nav.map((item) => {
const active =
item.href === "/"
? pathname === "/"
: !item.href.includes("#") && pathname === item.href;
return (
<Link key={item.href} href={item.href} className={active ? "active" : ""}>
{item.label}
</Link>
);
})}
</nav>
<a className="nav-phone" href={contact.mobileHref} aria-label="تماس با گلسموند">
<Phone size={17} />
<span>{contact.mobile}</span>
</a>
<UserMenu />
<button
className="menu-button"
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={open ? "بستن منو" : "باز کردن منو"}
>
{open ? <X /> : <Menu />}
</button>
</div>
<div className={`mobile-panel ${open ? "open" : ""}`}>
<nav className="container" aria-label="ناوبری موبایل">
{nav.map((item) => {
const active =
item.href === "/"
? pathname === "/"
: !item.href.includes("#") && pathname === item.href;
return (
<Link key={item.href} href={item.href} className={active ? "active" : ""}>
{item.label}
</Link>
);
})}
<a href={contact.mobileHref} className="mobile-call">
<Phone size={18} />
تماس با گلسموند
</a>
</nav>
</div>
</header>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
const revealSelectors = [
".section-heading",
".product-carousel",
".resume-grid > *",
".features-intro-grid > *",
".feature-card",
".technology-grid > *",
".video-content > *",
".article-card",
".about-grid > *",
".about-stats-grid > *",
".why-grid > *",
".contact-cards article",
".contact-main > *",
".map-section",
".catalog-product-card",
".category-intro",
".blog-list-card",
".product-detail-grid > *",
".entity-tabs",
".blog-content-wrap",
".comments-list article",
".footer-grid > *",
];
export function MotionObserver() {
const pathname = usePathname();
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const root = document.documentElement;
const elements: HTMLElement[] = [];
revealSelectors.forEach((selector) => {
document.querySelectorAll<HTMLElement>(selector).forEach((element, index) => {
element.dataset.reveal = "";
element.style.setProperty("--reveal-delay", `${Math.min(index, 5) * 70}ms`);
elements.push(element);
});
});
root.classList.add("motion-ready");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{
threshold: 0.12,
rootMargin: "0px 0px -55px",
},
);
elements.forEach((element) => observer.observe(element));
return () => {
observer.disconnect();
root.classList.remove("motion-ready");
elements.forEach((element) => {
delete element.dataset.reveal;
element.classList.remove("is-visible");
element.style.removeProperty("--reveal-delay");
});
};
}, [pathname]);
return null;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Phone } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
productImage,
productUrl,
type Product,
} from "@/lib/meshkee";
function getPageSize() {
if (typeof window === "undefined") return 5;
if (window.innerWidth < 620) return 1;
if (window.innerWidth < 900) return 2;
return 5;
}
export function ProductCarousel({ products }: { products: Product[] }) {
const [pageSize, setPageSize] = useState(5);
const [page, setPage] = useState(0);
useEffect(() => {
const update = () => {
setPageSize(getPageSize());
setPage(0);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
const pages = Math.max(1, products.length - pageSize + 1);
const visibleProducts = useMemo(() => {
const start = page;
return products.slice(start, start + pageSize);
}, [page, pageSize]);
return (
<div className="product-carousel">
<div
key={`${page}-${pageSize}`}
className="source-products-grid"
style={{ "--visible-products": pageSize } as React.CSSProperties}
aria-live="polite"
>
{visibleProducts.map((product) => (
<article className="source-product-card" key={product.id}>
<div className="source-product-image">
<Link href={productUrl(product)} aria-label={product.nameFa || product.title}>
<Image
src={productImage(product) || "/images/hero.jpg"}
alt={product.nameFa || product.title}
fill
sizes="(max-width: 620px) 100vw, (max-width: 900px) 50vw, (max-width: 1200px) 33vw, 20vw"
/>
</Link>
</div>
<div className="source-product-copy">
<div>
<h3>
<Link href={productUrl(product)}>{product.nameFa || product.title}</Link>
</h3>
<a href="tel:+989301293902">تماس بگیرید</a>
</div>
<a
className="product-phone"
href="tel:+989301293902"
aria-label={`تماس برای ${product.title}`}
>
<Phone />
</a>
</div>
</article>
))}
</div>
{pages > 1 && (
<div className="carousel-dots" aria-label="صفحات محصولات">
{Array.from({ length: pages }, (_, index) => (
<button
key={index}
type="button"
className={index === page ? "active" : ""}
onClick={() => setPage(index)}
aria-label={`صفحه ${index + 1}`}
aria-current={index === page ? "true" : undefined}
/>
))}
</div>
)}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import { ChevronLeft, ChevronRight, Expand, X } from "lucide-react";
import { useEffect, useState, type MouseEvent } from "react";
export function ProductGallery({
images,
alt,
}: {
images: string[];
alt: string;
}) {
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
const [origin, setOrigin] = useState("50% 50%");
const previous = () => setActive((value) => (value - 1 + images.length) % images.length);
const next = () => setActive((value) => (value + 1) % images.length);
useEffect(() => {
if (!lightbox) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setLightbox(false);
if (event.key === "ArrowLeft") next();
if (event.key === "ArrowRight") previous();
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [lightbox, images.length]);
function updateZoomOrigin(event: MouseEvent<HTMLButtonElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 100;
const y = ((event.clientY - bounds.top) / bounds.height) * 100;
setOrigin(`${x}% ${y}%`);
}
if (images.length === 0) return null;
return (
<>
<div className="product-gallery">
<button
className="product-gallery-main"
type="button"
onClick={() => setLightbox(true)}
onMouseMove={updateZoomOrigin}
onMouseLeave={() => setOrigin("50% 50%")}
aria-label="نمایش تصویر بزرگ"
>
<Image
src={images[active]}
alt={`${alt} - تصویر ${active + 1}`}
fill
loading="eager"
sizes="(max-width: 900px) 100vw, 55vw"
style={{ transformOrigin: origin }}
/>
<span><Expand /></span>
</button>
{images.length > 1 && (
<div className="gallery-thumbnails">
{images.map((image, index) => (
<button
key={`${image}-${index}`}
type="button"
className={index === active ? "active" : ""}
onClick={() => setActive(index)}
aria-label={`تصویر ${index + 1}`}
>
<Image
src={image}
alt={`${alt} - تصویر کوچک ${index + 1}`}
fill
sizes="90px"
/>
</button>
))}
</div>
)}
</div>
{lightbox && (
<div className="lightbox" role="dialog" aria-modal="true" aria-label={`گالری ${alt}`}>
<button className="lightbox-close" type="button" onClick={() => setLightbox(false)} aria-label="بستن">
<X />
</button>
{images.length > 1 && (
<>
<button className="lightbox-prev" type="button" onClick={previous} aria-label="تصویر قبلی">
<ChevronRight />
</button>
<button className="lightbox-next" type="button" onClick={next} aria-label="تصویر بعدی">
<ChevronLeft />
</button>
</>
)}
<div className="lightbox-image">
<Image src={images[active]} alt={`${alt} - تصویر بزرگ`} fill sizes="100vw" />
</div>
<span className="lightbox-count">{active + 1} / {images.length}</span>
</div>
)}
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import Link from "next/link";
import { ArrowLeft, Play } from "lucide-react";
import { features } from "@/data/site";
export function SectionHeading({
eyebrow,
englishTitle,
title,
description,
align = "center",
}: {
eyebrow: string;
englishTitle?: string;
title: string;
description?: string;
align?: "center" | "start";
}) {
return (
<div className={`section-heading ${align === "start" ? "align-start" : ""}`}>
{eyebrow && <span className="eyebrow">{eyebrow}</span>}
{englishTitle && <span className="english-title">{englishTitle}</span>}
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
);
}
export function Features({ compact = false }: { compact?: boolean }) {
return (
<section id="features" className={`features-section ${compact ? "compact" : ""}`}>
<div className="container">
{!compact && (
<SectionHeading
eyebrow=""
englishTitle="WHY GLASSMOND"
title="چرا گلسموند را انتخاب کنیم؟"
description="کیفیت، ایمنی و طراحی هوشمند؛ سه اصل همیشگی ما در ساخت هر آینه"
/>
)}
{compact && <h2 className="sr-only">ویژگی‌های آینه هوشمند گلسموند</h2>}
<div className="features-grid">
{features.map((feature) => {
const Icon = feature.icon;
return (
<article className="feature-card" key={feature.number}>
<span className="feature-number">{feature.number}</span>
<Icon className="feature-icon" />
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</article>
);
})}
</div>
</div>
</section>
);
}
export function VideoBanner() {
return (
<section className="video-banner">
<div className="container video-content">
<button className="play-button" type="button" aria-label="پخش ویدیوی معرفی گلسموند">
<Play />
</button>
<div>
<p>آینه گلسموند، حاصل تلاشی سخت برای نتیجه‌ای ماندگار</p>
<h2>آینه هوشمند گلسموند را بهتر بشناسید</h2>
</div>
<Link href="/about" className="outline-button">
داستان ما
<ArrowLeft />
</Link>
</div>
</section>
);
}
export function PageHero({
englishTitle,
title,
}: {
englishTitle: string;
title: string;
}) {
return (
<section className="page-hero">
<div className="container">
<span className="page-hero-label">{englishTitle}</span>
<h1>{title}</h1>
<div className="breadcrumbs">
<Link href="/">صفحه اصلی</Link>
<b>/</b>
<span>{title}</span>
</div>
</div>
</section>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { ChevronDown, LayoutDashboard, LogOut, UserRound } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/components/auth-provider";
import { BUSINESS_URL, CUSTOMER_URL } from "@/lib/meshkee";
export function UserMenu() {
const { user, loading, isAdmin, logout } = useAuth();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
if (loading || !user) {
return (
<a className="login-link" href={CUSTOMER_URL} aria-label="ورود به حساب کاربری">
<UserRound />
</a>
);
}
const displayName = user.firstName || user.name || user.cellNumber || "کاربر";
return (
<div className="user-menu" ref={rootRef}>
<button
type="button"
className="user-menu-trigger"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<UserRound />
<span>{displayName} عزیز</span>
<ChevronDown className={open ? "rotated" : ""} />
</button>
{open && (
<div className="user-dropdown">
<a href={`${CUSTOMER_URL}/profile`}>
<UserRound />
پروفایل من
</a>
{isAdmin && (
<a href={BUSINESS_URL}>
<LayoutDashboard />
مدیریت وب‌سایت
</a>
)}
<button type="button" onClick={logout}>
<LogOut />
خروج
</button>
</div>
)}
</div>
);
}