commit 98fde0232206062094802e75488c77ea6e16e58e Author: Alireza Hassani Date: Wed Aug 26 00:08:00 2026 +0330 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51aa53a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# dependencies +/node_modules + +# Next.js +/.next +/out + +# local files +.DS_Store +*.log + +# TypeScript +*.tsbuildinfo +next-env.d.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/Ai-data/context.md b/Ai-data/context.md new file mode 100644 index 0000000..e5d6552 --- /dev/null +++ b/Ai-data/context.md @@ -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. diff --git a/Ai-data/prompt-connect-backend.txt b/Ai-data/prompt-connect-backend.txt new file mode 100644 index 0000000..2e5eff7 --- /dev/null +++ b/Ai-data/prompt-connect-backend.txt @@ -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) \ No newline at end of file diff --git a/Ai-data/prompt-images-slot.txt b/Ai-data/prompt-images-slot.txt new file mode 100644 index 0000000..e5cda02 --- /dev/null +++ b/Ai-data/prompt-images-slot.txt @@ -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 → . linkUrl → wrap in . 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) \ No newline at end of file diff --git a/Ai-data/prompt-seo.txt b/Ai-data/prompt-seo.txt new file mode 100644 index 0000000..54a55b8 --- /dev/null +++ b/Ai-data/prompt-seo.txt @@ -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 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 diff --git a/Ai-data/prompt.txt b/Ai-data/prompt.txt new file mode 100644 index 0000000..5999b1e --- /dev/null +++ b/Ai-data/prompt.txt @@ -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) + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..fbfb00e --- /dev/null +++ b/app/about/page.tsx @@ -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 /> + </> + ); +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..1d4db56 --- /dev/null +++ b/app/api/contact/route.ts @@ -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 }); +} diff --git a/app/blog/[id]/[slug]/page.tsx b/app/blog/[id]/[slug]/page.tsx new file mode 100644 index 0000000..0c4e972 --- /dev/null +++ b/app/blog/[id]/[slug]/page.tsx @@ -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> + ); +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..27715db --- /dev/null +++ b/app/blog/page.tsx @@ -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> + </> + ); +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..d815bcc --- /dev/null +++ b/app/contact/page.tsx @@ -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> + </> + ); +} diff --git a/app/features/page.tsx b/app/features/page.tsx new file mode 100644 index 0000000..c07632b --- /dev/null +++ b/app/features/page.tsx @@ -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 /> + </> + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ec72e71 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,3371 @@ +@font-face { + font-family: "Iran Yekan"; + src: url("/fonts/iran-yekan-light.ttf") format("truetype"); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Iran Yekan"; + src: url("/fonts/iran-yekan-regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Iran Yekan"; + src: url("/fonts/iran-yekan-bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Montserrat Alternates"; + src: url("/fonts/montserrat-alternates-light.ttf") format("truetype"); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +:root { + --red: #1b1b1b; + --red-dark: #000; + --ink: #1b1b1b; + --ink-soft: #3b3b3b; + --muted: #777; + --line: #e9e9e9; + --paper: #fff; + --warm: #f6f1f0; + --cream: #fef8f4; + --container: 1240px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 90px; +} + +body { + margin: 0; + color: var(--ink); + background: var(--paper); + font-family: "Iran Yekan", Tahoma, sans-serif; + font-size: 14px; + font-weight: 300; + line-height: 1.85; + overflow-x: hidden; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +img { + max-width: 100%; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +.container { + width: min(var(--container), calc(100% - 48px)); + margin-inline: auto; +} + +.site-header { + position: fixed; + z-index: 50; + inset: 0 0 auto; + color: #fff; + border-bottom: 1px solid rgb(255 255 255 / 16%); + transition: background-color 0.3s ease, box-shadow 0.3s ease; +} + +.site-header.is-scrolled { + background: rgb(20 20 20 / 96%); + box-shadow: 0 10px 35px rgb(0 0 0 / 14%); + backdrop-filter: blur(12px); +} + +.nav-wrap { + min-height: 74px; + display: flex; + align-items: center; + gap: 28px; +} + +.brand { + display: flex; + align-items: center; + margin-left: auto; +} + +.brand img { + width: 130px; + height: auto; + object-fit: contain; +} + +.desktop-nav { + display: flex; + align-items: center; + gap: 25px; +} + +.desktop-nav a { + position: relative; + color: rgb(255 255 255 / 72%); + font-size: 12px; + font-weight: 300; + white-space: nowrap; + transition: color 0.2s ease, font-weight 0.2s ease; +} + +.desktop-nav a::after { + content: ""; + position: absolute; + right: 0; + bottom: -12px; + width: 0; + height: 1px; + background: #fff; + transition: width 0.25s ease; +} + +.desktop-nav a:hover, +.desktop-nav a.active { + color: #fff; +} + +.desktop-nav a.active { + font-weight: 400; +} + +.desktop-nav a.active::after { + width: 100%; +} + +.nav-phone { + direction: ltr; + display: inline-flex; + align-items: center; + gap: 9px; + padding-right: 24px; + border-right: 1px solid rgb(255 255 255 / 25%); + color: rgb(255 255 255 / 72%); + font-size: 11px; + font-weight: 300; + white-space: nowrap; +} + +.nav-phone svg { + color: currentColor; +} + +.login-link, +.user-menu-trigger { + min-width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 10px; + color: rgb(255 255 255 / 76%); + background: transparent; + border: 1px solid rgb(255 255 255 / 20%); + transition: color 0.2s ease, border-color 0.2s ease; +} + +.login-link:hover, +.user-menu-trigger:hover { + color: #fff; + border-color: rgb(255 255 255 / 58%); +} + +.login-link svg, +.user-menu-trigger svg { + width: 16px; + height: 16px; +} + +.user-menu { + position: relative; + direction: rtl; +} + +.user-menu-trigger { + font-size: 10px; +} + +.user-menu-trigger svg:last-child { + width: 13px; + transition: transform 0.2s ease; +} + +.user-menu-trigger svg.rotated { + transform: rotate(180deg); +} + +.user-dropdown { + position: absolute; + top: calc(100% + 12px); + left: 0; + width: 190px; + padding: 7px; + color: var(--ink); + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 18px 45px rgb(0 0 0 / 15%); +} + +.user-dropdown a, +.user-dropdown button { + width: 100%; + min-height: 42px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 11px; + color: #555; + background: transparent; + border: 0; + font-size: 11px; + text-align: right; + transition: color 0.2s ease, background-color 0.2s ease; +} + +.user-dropdown a:hover, +.user-dropdown button:hover { + color: #000; + background: #f4f4f4; +} + +.user-dropdown svg { + width: 15px; +} + +.menu-button { + width: 44px; + height: 44px; + display: none; + place-items: center; + color: #fff; + background: transparent; + border: 1px solid rgb(255 255 255 / 25%); +} + +.mobile-panel { + display: none; +} + +.home-hero { + position: relative; + min-height: 100svh; + color: #fff; + background: + linear-gradient(90deg, rgb(0 0 0 / 15%) 0%, rgb(0 0 0 / 30%) 45%, rgb(0 0 0 / 76%) 100%), + url("/images/hero.jpg") center / cover no-repeat; + display: flex; + align-items: center; +} + +.home-hero::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(0deg, rgb(0 0 0 / 26%), transparent 38%); +} + +.hero-content { + position: relative; + z-index: 1; + padding-top: 74px; +} + +.hero-kicker, +.eyebrow { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--red); + font-size: 11px; + font-weight: 400; + letter-spacing: 0.02em; +} + +.hero-kicker { + color: #fff; +} + +.eyebrow { + color: var(--ink); +} + +.hero-kicker::before, +.eyebrow::before { + content: ""; + width: 28px; + height: 1px; + background: currentColor; +} + +.hero-content h1 { + max-width: none; + width: fit-content; + margin: 16px 0 18px; + font-size: clamp(28px, 3.4vw, 44px); + font-weight: 300; + line-height: 1.38; + letter-spacing: -0.03em; + white-space: nowrap; +} + +.hero-content h1 strong { + color: #fff; + font-weight: 700; +} + +.hero-content > p { + max-width: 540px; + color: rgb(255 255 255 / 70%); + font-size: 13px; + line-height: 1.95; +} + +.hero-actions { + display: flex; + align-items: center; + gap: 24px; + margin-top: 29px; +} + +.primary-button, +.outline-button, +.text-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 46px; + padding: 0 22px; + border: 0; + font-size: 12px; + transition: all 0.25s ease; +} + +.primary-button { + color: #fff; + background: var(--ink); +} + +.primary-button:hover { + background: #343434; + transform: translateY(-2px); +} + +.home-hero .primary-button { + color: var(--ink); + background: #fff; +} + +.home-hero .primary-button:hover { + color: #fff; + background: var(--ink); +} + +.primary-button svg, +.outline-button svg, +.text-button svg { + width: 19px; +} + +.text-button { + min-height: 40px; + padding: 0; + color: #fff; + border-bottom: 1px solid rgb(255 255 255 / 55%); +} + +.text-button:hover { + color: #fff; + border-color: #fff; +} + +.hero-index { + position: absolute; + z-index: 1; + left: 42px; + bottom: 48px; + direction: ltr; + display: flex; + align-items: center; + gap: 14px; + writing-mode: vertical-rl; +} + +.hero-index span { + font-size: 13px; +} + +.hero-index i { + width: 1px; + height: 55px; + background: rgb(255 255 255 / 45%); +} + +.hero-index small { + color: rgb(255 255 255 / 50%); + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 9px; + letter-spacing: 0.35em; +} + +.scroll-hint { + position: absolute; + z-index: 1; + right: 3.5%; + bottom: 34px; + display: flex; + align-items: center; + gap: 13px; + color: rgb(255 255 255 / 70%); + font-size: 11px; + transform: rotate(-90deg); + transform-origin: right center; +} + +.scroll-hint i { + width: 55px; + height: 1px; + background: rgb(255 255 255 / 55%); +} + +.products-section, +.articles-section, +.contact-section { + padding: 90px 0; +} + +.section-heading { + max-width: 620px; + margin: 0 auto 44px; + text-align: center; +} + +.section-heading.align-start { + margin-inline: 0; + text-align: right; +} + +.section-heading .eyebrow { + margin-bottom: 11px; +} + +.english-title { + direction: ltr; + display: inline-flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; + color: #888; + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 10px; + font-weight: 300; + letter-spacing: 0.34em; + line-height: 1.5; + text-transform: uppercase; +} + +.english-title::before { + content: ""; + width: 22px; + height: 1px; + background: currentColor; + opacity: 0.82; +} + +.section-heading h2 { + margin-bottom: 10px; + font-size: clamp(24px, 2.3vw, 32px); + font-weight: 700; + line-height: 1.45; + letter-spacing: -0.025em; +} + +.section-heading p { + color: var(--muted); + font-size: 13px; + margin-bottom: 0; +} + +.products-section { + padding: 68px 0 56px; +} + +.products-section > .container { + width: min(1480px, calc(100% - 12px)); +} + +.products-section .section-heading { + margin-bottom: 38px; +} + +.products-section .section-heading h2 { + margin-bottom: 5px; + font-size: clamp(21px, 1.9vw, 27px); +} + +.products-section .english-title::before { + display: none; +} + +.products-section .section-heading p { + width: fit-content; + display: flex; + align-items: center; + gap: 10px; + margin-inline: auto; + color: #aaa; + font-size: 11px; +} + +.products-section .section-heading p::before { + content: ""; + width: 28px; + height: 1px; + background: currentColor; + opacity: 0.82; +} + +.source-products-grid { + --visible-products: 5; + display: grid; + grid-template-columns: repeat(var(--visible-products), minmax(0, 1fr)); + gap: 18px; +} + +.source-product-card { + min-width: 0; + overflow: hidden; + background: transparent; +} + +.source-product-image { + position: relative; + aspect-ratio: 1.02 / 1; + overflow: hidden; + background: var(--warm); +} + +.source-product-image img { + object-fit: cover; + transition: transform 0.45s ease; +} + +.source-product-card:hover .source-product-image img { + transform: scale(1.035); +} + +.source-product-copy { + position: relative; + width: calc(100% - 24px); + min-height: 77px; + margin: -30px auto 0; + padding: 14px 14px 12px 48px; + display: flex; + align-items: center; + background: #fff; + box-shadow: 0 8px 24px rgb(0 0 0 / 6%); +} + +.source-product-copy h3 { + display: -webkit-box; + min-height: 35px; + margin: 0 0 2px; + overflow: hidden; + color: #222; + font-size: 12px; + font-weight: 500; + line-height: 1.65; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.source-product-copy > div > a { + color: #999; + font-size: 9px; +} + +.product-phone { + position: absolute; + left: 11px; + bottom: 12px; + width: 30px; + height: 30px; + display: grid; + place-items: center; + color: var(--red); + border: 1px solid rgb(27 27 27 / 36%); + transition: color 0.2s ease, border-color 0.2s ease; +} + +.product-phone:hover { + color: var(--ink); + border-color: var(--ink); +} + +.product-phone svg { + width: 13px; + height: 13px; +} + +.carousel-dots { + direction: ltr; + min-height: 32px; + display: flex; + align-items: flex-end; + justify-content: center; + gap: 4px; +} + +.carousel-dots button { + width: 7px; + height: 7px; + padding: 0; + background: transparent; + border: 1px solid #bbb; + border-radius: 50%; +} + +.carousel-dots button.active { + background: var(--red); + border-color: var(--red); +} + +.article-copy a { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--ink-soft); + font-size: 13px; + font-weight: 700; +} + +.article-copy a svg { + width: 17px; + color: var(--red); + transition: transform 0.2s ease; +} + +.article-copy a:hover svg { + transform: translateX(-5px); +} + +.resume-section { + padding: 90px 0; + background: var(--cream); +} + +.resume-grid { + display: grid; + grid-template-columns: 0.92fr 1.08fr; + align-items: center; + gap: 80px; +} + +.resume-grid .section-heading { + margin-bottom: 25px; +} + +.resume-text { + color: var(--muted); + font-size: 14px; +} + +.check-list { + margin: 28px 0 0; + padding: 0; + display: grid; + gap: 12px; + list-style: none; +} + +.check-list li { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.check-list svg { + flex: 0 0 20px; + margin-top: 5px; + color: var(--red); +} + +.resume-image { + position: relative; + min-height: 590px; +} + +.resume-image::before { + content: ""; + position: absolute; + inset: 28px -28px -28px 28px; + border: 1px solid rgb(27 27 27 / 20%); +} + +.resume-image > img { + object-fit: cover; +} + +.experience-badge { + position: absolute; + left: -28px; + bottom: 38px; + width: 170px; + aspect-ratio: 1; + display: grid; + place-content: center; + text-align: center; + color: #fff; + background: var(--ink); +} + +.experience-badge strong { + display: block; + font-size: 42px; + line-height: 1.2; +} + +.experience-badge span { + font-size: 12px; +} + +.features-section { + padding: 90px 0; + background: var(--ink); + color: #fff; +} + +.features-intro, +.technology-section { + padding: 90px 0; +} + +.features-intro-grid { + display: grid; + grid-template-columns: 1.05fr 0.95fr; + align-items: center; + gap: 80px; +} + +.features-intro-image { + position: relative; + min-height: 570px; + overflow: hidden; + background: #f3f3f3; +} + +.features-intro-image img { + object-fit: cover; + transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); +} + +.features-intro-image:hover img { + transform: scale(1.025); +} + +.features-intro-image > span { + position: absolute; + left: 22px; + bottom: 22px; + padding: 8px 12px; + color: #fff; + background: rgb(0 0 0 / 76%); + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 8px; + font-weight: 300; + letter-spacing: 0.3em; +} + +.features-intro-copy .section-heading { + margin-bottom: 20px; +} + +.features-intro-copy > p { + color: var(--muted); + font-size: 14px; + line-height: 2.1; +} + +.feature-specs { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 32px; + border-top: 1px solid var(--line); + border-right: 1px solid var(--line); +} + +.feature-specs > div { + min-height: 115px; + display: grid; + align-content: center; + padding: 17px; + border-left: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.feature-specs strong { + font-family: "Montserrat Alternates", "Iran Yekan", sans-serif; + font-size: 20px; + font-weight: 300; +} + +.feature-specs span { + color: var(--muted); + font-size: 10px; + line-height: 1.7; +} + +.technology-section { + background: #f7f7f7; +} + +.technology-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid #ddd; + border-right: 1px solid #ddd; +} + +.technology-grid article { + position: relative; + min-height: 310px; + padding: 38px 30px; + background: #f7f7f7; + border-left: 1px solid #ddd; + border-bottom: 1px solid #ddd; + transition: background-color 0.3s ease, transform 0.3s ease; +} + +.technology-grid article:hover { + z-index: 1; + background: #fff; + transform: translateY(-5px); +} + +.technology-grid article > svg { + width: 34px; + height: 34px; + margin-bottom: 28px; + stroke-width: 1.25; +} + +.technology-number { + position: absolute; + top: 22px; + left: 22px; + color: #bbb; + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 11px; + letter-spacing: 0.12em; +} + +.technology-grid small { + direction: ltr; + display: block; + margin-bottom: 4px; + color: #999; + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 8px; + letter-spacing: 0.22em; +} + +.technology-grid h3 { + margin-bottom: 12px; + font-size: 16px; + font-weight: 700; +} + +.technology-grid p { + margin-bottom: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.9; +} + +.features-section.compact { + padding-top: 90px; +} + +.features-section .section-heading p { + color: rgb(255 255 255 / 58%); +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid rgb(255 255 255 / 12%); + border-right: 1px solid rgb(255 255 255 / 12%); +} + +.feature-card { + position: relative; + min-height: 250px; + padding: 32px 30px; + border-left: 1px solid rgb(255 255 255 / 12%); + border-bottom: 1px solid rgb(255 255 255 / 12%); + overflow: hidden; + transition: background-color 0.3s ease; +} + +.feature-card:hover { + background: #252525; +} + +.feature-number { + position: absolute; + top: 23px; + left: 24px; + color: rgb(255 255 255 / 20%); + font-size: 35px; + font-weight: 300; +} + +.feature-icon { + width: 36px; + height: 36px; + margin-bottom: 21px; + color: #cfcfcf; + stroke-width: 1.4; +} + +.feature-card h3 { + margin-bottom: 12px; + font-size: 16px; + font-weight: 700; +} + +.feature-card p { + margin-bottom: 0; + color: rgb(255 255 255 / 56%); + font-size: 13px; +} + +.video-banner { + position: relative; + min-height: 430px; + display: flex; + align-items: center; + color: #fff; + background: + linear-gradient(rgb(0 0 0 / 66%), rgb(0 0 0 / 66%)), + url("/images/video-cover.jpg") center / cover fixed; +} + +.video-content { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 34px; +} + +.play-button { + width: 96px; + height: 96px; + display: grid; + place-items: center; + color: #fff; + background: rgb(255 255 255 / 5%); + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 50%; + backdrop-filter: blur(2px); + transition: + color 0.3s ease, + background-color 0.3s ease, + border-color 0.3s ease, + transform 0.3s ease; +} + +.play-button:hover { + color: var(--ink); + background: #fff; + border-color: #fff; + transform: scale(1.06); +} + +.play-button svg { + width: 30px; + height: 30px; + fill: none; + stroke-width: 1.4; +} + +.video-content p { + margin-bottom: 7px; + color: rgb(255 255 255 / 62%); + font-size: 13px; +} + +.video-content h2 { + margin-bottom: 0; + font-size: clamp(23px, 2.5vw, 34px); + font-weight: 400; +} + +.outline-button { + color: #fff; + background: transparent; + border: 1px solid rgb(255 255 255 / 55%); +} + +.outline-button:hover { + color: var(--ink); + background: #fff; +} + +.articles-section { + background: #fff; +} + +.articles-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 22px; +} + +.article-card { + background: #fff; + border: 1px solid var(--line); +} + +.article-image { + position: relative; + height: 245px; + overflow: hidden; +} + +.article-image img { + object-fit: cover; + transition: transform 0.5s ease; +} + +.article-card:hover .article-image img { + transform: scale(1.055); +} + +.article-copy { + padding: 23px; +} + +.article-copy > span { + color: var(--red); + font-size: 11px; +} + +.article-copy h3 { + min-height: 56px; + margin: 7px 0 10px; + font-size: 14px; + font-weight: 400; + line-height: 1.7; +} + +.article-copy p { + display: -webkit-box; + min-height: 76px; + margin-bottom: 19px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.page-hero { + position: relative; + min-height: 460px; + display: flex; + align-items: flex-end; + padding-bottom: 85px; + color: #fff; + background: + linear-gradient(90deg, rgb(0 0 0 / 46%), rgb(0 0 0 / 67%)), + url("/images/page-hero.jpg") center top / cover no-repeat; +} + +.page-hero > .container { + position: relative; + z-index: 1; +} + +.page-hero > .container > span { + color: var(--red); + font-size: 13px; + font-weight: 700; +} + +.page-hero .page-hero-label { + direction: ltr; + display: inline-flex; + align-items: center; + gap: 10px; + color: #fff; + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 10px; + font-weight: 300; + letter-spacing: 0.34em; + line-height: 1.5; +} + +.page-hero-label::before { + content: ""; + width: 24px; + height: 1px; + background: currentColor; + opacity: 0.82; +} + +.page-hero h1 { + margin: 12px 0 22px; + font-size: clamp(31px, 3.7vw, 46px); + font-weight: 400; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 11px; + color: rgb(255 255 255 / 68%); + font-size: 12px; +} + +.breadcrumbs a { + color: #fff; +} + +.breadcrumbs b { + color: var(--red); +} + +.about-intro, +.why-section { + padding: 120px 0; +} + +.about-grid { + display: grid; + grid-template-columns: 1fr 0.92fr; + align-items: center; + gap: 85px; +} + +.about-gallery { + position: relative; + min-height: 640px; +} + +.gallery-main { + position: absolute; + inset: 0 20% 95px 0; +} + +.gallery-main img, +.gallery-side img, +.why-image img { + object-fit: cover; +} + +.gallery-side { + position: absolute; + width: 44%; + height: 44%; + right: 0; + bottom: 0; + border: 10px solid #fff; +} + +.about-year { + position: absolute; + z-index: 2; + left: 0; + bottom: 34px; + width: 160px; + min-height: 138px; + display: grid; + place-content: center; + text-align: center; + color: #fff; + background: var(--ink); +} + +.about-year strong { + font-size: 34px; +} + +.about-year span { + font-size: 11px; +} + +.about-copy .section-heading { + margin-bottom: 22px; +} + +.about-copy > p, +.why-grid > div > p { + color: var(--muted); + font-size: 15px; +} + +.about-highlights { + display: grid; + gap: 16px; + margin-top: 28px; +} + +.about-highlights > div { + display: flex; + align-items: center; + gap: 17px; +} + +.about-highlights svg { + flex: 0 0 38px; + color: var(--ink); + stroke-width: 1.4; +} + +.about-highlights span { + color: var(--muted); + font-size: 13px; +} + +.about-highlights b { + display: block; + color: var(--ink); + font-size: 15px; + font-weight: 700; +} + +.about-stats { + color: #fff; + background: var(--ink); +} + +.about-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-right: 1px solid rgb(255 255 255 / 13%); +} + +.about-stats article { + min-height: 190px; + display: grid; + place-content: center; + padding: 28px; + text-align: center; + border-left: 1px solid rgb(255 255 255 / 13%); +} + +.about-stats article > span { + color: rgb(255 255 255 / 48%); + font-family: "Montserrat Alternates", Arial, sans-serif; + font-size: 9px; + font-weight: 300; + letter-spacing: 0.3em; +} + +.about-stats strong { + margin: 3px 0 0; + font-size: 38px; + font-weight: 300; + line-height: 1.45; +} + +.about-stats p { + margin-bottom: 0; + color: rgb(255 255 255 / 68%); + font-size: 12px; +} + +.why-section { + background: var(--cream); +} + +.why-grid { + display: grid; + grid-template-columns: 0.92fr 1.08fr; + align-items: center; + gap: 80px; +} + +.why-grid .section-heading { + margin-bottom: 22px; +} + +.why-image { + position: relative; + min-height: 570px; +} + +.contact-section { + background: #fff; +} + +.contact-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-bottom: 80px; +} + +.contact-cards article { + min-height: 230px; + padding: 35px; + text-align: center; + background: #f6f6f6; + border-bottom: 3px solid transparent; + transition: border-color 0.25s ease, transform 0.25s ease; +} + +.contact-cards article:hover { + border-color: var(--red); + transform: translateY(-5px); +} + +.contact-cards article > span { + width: 62px; + height: 62px; + display: grid; + place-items: center; + margin: 0 auto 18px; + color: #fff; + background: var(--ink); + border-radius: 50%; +} + +.contact-cards svg { + width: 24px; +} + +.contact-cards h3 { + margin-bottom: 7px; + font-size: 18px; + font-weight: 700; +} + +.contact-cards p, +.contact-cards a { + margin-bottom: 0; + color: var(--muted); + font-size: 13px; +} + +.contact-cards a { + direction: ltr; + display: inline-block; +} + +.contact-main { + display: grid; + grid-template-columns: 1fr 360px; + gap: 45px; + align-items: stretch; +} + +.form-panel { + padding: 48px; + border: 1px solid var(--line); +} + +.form-panel .section-heading { + margin-bottom: 30px; +} + +.contact-form { + display: grid; + gap: 20px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.contact-form label { + display: grid; + gap: 7px; +} + +.contact-form label > span { + color: var(--ink-soft); + font-size: 12px; +} + +.contact-form input, +.contact-form textarea { + width: 100%; + color: var(--ink); + background: #fafafa; + border: 1px solid #e7e7e7; + border-radius: 0; + outline: 0; + transition: border-color 0.2s ease; +} + +.contact-form input { + height: 52px; + padding: 0 16px; +} + +.contact-form textarea { + padding: 14px 16px; + resize: vertical; +} + +.contact-form input:focus, +.contact-form textarea:focus { + border-color: var(--red); +} + +.contact-form .primary-button { + justify-self: end; +} + +.form-error { + margin: -5px 0 0; + color: var(--red); + font-size: 12px; + font-weight: 700; +} + +.contact-aside { + padding: 45px 36px; + color: #fff; + background: var(--ink); +} + +.contact-aside h3 { + margin-bottom: 15px; + font-size: 21px; + font-weight: 700; + line-height: 1.6; +} + +.contact-aside p { + margin-bottom: 30px; + color: rgb(255 255 255 / 58%); + font-size: 13px; +} + +.contact-aside a { + display: flex; + align-items: center; + gap: 12px; + min-height: 53px; + margin-top: 12px; + padding: 0 18px; + border: 1px solid rgb(255 255 255 / 20%); + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.contact-aside a:hover { + background: #2d2d2d; + border-color: rgb(255 255 255 / 42%); +} + +.contact-aside svg { + width: 20px; +} + +.map-section { + height: 480px; + filter: grayscale(0.35); +} + +.map-section iframe { + display: block; +} + +.footer { + color: #fff; + background: #191919; +} + +.footer-grid { + display: grid; + grid-template-columns: 1.3fr 0.65fr 1fr; + gap: 90px; + padding-top: 82px; + padding-bottom: 75px; +} + +.footer-about > img { + width: 160px; + height: auto; + object-fit: contain; + margin-bottom: 20px; +} + +.footer-about > p { + max-width: 430px; + color: rgb(255 255 255 / 54%); + font-size: 13px; +} + +.footer h3 { + margin: 8px 0 23px; + font-size: 17px; + font-weight: 700; +} + +.socials { + display: flex; + gap: 9px; + margin-top: 22px; +} + +.socials a { + width: 40px; + height: 40px; + display: grid; + place-items: center; + color: rgb(255 255 255 / 75%); + border: 1px solid rgb(255 255 255 / 17%); + transition: color 0.2s ease, background-color 0.2s ease; +} + +.socials a:hover { + color: #fff; + background: #303030; +} + +.socials svg { + width: 17px; +} + +.footer-links, +.footer-contact { + margin: 0; + padding: 0; + list-style: none; +} + +.footer-links { + display: grid; + gap: 8px; +} + +.footer-links a { + color: rgb(255 255 255 / 57%); + font-size: 13px; + transition: color 0.2s ease, padding-right 0.2s ease; +} + +.footer-links a:hover { + padding-right: 5px; + color: var(--red); +} + +.footer-contact { + display: grid; + gap: 16px; +} + +.footer-contact li { + display: flex; + align-items: flex-start; + gap: 12px; + color: rgb(255 255 255 / 57%); + font-size: 13px; +} + +.footer-contact svg { + flex: 0 0 18px; + margin-top: 5px; + color: var(--red); +} + +.footer-contact a { + direction: ltr; +} + +.footer-cta { + display: inline-flex; + align-items: center; + gap: 9px; + margin-top: 24px; + color: var(--red); + font-size: 13px; +} + +.footer-cta svg { + width: 17px; +} + +.footer-bottom { + color: rgb(255 255 255 / 47%); + background: #050505; +} + +.footer-bottom-inner { + min-height: 94px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + font-size: 11px; +} + +.meshkee-credit { + position: relative; + display: flex; + align-items: center; + gap: 10px; + min-width: 270px; + min-height: 58px; + padding: 8px 17px 8px 13px; + transition: color 0.2s ease; +} + +.meshkee-credit:hover { + color: #fff; +} + +.meshkee-credit-frame { + position: absolute; + inset: 0; + pointer-events: none; +} + +.meshkee-credit-frame i { + position: absolute; + display: block; + background: rgb(255 255 255 / 32%); + transition: background-color 0.2s ease, transform 0.35s ease; +} + +.meshkee-credit-frame .line-l, +.meshkee-credit-frame .line-r { + width: 1px; + height: 14px; +} + +.meshkee-credit-frame .line-t, +.meshkee-credit-frame .line-b { + width: 14px; + height: 1px; +} + +.meshkee-credit-frame .line-l { + left: 0; + bottom: 0; +} + +.meshkee-credit-frame .line-t { + left: 0; + top: 0; +} + +.meshkee-credit-frame .line-r { + right: 0; + top: 0; +} + +.meshkee-credit-frame .line-b { + right: 0; + bottom: 0; +} + +.meshkee-credit:hover .meshkee-credit-frame i { + background: var(--red); +} + +.meshkee-credit:hover .line-l, +.meshkee-credit:hover .line-r { + transform: scaleY(1.55); +} + +.meshkee-credit:hover .line-t, +.meshkee-credit:hover .line-b { + transform: scaleX(1.55); +} + +.meshkee-credit-logo { + width: 38px; + height: 38px; + object-fit: contain; +} + +.meshkee-credit-text { + display: grid; + line-height: 1.25; + text-align: left; +} + +.meshkee-credit-text > span { + color: rgb(255 255 255 / 42%); + font-family: Arial, sans-serif; + font-size: 8px; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.meshkee-credit-text strong { + color: #fff; + font-family: Arial, sans-serif; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.02em; +} + +/* Entry and scroll motion */ +.motion-ready .site-header { + animation: header-enter 0.75s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready .hero-kicker { + animation: text-reveal 0.7s 0.12s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready .hero-content h1 { + animation: title-reveal 0.95s 0.22s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.motion-ready .hero-content > p { + animation: text-reveal 0.75s 0.42s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready .hero-actions { + animation: text-reveal 0.75s 0.56s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready .page-hero > .container > span { + animation: text-reveal 0.65s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready .page-hero h1 { + animation: title-reveal 0.85s 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.motion-ready .page-hero .breadcrumbs { + animation: text-reveal 0.65s 0.43s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready [data-reveal] { + opacity: 0; + filter: blur(4px); + transform: translateY(30px); + transition: + opacity 0.75s cubic-bezier(0.22, 1, 0.36, 1), + filter 0.75s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.75s cubic-bezier(0.22, 1, 0.36, 1); + transition-delay: var(--reveal-delay, 0ms); + will-change: opacity, filter, transform; +} + +.motion-ready [data-reveal].is-visible { + opacity: 1; + filter: blur(0); + transform: translateY(0); +} + +.motion-ready .product-carousel.is-visible .source-product-card { + animation: product-card-enter 0.58s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.motion-ready .product-carousel.is-visible .source-product-card:nth-child(2) { + animation-delay: 60ms; +} + +.motion-ready .product-carousel.is-visible .source-product-card:nth-child(3) { + animation-delay: 120ms; +} + +.motion-ready .product-carousel.is-visible .source-product-card:nth-child(4) { + animation-delay: 180ms; +} + +.motion-ready .product-carousel.is-visible .source-product-card:nth-child(5) { + animation-delay: 240ms; +} + +@keyframes header-enter { + from { + opacity: 0; + transform: translateY(-24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes text-reveal { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes title-reveal { + from { + opacity: 0; + clip-path: inset(0 0 100% 0); + transform: translateY(42px); + } + to { + opacity: 1; + clip-path: inset(0 0 0 0); + transform: translateY(0); + } +} + +@keyframes product-card-enter { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Monochrome accents: black on light surfaces, white on dark surfaces. */ +.features-section .eyebrow, +.features-section .english-title, +.page-hero > .container > span, +.page-hero .breadcrumbs b, +.footer-links a:hover, +.footer-contact svg, +.footer-cta { + color: #fff; +} + +.meshkee-credit:hover .meshkee-credit-frame i { + background: #fff; +} + +@media (max-width: 1120px) { + .desktop-nav { + gap: 18px; + } + + .nav-phone span { + display: none; + } + + .articles-grid { + grid-template-columns: repeat(2, 1fr); + } + + .article-image { + height: 310px; + } + + .about-grid { + gap: 50px; + } +} + +@media (max-width: 860px) { + .container { + width: min(calc(100% - 34px), var(--container)); + } + + .nav-wrap { + min-height: 68px; + } + + .brand img { + width: 116px; + } + + .desktop-nav, + .nav-phone { + display: none; + } + + .menu-button { + display: grid; + } + + .mobile-panel { + display: block; + max-height: 0; + overflow: hidden; + color: #fff; + background: #161616; + transition: max-height 0.35s ease; + } + + .mobile-panel.open { + max-height: 560px; + } + + .mobile-panel nav { + display: grid; + padding-block: 18px 25px; + } + + .mobile-panel nav > a { + padding: 10px 0; + color: rgb(255 255 255 / 70%); + font-size: 13px; + font-weight: 300; + border-bottom: 1px solid rgb(255 255 255 / 10%); + } + + .mobile-panel nav > a.active { + color: #fff; + font-weight: 400; + border-color: #fff; + } + + .mobile-panel .mobile-call { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 12px; + color: #fff; + background: #303030; + border: 0; + } + + .hero-content h1 { + font-size: clamp(25px, 4.8vw, 36px); + } + + .scroll-hint, + .hero-index { + display: none; + } + + .products-section, + .articles-section, + .contact-section, + .resume-section, + .features-intro, + .features-section, + .technology-section, + .about-intro, + .why-section { + padding-block: 82px; + } + + .resume-grid, + .about-grid, + .features-intro-grid, + .why-grid { + grid-template-columns: 1fr; + gap: 55px; + } + + .resume-image, + .features-intro-image, + .why-image { + min-height: 500px; + } + + .features-grid { + grid-template-columns: repeat(2, 1fr); + } + + .technology-grid { + grid-template-columns: repeat(2, 1fr); + } + + .video-content { + grid-template-columns: auto 1fr; + } + + .video-content .outline-button { + grid-column: 2; + justify-self: start; + } + + .page-hero { + min-height: 440px; + padding-bottom: 65px; + } + + .about-gallery { + min-height: 590px; + } + + .contact-main { + grid-template-columns: 1fr; + } + + .footer-grid { + grid-template-columns: 1fr 1fr; + gap: 50px; + } + + .footer-about { + grid-column: 1 / -1; + } +} + +@media (max-width: 600px) { + body { + font-size: 14px; + } + + .home-hero { + min-height: 760px; + background-position: 42% center; + } + + .home-hero::after { + content: ""; + position: absolute; + inset: 0; + background: rgb(0 0 0 / 18%); + } + + .hero-content { + z-index: 2; + padding-top: 76px; + } + + .hero-kicker, + .eyebrow { + font-size: 11px; + } + + .hero-content h1 { + margin-top: 16px; + font-size: 23px; + } + + .hero-content > p { + font-size: 14px; + } + + .hero-actions { + align-items: flex-start; + flex-direction: column; + gap: 16px; + } + + .section-heading { + margin-bottom: 38px; + } + + .section-heading h2 { + font-size: 24px; + } + + .features-grid, + .articles-grid, + .contact-cards, + .footer-grid { + grid-template-columns: 1fr; + } + + .feature-specs, + .technology-grid { + grid-template-columns: 1fr; + } + + .products-section { + padding-block: 68px 54px; + } + + .products-section > .container { + width: min(calc(100% - 34px), var(--container)); + } + + .resume-grid { + gap: 45px; + } + + .resume-image, + .features-intro-image, + .why-image { + min-height: 390px; + } + + .resume-image::before { + display: none; + } + + .experience-badge { + left: -8px; + bottom: 20px; + width: 135px; + } + + .feature-card { + min-height: 245px; + padding: 32px 28px; + } + + .video-banner { + min-height: 500px; + background-attachment: scroll; + } + + .video-content { + grid-template-columns: 1fr; + text-align: center; + } + + .play-button { + width: 74px; + height: 74px; + margin-inline: auto; + } + + .video-content .outline-button { + grid-column: 1; + justify-self: center; + } + + .article-image { + height: 280px; + } + + .page-hero { + min-height: 390px; + padding-bottom: 50px; + } + + .page-hero h1 { + font-size: 32px; + } + + .about-gallery { + min-height: 450px; + } + + .gallery-main { + inset: 0 12% 65px 0; + } + + .gallery-side { + width: 42%; + height: 40%; + border-width: 6px; + } + + .about-year { + width: 125px; + min-height: 105px; + bottom: 20px; + } + + .about-year strong { + font-size: 27px; + } + + .about-stats-grid { + grid-template-columns: 1fr; + border-right: 0; + border-top: 1px solid rgb(255 255 255 / 13%); + } + + .about-stats article { + min-height: 150px; + border-left: 0; + border-bottom: 1px solid rgb(255 255 255 / 13%); + } + + .contact-cards { + gap: 14px; + margin-bottom: 55px; + } + + .contact-cards article { + min-height: 205px; + } + + .form-panel, + .contact-aside { + padding: 30px 22px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .footer-grid { + gap: 38px; + padding-block: 60px; + } + + .footer-about { + grid-column: auto; + } + + .footer-bottom-inner { + min-height: 130px; + align-items: flex-start; + justify-content: center; + flex-direction: column; + gap: 10px; + padding-block: 20px; + } + + .meshkee-credit { + min-width: min(100%, 270px); + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* Meshkee catalog, journal and account integration */ +.source-product-image > a { + position: absolute; + inset: 0; + display: block; +} + +.source-product-copy h3 a:hover { + color: #666; +} + +.empty-state { + width: 100%; + padding: 55px 25px; + color: #777; + background: #f6f6f6; + text-align: center; +} + +.empty-state h2 { + margin-bottom: 8px; + color: var(--ink); + font-size: 20px; +} + +.empty-state a { + display: inline-block; + margin-top: 16px; + color: #111; + border-bottom: 1px solid currentColor; +} + +.catalog-section, +.blog-list-section { + padding: 90px 0 110px; + background: #fff; +} + +.catalog-filters { + display: grid; + grid-template-columns: minmax(260px, 1.5fr) minmax(210px, 0.8fr) auto; + align-items: end; + gap: 18px; + margin-bottom: 32px; + padding: 24px; + background: #f6f6f6; + border: 1px solid #ededed; +} + +.catalog-filters label { + display: grid; + gap: 8px; + color: #555; + font-size: 11px; +} + +.catalog-filters label > div { + position: relative; +} + +.catalog-filters input, +.catalog-filters select { + width: 100%; + height: 48px; + padding: 0 15px; + color: #222; + background: #fff; + border: 1px solid #dedede; + outline: none; + font: inherit; + transition: border-color 0.2s ease; +} + +.catalog-filters input { + padding-left: 44px; +} + +.catalog-filters input:focus, +.catalog-filters select:focus { + border-color: #777; +} + +.catalog-filters label svg { + position: absolute; + left: 15px; + top: 50%; + width: 17px; + transform: translateY(-50%); + color: #777; + pointer-events: none; +} + +.catalog-filters .primary-button { + min-width: 135px; + height: 48px; +} + +.catalog-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 25px; + color: #777; + font-size: 11px; +} + +.catalog-summary strong { + color: #111; + font-size: 16px; +} + +.catalog-summary a { + color: #333; + border-bottom: 1px solid #aaa; +} + +.category-intro { + max-width: 760px; + margin-bottom: 45px; +} + +.category-intro .english-title { + justify-content: flex-start; +} + +.category-intro h2 { + margin-top: 14px; + color: #1b1b1b; + font-size: clamp(24px, 3vw, 36px); +} + +.category-intro p { + margin-top: 13px; + color: #707070; + line-height: 2; +} + +.catalog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 26px; +} + +.catalog-product-card { + min-width: 0; + background: #f7f7f7; + border: 1px solid #eeeeee; + overflow: hidden; +} + +.catalog-product-image { + position: relative; + display: block; + aspect-ratio: 1 / 1.08; + overflow: hidden; + background: #ececec; +} + +.catalog-product-image img { + object-fit: cover; + transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); +} + +.catalog-product-card:hover .catalog-product-image img { + transform: scale(1.045); +} + +.catalog-product-copy { + padding: 23px 22px 24px; +} + +.catalog-product-copy > span { + display: block; + margin-bottom: 8px; + color: #8b8b8b; + font-size: 9px; +} + +.catalog-product-copy h2, +.catalog-product-copy h3 { + min-height: 52px; + color: #222; + font-size: 15px; + line-height: 1.75; +} + +.catalog-product-copy h2 a, +.catalog-product-copy h3 a { + transition: color 0.2s ease; +} + +.catalog-product-copy h2 a:hover, +.catalog-product-copy h3 a:hover { + color: #666; +} + +.catalog-product-copy > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-top: 18px; + padding-top: 17px; + border-top: 1px solid #e3e3e3; +} + +.catalog-product-copy p { + color: #777; + font-size: 10px; +} + +.catalog-product-copy > div > a { + display: inline-flex; + align-items: center; + gap: 7px; + color: #111; + font-size: 10px; +} + +.catalog-product-copy svg { + width: 15px; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 28px; + margin-top: 55px; + color: #777; + font-size: 11px; +} + +.pagination a { + min-width: 88px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + color: #222; + border: 1px solid #d6d6d6; + transition: color 0.2s ease, background-color 0.2s ease; +} + +.pagination a:hover { + color: #fff; + background: #1b1b1b; +} + +.pagination svg { + width: 14px; +} + +.product-detail-top { + padding: 128px 0 85px; + background: #f7f7f7; +} + +.detail-breadcrumbs { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 30px; + color: #8b8b8b; + font-size: 9px; +} + +.detail-breadcrumbs a { + transition: color 0.2s ease; +} + +.detail-breadcrumbs a:hover, +.detail-breadcrumbs b { + color: #222; +} + +.product-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr); + align-items: start; + gap: clamp(45px, 7vw, 100px); +} + +.product-gallery { + min-width: 0; +} + +.product-gallery-main { + position: relative; + width: 100%; + aspect-ratio: 1 / 1.05; + display: block; + padding: 0; + background: #ececec; + border: 0; + overflow: hidden; + cursor: zoom-in; +} + +.product-gallery-main > img { + object-fit: cover; + transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1); +} + +.product-gallery-main:hover > img { + transform: scale(1.35); +} + +.product-gallery-main > span { + position: absolute; + left: 18px; + bottom: 18px; + z-index: 2; + width: 42px; + height: 42px; + display: grid; + place-items: center; + color: #fff; + background: rgb(0 0 0 / 70%); +} + +.product-gallery-main span svg { + width: 18px; +} + +.gallery-thumbnails { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.gallery-thumbnails button { + position: relative; + aspect-ratio: 1; + padding: 0; + background: #eee; + border: 1px solid transparent; + overflow: hidden; +} + +.gallery-thumbnails button.active { + border-color: #111; +} + +.gallery-thumbnails img { + object-fit: cover; +} + +.product-detail-copy { + padding-top: 20px; +} + +.product-detail-copy .english-title { + justify-content: flex-start; +} + +.product-detail-copy .product-category { + margin: 25px 0 8px; + color: #888; + font-size: 10px; +} + +.product-detail-copy h1 { + max-width: 560px; + color: #1b1b1b; + font-size: clamp(25px, 3vw, 38px); + line-height: 1.55; +} + +.product-summary { + margin-top: 16px; + color: #666; + line-height: 2; +} + +.product-facts { + display: grid; + gap: 0; + margin: 32px 0; + border-top: 1px solid #dedede; +} + +.product-facts > div { + min-height: 48px; + display: grid; + grid-template-columns: 105px 1fr; + align-items: center; + gap: 14px; + border-bottom: 1px solid #dedede; +} + +.product-facts dt { + color: #888; + font-size: 10px; +} + +.product-facts dd { + color: #2b2b2b; + font-size: 11px; +} + +.product-assurance { + display: flex; + align-items: center; + gap: 11px; + color: #555; +} + +.product-assurance svg { + width: 21px; + color: #111; +} + +.product-assurance p { + font-size: 10px; +} + +.product-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 30px; +} + +.product-detail-actions a { + min-height: 48px; +} + +.product-detail-actions svg { + width: 16px; +} + +.outline-dark-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 23px; + color: #222; + border: 1px solid #aaa; + font-size: 11px; + transition: color 0.2s ease, background-color 0.2s ease; +} + +.outline-dark-button:hover { + color: #fff; + background: #1b1b1b; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + background: rgb(0 0 0 / 94%); +} + +.lightbox-image { + position: relative; + width: min(86vw, 1100px); + height: 84vh; +} + +.lightbox-image img { + object-fit: contain; +} + +.lightbox button { + position: absolute; + z-index: 3; + display: grid; + place-items: center; + color: #fff; + background: rgb(255 255 255 / 7%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.lightbox-close { + top: 28px; + left: 28px; + width: 46px; + height: 46px; +} + +.lightbox-prev, +.lightbox-next { + top: 50%; + width: 52px; + height: 62px; + transform: translateY(-50%); +} + +.lightbox-prev { + right: 28px; +} + +.lightbox-next { + left: 28px; +} + +.lightbox-count { + position: absolute; + bottom: 22px; + color: rgb(255 255 255 / 68%); + direction: ltr; + font: 11px Arial, sans-serif; +} + +.product-information { + padding: 85px 0 95px; + background: #fff; +} + +.entity-tabs { + border: 1px solid #e3e3e3; +} + +.tab-buttons { + display: flex; + background: #f6f6f6; + border-bottom: 1px solid #e3e3e3; +} + +.tab-buttons button { + position: relative; + min-width: 160px; + height: 58px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #777; + background: transparent; + border: 0; + font-size: 11px; +} + +.tab-buttons button::after { + content: ""; + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + background: #111; + transform: scaleX(0); + transition: transform 0.25s ease; +} + +.tab-buttons button.active { + color: #111; + background: #fff; +} + +.tab-buttons button.active::after { + transform: scaleX(1); +} + +.tab-buttons button span { + min-width: 20px; + height: 20px; + display: grid; + place-items: center; + color: #fff; + background: #333; + font-size: 8px; +} + +.tab-content { + min-height: 280px; + padding: clamp(28px, 5vw, 60px); +} + +.cms-content { + color: #4e4e4e; + font-size: 13px; + line-height: 2.35; +} + +.cms-content p, +.cms-content ul, +.cms-content ol, +.cms-content blockquote { + margin-bottom: 19px; +} + +.cms-content h2 { + margin: 32px 0 14px; + color: #1b1b1b; + font-size: 22px; +} + +.cms-content h3 { + margin: 26px 0 12px; + color: #222; + font-size: 17px; +} + +.cms-content ul, +.cms-content ol { + padding-right: 22px; +} + +.cms-content a { + color: #111; + border-bottom: 1px solid #777; +} + +.cms-content img { + width: auto; + height: auto; + display: block; + margin: 28px auto; +} + +.comments-panel { + display: grid; + gap: 38px; +} + +.comments-list { + display: grid; + gap: 14px; +} + +.comments-list article { + display: grid; + grid-template-columns: 42px 1fr; + gap: 14px; + padding: 20px; + background: #f7f7f7; +} + +.comments-list article > span { + width: 42px; + height: 42px; + display: grid; + place-items: center; + color: #555; + background: #e8e8e8; + border-radius: 50%; +} + +.comments-list svg { + width: 17px; +} + +.comments-list h3 { + margin-bottom: 5px; + color: #222; + font-size: 11px; +} + +.comments-list p { + color: #666; + font-size: 11px; + line-height: 2; +} + +.comments-empty { + min-height: 150px; + display: grid; + place-items: center; + align-content: center; + gap: 10px; + color: #888; + background: #f7f7f7; + text-align: center; +} + +.comments-empty svg { + width: 24px; +} + +.comment-form { + display: grid; + gap: 11px; + padding: 24px; + background: #f7f7f7; +} + +.comment-form label { + color: #333; + font-size: 11px; +} + +.comment-form textarea { + width: 100%; + resize: vertical; + padding: 14px; + color: #222; + background: #fff; + border: 1px solid #ddd; + outline: none; + font: inherit; + line-height: 1.8; +} + +.comment-form textarea:focus { + border-color: #777; +} + +.comment-form .primary-button { + justify-self: start; + margin-top: 5px; +} + +.comment-form .primary-button:disabled, +.contact-form .primary-button:disabled { + cursor: wait; + opacity: 0.6; +} + +.comment-success, +.form-success { + color: #287645; + font-size: 11px; +} + +.comment-login-box { + display: grid; + grid-template-columns: 45px 1fr auto; + align-items: center; + gap: 15px; + padding: 24px; + background: #f7f7f7; + border: 1px solid #e8e8e8; +} + +.comment-login-box > svg { + width: 24px; + color: #333; +} + +.comment-login-box h3 { + margin-bottom: 4px; + color: #222; + font-size: 13px; +} + +.comment-login-box p { + color: #777; + font-size: 10px; +} + +.comment-login-box .primary-button { + min-height: 44px; +} + +.related-section { + padding: 90px 0 110px; + background: #f4f4f4; +} + +.related-section .section-heading { + margin-bottom: 42px; +} + +.blog-list-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 28px; +} + +.blog-list-card { + min-width: 0; + border: 1px solid #e9e9e9; + background: #fff; +} + +.blog-list-image { + position: relative; + display: block; + aspect-ratio: 1.25 / 1; + background: #eee; + overflow: hidden; +} + +.blog-list-image img { + object-fit: cover; + transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); +} + +.blog-list-card:hover .blog-list-image img { + transform: scale(1.045); +} + +.blog-list-copy { + padding: 23px; +} + +.blog-list-copy > span { + display: block; + margin-bottom: 9px; + color: #999; + font-size: 9px; +} + +.blog-list-copy h2 { + min-height: 54px; + color: #222; + font-size: 15px; + line-height: 1.8; +} + +.blog-list-copy > p { + height: 75px; + margin-top: 10px; + color: #777; + font-size: 10px; + line-height: 1.9; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.blog-list-copy > a { + display: inline-flex; + align-items: center; + gap: 7px; + margin-top: 19px; + color: #222; + font-size: 10px; + border-bottom: 1px solid #aaa; +} + +.blog-list-copy svg { + width: 14px; +} + +.blog-detail-header { + padding: 150px 0 100px; + color: #fff; + background: #171717; +} + +.detail-breadcrumbs.light { + color: rgb(255 255 255 / 48%); +} + +.detail-breadcrumbs.light a:hover, +.detail-breadcrumbs.light b { + color: #fff; +} + +.blog-detail-header .page-hero-label { + display: flex; + align-items: center; + gap: 13px; + color: rgb(255 255 255 / 70%); + font-family: var(--font-en); + font-size: 9px; + letter-spacing: 0.14em; +} + +.blog-detail-header .page-hero-label::before { + content: ""; + width: 36px; + height: 1px; + background: currentColor; +} + +.blog-detail-header h1 { + max-width: 950px; + margin-top: 22px; + color: #fff; + font-size: clamp(30px, 4.2vw, 55px); + line-height: 1.55; +} + +.blog-meta { + display: flex; + flex-wrap: wrap; + gap: 22px; + margin-top: 27px; + color: rgb(255 255 255 / 58%); +} + +.blog-meta span { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 9px; +} + +.blog-meta svg { + width: 14px; +} + +.blog-detail-image { + position: relative; + height: min(52vw, 620px); + margin-top: -50px; + background: #eee; + overflow: hidden; +} + +.blog-detail-image img { + object-fit: cover; +} + +.blog-content-wrap { + max-width: 900px; + padding-top: 70px; + padding-bottom: 90px; +} + +.blog-lead { + margin-bottom: 38px; + padding-right: 22px; + color: #333; + border-right: 2px solid #222; + font-size: 15px; + line-height: 2.25; +} + +.blog-comments { + padding: 80px 0 95px; + background: #f6f6f6; +} + +.blog-comments .section-heading { + margin-bottom: 35px; +} + +.form-success { + grid-column: 1 / -1; +} + +@media (max-width: 1000px) { + .catalog-grid, + .blog-list-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .product-detail-grid { + grid-template-columns: 1fr; + gap: 45px; + } + + .product-gallery-main { + aspect-ratio: 1.15 / 1; + } + + .product-detail-copy { + padding-top: 0; + } +} + +@media (max-width: 860px) { + .user-menu-trigger span, + .user-menu-trigger svg:last-child { + display: none; + } + + .user-menu-trigger { + width: 38px; + padding: 0; + } + + .catalog-filters { + grid-template-columns: 1fr 1fr; + } + + .catalog-filters .primary-button { + grid-column: 1 / -1; + justify-self: start; + } + + .blog-detail-header { + padding: 125px 0 85px; + } + + .blog-detail-image { + width: calc(100% - 34px); + } +} + +@media (max-width: 620px) { + .catalog-section, + .blog-list-section { + padding: 60px 0 80px; + } + + .catalog-filters { + grid-template-columns: 1fr; + padding: 18px; + } + + .catalog-filters .primary-button { + grid-column: auto; + width: 100%; + } + + .catalog-grid, + .blog-list-grid { + grid-template-columns: 1fr; + } + + .catalog-product-copy h2, + .catalog-product-copy h3, + .blog-list-copy h2 { + min-height: 0; + } + + .pagination { + gap: 10px; + } + + .pagination a { + min-width: 74px; + } + + .product-detail-top { + padding: 105px 0 65px; + } + + .product-gallery-main { + aspect-ratio: 1 / 1.08; + } + + .gallery-thumbnails { + grid-template-columns: repeat(4, 1fr); + } + + .product-detail-copy h1 { + font-size: 25px; + } + + .product-detail-actions { + display: grid; + } + + .product-detail-actions a { + width: 100%; + } + + .product-information, + .related-section { + padding: 60px 0 75px; + } + + .tab-buttons button { + min-width: 0; + flex: 1; + } + + .tab-content { + padding: 24px 18px; + } + + .comment-login-box { + grid-template-columns: 38px 1fr; + } + + .comment-login-box .primary-button { + grid-column: 1 / -1; + width: 100%; + } + + .lightbox-image { + width: 94vw; + height: 78vh; + } + + .lightbox-prev { + right: 10px; + } + + .lightbox-next { + left: 10px; + } + + .lightbox-close { + top: 16px; + left: 16px; + } + + .blog-detail-header h1 { + font-size: 29px; + } + + .blog-detail-image { + height: 64vw; + } + + .blog-content-wrap { + padding-top: 50px; + padding-bottom: 65px; + } + + .blog-lead { + font-size: 13px; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f054f53 --- /dev/null +++ b/app/layout.tsx @@ -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> + ); +} diff --git a/app/meshkee/sitemap-config.json/route.ts b/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..7270f42 --- /dev/null +++ b/app/meshkee/sitemap-config.json/route.ts @@ -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, + }); +} diff --git a/app/meshkee/static-image-slots/route.ts b/app/meshkee/static-image-slots/route.ts new file mode 100644 index 0000000..19908ba --- /dev/null +++ b/app/meshkee/static-image-slots/route.ts @@ -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, + }, + ], + }); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b457003 --- /dev/null +++ b/app/page.tsx @@ -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> + </> + ); +} diff --git a/app/products/[id]/[slug]/page.tsx b/app/products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..6f67d7b --- /dev/null +++ b/app/products/[id]/[slug]/page.tsx @@ -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> + )} + </> + ); +} diff --git a/app/products/category/[id]/[slug]/page.tsx b/app/products/category/[id]/[slug]/page.tsx new file mode 100644 index 0000000..f08d863 --- /dev/null +++ b/app/products/category/[id]/[slug]/page.tsx @@ -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> + </> + ); +} diff --git a/app/products/page.tsx b/app/products/page.tsx new file mode 100644 index 0000000..b50c820 --- /dev/null +++ b/app/products/page.tsx @@ -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> + </> + ); +} diff --git a/components/api-blog-card.tsx b/components/api-blog-card.tsx new file mode 100644 index 0000000..5c50ee7 --- /dev/null +++ b/components/api-blog-card.tsx @@ -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> + ); +} diff --git a/components/api-product-card.tsx b/components/api-product-card.tsx new file mode 100644 index 0000000..645d04c --- /dev/null +++ b/components/api-product-card.tsx @@ -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> + ); +} diff --git a/components/auth-provider.tsx b/components/auth-provider.tsx new file mode 100644 index 0000000..13d5a1a --- /dev/null +++ b/components/auth-provider.tsx @@ -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); +} diff --git a/components/contact-form.tsx b/components/contact-form.tsx new file mode 100644 index 0000000..cd16343 --- /dev/null +++ b/components/contact-form.tsx @@ -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> + ); +} diff --git a/components/entity-comments.tsx b/components/entity-comments.tsx new file mode 100644 index 0000000..a9c5ae4 --- /dev/null +++ b/components/entity-comments.tsx @@ -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> + ); +} diff --git a/components/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000..2b9a049 --- /dev/null +++ b/components/footer.tsx @@ -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> + ); +} diff --git a/components/header.tsx b/components/header.tsx new file mode 100644 index 0000000..b697546 --- /dev/null +++ b/components/header.tsx @@ -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> + ); +} diff --git a/components/motion-observer.tsx b/components/motion-observer.tsx new file mode 100644 index 0000000..b94048b --- /dev/null +++ b/components/motion-observer.tsx @@ -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; +} diff --git a/components/product-carousel.tsx b/components/product-carousel.tsx new file mode 100644 index 0000000..380f342 --- /dev/null +++ b/components/product-carousel.tsx @@ -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> + ); +} diff --git a/components/product-gallery.tsx b/components/product-gallery.tsx new file mode 100644 index 0000000..421e0ec --- /dev/null +++ b/components/product-gallery.tsx @@ -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> + )} + </> + ); +} diff --git a/components/sections.tsx b/components/sections.tsx new file mode 100644 index 0000000..115b2d2 --- /dev/null +++ b/components/sections.tsx @@ -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> + ); +} diff --git a/components/user-menu.tsx b/components/user-menu.tsx new file mode 100644 index 0000000..1aa1e33 --- /dev/null +++ b/components/user-menu.tsx @@ -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> + ); +} diff --git a/data/generated-sitemap-pages.ts b/data/generated-sitemap-pages.ts new file mode 100644 index 0000000..26aa19f --- /dev/null +++ b/data/generated-sitemap-pages.ts @@ -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; diff --git a/data/site.ts b/data/site.ts new file mode 100644 index 0000000..3ae7205 --- /dev/null +++ b/data/site.ts @@ -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", +}; diff --git a/lib/meshkee.ts b/lib/meshkee.ts new file mode 100644 index 0000000..ea432ee --- /dev/null +++ b/lib/meshkee.ts @@ -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 ""; + } +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..fecb76f --- /dev/null +++ b/next.config.ts @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..580133b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1674 @@ +{ + "name": "glassmond", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glassmond", + "version": "1.0.0", + "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" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.3.tgz", + "integrity": "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.3.tgz", + "integrity": "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.3.tgz", + "integrity": "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.3.tgz", + "integrity": "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.3.tgz", + "integrity": "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.3.tgz", + "integrity": "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.3.tgz", + "integrity": "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.3.tgz", + "integrity": "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.3.tgz", + "integrity": "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", + "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/sanitize-html": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", + "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^10.1" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/@types/sanitize-html/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, + "node_modules/lucide-react": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", + "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.3.tgz", + "integrity": "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.3", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.3", + "@next/swc-darwin-x64": "16.3.3", + "@next/swc-linux-arm64-gnu": "16.3.3", + "@next/swc-linux-arm64-musl": "16.3.3", + "@next/swc-linux-x64-gnu": "16.3.3", + "@next/swc-linux-x64-musl": "16.3.3", + "@next/swc-win32-arm64-msvc": "16.3.3", + "@next/swc-win32-x64-msvc": "16.3.3", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/sanitize-html": { + "version": "2.17.7", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", + "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b80a44 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/fonts/iran-yekan-bold.ttf b/public/fonts/iran-yekan-bold.ttf new file mode 100644 index 0000000..6d98764 Binary files /dev/null and b/public/fonts/iran-yekan-bold.ttf differ diff --git a/public/fonts/iran-yekan-light.ttf b/public/fonts/iran-yekan-light.ttf new file mode 100644 index 0000000..b6114ee Binary files /dev/null and b/public/fonts/iran-yekan-light.ttf differ diff --git a/public/fonts/iran-yekan-regular.ttf b/public/fonts/iran-yekan-regular.ttf new file mode 100644 index 0000000..c662408 Binary files /dev/null and b/public/fonts/iran-yekan-regular.ttf differ diff --git a/public/fonts/montserrat-alternates-light.ttf b/public/fonts/montserrat-alternates-light.ttf new file mode 100644 index 0000000..cb51843 Binary files /dev/null and b/public/fonts/montserrat-alternates-light.ttf differ diff --git a/public/images/about/gallery-1.jpg b/public/images/about/gallery-1.jpg new file mode 100644 index 0000000..868d154 Binary files /dev/null and b/public/images/about/gallery-1.jpg differ diff --git a/public/images/about/gallery-2.jpg b/public/images/about/gallery-2.jpg new file mode 100644 index 0000000..81f3f66 Binary files /dev/null and b/public/images/about/gallery-2.jpg differ diff --git a/public/images/about/gallery-3.jpg b/public/images/about/gallery-3.jpg new file mode 100644 index 0000000..5d17996 Binary files /dev/null and b/public/images/about/gallery-3.jpg differ diff --git a/public/images/about/gallery-4.jpg b/public/images/about/gallery-4.jpg new file mode 100644 index 0000000..9a7a497 Binary files /dev/null and b/public/images/about/gallery-4.jpg differ diff --git a/public/images/articles/decorative-definition.jpg b/public/images/articles/decorative-definition.jpg new file mode 100644 index 0000000..c542c6a Binary files /dev/null and b/public/images/articles/decorative-definition.jpg differ diff --git a/public/images/articles/decorative-mirrors.jpg b/public/images/articles/decorative-mirrors.jpg new file mode 100644 index 0000000..7f73313 Binary files /dev/null and b/public/images/articles/decorative-mirrors.jpg differ diff --git a/public/images/articles/interior-mirror.jpg b/public/images/articles/interior-mirror.jpg new file mode 100644 index 0000000..26b5500 Binary files /dev/null and b/public/images/articles/interior-mirror.jpg differ diff --git a/public/images/articles/mirror-in-decor.jpg b/public/images/articles/mirror-in-decor.jpg new file mode 100644 index 0000000..5a185ca Binary files /dev/null and b/public/images/articles/mirror-in-decor.jpg differ diff --git a/public/images/hero.jpg b/public/images/hero.jpg new file mode 100644 index 0000000..a9122f6 Binary files /dev/null and b/public/images/hero.jpg differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..2f7741e Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/meshkee-logo-white.png b/public/images/meshkee-logo-white.png new file mode 100644 index 0000000..a10ecd3 Binary files /dev/null and b/public/images/meshkee-logo-white.png differ diff --git a/public/images/page-hero.jpg b/public/images/page-hero.jpg new file mode 100644 index 0000000..7ddb2cb Binary files /dev/null and b/public/images/page-hero.jpg differ diff --git a/public/images/products/clw-defogger.jpg b/public/images/products/clw-defogger.jpg new file mode 100644 index 0000000..4d6a3b2 Binary files /dev/null and b/public/images/products/clw-defogger.jpg differ diff --git a/public/images/products/clw-makeup.jpg b/public/images/products/clw-makeup.jpg new file mode 100644 index 0000000..cd30732 Binary files /dev/null and b/public/images/products/clw-makeup.jpg differ diff --git a/public/images/products/hlw-horizontal.jpg b/public/images/products/hlw-horizontal.jpg new file mode 100644 index 0000000..51088c9 Binary files /dev/null and b/public/images/products/hlw-horizontal.jpg differ diff --git a/public/images/products/slw-makeup.jpg b/public/images/products/slw-makeup.jpg new file mode 100644 index 0000000..a3807ee Binary files /dev/null and b/public/images/products/slw-makeup.jpg differ diff --git a/public/images/products/slw-oval.jpg b/public/images/products/slw-oval.jpg new file mode 100644 index 0000000..56f91ce Binary files /dev/null and b/public/images/products/slw-oval.jpg differ diff --git a/public/images/products/vlw-makeup.jpg b/public/images/products/vlw-makeup.jpg new file mode 100644 index 0000000..ce46afc Binary files /dev/null and b/public/images/products/vlw-makeup.jpg differ diff --git a/public/images/products/vlw-vertical.jpg b/public/images/products/vlw-vertical.jpg new file mode 100644 index 0000000..a278b10 Binary files /dev/null and b/public/images/products/vlw-vertical.jpg differ diff --git a/public/images/video-cover.jpg b/public/images/video-cover.jpg new file mode 100644 index 0000000..d7fae4d Binary files /dev/null and b/public/images/video-cover.jpg differ diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..66ea3aa --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -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)}.`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3f0b70d --- /dev/null +++ b/tsconfig.json @@ -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" + ] +}