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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user