Ship Meshkee-backed Ayoubi Gallery storefront.

Replace the coming-soon page with products, mega menu, home slots, and catalog UI polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-10 23:44:59 +03:30
co-authored by Cursor
parent 711f75eb93
commit 2ee76f4566
71 changed files with 6309 additions and 271 deletions
+117
View File
@@ -0,0 +1,117 @@
# Ayoubi Gallery — AI / Project Context
## Source of truth
- Live domain: `https://ayoubi.jewelry`
- Design language: black ground, ivory type, gold lines, Doran + Cinzel, flat surfaces
- Backend: https://api.meshkee.com/docs/website
- Domain: `ayoubi.jewelry` (`NEXT_PUBLIC_WEBSITE_DOMAIN`)
- API base: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_MESHKEE_API_BASE`)
- Tenant business id: `42` (`GET /tenants/ayoubi.jewelry`, nameFa: گالری ایوبی فخیم)
- Enabled modules: `products`, `blog`, `finance`. `specialProductsSource`: `store_item`
- Prompt reference: `https://api.meshkee.com/api/v1/public/ai-prompts/ayoubi-gallery/backend/253d39eea2a4047c39296d1a`
## Stack
- Next.js 15 App Router + React 19 + TypeScript
- `output: "standalone"` in `next.config.ts` (required for the Meshkee websites VM)
- Language: `fa`, direction: `rtl`
- Fonts: Doran (`--font-doran`), Cinzel (`--font-cinzel`)
- Styling: `src/app/globals.css` only (no Tailwind)
## Color palette
- Background: `#070708` (`--bg`)
- Elevated: `#101012`
- Ivory text: `#f3eee6` (`--fg`)
- Muted: `rgba(243, 238, 230, 0.62)`
- Gold: `#c9a86a` (`--accent`)
- Hairline: `rgba(243, 238, 230, 0.14)`
- Flat: no shadows, square corners, 1px borders
- Header is fixed. Frosted over the home video; solid `#000` after scroll (`.header--solid`)
- Header tools: account menu, cart (badge + popup), phone, Instagram
## Auth (customer dashboard)
- Do **not** add `/login`, `/register`, `/cart`, or `/checkout` on this site
- Anonymous account icon → `https://customer.ayoubi.jewelry`
- Logged in (cookie `meshkee_customer_access_token`, may be chunked): show `{firstName|nameFa|name} عزیز`
- Dropdown: `پروفایل من`, `خروج`, and if admin `مدیریت وبسایت` → `https://business.ayoubi.jewelry`
- Session: `src/lib/auth.ts` + `src/lib/authUser.ts` via `GET /auth/me`
## Shopping cart (mini-cart only)
- Header cart icon + quantity badge + popup (`CartButton` + `CartProvider` + `guestCart.ts`)
- Persist `meshkee-guest-cart` in localStorage + cookie on `.ayoubi.jewelry`
- Add-to-cart only when `GET .../store-items/by-product/{productId}` returns in-stock variants
- Line `id` = `storeItemVariantId`; price = `discountedPrice ?? price`
- Continue: logged in → `customer.../checkout/cart?guestCart=…`; else login with that redirect
- Never call `/businesses/{id}/cart*` from this site
## Pages
| Route | Purpose |
| --- | --- |
| `/` | Video hero, category studio, local/CMS gallery, follow, carousel, parallax, visit |
| `/products` | Catalog from Meshkee products API + jewelry category filters + pagination |
| `/products/[id]/[nameFaSlug]` | Product detail (canonical); redirect if slug drifts |
| `/products/category/[id]/[nameFaSlug]` | Category product list |
| `/portfolio` | Portfolio list (API; empty until CMS content) |
| `/portfolio/[slug]` | Portfolio detail: lightbox gallery, zoom, similar row |
| `/blog` | Blog list (API; empty until CMS content) |
| `/blog/[slug]` | Blog detail |
| `/about` | درباره گالری |
| `/contact` | Contact facts + form → `POST /api/contact` → Meshkee contact-submissions |
## Product detail requirements
- Media gallery with hover zoom (desktop) + lightbox
- Tabs: توضیحات، مشخصات فنی (via `.../technical-info` join), دیدگاه‌ها
- Comments: form only when authenticated; else login link to customer portal
- Similar products from `relatedProducts`
- Emit `product.schema.jsonLd` when present
## Homepage (visual gallery)
- Local images in `public/assets/images/` / `gallery` in `src/data/site.ts` as fallbacks
- CMS slots: `categories`, `four-banner`, `masonry-image`, `instagram-image`, `last-banner` (see Static image slots)
- Homepage masonry is atmospheric; purchasable catalog is `/products`
## Brand copy (fallback)
- Name: گالری طلا و جواهر ایوبی فخیم / Ayoubi Fakhim Jewelry
- Tagline: این طلا همیشه مده
- Address: قم - سالاریه - اریکه ملل
- Phones: ۰۹۹۹ ۱۲۹ ۶۶۶۶ (`09991296666`), ۰۹۳۷ ۱۴۴ ۸۹۰۵ (`09371448905`)
- Instagram: https://www.instagram.com/ayoubifakhim/
- Logo / favicon fallback: `public/Ayoobi-logo.png`
## Meshkee helpers
- `src/lib/meshkee.ts` — business-info, favicon
- `src/lib/catalog.ts` — products, categories, blogs, portfolios, comments, store-items, `/auth/me`
- `src/lib/slug.ts` — canonical product/category/blog/portfolio hrefs
- Favicon: `GET /website/favicon` → `metadata.icons`, else `/Ayoobi-logo.png`
### Static image slots
Catalog: `GET /meshkee/static-image-slots` from `STATIC_IMAGE_SLOTS` in `src/lib/static-images.ts`.
Fetch per page: `GET /tenants/{domain}/website/static-images?pageKey=…`.
Empty/fail → keep local placeholders. `url` → `<img>`; `linkUrl` → wrap in `<a>`.
Use `titleFa` / `titleEn` / `subtext` to match the original UI labels.
Apply `slot.aspectRatio` from the API (fallback to catalog). **If an on-page frame’s size or aspect ratio changes in development, update the matching `STATIC_IMAGE_SLOTS` entry in the same change.**
| key | kind | itemCount | aspectRatio | width | UI |
| --- | --- | --- | --- | --- | --- |
| `categories` | list | null | `1:1` | 320 | Category row — `titleFa`/`titleEn` beside image (icon fallback) |
| `four-banner` | list | 4 | `3:4` | 1440 | Sticky “از فلز تا پوشیدن” — Fa/En/subtext + image |
| `masonry-image` | list | null | `3:4` | 960 | Masonry gallery (free display height) |
| `instagram-image` | single | 1 | `4:5` | 480 | Follow frame photo + optional Fa/En/subtext |
| `last-banner` | single | 1 | `16:9` | 1600 | Parallax end banner |
| `about-portrait` | single | 1 | `3:4` | 900 | About portrait |
Helpers: `src/lib/home-slots.ts`, `SlotImage`.
### Tags & badges
From `business-info`: `metaTags` as `{ html }` **or** `{ name, content }` → root `<head>` via `CmsMeta`. `trustBadges[].embedHtml` → footer (scripts stripped). Empty → omit; do not hardcode eNamad/Samandehi.
### SEO
- Do not ship local `sitemap.xml` / `robots.txt` / `app/sitemap.ts` / `app/robots.ts`
- Static pages: `GET /meshkee/sitemap-config.json`
- One H1 per page; content images have alt; use `seoMetaTitle` / `seoMetaDescription` when set
- Organization JSON-LD from business-info; product/blog detail emit their own `schema.jsonLd`
## Important paths
- Layout / providers: `src/app/layout.tsx`, `src/components/providers/*`
- Header / footer: `src/components/Header.tsx`, `AccountMenu.tsx`, `CartButton.tsx`, `Footer.tsx`
- Catalog UI: `MediaGallery.tsx`, `ProductTabs.tsx`, `AddToCart.tsx`, `CatalogCard.tsx`, `CommentSection.tsx`
- Contact: `src/components/ContactForm.tsx` + toast, `src/app/api/contact/route.ts`
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import Link from "next/link";
import { gallery, site } from "@/data/site";
import { resolveAbout, resolveAddress } from "@/lib/content";
import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots, imagesForSlot } from "@/lib/static-images";
export const metadata: Metadata = {
title: "درباره ما",
description:
"آشنایی با گالری طلا و جواهر ایوبی فخیم در قم؛ طراحی و عرضه طلا، الماس و جواهر در سالاریه، مجتمع اریکه ملل.",
openGraph: {
title: "درباره گالری طلا و جواهر ایوبی فخیم",
description:
"آشنایی با گالری طلا و جواهر ایوبی فخیم در قم؛ طراحی و عرضه طلا، الماس و جواهر در سالاریه، مجتمع اریکه ملل.",
images: [{ url: gallery[0].src, alt: gallery[0].alt }],
},
};
export default async function AboutPage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]);
const paragraphs = resolveAbout(info);
const portrait = imagesForSlot(slots, "about-portrait")[0];
const image = {
src: portrait?.url || gallery[0].src,
alt: portrait?.titleFa || portrait?.titleEn || gallery[0].alt,
href: portrait?.linkUrl || undefined,
};
const address = resolveAddress(info);
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">{site.shortFa}</p>
<h1>درباره گالری</h1>
<p className="lead">{paragraphs[0]}</p>
</header>
<section className="about wrap" aria-labelledby="craft-title">
<figure className="about__figure">
{image.href ? (
<a href={image.href}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={image.alt} />
</a>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={image.src} alt={image.alt} />
)}
</figure>
<div>
<h2 id="craft-title">ساخت و انتخاب</h2>
{paragraphs.slice(1).map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
{info.vision?.trim() ? <p>{info.vision.trim()}</p> : null}
</div>
</section>
<section className="visit" aria-labelledby="place-title">
<div className="wrap visit__grid">
<div>
<h2 id="place-title">نشانی گالری</h2>
<p>{address}</p>
</div>
<div>
<p className="visit__label">ساعت حضور</p>
<p>برای بازدید هماهنگ کنید تا قطعه را آرام ببینید.</p>
</div>
<div>
<Link className="button" href="/contact">
تماس با گالری
</Link>
</div>
</div>
</section>
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as {
title?: string;
name?: string;
email?: string;
cellNumber?: string;
text?: string;
} | null;
if (!body?.title?.trim() || !body.name?.trim() || !body.text?.trim()) {
return NextResponse.json({ message: "نام، موضوع و متن پیام الزامی است." }, { status: 400 });
}
const response = await fetch(
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/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 });
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/CmsMeta";
import { asRichHtml, getBlogBySlug, safe } from "@/lib/catalog";
type PageProps = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = await safe(getBlogBySlug(slug), null);
if (!data) return { title: "نوشته پیدا نشد" };
const title = data.blog.seoMetaTitle || data.blog.title || data.blog.nameFa || slug;
const description = data.blog.seoMetaDescription || data.blog.summary || title;
const image = data.blog.thumbnailUrl || data.blog.image;
return {
title,
description,
openGraph: image ? { title, description, images: [{ url: image, alt: title }] } : { title, description },
};
}
export default async function BlogDetailPage({ params }: PageProps) {
const { slug } = await params;
const data = await safe(getBlogBySlug(slug), null);
if (!data) notFound();
const blog = data.blog;
const title = blog.title || blog.nameFa || slug;
const html = asRichHtml(blog.contentHtml || blog.descriptionHtml);
const image = blog.thumbnailUrl || blog.image;
return (
<main className="page">
<JsonLd data={blog.schema?.jsonLd ?? null} />
<article className="article wrap">
<header className="page-intro">
{blog.categoryNameFa ? <p className="kicker">{blog.categoryNameFa}</p> : null}
<h1>{title}</h1>
{blog.summary ? <p className="lead">{blog.summary}</p> : null}
</header>
{image ? (
<figure className="article__figure">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={title} />
</figure>
) : null}
{html ? (
<div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<p className="category-empty">متن این نوشته هنوز تکمیل نشده است.</p>
)}
<Link className="text-link" href="/blog">
بازگشت به مجله
</Link>
</article>
</main>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogCard } from "@/components/CatalogCard";
import { getBlogs, safe } from "@/lib/catalog";
import { toFaDigits } from "@/lib/format";
import { getPageSlots } from "@/lib/static-images";
import { blogHref } from "@/lib/slug";
export const metadata: Metadata = {
title: "مجله",
description: "نوشته‌ها و مطالب گالری طلا و جواهر ایوبی فخیم.",
};
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageRaw } = await searchParams;
const page = Math.max(1, Number(pageRaw) || 1);
const pageSize = 12;
const [result] = await Promise.all([
safe(getBlogs({ page, pageSize }), { items: [], total: 0, page, pageSize }),
getPageSlots("blog"),
]);
const totalPages = Math.max(1, Math.ceil(result.total / pageSize));
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">گالری ایوبی فخیم</p>
<h1>مجله</h1>
<p className="lead">نوشته‌ها و خبرهای گالری.</p>
</header>
<section className="wrap catalog" aria-label="فهرست نوشته‌ها">
{result.items.length ? (
<ul className="product-grid">
{result.items.map((blog) => (
<li key={blog.id}>
<CatalogCard
href={blogHref(blog.slug)}
image={blog.thumbnailUrl || blog.image}
category={blog.categoryNameFa}
title={blog.title || blog.nameFa || blog.slug}
summary={blog.summary}
/>
</li>
))}
</ul>
) : (
<p className="category-empty">هنوز نوشته‌ای منتشر نشده است.</p>
)}
{totalPages > 1 && result.items.length ? (
<nav className="pager" aria-label="صفحه‌بندی">
{page > 1 ? <Link href={`/blog?page=${page - 1}`}>قبلی</Link> : null}
<span>
صفحه {toFaDigits(page)} از {toFaDigits(totalPages)}
</span>
{page < totalPages ? <Link href={`/blog?page=${page + 1}`}>بعدی</Link> : null}
</nav>
) : null}
</section>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
import { site } from "@/data/site";
import { resolveAddress, resolveEmails, resolveInstagram, resolvePhones } from "@/lib/content";
import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots } from "@/lib/static-images";
export const metadata: Metadata = {
title: "تماس با ما",
description:
"تماس با گالری طلا و جواهر ایوبی فخیم؛ آدرس قم، سالاریه، اریکه ملل و شماره‌های ۰۹۹۹۱۲۹۶۶۶۶ و ۰۹۳۷۱۴۴۸۹۰۵.",
openGraph: {
title: "تماس با گالری طلا و جواهر ایوبی فخیم",
description:
"تماس با گالری طلا و جواهر ایوبی فخیم؛ آدرس قم، سالاریه، اریکه ملل و شماره‌های ۰۹۹۹۱۲۹۶۶۶۶ و ۰۹۳۷۱۴۴۸۹۰۵.",
},
};
export default async function ContactPage() {
const [info] = await Promise.all([getBusinessInfo(), getPageSlots("contact")]);
const address = resolveAddress(info);
const phones = resolvePhones(info);
const emails = resolveEmails(info);
const instagram = resolveInstagram(info);
const mapHref = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">قم، سالاریه</p>
<h1>تماس با ما</h1>
<p className="lead">برای بازدید، سفارش یا پرسش درباره یک قطعه پیام بگذارید یا تماس بگیرید.</p>
</header>
<section className="contact wrap" aria-labelledby="reach-title">
<div>
<h2 id="reach-title">راه‌های ارتباط</h2>
<dl className="facts">
<div>
<dt>آدرس</dt>
<dd>{address}</dd>
</div>
<div>
<dt>تلفن</dt>
<dd>
{phones.map((phone) => (
<a key={phone.href} href={`tel:${phone.href}`} dir="ltr">
{phone.display}
</a>
))}
</dd>
</div>
{emails.length ? (
<div>
<dt>ایمیل</dt>
<dd>
{emails.map((email) => (
<a key={email} href={`mailto:${email}`} dir="ltr">
{email}
</a>
))}
</dd>
</div>
) : null}
<div>
<dt>اینستاگرام</dt>
<dd>
<a href={instagram.href} target="_blank" rel="noopener noreferrer" dir="ltr">
{instagram.handle}
</a>
</dd>
</div>
</dl>
<a className="text-link" href={mapHref} target="_blank" rel="noopener noreferrer">
مسیر روی نقشه
</a>
</div>
<div>
<h2>پیام به گالری</h2>
<ContactForm />
</div>
</section>
<p className="wrap contact__note">{site.nameFa}</p>
</main>
);
}
+1967 -166
View File
File diff suppressed because it is too large Load Diff
+86 -34
View File
@@ -1,8 +1,21 @@
import type { Metadata } from "next";
import { Cinzel } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import { CmsMeta, JsonLd } from "@/components/CmsMeta";
import { ContentSequence } from "@/components/ContentSequence";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { CartProvider } from "@/components/providers/CartProvider";
import { ToastProvider } from "@/components/providers/ToastProvider";
import { site } from "@/data/site";
import { getSession } from "@/lib/auth";
import { getCategories, safe } from "@/lib/catalog";
import { megaMenuGroups } from "@/lib/categoryTree";
import { resolveAddress, resolveInstagram, resolveLogo, resolvePhones } from "@/lib/content";
import { siteUrl } from "@/lib/config";
import { getBusinessInfo, getFavicon } from "@/lib/meshkee";
import "./globals.css";
@@ -15,47 +28,86 @@ const cinzel = Cinzel({
const doran = localFont({
src: [
{
path: "../fonts/doran/Doran-Thin.woff",
weight: "100",
style: "normal",
},
{
path: "../fonts/doran/Doran-Regular.woff",
weight: "400",
style: "normal",
},
{
path: "../fonts/doran/Doran-Medium.woff",
weight: "500",
style: "normal",
},
{
path: "../fonts/doran/Doran-Bold.woff",
weight: "700",
style: "normal",
},
{ path: "../fonts/doran/Doran-Thin.woff", weight: "100", style: "normal" },
{ path: "../fonts/doran/Doran-Regular.woff", weight: "400", style: "normal" },
{ path: "../fonts/doran/Doran-Medium.woff", weight: "500", style: "normal" },
{ path: "../fonts/doran/Doran-Bold.woff", weight: "700", style: "normal" },
],
variable: "--font-doran",
display: "swap",
});
export const metadata: Metadata = {
title: `${site.nameFa} | ${site.tagline}`,
description: site.description,
icons: {
icon: "/Ayoobi-logo.png",
},
};
const iranYekan = localFont({
src: [{ path: "../fonts/iranyekan/IranYekan-Light.woff", weight: "300", style: "normal" }],
variable: "--font-iranyekan",
display: "swap",
});
export async function generateMetadata(): Promise<Metadata> {
const favicon = await getFavicon();
const icon = favicon.faviconUrl || "/Ayoobi-logo.png";
return {
metadataBase: new URL(siteUrl),
title: {
default: `${site.nameFa} | طلا و جواهر در قم`,
template: `%s | ${site.shortFa}`,
},
description: site.description,
icons: [{ url: icon, type: "image/png" }],
openGraph: {
type: "website",
locale: "fa_IR",
siteName: site.nameFa,
title: site.nameFa,
description: site.description,
images: [{ url: "/Ayoobi-logo.png", alt: site.nameFa }],
},
};
}
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const [info, user, categories] = await Promise.all([
getBusinessInfo(),
getSession(),
safe(getCategories("product"), []),
]);
const logo = resolveLogo(info);
const instagram = resolveInstagram(info);
const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null;
const categoryGroups = megaMenuGroups(categories);
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fa" dir="rtl" className={`${cinzel.variable} ${doran.variable}`}>
<body>{children}</body>
<html lang="fa" dir="rtl" className={`${cinzel.variable} ${doran.variable} ${iranYekan.variable}`}>
<head>
<Script id="content-sequence" strategy="beforeInteractive">
{`document.documentElement.classList.toggle("seq",!window.matchMedia("(prefers-reduced-motion: reduce)").matches)`}
</Script>
<CmsMeta tags={info.metaTags ?? []} />
</head>
<body>
<ToastProvider>
<CartProvider>
<JsonLd data={jsonLd} />
<ContentSequence />
<Header
logo={logo}
instagram={instagram}
phone={resolvePhones(info)[0]?.href ?? ""}
user={user}
categoryGroups={categoryGroups}
/>
{children}
<Footer
logo={logo}
address={resolveAddress(info)}
phones={resolvePhones(info)}
instagram={instagram}
badges={info.trustBadges ?? []}
/>
</CartProvider>
</ToastProvider>
</body>
</html>
);
}
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export function GET() {
return Response.json(sitemapConfig, { headers });
}
@@ -0,0 +1,16 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "صفحه پیدا نشد",
description: "این نشانی در گالری طلا و جواهر ایوبی فخیم وجود ندارد.",
robots: { index: false, follow: false },
};
export default function NotFound() {
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">۴۰۴</p>
<h1>صفحه پیدا نشد</h1>
<p className="lead">این نشانی در گالری نیست.</p>
<Link className="button" href="/">
بازگشت به خانه
</Link>
</header>
</main>
);
}
+89 -61
View File
@@ -1,78 +1,106 @@
import Image from "next/image";
import type { Metadata } from "next";
import Link from "next/link";
import { site } from "@/data/site";
import { CategoryStudio } from "@/components/CategoryStudio";
import { HeroVideo } from "@/components/HeroVideo";
import { ParallaxFrame } from "@/components/ParallaxFrame";
import { ProductCarousel } from "@/components/ProductCarousel";
import { SlotImage } from "@/components/SlotImage";
import { gallery, site } from "@/data/site";
import { resolveAddress, resolveInstagram, resolvePhones } from "@/lib/content";
import {
resolveFourBanner,
resolveHomeCategories,
resolveInstagramSlot,
resolveLastBanner,
resolveMasonry,
} from "@/lib/home-slots";
import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots } from "@/lib/static-images";
export const metadata: Metadata = {
title: { absolute: "گالری طلا و جواهر ایوبی فخیم | طلا و جواهر در قم" },
description: site.description,
openGraph: {
title: "گالری طلا و جواهر ایوبی فخیم",
description: site.description,
images: [{ url: gallery[0].src, alt: gallery[0].alt }],
},
};
export default async function HomePage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
const address = resolveAddress(info);
const phones = resolvePhones(info);
const instagram = resolveInstagram(info);
const categories = resolveHomeCategories(slots);
const pieces = resolveMasonry(slots);
const storySteps = resolveFourBanner(slots);
const follow = resolveInstagramSlot(slots, instagram.href);
const lastBanner = resolveLastBanner(slots);
export default function HomePage() {
return (
<main className="coming-soon">
<section className="coming-soon__panel" aria-label="اطلاعات گالری">
<div className="coming-soon__content">
<Image
className="coming-soon__logo"
src="/Ayoobi-logo.png"
alt={site.nameFa}
width={360}
height={360}
priority
/>
<main>
<HeroVideo />
<CategoryStudio categories={categories} pieces={pieces} storySteps={storySteps} />
<div className="coming-soon__brand">
<h1 className="coming-soon__name">{site.nameEn}</h1>
<p className="coming-soon__name-fa">{site.nameFa}</p>
<section className="follow" aria-labelledby="follow-title">
<div className="wrap follow__grid">
<div className="follow__copy">
<p className="kicker" dir="ltr">
{follow.kicker}
</p>
<h2 id="follow-title">{follow.title}</h2>
<p className="lead">{follow.lead}</p>
<a className="text-link" href={follow.href} target="_blank" rel="noopener noreferrer" dir="ltr">
{instagram.handle}
</a>
</div>
<figure className="follow__photo">
<SlotImage
image={{ url: follow.src, linkUrl: follow.linkUrl, titleFa: follow.title }}
alt={follow.alt}
style={follow.style}
/>
</figure>
</div>
</section>
<p className="coming-soon__tagline">{site.tagline}</p>
<ProductCarousel items={pieces} />
<ParallaxFrame
src={lastBanner.src}
alt={lastBanner.alt}
kicker={lastBanner.kicker}
title={lastBanner.title}
subtext={lastBanner.subtext}
href={lastBanner.href}
/>
<div className="coming-soon__meta">
<div>
<span className="coming-soon__label">آدرس</span>
<p className="coming-soon__address">{site.address}</p>
</div>
<div className="coming-soon__phones">
<span className="coming-soon__label">تماس</span>
{site.phones.map((phone) => (
<a
key={phone.href}
className="coming-soon__phone"
href={`tel:${phone.href}`}
dir="ltr"
>
<section className="visit" aria-labelledby="visit-title">
<div className="wrap visit__grid">
<div>
<h2 id="visit-title">بازدید از گالری</h2>
<p className="lead">قطعه‌ها را در گالری از نزدیک ببینید.</p>
</div>
<div>
<p className="visit__label">آدرس</p>
<p>{address}</p>
</div>
<div>
<p className="visit__label">تماس</p>
<div className="visit__phones">
{phones.map((phone) => (
<a key={phone.href} href={`tel:${phone.href}`} dir="ltr">
{phone.display}
</a>
))}
</div>
<div className="coming-soon__socials">
<span className="coming-soon__label">اینستاگرام</span>
{site.socials.map((social) => (
<a
key={social.href}
className="coming-soon__social"
href={social.href}
target="_blank"
rel="noopener noreferrer"
dir="ltr"
>
{social.handle}
</a>
))}
</div>
<Link className="text-link" href="/contact">
هماهنگی بازدید
</Link>
</div>
</div>
</section>
<section className="coming-soon__media" aria-label="ویدیو معرفی">
<video
className="coming-soon__video"
src="/intro.mp4"
autoPlay
muted
loop
playsInline
preload="auto"
/>
</section>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CatalogCard } from "@/components/CatalogCard";
import { MediaGallery } from "@/components/MediaGallery";
import {
asRichHtml,
getPortfolioBySlug,
getPortfolios,
portfolioImages,
safe,
} from "@/lib/catalog";
import { portfolioHref } from "@/lib/slug";
type PageProps = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = await safe(getPortfolioBySlug(slug), null);
if (!data) return { title: "نمونه کار پیدا نشد" };
const title = data.portfolio.seoMetaTitle || data.portfolio.title || data.portfolio.nameFa || slug;
const description = data.portfolio.seoMetaDescription || data.portfolio.summary || title;
const image = data.portfolio.thumbnailUrl || data.portfolio.image;
return {
title,
description,
openGraph: image ? { title, description, images: [{ url: image, alt: title }] } : { title, description },
};
}
export default async function PortfolioDetailPage({ params }: PageProps) {
const { slug } = await params;
const data = await safe(getPortfolioBySlug(slug), null);
if (!data) notFound();
const portfolio = data.portfolio;
const title = portfolio.title || portfolio.nameFa || slug;
const html = asRichHtml(portfolio.contentHtml || portfolio.descriptionHtml);
const images = portfolioImages(portfolio);
const related = await safe(getPortfolios({ page: 1, pageSize: 8 }), {
items: [],
total: 0,
page: 1,
pageSize: 8,
});
const similar = related.items.filter((item) => item.id !== portfolio.id).slice(0, 3);
return (
<main className="page">
<article className="product wrap">
<MediaGallery images={images} title={title} />
<div>
<p className="kicker">نمونه کار</p>
<h1>{title}</h1>
{portfolio.summary ? <p>{portfolio.summary}</p> : null}
{html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null}
{portfolio.projectUrl ? (
<a className="text-link" href={portfolio.projectUrl} target="_blank" rel="noopener noreferrer">
مشاهده پروژه
</a>
) : null}
<div className="product__actions">
<Link className="text-link" href="/portfolio">
بازگشت به نمونه کارها
</Link>
</div>
</div>
</article>
{similar.length ? (
<section className="related wrap" aria-labelledby="similar-portfolio">
<h2 id="similar-portfolio">نمونه کارهای دیگر</h2>
<ul className="related__grid">
{similar.map((item) => (
<li key={item.id}>
<CatalogCard
href={portfolioHref(item.slug)}
image={item.thumbnailUrl || item.image}
title={item.title || item.nameFa || item.slug}
/>
</li>
))}
</ul>
</section>
) : null}
</main>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogCard } from "@/components/CatalogCard";
import { getPortfolios, safe } from "@/lib/catalog";
import { toFaDigits } from "@/lib/format";
import { getPageSlots } from "@/lib/static-images";
import { portfolioHref } from "@/lib/slug";
export const metadata: Metadata = {
title: "نمونه کارها",
description: "نمونه کارهای گالری طلا و جواهر ایوبی فخیم.",
};
export default async function PortfolioPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageRaw } = await searchParams;
const page = Math.max(1, Number(pageRaw) || 1);
const pageSize = 12;
const [result] = await Promise.all([
safe(getPortfolios({ page, pageSize }), { items: [], total: 0, page, pageSize }),
getPageSlots("portfolio"),
]);
const totalPages = Math.max(1, Math.ceil(result.total / pageSize));
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">گالری ایوبی فخیم</p>
<h1>نمونه کارها</h1>
<p className="lead">پروژه‌ها و قطعه‌های منتخب گالری.</p>
</header>
<section className="wrap catalog" aria-label="فهرست نمونه کارها">
{result.items.length ? (
<ul className="product-grid">
{result.items.map((item) => (
<li key={item.id}>
<CatalogCard
href={portfolioHref(item.slug)}
image={item.thumbnailUrl || item.image}
title={item.title || item.nameFa || item.slug}
summary={item.summary}
/>
</li>
))}
</ul>
) : (
<p className="category-empty">هنوز نمونه کاری منتشر نشده است.</p>
)}
{totalPages > 1 && result.items.length ? (
<nav className="pager" aria-label="صفحه‌بندی">
{page > 1 ? <Link href={`/portfolio?page=${page - 1}`}>قبلی</Link> : null}
<span>
صفحه {toFaDigits(page)} از {toFaDigits(totalPages)}
</span>
{page < totalPages ? <Link href={`/portfolio?page=${page + 1}`}>بعدی</Link> : null}
</nav>
) : null}
</section>
</main>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { AddToCart } from "@/components/AddToCart";
import { CatalogCard } from "@/components/CatalogCard";
import { JsonLd } from "@/components/CmsMeta";
import { MediaGallery } from "@/components/MediaGallery";
import { ProductTabs } from "@/components/ProductTabs";
import { getSession } from "@/lib/auth";
import {
getComments,
getProductById,
getProductTechnicalInfo,
getStoreItemByProduct,
asRichHtml,
productImages,
safe,
} from "@/lib/catalog";
import { productTeaser } from "@/lib/format";
import { faSlug, productHref, sameSlug } from "@/lib/slug";
import { buildSpecRows } from "@/lib/technicalInfo";
type PageProps = {
params: Promise<{ id: string; slug: string }>;
};
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const data = await safe(getProductById(id), null);
if (!data) return { title: "قطعه پیدا نشد" };
const title = data.product.seoMetaTitle || data.product.nameFa || data.product.title;
const description =
data.product.seoMetaDescription ||
data.product.summary ||
(data.product.descriptionHtml || "").replace(/<[^>]+>/g, " ").slice(0, 160) ||
title;
const image = data.product.thumbnailUrl || data.product.image;
return {
title,
description,
openGraph: image ? { title, description, images: [{ url: image, alt: title }] } : { title, description },
};
}
export default async function ProductDetailPage({ params }: PageProps) {
const { id, slug } = await params;
const data = await safe(getProductById(id), null);
if (!data) notFound();
const { product, relatedProducts } = data;
const title = product.nameFa || product.title;
const canonicalSlug = faSlug(title);
if (!sameSlug(slug, canonicalSlug)) {
redirect(productHref(product.id, product.nameFa, product.title));
}
const [{ storeItem }, technical, { items: comments }, user] = await Promise.all([
safe(getStoreItemByProduct(product.id), { storeItem: null }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
getSession(),
]);
const images = productImages(product);
const specRows = buildSpecRows(technical.form, technical.values);
const variants = storeItem?.variants ?? [];
return (
<main className="page">
<JsonLd data={product.schema?.jsonLd ?? null} />
<article className="product wrap">
<MediaGallery images={images} title={title} />
<div className="product__copy">
{product.categoryNameFa ? <p className="kicker">{product.categoryNameFa}</p> : null}
<h1>{title}</h1>
{product.summary ? <p>{product.summary}</p> : null}
<p>این قطعه در گالری است. برای دیدن آن از نزدیک، زمان بازدید را هماهنگ کنید.</p>
<AddToCart product={product} variants={variants} />
<div className="product__actions">
<Link className="button" href="/contact">
هماهنگی بازدید
</Link>
<Link className="text-link" href="/products">
بازگشت به فهرست
</Link>
</div>
</div>
</article>
<div className="wrap">
<ProductTabs
title={title}
descriptionHtml={asRichHtml(product.descriptionHtml)}
specRows={specRows}
comments={comments}
productId={product.id}
user={user}
/>
</div>
{relatedProducts.length ? (
<section className="related wrap" aria-labelledby="related-title">
<h2 id="related-title">قطعه‌های مشابه</h2>
<ul className="related__grid">
{relatedProducts.slice(0, 4).map((item) => (
<li key={item.id}>
<CatalogCard
href={productHref(item.id, item.nameFa, item.title)}
image={item.thumbnailUrl || item.image}
category={item.categoryNameFa || item.categoryName}
title={item.nameFa || item.title}
summary={productTeaser(item)}
/>
</li>
))}
</ul>
</section>
) : null}
</main>
);
}
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { CatalogCard } from "@/components/CatalogCard";
import { getCategories, getCategoryById, getProducts, jewelryCategories, safe } from "@/lib/catalog";
import { productTeaser, toFaDigits } from "@/lib/format";
import { categoryHref, faSlug, productHref, sameSlug } from "@/lib/slug";
type PageProps = {
params: Promise<{ id: string; slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const category = await safe(getCategoryById(id), null);
if (!category) return { title: "دسته پیدا نشد" };
const title = category.seoMetaTitle || category.nameFa || category.name;
return {
title,
description: category.seoMetaDescription || `قطعات دسته ${category.nameFa || category.name} در گالری ایوبی فخیم.`,
};
}
export default async function ProductCategoryPage({ params, searchParams }: PageProps) {
const { id, slug } = await params;
const { page: pageRaw } = await searchParams;
const page = Math.max(1, Number(pageRaw) || 1);
const pageSize = 24;
const category = await safe(getCategoryById(id), null);
if (!category) notFound();
const title = category.nameFa || category.name;
const canonical = faSlug(title);
if (!sameSlug(slug, canonical)) {
redirect(categoryHref(category.id, category.nameFa, category.name));
}
const [filters, result] = await Promise.all([
safe(getCategories("product"), []).then(jewelryCategories),
safe(getProducts({ page, pageSize, categoryId: id }), {
items: [],
total: 0,
page,
pageSize,
}),
]);
const totalPages = Math.max(1, Math.ceil(result.total / pageSize));
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">دسته‌بندی</p>
<h1>{title}</h1>
<p className="lead">{toFaDigits(result.total)} قطعه در این دسته.</p>
</header>
<section className="wrap catalog" aria-label={title}>
<ul className="filters">
<li>
<Link href="/products">همه</Link>
</li>
{filters.map((item) => (
<li key={item.id}>
<Link
href={categoryHref(item.id, item.nameFa, item.name)}
aria-current={item.id === id ? "page" : undefined}
>
{item.nameFa || item.name}
</Link>
</li>
))}
</ul>
{result.items.length ? (
<ul className="product-grid">
{result.items.map((product) => (
<li key={product.id}>
<CatalogCard
href={productHref(product.id, product.nameFa, product.title)}
image={product.thumbnailUrl || product.image}
category={product.categoryNameFa || product.categoryName}
title={product.nameFa || product.title}
summary={productTeaser(product)}
/>
</li>
))}
</ul>
) : (
<p className="category-empty">در این دسته قطعه‌ای نیست.</p>
)}
{totalPages > 1 ? (
<nav className="pager" aria-label="صفحه‌بندی">
{page > 1 ? (
<Link href={`${categoryHref(id, title)}?page=${page - 1}`}>قبلی</Link>
) : null}
<span>
صفحه {toFaDigits(page)} از {toFaDigits(totalPages)}
</span>
{page < totalPages ? (
<Link href={`${categoryHref(id, title)}?page=${page + 1}`}>بعدی</Link>
) : null}
</nav>
) : null}
</section>
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogCard } from "@/components/CatalogCard";
import { getCategories, getProducts, jewelryCategories, safe } from "@/lib/catalog";
import { productTeaser, toFaDigits } from "@/lib/format";
import { getPageSlots } from "@/lib/static-images";
import { categoryHref, productHref } from "@/lib/slug";
export const metadata: Metadata = {
title: "محصولات",
description: "فهرست محصولات گالری طلا و جواهر ایوبی فخیم؛ انگشتر، گوشواره، گردنبند، ساعت و زنجیر.",
};
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; categoryId?: string; q?: string }>;
}) {
const { page: pageRaw, categoryId, q } = await searchParams;
const page = Math.max(1, Number(pageRaw) || 1);
const pageSize = 24;
const [categories, result] = await Promise.all([
safe(getCategories("product"), []),
safe(getProducts({ page, pageSize, categoryId, name: q }), {
items: [],
total: 0,
page,
pageSize,
}),
getPageSlots("products"),
]);
const filters = jewelryCategories(categories);
const totalPages = Math.max(1, Math.ceil(result.total / pageSize));
return (
<main className="page">
<header className="page-intro wrap">
<p className="kicker">گالری ایوبی فخیم</p>
<h1>محصولات</h1>
<p className="lead">
{toFaDigits(result.total)} محصول از مجموعه گالری. برای دیدن هر کدام از نزدیک، بازدید را هماهنگ کنید.
</p>
</header>
<section className="wrap catalog" aria-label="فهرست محصولات">
<ul className="filters">
<li>
<Link href="/products" aria-current={!categoryId ? "page" : undefined}>
همه
</Link>
</li>
{filters.map((category) => (
<li key={category.id}>
<Link
href={categoryHref(category.id, category.nameFa, category.name)}
aria-current={categoryId === category.id ? "page" : undefined}
>
{category.nameFa || category.name}
</Link>
</li>
))}
</ul>
{result.items.length ? (
<ul className="product-grid">
{result.items.map((product) => (
<li key={product.id}>
<CatalogCard
href={productHref(product.id, product.nameFa, product.title)}
image={product.thumbnailUrl || product.image}
category={product.categoryNameFa || product.categoryName}
title={product.nameFa || product.title}
summary={productTeaser(product)}
/>
</li>
))}
</ul>
) : (
<p className="category-empty">در این فهرست قطعه‌ای نیست.</p>
)}
{totalPages > 1 ? (
<nav className="pager" aria-label="صفحه‌بندی">
{page > 1 ? (
<Link
href={`/products?page=${page - 1}${categoryId ? `&categoryId=${categoryId}` : ""}`}
>
قبلی
</Link>
) : null}
<span>
صفحه {toFaDigits(page)} از {toFaDigits(totalPages)}
</span>
{page < totalPages ? (
<Link
href={`/products?page=${page + 1}${categoryId ? `&categoryId=${categoryId}` : ""}`}
>
بعدی
</Link>
) : null}
</nav>
) : null}
</section>
</main>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { displayName, isAdmin } from "@/lib/authUser";
import { businessPortalUrl, customerPortalUrl } from "@/lib/config";
import type { AuthUser } from "@/lib/types";
export function AccountMenu({ user }: { user: AuthUser | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (event: MouseEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
if (!user) {
return (
<a className="header__icon" href={customerPortalUrl} aria-label="ورود | ثبت نام">
<UserIcon />
</a>
);
}
return (
<div className="account" ref={ref}>
<button
type="button"
className="account__trigger"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((value) => !value)}
>
<UserIcon />
<span className="account__name">{displayName(user)}</span>
</button>
{open ? (
<div className="account__menu" role="menu">
<a href={`${customerPortalUrl}/profile`} role="menuitem">
پروفایل من
</a>
{isAdmin(user) ? (
<a href={businessPortalUrl} role="menuitem">
مدیریت وبسایت
</a>
) : null}
<a href={`${customerPortalUrl}/logout`} role="menuitem">
خروج
</a>
</div>
) : null}
</div>
);
}
function UserIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19.2v-.7a4.5 4.5 0 0 1 4.5-4.5h5a4.5 4.5 0 0 1 4.5 4.5v.7" />
</svg>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { useCart } from "@/components/providers/CartProvider";
import { useToast } from "@/components/providers/ToastProvider";
import { formatPrice, isInStock, toFaDigits } from "@/lib/format";
import type { ApiProduct, StoreVariant } from "@/lib/types";
export function AddToCart({ product, variants }: { product: ApiProduct; variants: StoreVariant[] }) {
const inStock = variants.filter((variant) => isInStock(variant.stockQuantity));
const [selectedId, setSelectedId] = useState(inStock[0]?.id);
const [quantity, setQuantity] = useState(1);
const { addItem } = useCart();
const { showToast } = useToast();
const selected = inStock.find((variant) => variant.id === selectedId) ?? inStock[0];
if (!inStock.length || !selected) return null;
const price = selected.discountedPrice ?? selected.price;
const title = product.nameFa || product.title;
const image = product.thumbnailUrl || product.image || product.thumbnail || null;
const max = selected.stockQuantity == null ? 99 : Math.max(1, selected.stockQuantity);
function add() {
if (!selected) return;
addItem({
id: selected.id,
name: title,
slug: product.slug,
price: selected.discountedPrice ?? selected.price ?? 0,
originalPrice: selected.discountedPrice != null ? selected.price ?? undefined : undefined,
image,
quantity,
});
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
}
return (
<div className="buy">
{inStock.length > 1 ? (
<div className="buy__variants">
<p className="visit__label">انتخاب گزینه</p>
<ul>
{inStock.map((variant) => (
<li key={variant.id}>
<button
type="button"
aria-pressed={selected.id === variant.id}
onClick={() => setSelectedId(variant.id)}
>
{variant.label}
</button>
</li>
))}
</ul>
</div>
) : null}
{price != null ? (
<p className="buy__price" dir="ltr">
{formatPrice(price)} <span>تومان</span>
</p>
) : null}
<div className="buy__qty">
<button type="button" aria-label="کاهش" onClick={() => setQuantity((value) => Math.max(1, value - 1))}>
−
</button>
<span>{toFaDigits(quantity)}</span>
<button
type="button"
aria-label="افزایش"
onClick={() => setQuantity((value) => Math.min(max, value + 1))}
>
+
</button>
</div>
<button type="button" className="button" onClick={add}>
افزودن به سبد
</button>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCart } from "@/components/providers/CartProvider";
import { hasCustomerSessionCookie } from "@/lib/authUser";
import { customerPortalUrl } from "@/lib/config";
import { formatPrice, toFaDigits } from "@/lib/format";
import { encodeGuestCart } from "@/lib/guestCart";
export function CartButton() {
const { items, count, total, updateItem, removeItem } = useCart();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (event: MouseEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
function handleContinue() {
const encoded = encodeGuestCart(items);
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
const url = hasCustomerSessionCookie()
? `${customerPortalUrl}${checkoutPath}`
: `${customerPortalUrl}/login?redirect=${encodeURIComponent(checkoutPath)}`;
window.location.href = url;
}
return (
<div className="cart" ref={ref}>
<button
type="button"
className="header__icon cart__trigger"
aria-label="سبد خرید"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<CartIcon />
{count > 0 ? (
<span className="cart__badge">{toFaDigits(count > 9 ? "9+" : count)}</span>
) : null}
</button>
{open ? (
<div className="cart__popup">
<p className="cart__title">سبد خرید</p>
{items.length === 0 ? (
<p className="cart__empty">سبد خرید شما خالی است.</p>
) : (
<>
<ul className="cart__list">
{items.map((item) => (
<li key={item.id}>
{item.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.image} alt="" />
) : (
<span className="cart__placeholder" />
)}
<div>
<p>{item.name}</p>
<div className="cart__qty">
<button type="button" aria-label="کاهش" onClick={() => updateItem(item.id, item.quantity - 1)}>
−
</button>
<span>{toFaDigits(item.quantity)}</span>
<button type="button" aria-label="افزایش" onClick={() => updateItem(item.id, item.quantity + 1)}>
+
</button>
</div>
</div>
<div className="cart__side">
<span dir="ltr">{formatPrice(item.price)}</span>
<button type="button" onClick={() => removeItem(item.id)}>
حذف
</button>
</div>
</li>
))}
</ul>
<div className="cart__foot">
<p>
<span>جمع کل</span>
<strong dir="ltr">
{formatPrice(total)} <span>تومان</span>
</strong>
</p>
<button type="button" className="button" onClick={handleContinue}>
ادامه
</button>
</div>
</>
)}
</div>
) : null}
</div>
);
}
function CartIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6h15l-1.5 9h-12z" />
<path d="M6 6 5 3H2" />
<circle cx="9" cy="20" r="1.2" />
<circle cx="18" cy="20" r="1.2" />
</svg>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from "next/link";
export function CatalogCard({
href,
image,
category,
title,
summary,
}: {
href: string;
image?: string | null;
category?: string | null;
title: string;
summary?: string | null;
}) {
return (
<Link className="product-card" href={href}>
<span className="product-card__frame">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt={title} />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src="/Ayoobi-logo.png" alt="" />
)}
</span>
{category ? <span className="product-card__category">{category}</span> : null}
<span className="product-card__title">{title}</span>
{summary ? <span className="product-card__summary">{summary}</span> : null}
</Link>
);
}
+79
View File
@@ -0,0 +1,79 @@
export function CategoryIcon({ name }: { name: string }) {
return (
<svg viewBox="0 0 48 48" aria-hidden="true">
{paths(name)}
</svg>
);
}
function paths(name: string) {
switch (name) {
case "گوشواره":
return (
<>
<path d="M24 6v6" />
<circle cx="24" cy="16" r="3" />
<circle cx="24" cy="31" r="8" />
</>
);
case "گردنبند":
return (
<>
<path d="M8 14c4 16 28 16 32 0" />
<path d="M24 29v6" />
<circle cx="24" cy="39" r="3" />
</>
);
case "دستبند":
return (
<>
<ellipse cx="24" cy="24" rx="15" ry="10" />
<path d="M14 24h20" />
</>
);
case "ساعت":
return (
<>
<path d="M18 6h12v6H18zM18 36h12v6H18z" />
<circle cx="24" cy="24" r="10" />
<path d="M24 24V18M24 24h5" />
</>
);
case "زنجیر":
return (
<>
<rect x="4" y="18" width="12" height="12" rx="6" />
<rect x="18" y="18" width="12" height="12" rx="6" />
<rect x="32" y="18" width="12" height="12" rx="6" />
</>
);
case "ست و نیم ست":
return (
<>
<circle cx="15" cy="30" r="8" />
<path d="M15 22 13 12h4L15 22" />
<circle cx="33" cy="16" r="3" />
<circle cx="33" cy="30" r="7" />
</>
);
case "گل سینه":
return (
<>
<circle cx="24" cy="24" r="3" />
<circle cx="24" cy="13" r="4.5" />
<circle cx="33.5" cy="18.5" r="4.5" />
<circle cx="33.5" cy="29.5" r="4.5" />
<circle cx="24" cy="35" r="4.5" />
<circle cx="14.5" cy="29.5" r="4.5" />
<circle cx="14.5" cy="18.5" r="4.5" />
</>
);
default:
return (
<>
<circle cx="24" cy="29" r="10" />
<path d="M24 19 19 8h10L24 19" />
</>
);
}
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { CategoryIcon } from "@/components/CategoryIcon";
import { Gallery } from "@/components/Gallery";
import { StickyFrame } from "@/components/StickyFrame";
import { site, type GalleryItem } from "@/data/site";
import type { HomeCategory, StoryStep } from "@/lib/home-slots";
export function CategoryStudio({
categories,
pieces,
storySteps,
}: {
categories: HomeCategory[];
pieces: GalleryItem[];
storySteps: StoryStep[];
}) {
const [selected, setSelected] = useState<string | null>(null);
const shown = selected ? pieces.filter((piece) => piece.category === selected) : pieces;
function choose(name: string) {
const next = selected === name ? null : name;
setSelected(next);
if (next) {
document.getElementById("collection")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
return (
<>
<section className="categories" aria-labelledby="home-title">
<div className="wrap categories__inner">
<p className="kicker" dir="ltr">
{site.nameEn}
</p>
<h1 id="home-title">{site.nameFa}</h1>
<ul className="category-grid">
{categories.map((category) => (
<li key={`${category.fa}-${category.en}`}>
{category.href ? (
<a className="category-frame" href={category.href}>
<CategoryVisual category={category} />
</a>
) : (
<button
type="button"
className="category-frame"
aria-pressed={selected === category.fa}
onClick={() => choose(category.fa)}
>
<CategoryVisual category={category} />
</button>
)}
</li>
))}
</ul>
</div>
</section>
<StickyFrame steps={storySteps} />
<section className="section" id="collection" aria-label={selected ?? "مجموعه"}>
<div className="wrap">
{shown.length ? (
<Gallery items={shown} />
) : (
<p className="category-empty">نمونه‌ای از این دسته در ویترین آنلاین نیست.</p>
)}
<p className="catalog-more">
<Link className="text-link" href="/products">
فهرست کامل محصولات
</Link>
</p>
</div>
</section>
</>
);
}
function CategoryVisual({ category }: { category: HomeCategory }) {
return (
<>
{category.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="category-frame__media" src={category.image} alt="" />
) : (
<CategoryIcon name={category.fa} />
)}
<span className="category-frame__fa">{category.fa}</span>
{category.en ? (
<span className="category-frame__en" dir="ltr">
{category.en}
</span>
) : null}
</>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { parseHeadTag } from "@/lib/content";
export type CmsMetaTag = {
html?: string;
name?: string;
content?: string;
property?: string;
httpEquiv?: string;
};
export function CmsMeta({ tags }: { tags: CmsMetaTag[] }) {
return (
<>
{tags.map((tag, index) => {
if (tag.html) {
const parsed = parseHeadTag(tag.html);
if (!parsed) return null;
if (parsed.tag === "link") return <link key={index} {...parsed.attrs} />;
return <meta key={index} {...parsed.attrs} />;
}
if (tag.name && tag.content != null) {
return <meta key={index} name={tag.name} content={tag.content} />;
}
if (tag.property && tag.content != null) {
return <meta key={index} property={tag.property} content={tag.content} />;
}
if (tag.httpEquiv && tag.content != null) {
return <meta key={index} httpEquiv={tag.httpEquiv} content={tag.content} />;
}
return null;
})}
</>
);
}
export function JsonLd({ data }: { data: Record<string, unknown> | null | undefined }) {
if (!data) return null;
return (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useState, type FormEvent } from "react";
import { displayName } from "@/lib/authUser";
import { postComment } from "@/lib/catalog";
import { customerPortalUrl } from "@/lib/config";
import type { AuthUser, EntityComment } from "@/lib/types";
export function CommentSection({
entityType,
entityId,
initialComments,
user,
}: {
entityType: "product" | "blog" | "portfolio";
entityId: string;
initialComments: EntityComment[];
user: AuthUser | null;
}) {
const [comments, setComments] = useState(initialComments);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!user || !text.trim()) return;
setSubmitting(true);
setError(null);
try {
const { comment } = await postComment({
entityType,
entityId,
authorName: displayName(user).replace(/ عزیز$/, ""),
text: text.trim(),
});
setComments((prev) => [comment, ...prev]);
setText("");
} catch {
setError("ثبت دیدگاه انجام نشد. دوباره تلاش کنید.");
} finally {
setSubmitting(false);
}
}
return (
<div className="comments">
{user ? (
<form className="form" onSubmit={onSubmit}>
<label>
دیدگاه
<textarea value={text} onChange={(event) => setText(event.target.value)} rows={4} required />
</label>
{error ? <p className="form__note form__note--error">{error}</p> : null}
<button className="button" type="submit" disabled={submitting || !text.trim()}>
{submitting ? "در حال ارسال" : "ثبت دیدگاه"}
</button>
</form>
) : (
<div className="comments__login">
<p>برای ثبت دیدگاه وارد حساب کاربری شوید.</p>
<a className="button" href={`${customerPortalUrl}/login`}>
ورود | ثبت‌نام
</a>
</div>
)}
{comments.length ? (
<ul className="comments__list">
{comments.map((comment) => (
<li key={comment.id}>
<p className="comments__author">{comment.authorName || "کاربر"}</p>
{comment.text ? <p>{comment.text}</p> : null}
</li>
))}
</ul>
) : (
<p className="category-empty">هنوز دیدگاهی ثبت نشده است.</p>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { FormEvent, useState } from "react";
import { useToast } from "@/components/providers/ToastProvider";
type Status = "idle" | "sending" | "sent" | "error";
export function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const [message, setMessage] = useState("");
const { showToast } = useToast();
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const payload = {
name: String(data.get("name") ?? ""),
cellNumber: String(data.get("cellNumber") ?? ""),
title: String(data.get("title") ?? ""),
text: String(data.get("text") ?? ""),
};
setStatus("sending");
setMessage("");
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { message?: string } | null;
setStatus("error");
setMessage(body?.message || "ارسال پیام انجام نشد. دوباره تلاش کنید.");
showToast("ارسال پیام انجام نشد", "error");
return;
}
form.reset();
setStatus("sent");
setMessage("پیام شما ثبت شد. از گالری با شما تماس می‌گیریم.");
showToast("پیام شما با موفقیت ارسال شد");
} catch {
setStatus("error");
setMessage("ارتباط برقرار نشد. شماره گالری را بگیرید یا دوباره تلاش کنید.");
showToast("ارتباط برقرار نشد", "error");
}
}
return (
<form
className="form"
onSubmit={onSubmit}
onChange={() => {
if (status === "sent") setStatus("idle");
}}
>
<div className="form__row">
<label>
نام
<input name="name" required autoComplete="name" />
</label>
<label>
موبایل
<input name="cellNumber" required autoComplete="tel" inputMode="tel" dir="ltr" />
</label>
</div>
<label>
موضوع
<input name="title" required />
</label>
<label>
پیام
<textarea name="text" required rows={6} />
</label>
<button className="button" type="submit" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال" : "ارسال پیام"}
</button>
{message ? (
<p className={status === "error" ? "form__note form__note--error" : "form__note"} role="status">
{message}
</p>
) : null}
</form>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { usePathname } from "next/navigation";
import { useLayoutEffect } from "react";
const content =
".hero__video, .hero__fa, .hero__en, .categories .kicker, .categories h1, .category-grid > li, .sticky-story, .gallery > li, .category-empty, .catalog-more, .follow__copy > *, .follow__photo, .carousel__head, .carousel__track > li, .parallax, .visit__grid > *, .page-intro > *, .filters, .product-grid > li, .about__figure, .about > div > *, .product > *, .detail-tabs, .related h2, .related__grid > li, .article > *, .contact h2, .facts > div, .contact .text-link, .form > *, .contact__note";
const fadeOnly = ".sticky-story, .hero__video";
export function ContentSequence() {
const pathname = usePathname();
useLayoutEffect(() => {
const root = document.documentElement;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
root.classList.remove("seq");
return;
}
root.classList.add("seq");
document.querySelectorAll<HTMLElement>(".seq-ready").forEach((el) => {
el.classList.remove("seq-ready", "seq-in", "seq-still");
el.style.removeProperty("--seq-i");
});
let next = 0;
let frame = 0;
let cool = 0;
const pending = new Set<HTMLElement>();
const watched = new WeakSet<HTMLElement>();
const flush = () => {
frame = 0;
const items = [...pending].sort((a, b) => {
const pos = a.compareDocumentPosition(b);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
pending.clear();
items.forEach((el, index) => {
el.style.setProperty("--seq-i", String(next + index));
el.classList.add("seq-in");
});
next += items.length;
window.clearTimeout(cool);
cool = window.setTimeout(() => {
next = 0;
}, 480);
};
const reveal = (el: HTMLElement) => {
if (el.classList.contains("seq-in")) return;
pending.add(el);
if (!frame) frame = requestAnimationFrame(flush);
};
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
observer.unobserve(entry.target);
reveal(entry.target as HTMLElement);
}
},
{ threshold: 0.08, rootMargin: "0px 0px -6% 0px" },
);
const mark = () => {
document.querySelectorAll<HTMLElement>(content).forEach((el) => {
if (watched.has(el)) return;
watched.add(el);
el.classList.add("seq-ready");
if (el.matches(fadeOnly)) el.classList.add("seq-still");
observer.observe(el);
});
};
mark();
const changes = new MutationObserver(mark);
changes.observe(document.body, { childList: true, subtree: true });
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(cool);
observer.disconnect();
changes.disconnect();
};
}, [pathname]);
return null;
}
+77
View File
@@ -0,0 +1,77 @@
import Image from "next/image";
import Link from "next/link";
import { site } from "@/data/site";
import { stripScripts, type PhoneLink } from "@/lib/content";
type FooterProps = {
logo: string;
address: string;
phones: PhoneLink[];
instagram: { href: string; handle: string };
badges: { kind?: string; embedHtml?: string }[];
};
export function Footer({ logo, address, phones, instagram, badges }: FooterProps) {
const year = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric" }).format(new Date());
return (
<footer className="footer">
<div className="wrap footer__grid">
<div className="footer__brand">
<Image src={logo} alt="" width={72} height={72} className="footer__logo" />
<p className="footer__name">{site.nameFa}</p>
<p className="footer__en" dir="ltr">
{site.nameEn}
</p>
</div>
<div>
<p className="footer__label">آدرس</p>
<p className="footer__text">{address}</p>
</div>
<div>
<p className="footer__label">تماس</p>
<div className="footer__links">
{phones.map((phone) => (
<a key={phone.href} href={`tel:${phone.href}`} dir="ltr">
{phone.display}
</a>
))}
<a href={instagram.href} target="_blank" rel="noopener noreferrer" dir="ltr">
{instagram.handle}
</a>
</div>
</div>
<nav className="footer__links" aria-label="پیوندهای پایین صفحه">
<Link href="/">خانه</Link>
<Link href="/products">محصولات</Link>
<Link href="/blog">مجله</Link>
<Link href="/about">درباره ما</Link>
<Link href="/contact">تماس با ما</Link>
</nav>
</div>
{badges.some((badge) => badge.embedHtml) ? (
<div className="wrap footer__badges">
{badges.map((badge, index) =>
badge.embedHtml ? (
<div
key={`${badge.kind ?? "badge"}-${index}`}
dangerouslySetInnerHTML={{ __html: stripScripts(badge.embedHtml) }}
/>
) : null,
)}
</div>
) : null}
<div className="wrap footer__base">
<p>
© {year} {site.shortFa}
</p>
</div>
</footer>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { GalleryItem } from "@/data/site";
function Frame({ item }: { item: GalleryItem }) {
const local = item.src.startsWith("/");
if (local) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.src} alt={item.alt} />
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.src} alt={item.alt} />
);
}
export function Gallery({ items }: { items: GalleryItem[] }) {
const [active, setActive] = useState<GalleryItem | null>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (active && !dialog.open) dialog.showModal();
if (!active && dialog.open) dialog.close();
}, [active]);
return (
<>
<ul className="gallery">
{items.map((item) => (
<li key={`${item.src}-${item.title}`}>
<figure className="piece">
{item.href ? (
<a className="piece__frame" href={item.href}>
<Frame item={item} />
</a>
) : (
<button type="button" className="piece__frame" aria-label={item.title} onClick={() => setActive(item)}>
<Frame item={item} />
</button>
)}
<figcaption>
<span className="piece__category">{item.category}</span>
<span className="piece__title">{item.title}</span>
</figcaption>
</figure>
</li>
))}
</ul>
<dialog
ref={dialogRef}
className="lightbox"
onClose={() => setActive(null)}
aria-label={active?.title ?? "نمایش قطعه"}
>
{active ? (
<div className="lightbox__body">
<button type="button" className="lightbox__close" onClick={() => dialogRef.current?.close()}>
بستن
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={active.src} alt={active.alt} />
<p>
<span>{active.category}</span>
{active.title}
</p>
</div>
) : null}
</dialog>
</>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { AccountMenu } from "@/components/AccountMenu";
import { ProductsMegaMenu } from "@/components/ProductsMegaMenu";
import { site } from "@/data/site";
import type { MenuCategory } from "@/lib/categoryTree";
import type { AuthUser } from "@/lib/types";
const links = [
{ href: "/", label: "خانه" },
{ href: "/blog", label: "مجله" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export function Header({
logo,
instagram,
phone,
user,
categoryGroups,
}: {
logo: string;
instagram: { href: string };
phone: string;
user: AuthUser | null;
categoryGroups: MenuCategory[];
}) {
const pathname = usePathname();
const [solid, setSolid] = useState(false);
useEffect(() => {
const onScroll = () => setSolid(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [pathname]);
return (
<header className={solid ? "header header--solid" : "header"}>
<div className="wrap header__bar">
<Link href="/" className="brand">
<Image src={logo} alt="" width={88} height={88} className="brand__logo" priority />
<span className="brand__names">
<span className="brand__en" dir="ltr">
{site.nameEn}
</span>
<span className="brand__fa">{site.shortFa}</span>
</span>
</Link>
<div className="header__end">
<nav className="nav" aria-label="اصلی">
<Link href="/" aria-current={pathname === "/" ? "page" : undefined}>
خانه
</Link>
<ProductsMegaMenu groups={categoryGroups} />
{links.slice(1).map((link) => {
const active = pathname.startsWith(link.href);
return (
<Link key={link.href} href={link.href} aria-current={active ? "page" : undefined}>
{link.label}
</Link>
);
})}
</nav>
<div className="header__tools">
<AccountMenu user={user} />
<a className="header__icon" href={`tel:${phone}`} aria-label="تماس">
<PhoneIcon />
</a>
<a
className="header__icon"
href={instagram.href}
target="_blank"
rel="noopener noreferrer"
aria-label="اینستاگرام"
>
<InstagramIcon />
</a>
</div>
</div>
</div>
</header>
);
}
function PhoneIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M22 16.9v2.2a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 1h2.2a2 2 0 0 1 2 1.7c.1 1 .3 1.9.6 2.8a2 2 0 0 1-.5 2.1L7.1 8.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.5c.9.3 1.8.5 2.8.6a2 2 0 0 1 1.7 2.2Z" />
</svg>
);
}
function InstagramIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4.5" y="4.5" width="15" height="15" rx="4" />
<circle cx="12" cy="12" r="3.4" />
<circle cx="16.6" cy="7.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useEffect, useRef } from "react";
export function HeroVideo() {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = ref.current;
if (!video) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (!reduce.matches) return;
video.pause();
video.controls = true;
}, []);
return (
<section className="hero" aria-label="ویدیو معرفی گالری">
<video
ref={ref}
className="hero__video"
src="/assets/video/MakeJewel.mp4"
autoPlay
muted
loop
playsInline
preload="auto"
/>
<div className="hero__brand">
<p className="hero__fa">حس طلایی، افسون جواهر</p>
<p className="hero__en" dir="ltr">
Golden feeling, jewel enchantment
</p>
</div>
</section>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState } from "react";
export function MediaGallery({ images, title }: { images: string[]; title: string }) {
const gallery = images.length ? images : ["/Ayoobi-logo.png"];
const [active, setActive] = useState(0);
const [zoom, setZoom] = useState<{ x: number; y: number } | null>(null);
const [lightbox, setLightbox] = useState(false);
const prev = () => setActive((index) => (index - 1 + gallery.length) % gallery.length);
const next = () => setActive((index) => (index + 1) % gallery.length);
useEffect(() => {
if (!lightbox) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setLightbox(false);
if (event.key === "ArrowLeft") next();
if (event.key === "ArrowRight") prev();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lightbox, active]);
return (
<div className="media-gallery">
<div
className="media-gallery__stage"
onMouseMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setZoom({
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
});
}}
onMouseLeave={() => setZoom(null)}
onClick={() => setLightbox(true)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setLightbox(true);
}
}}
aria-label={`گالری تصاویر ${title}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={gallery[active]} alt={title} />
{zoom ? (
<span
className="media-gallery__zoom"
style={{
backgroundImage: `url(${gallery[active]})`,
backgroundPosition: `${zoom.x}% ${zoom.y}%`,
}}
aria-hidden
/>
) : null}
{gallery.length > 1 ? (
<>
<button
type="button"
className="media-gallery__nav"
aria-label="قبلی"
onClick={(event) => {
event.stopPropagation();
prev();
}}
>
›
</button>
<button
type="button"
className="media-gallery__nav media-gallery__nav--next"
aria-label="بعدی"
onClick={(event) => {
event.stopPropagation();
next();
}}
>
‹
</button>
</>
) : null}
</div>
{gallery.length > 1 ? (
<ul className="media-gallery__thumbs">
{gallery.map((src, index) => (
<li key={`${src}-${index}`}>
<button
type="button"
className={index === active ? "is-active" : undefined}
onClick={() => setActive(index)}
aria-label={`تصویر ${index + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt="" />
</button>
</li>
))}
</ul>
) : null}
{lightbox ? (
<div className="media-lightbox" role="dialog" aria-modal aria-label={`گالری ${title}`}>
<div className="media-lightbox__bar">
<span>
{active + 1} / {gallery.length}
</span>
<button type="button" onClick={() => setLightbox(false)}>
بستن
</button>
</div>
<div className="media-lightbox__body">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={gallery[active]} alt={title} />
{gallery.length > 1 ? (
<>
<button type="button" className="media-lightbox__nav" onClick={prev} aria-label="قبلی">
›
</button>
<button
type="button"
className="media-lightbox__nav media-lightbox__nav--next"
onClick={next}
aria-label="بعدی"
>
‹
</button>
</>
) : null}
</div>
</div>
) : null}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
export function ParallaxFrame({
src,
alt,
kicker,
title,
subtext,
href,
}: {
src: string;
alt: string;
kicker: string;
title: string;
subtext?: string;
href?: string;
style?: CSSProperties;
}) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const root = ref.current;
if (!root) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduce.matches) return;
let raf = 0;
const update = () => {
raf = 0;
const rect = root.getBoundingClientRect();
const view = window.innerHeight;
if (rect.bottom < -80 || rect.top > view + 80) return;
const progress = (rect.top + rect.height / 2 - view / 2) / view;
const y = Math.max(-72, Math.min(72, progress * -110));
root.style.setProperty("--parallax-y", `${y.toFixed(2)}px`);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, []);
const media = (
// eslint-disable-next-line @next/next/no-img-element
<img className="parallax__media" src={src} alt={alt} />
);
return (
<section className="parallax" ref={ref} aria-labelledby="parallax-title">
{href ? (
<a href={href} className="parallax__link" aria-label={title}>
{media}
</a>
) : (
media
)}
<div className="parallax__shade" />
<div className="parallax__plate" aria-hidden="true" />
<div className="parallax__copy">
<p className="kicker" dir="ltr">
{kicker}
</p>
<h2 id="parallax-title">{title}</h2>
{subtext ? <p className="lead">{subtext}</p> : null}
</div>
</section>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { GalleryItem } from "@/data/site";
function stepTo(track: HTMLElement, direction: 1 | -1) {
const box = track.getBoundingClientRect();
const cards = [...track.children] as HTMLElement[];
const leading = cards.findIndex((card) => {
const rect = card.getBoundingClientRect();
return rect.right <= box.right + 12 && rect.left < box.right;
});
const index = leading < 0 ? 0 : leading;
const next = Math.max(0, Math.min(cards.length - 1, index + direction));
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
cards[next].scrollIntoView({
behavior: reduce ? "auto" : "smooth",
block: "nearest",
inline: "start",
});
}
export function ProductCarousel({ items }: { items: GalleryItem[] }) {
const trackRef = useRef<HTMLUListElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const [active, setActive] = useState<GalleryItem | null>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (active && !dialog.open) dialog.showModal();
if (!active && dialog.open) dialog.close();
}, [active]);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const update = () => {
const box = track.getBoundingClientRect();
const cards = [...track.children] as HTMLElement[];
if (!cards.length) return;
const first = cards[0].getBoundingClientRect();
const last = cards[cards.length - 1].getBoundingClientRect();
setAtStart(Math.abs(first.right - box.right) < 8);
setAtEnd(last.left >= box.left - 8);
};
update();
track.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
track.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [items]);
return (
<section className="carousel" aria-labelledby="carousel-title">
<div className="wrap">
<div className="carousel__head">
<h2 id="carousel-title">قطعات منتخب</h2>
<div className="carousel__nav">
<button type="button" aria-label="قبلی" disabled={atStart} onClick={() => trackRef.current && stepTo(trackRef.current, -1)}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
</button>
<button type="button" aria-label="بعدی" disabled={atEnd} onClick={() => trackRef.current && stepTo(trackRef.current, 1)}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 6l-6 6 6 6" />
</svg>
</button>
</div>
</div>
<ul className="carousel__track" ref={trackRef}>
{items.map((item) => (
<li key={`${item.src}-${item.title}`}>
<figure className="carousel__card">
{item.href ? (
<a className="carousel__frame" href={item.href}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.src} alt={item.alt} />
</a>
) : (
<button type="button" className="carousel__frame" aria-label={item.title} onClick={() => setActive(item)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.src} alt={item.alt} />
</button>
)}
<figcaption>
<span className="piece__category">{item.category}</span>
<span className="piece__title">{item.title}</span>
</figcaption>
</figure>
</li>
))}
</ul>
</div>
<dialog ref={dialogRef} className="lightbox" onClose={() => setActive(null)} aria-label={active?.title ?? "نمایش قطعه"}>
{active ? (
<div className="lightbox__body">
<button type="button" className="lightbox__close" onClick={() => dialogRef.current?.close()}>
بستن
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={active.src} alt={active.alt} />
<p>
<span>{active.category}</span>
{active.title}
</p>
</div>
) : null}
</dialog>
</section>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { CommentSection } from "@/components/CommentSection";
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
type Tab = "description" | "technical" | "comments";
export function ProductTabs({
title,
descriptionHtml,
specRows,
comments,
productId,
user,
}: {
title: string;
descriptionHtml?: string;
specRows: SpecRow[];
comments: EntityComment[];
productId: string;
user: AuthUser | null;
}) {
const [tab, setTab] = useState<Tab>("description");
const tabs: { key: Tab; label: string }[] = [
{ key: "description", label: "توضیحات" },
{ key: "technical", label: "مشخصات فنی" },
{
key: "comments",
label: comments.length ? `دیدگاه‌ها (${comments.length})` : "دیدگاه‌ها",
},
];
return (
<section className="detail-tabs" aria-label="جزئیات قطعه">
<div className="detail-tabs__nav" role="tablist">
{tabs.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.label}
</button>
))}
</div>
<div className="detail-tabs__panel" role="tabpanel">
{tab === "description" ? (
descriptionHtml ? (
<div className="rich-text" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : (
<p className="category-empty">توضیحی برای «{title}» ثبت نشده است.</p>
)
) : null}
{tab === "technical" ? (
specRows.length ? (
<dl className="specs">
{specRows.map((row) => (
<div key={row.key}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
) : (
<p className="category-empty">مشخصات فنی ثبت نشده است.</p>
)
) : null}
{tab === "comments" ? (
<CommentSection
entityType="product"
entityId={productId}
initialComments={comments}
user={user}
/>
) : null}
</div>
</section>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useId, useRef, useState } from "react";
import type { MenuCategory } from "@/lib/categoryTree";
export function ProductsMegaMenu({ groups }: { groups: MenuCategory[] }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const menuId = useId();
const active = pathname.startsWith("/products");
function clearCloseTimer() {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}
function openMenu() {
clearCloseTimer();
setOpen(true);
}
function scheduleClose() {
clearCloseTimer();
closeTimer.current = setTimeout(() => {
setOpen(false);
closeTimer.current = null;
}, 160);
}
useEffect(() => {
setOpen(false);
clearCloseTimer();
}, [pathname]);
useEffect(() => () => clearCloseTimer(), []);
useEffect(() => {
if (!open) return;
const onPointer = (event: MouseEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div
className={open ? "mega is-open" : "mega"}
ref={ref}
onMouseEnter={openMenu}
onMouseLeave={scheduleClose}
>
<button
type="button"
className="mega__trigger"
aria-expanded={open}
aria-controls={menuId}
aria-current={active ? "page" : undefined}
onClick={() => {
// Touch devices have no hover; desktop hover already opens the panel.
if (window.matchMedia("(hover: none)").matches) {
clearCloseTimer();
setOpen((value) => !value);
} else {
openMenu();
}
}}
>
محصولات
<span className="mega__chev" aria-hidden>
▾
</span>
</button>
<div className="mega__panel" id={menuId} hidden={!open}>
<div className="mega__inner wrap">
{groups.length ? (
<ul className="mega__groups">
{groups.map((group) => (
<li key={group.id}>
<Link className="mega__l1" href={group.href} onClick={() => setOpen(false)}>
{group.nameFa}
</Link>
{group.children.length ? (
<ul className="mega__l2">
{group.children.map((child) => (
<li key={child.id}>
<Link href={child.href} onClick={() => setOpen(false)}>
{child.nameFa}
</Link>
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : (
<p className="mega__empty">دسته‌بندی‌ها هنوز تکمیل نشده‌اند.</p>
)}
<Link className="mega__all" href="/products" onClick={() => setOpen(false)}>
همه محصولات
</Link>
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { CSSProperties } from "react";
import type { StaticImage } from "@/lib/static-images";
/** Renders a CMS static image; wraps in `<a>` when `linkUrl` is set. */
export function SlotImage({
image,
alt,
className,
style,
}: {
image: Pick<StaticImage, "url" | "linkUrl" | "titleFa" | "titleEn">;
alt?: string;
className?: string;
style?: CSSProperties;
}) {
if (!image.url) return null;
const resolvedAlt = alt || image.titleFa || image.titleEn || "";
const media = (
// eslint-disable-next-line @next/next/no-img-element
<img src={image.url} alt={resolvedAlt} className={className} style={style} />
);
if (!image.linkUrl) return media;
return (
<a href={image.linkUrl} className="slot-link">
{media}
</a>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { StoryStep } from "@/lib/home-slots";
export function StickyFrame({
steps,
imageStyle,
}: {
steps: StoryStep[];
imageStyle?: CSSProperties;
}) {
const rootRef = useRef<HTMLElement>(null);
const indexRef = useRef(0);
const [index, setIndex] = useState(0);
const [under, setUnder] = useState(0);
const [still, setStill] = useState(false);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduce.matches) {
setStill(true);
return;
}
const stage = root.querySelector<HTMLElement>(".sticky-story__stage");
if (!stage) return;
let raf = 0;
const update = () => {
raf = 0;
const header = document.querySelector(".header")?.getBoundingClientRect().height ?? 0;
const total = root.offsetHeight - stage.offsetHeight;
if (total <= 0) return;
const traveled = header - root.getBoundingClientRect().top;
const progress = Math.min(1, Math.max(0, traveled / total));
root.style.setProperty("--story", progress.toFixed(4));
const next = Math.min(steps.length - 1, Math.floor(progress * steps.length));
if (next !== indexRef.current) {
setUnder(indexRef.current);
indexRef.current = next;
setIndex(next);
}
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [steps.length]);
return (
<section className="sticky-story" ref={rootRef} aria-label="از فلز تا پوشیدن">
<div className="sticky-story__stage">
{steps.map((step, i) => {
const media = (
// eslint-disable-next-line @next/next/no-img-element
<img src={step.src} alt={step.alt} style={{ objectPosition: step.position, ...imageStyle }} />
);
return (
<article
key={`${step.en}-${step.src}`}
className={[
"sticky-story__slide",
i === index ? "is-on" : "",
i === under && i !== index ? "is-under" : "",
]
.filter(Boolean)
.join(" ")}
aria-hidden={!still && i !== index}
>
{step.href ? (
<a href={step.href} className="sticky-story__media" aria-label={step.fa}>
{media}
</a>
) : (
media
)}
<div className="sticky-story__shade" />
<div className="sticky-story__copy">
<p className="kicker" dir="ltr">
{step.en}
</p>
<h2>{step.fa}</h2>
<p className="lead">{step.text}</p>
</div>
</article>
);
})}
<div className="sticky-story__plate" aria-hidden="true" />
<ol className="sticky-story__rail" aria-hidden="true">
{steps.map((step, i) => (
<li key={`${step.en}-rail`} className={i === index ? "is-on" : undefined}>
{String(i + 1).padStart(2, "0")}
</li>
))}
</ol>
<div className="sticky-story__meter" aria-hidden="true">
<span />
</div>
</div>
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import {
addGuestCartItem,
readGuestCart,
removeGuestCartItem,
updateGuestCartItem,
type GuestCartItem,
} from "@/lib/guestCart";
type CartContextValue = {
items: GuestCartItem[];
count: number;
total: number;
addItem: (item: GuestCartItem) => void;
updateItem: (id: string, quantity: number) => void;
removeItem: (id: string) => void;
};
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<GuestCartItem[]>([]);
useEffect(() => {
queueMicrotask(() => setItems(readGuestCart()));
}, []);
const addItem = useCallback((item: GuestCartItem) => {
setItems(addGuestCartItem(item));
}, []);
const updateItem = useCallback((id: string, quantity: number) => {
setItems(updateGuestCartItem(id, quantity));
}, []);
const removeItem = useCallback((id: string) => {
setItems(removeGuestCartItem(id));
}, []);
const count = items.reduce((sum, item) => sum + item.quantity, 0);
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<CartContext.Provider value={{ items, count, total, addItem, updateItem, removeItem }}>
{children}
</CartContext.Provider>
);
}
export function useCart(): CartContextValue {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}
@@ -0,0 +1,50 @@
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
type ToastItem = {
id: number;
message: string;
type: "success" | "error";
};
type ToastContextValue = {
showToast: (message: string, type?: "success" | "error") => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback((message: string, type: "success" | "error" = "success") => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, message, type }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 3500);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="toast-stack" aria-live="polite">
{toasts.map((toast) => (
<div
key={toast.id}
className={toast.type === "error" ? "toast toast--error" : "toast"}
role="status"
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) throw new Error("useToast must be used within ToastProvider");
return context;
}
+131 -10
View File
@@ -1,18 +1,14 @@
export const site = {
nameFa: "گالری طلا و جواهر ایوبی فخیم",
nameEn: "Ayoubi Fakhim Jewelry",
tagline: "به‌زودی",
description: "گالری طلا و جواهر ایوبی فخیم — به‌زودی",
shortFa: "گالری ایوبی فخیم",
tagline: "این طلا همیشه مده",
description:
"گالری طلا و جواهر ایوبی فخیم در قم؛ مجموعه‌ای از انگشتر، گوشواره، گردنبند و ساعت طلا. بازدید در سالاریه، اریکه ملل.",
address: "قم - سالاریه - اریکه ملل",
phones: [
{
href: "09991296666",
display: "۰۹۹۹ ۱۲۹ ۶۶۶۶",
},
{
href: "09371448905",
display: "۰۹۳۷ ۱۴۴ ۸۹۰۵",
},
{ href: "09991296666", display: "۰۹۹۹ ۱۲۹ ۶۶۶۶" },
{ href: "09371448905", display: "۰۹۳۷ ۱۴۴ ۸۹۰۵" },
],
socials: [
{
@@ -21,4 +17,129 @@ export const site = {
handle: "@ayoubifakhim",
},
],
logo: "/Ayoobi-logo.png",
about: [
"گالری طلا و جواهر ایوبی فخیم در سالاریه قم، مجتمع اریکه ملل، جواهر را برای پوشیدن و ماندن عرضه می‌کند.",
"کار از انتخاب فلز و سنگ آغاز می‌شود: طلا، الماس، مروارید و سنگ‌های رنگی. طرح‌ها ساده و دقیق‌اند و آنچه در تصویر می‌بینید، همان پرداخت، نشست سنگ و تناسب قطعه است.",
"در گالری می‌توانید انگشتر، گوشواره، گردنبند، زنجیر و ساعت طلا را از نزدیک ببینید. برای هماهنگی بازدید یا پرسش درباره یک قطعه، از صفحه تماس استفاده کنید.",
],
} as const;
export type GalleryItem = {
src: string;
title: string;
category: string;
alt: string;
href?: string;
};
export type Product = {
slug: string;
src: string;
title: string;
category: string;
alt: string;
summary: string;
};
export const products: Product[] = [
{
slug: "diamond-necklace",
src: "/assets/images/761279832_18116759840511213_5427821256309530192_n.jpeg",
title: "گردنبند الماس",
category: "گردنبند",
alt: "گردنبند الماس با آویزهای اشکی روی زمینه طراحی کلاسیک",
summary: "گردنبند با آویزهای اشکی الماس. نشست سنگ و خط زنجیر در تصویر همان است که در گالری دیده می‌شود.",
},
{
slug: "pearl-ring",
src: "/assets/images/498025374_9837247912996594_6060652491414854135_n.jpg",
title: "انگشتر مروارید",
category: "انگشتر",
alt: "سه انگشتر مروارید با هاله نگین، در طلا و نقره",
summary: "سه انگشتر با مروارید مرکزی و هاله نگین، در طلا و نقره. تناسب حلقه و نشست مروارید از نزدیک مشخص است.",
},
{
slug: "triple-hoop-earring",
src: "/assets/images/503741244_1401121107696246_2944516772511839745_n.jpg",
title: "گوشواره سه‌حلقه",
category: "گوشواره",
alt: "گوشواره آویز با سه حلقه طلا و نگین‌نشان",
summary: "گوشواره آویز با سه حلقه طلا و ردیف نگین. وزن و حرکت حلقه‌ها را باید روی گوش دید.",
},
{
slug: "leaf-necklace",
src: "/assets/images/842977415_18126295883511213_701670290850293570_n.jpg",
title: "گردنبند برگ‌نشان",
category: "گردنبند",
alt: "گردنبند دو رج با آویز برگ‌مانند الماس روی پارچه قهوه‌ای",
summary: "گردنبند دو رج با آویز برگ‌مانند الماس. دو خط زنجیر و فرم آویز، قطعه را جمع‌وجور نگه می‌دارد.",
},
{
slug: "clover-watch",
src: "/assets/images/541987645_18076083023511213_4894611079046832481_n.jpg",
title: "ساعت شبدری طلا",
category: "ساعت",
alt: "ساعت طلایی به شکل گل شبدر با بند چرمی سرمه‌ای",
summary: "ساعت طلا به شکل گل شبدر، با بند چرمی سرمه‌ای. قاب و بند را در گالری می‌توان روی مچ سنجید.",
},
{
slug: "sapphire-earring",
src: "/assets/images/778082448_18119807777511213_8387248507204531029_n.jpg",
title: "گوشواره یاقوت کبود",
category: "گوشواره",
alt: "جفت گوشواره آویز با سنگ‌های یاقوت کبود و الماس",
summary: "جفت گوشواره آویز با یاقوت کبود و الماس. رنگ سنگ و چیدمان نگین در نور گالری روشن‌تر دیده می‌شود.",
},
{
slug: "gold-chain",
src: "/assets/images/541932677_18076083281511213_2998824066892089068_n.jpg",
title: "زنجیر طلا",
category: "زنجیر",
alt: "زنجیر طلا با قفل، روی روبان سرمه‌ای",
summary: "زنجیر طلا با قفل. ضخامت حلقه‌ها و نشستن قفل، چیزی است که تصویر فقط اشاره می‌کند.",
},
{
slug: "emerald-earring",
src: "/assets/images/834767155_18125669717511213_5824315146052276957_n.jpg",
title: "گوشواره زمرد",
category: "گوشواره",
alt: "گوشواره‌های زمرد و الماس روی گل شیپوری سرخ",
summary: "گوشواره با زمرد و الماس. برش سنگ سبز و هاله نگین، مرکز قطعه است.",
},
{
slug: "pendant-necklace",
src: "/assets/images/825269750_18124978682511213_3830069416169726999_n.jpg",
title: "گردنبند آویز",
category: "گردنبند",
alt: "گردنبند تنیسی با آویز مستطیل الماس روی ابریشم سرخ",
summary: "گردنبند تنیسی با آویز مستطیل الماس. خط نگین‌ها تا آویز ادامه دارد.",
},
{
slug: "brilliant-ring",
src: "/assets/images/830137832_18125270558511213_7995126111595658575_n.jpg",
title: "انگشتر برلیان",
category: "انگشتر",
alt: "انگشتر طلا با ردیف نگین و مرکز برلیان",
summary: "انگشتر طلا با مرکز برلیان و ردیف نگین روی حلقه. ارتفاع نگین و پهنای حلقه را باید در دست دید.",
},
{
slug: "brilliance-bands",
src: "/assets/images/541582664_18076098125511213_2119871898364747318_n.jpg",
title: "حلقه‌های برلیانس",
category: "انگشتر",
alt: "چهار حلقه باریک طلا و نقره با نگین و سنگ مشکی",
summary: "چهار حلقه باریک در طلا و نقره، با نگین و سنگ مشکی. می‌توان هر حلقه را جدا یا کنار هم پوشید.",
},
];
export function getProduct(slug: string) {
return products.find((product) => product.slug === slug);
}
export const gallery: GalleryItem[] = products.map((product) => ({
src: product.src,
title: product.title,
category: product.category,
alt: product.alt,
}));
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { reassembleChunkedCookie } from "@/lib/authUser";
import { AUTH_TOKEN_COOKIE } from "@/lib/config";
import { getMe, safe } from "@/lib/catalog";
import type { AuthUser } from "@/lib/types";
export { displayName, isAdmin } from "@/lib/authUser";
export async function getSession(): Promise<AuthUser | null> {
const store = await cookies();
const raw = reassembleChunkedCookie((name) => store.get(name)?.value, AUTH_TOKEN_COOKIE);
if (!raw) return null;
const token = decodeURIComponent(raw);
const result = await safe(getMe(token), null);
return result?.user ?? null;
}
+42
View File
@@ -0,0 +1,42 @@
import { AUTH_TOKEN_COOKIE } from "@/lib/config";
import type { AuthUser } from "@/lib/types";
export function displayName(user: AuthUser): string {
const name = user.firstName || user.nameFa || user.name || "کاربر";
return `${name} عزیز`;
}
export function isAdmin(user: AuthUser): boolean {
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
}
export function reassembleChunkedCookie(
getCookie: (name: string) => string | undefined,
baseName: string,
): string | undefined {
const direct = getCookie(baseName);
if (direct) return direct;
const count = Number(getCookie(`${baseName}_n`));
if (!Number.isInteger(count) || count <= 0) return undefined;
const parts: string[] = [];
for (let i = 0; i < count; i++) {
const part = getCookie(`${baseName}_${i}`);
if (!part) return undefined;
parts.push(part);
}
return parts.join("");
}
function readCookieFromDocument(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.slice(name.length + 1);
}
export function hasCustomerSessionCookie(): boolean {
return Boolean(reassembleChunkedCookie(readCookieFromDocument, AUTH_TOKEN_COOKIE));
}
+181
View File
@@ -0,0 +1,181 @@
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
import type {
ApiBlog,
ApiCategory,
ApiPortfolio,
ApiProduct,
AuthUser,
EntityComment,
PagedResult,
StoreItemVariants,
TechnicalInfoForm,
TechnicalInfoValue,
} from "@/lib/types";
type Query = Record<string, string | number | boolean | undefined>;
function tenant(path: string) {
return `/tenants/${encodeURIComponent(websiteDomain)}${path}`;
}
async function apiGet<T>(path: string, params?: Query, revalidate = 60): Promise<T> {
const url = new URL(`${meshkeeApiBase}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
const response = await fetch(url.toString(), { next: { revalidate } });
if (!response.ok) throw new Error(`API ${response.status}: ${path}`);
return (await response.json()) as T;
}
async function apiPost<T>(path: string, body: unknown, token?: string): Promise<T> {
const response = await fetch(`${meshkeeApiBase}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!response.ok) throw new Error(`API ${response.status}: ${path}`);
return (await response.json()) as T;
}
async function apiGetAuthed<T>(path: string, token: string): Promise<T> {
const response = await fetch(`${meshkeeApiBase}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!response.ok) throw new Error(`API ${response.status}: ${path}`);
return (await response.json()) as T;
}
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
try {
return await promise;
} catch {
return fallback;
}
}
export function getCategories(entityType: "product" | "blog" | "portfolio" = "product") {
return apiGet<{ items: ApiCategory[] }>(tenant("/categories"), { entityType }).then(
(data) => data.items ?? [],
);
}
export function getCategoryById(id: string) {
return apiGet<{ category: ApiCategory }>(tenant(`/categories/by-id/${id}`)).then(
(data) => data.category,
);
}
export function getProducts(
params: {
page?: number;
pageSize?: number;
name?: string;
categoryId?: string;
tag?: string;
} = {},
) {
return apiGet<PagedResult<ApiProduct>>(tenant("/products"), params);
}
export function getProductById(id: string) {
return apiGet<{ product: ApiProduct; relatedProducts: ApiProduct[] }>(
tenant(`/products/by-id/${id}`),
);
}
export function getProductTechnicalInfo(id: string) {
return apiGet<{ form: TechnicalInfoForm | null; values: TechnicalInfoValue[] }>(
tenant(`/products/by-id/${id}/technical-info`),
);
}
export function getStoreItemByProduct(productId: string) {
return apiGet<{ storeItem: StoreItemVariants | null }>(
tenant(`/store-items/by-product/${productId}`),
);
}
export function getBlogs(params: { page?: number; pageSize?: number; title?: string } = {}) {
return apiGet<PagedResult<ApiBlog>>(tenant("/blogs"), params);
}
export function getBlogBySlug(slug: string) {
return apiGet<{ blog: ApiBlog }>(tenant(`/blogs/${encodeURIComponent(slug)}`));
}
export function getPortfolios(params: { page?: number; pageSize?: number } = {}) {
return apiGet<PagedResult<ApiPortfolio>>(tenant("/portfolios"), params);
}
export function getPortfolioBySlug(slug: string) {
return apiGet<{ portfolio: ApiPortfolio }>(tenant(`/portfolios/${encodeURIComponent(slug)}`));
}
export function getComments(entityType: "product" | "blog" | "portfolio", entityId: string) {
return apiGet<{ items: EntityComment[] }>(tenant("/comments"), { entityType, entityId });
}
export function postComment(input: {
entityType: "product" | "blog" | "portfolio";
entityId: string;
authorName: string;
text: string;
authorEmail?: string;
}) {
return apiPost<{ comment: EntityComment }>(tenant("/comments"), input);
}
export function getMe(token: string) {
return apiGetAuthed<{ user: AuthUser }>("/auth/me", token);
}
/** Turn plain-text CMS bodies into safe HTML paragraphs when tags are absent. */
export function asRichHtml(input?: string | null): string | undefined {
if (!input?.trim()) return undefined;
if (/<[a-z][\s\S]*>/i.test(input)) return input;
return input
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => `<p>${line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</p>`)
.join("");
}
export function productImages(product: ApiProduct): string[] {
const urls = [
product.thumbnailUrl,
product.image,
product.thumbnail,
...(product.images ?? []).map((image) => image.url),
].filter((url): url is string => Boolean(url));
return [...new Set(urls)];
}
export function portfolioImages(portfolio: ApiPortfolio): string[] {
const urls = [
portfolio.thumbnailUrl,
portfolio.image,
...(portfolio.images ?? []).map((image) => image.url),
].filter((url): url is string => Boolean(url));
return [...new Set(urls)];
}
export function jewelryCategories(categories: ApiCategory[]) {
const jewelryParent = categories.find((item) => item.slug === "gold-jewels-and-rhinestones");
if (!jewelryParent) {
return categories.filter((item) => item.parentId && item.isActive !== false);
}
return categories
.filter((item) => item.parentId === jewelryParent.id && item.isActive !== false)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
+10
View File
@@ -0,0 +1,10 @@
export type CategoryFrame = { fa: string; en: string };
export const featuredCategories: CategoryFrame[] = [
{ fa: "انگشتر", en: "Ring" },
{ fa: "گوشواره", en: "Earring" },
{ fa: "گردنبند", en: "Necklace" },
{ fa: "دستبند", en: "Bracelet" },
{ fa: "ساعت", en: "Watch" },
{ fa: "زنجیر", en: "Chain" },
];
+70
View File
@@ -0,0 +1,70 @@
import type { ApiCategory } from "@/lib/types";
import { categoryHref } from "@/lib/slug";
export type MenuCategory = {
id: string;
name: string;
nameFa: string;
href: string;
children: MenuCategory[];
};
function sortCats(items: ApiCategory[]) {
return [...items].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
function toMenu(category: ApiCategory, children: MenuCategory[]): MenuCategory {
return {
id: category.id,
name: category.name,
nameFa: category.nameFa || category.name,
href: categoryHref(category.id, category.nameFa, category.name),
children,
};
}
/** Build a parent→children tree from the flat categories list. */
export function buildCategoryTree(categories: ApiCategory[]): MenuCategory[] {
const active = categories.filter((item) => item.isActive !== false);
const byParent = new Map<string | null, ApiCategory[]>();
for (const category of active) {
const key = category.parentId ?? null;
const list = byParent.get(key) ?? [];
list.push(category);
byParent.set(key, list);
}
function walk(parentId: string | null): MenuCategory[] {
return sortCats(byParent.get(parentId) ?? []).map((category) =>
toMenu(category, walk(category.id)),
);
}
return walk(null);
}
/**
* Level-one groups with level-two children for the products mega menu.
* Walks past single-child chains so a generic root like «کالاها» does not
* become an empty column.
*/
export function megaMenuGroups(categories: ApiCategory[]): MenuCategory[] {
let nodes = buildCategoryTree(categories);
while (nodes.length === 1) {
const only = nodes[0];
if (!only.children.length) break;
const hasGrandchildren = only.children.some((child) => child.children.length > 0);
if (hasGrandchildren) {
nodes = only.children;
continue;
}
// Single parent whose children are leaves → one L1 column with those L2 links
return [only];
}
return nodes;
}
+16
View File
@@ -0,0 +1,16 @@
export const websiteDomain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "ayoubi.jewelry";
export const meshkeeApiBase =
process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1";
export const siteUrl = `https://${websiteDomain}`;
export const customerPortalUrl = `https://customer.${websiteDomain}`;
export const businessPortalUrl = `https://business.${websiteDomain}`;
/** Written by the customer dashboard on `.${websiteDomain}` — storefront only reads it. */
export const AUTH_TOKEN_COOKIE = "meshkee_customer_access_token";
/** Guest mini-cart key — must match the customer dashboard exactly. */
export const GUEST_CART_STORAGE_KEY = "meshkee-guest-cart";
+78
View File
@@ -0,0 +1,78 @@
import { site } from "@/data/site";
import type { BusinessInfo } from "@/lib/meshkee";
const faDigits = "۰۱۲۳۴۵۶۷۸۹";
export function toLatinDigits(value: string) {
return value.replace(/[۰-۹]/g, (digit) => String(faDigits.indexOf(digit)));
}
function textOf(value: unknown) {
if (typeof value === "string") return value.trim();
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>;
for (const key of ["value", "number", "phone", "address", "text", "email", "title"]) {
if (typeof record[key] === "string" && record[key].trim()) return record[key].trim();
}
return "";
}
export type PhoneLink = { href: string; display: string };
export function resolvePhones(info: BusinessInfo): PhoneLink[] {
const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean);
if (!fromApi.length) return [...site.phones];
return fromApi.map((display) => ({
display,
href: toLatinDigits(display).replace(/[^\d+]/g, ""),
}));
}
export function resolveAddress(info: BusinessInfo) {
const fromApi = (info.addresses ?? []).map(textOf).filter(Boolean);
return fromApi[0] || site.address;
}
export function resolveEmails(info: BusinessInfo) {
return (info.emails ?? []).map(textOf).filter(Boolean);
}
export function resolveInstagram(info: BusinessInfo) {
const raw = info.socialMedia?.instagram?.trim();
if (!raw) return site.socials[0];
const href = raw.startsWith("http")
? raw
: `https://instagram.com/${raw.replace(/^@/, "")}`;
const handle = raw.startsWith("http") ? site.socials[0].handle : raw.startsWith("@") ? raw : `@${raw}`;
return { label: "اینستاگرام", href, handle };
}
export function resolveAbout(info: BusinessInfo) {
const about = info.about?.trim();
if (!about) return [...site.about];
const parts = about.split(/\n+/).map((part) => part.trim()).filter(Boolean);
return parts.length ? parts : [about];
}
export function resolveLogo(info: BusinessInfo) {
return info.logoDarkUrl || info.logoUrl || site.logo;
}
export function stripScripts(html: string) {
return html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "");
}
export function parseHeadTag(html: string) {
const source = html.trim();
const match = source.match(/^<(meta|link)\s+([^>]*?)\/?\s*>$/i);
if (!match) return null;
const attrs: Record<string, string> = {};
const attrRe = /([:@\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let found = attrRe.exec(match[2]);
while (found) {
attrs[found[1]] = found[2] ?? found[3] ?? "";
found = attrRe.exec(match[2]);
}
if (!Object.keys(attrs).length) return null;
return { tag: match[1].toLowerCase() as "meta" | "link", attrs };
}
+48
View File
@@ -0,0 +1,48 @@
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function toFaDigits(input: string | number): string {
return String(input).replace(/\d/g, (digit) => FA_DIGITS[Number(digit)]);
}
export function formatPrice(value: number | null | undefined): string {
if (value === null || value === undefined) return "";
return toFaDigits(value.toLocaleString("en-US"));
}
export function isInStock(stockQuantity: number | null | undefined): boolean {
return stockQuantity === null || (stockQuantity ?? 0) > 0;
}
export function plainText(html?: string | null, maxLen = 140): string {
if (!html) return "";
const text = html
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
if (!text) return "";
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen).replace(/\s+\S*$/, "")}…`;
}
export function productTeaser(product: {
nameFa?: string | null;
title?: string | null;
categoryNameFa?: string | null;
categoryName?: string | null;
summary?: string | null;
seoMetaDescription?: string | null;
descriptionHtml?: string | null;
}): string {
const fromApi =
product.summary?.trim() ||
product.seoMetaDescription?.trim() ||
plainText(product.descriptionHtml, 120);
if (fromApi) return fromApi;
const name = product.nameFa || product.title || "این قطعه";
const category = product.categoryNameFa || product.categoryName;
return category
? `${name} از دسته ${category}. برای دیدن از نزدیک، بازدید از گالری را هماهنگ کنید.`
: `${name} از مجموعه گالری ایوبی فخیم. برای دیدن از نزدیک، بازدید را هماهنگ کنید.`;
}
+86
View File
@@ -0,0 +1,86 @@
import { GUEST_CART_STORAGE_KEY, websiteDomain } from "@/lib/config";
export type GuestCartItem = {
id: string;
name: string;
slug: string;
price: number;
originalPrice?: number;
image: string | null;
quantity: number;
};
export function encodeGuestCart(items: GuestCartItem[]): string {
const json = JSON.stringify(items);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const COOKIE_SIZE_LIMIT = 3500;
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
function readLocalStorage(): GuestCartItem[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(GUEST_CART_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as GuestCartItem[]) : [];
} catch {
return [];
}
}
function writeStorage(items: GuestCartItem[]): void {
if (typeof window === "undefined") return;
const json = JSON.stringify(items);
try {
window.localStorage.setItem(GUEST_CART_STORAGE_KEY, json);
} catch {
// private mode / quota
}
const encoded = encodeURIComponent(json);
if (encoded.length > COOKIE_SIZE_LIMIT) return;
try {
document.cookie = `${GUEST_CART_STORAGE_KEY}=${encoded}; Domain=.${websiteDomain}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
} catch {
// cookie blocked
}
}
export function readGuestCart(): GuestCartItem[] {
return readLocalStorage();
}
export function addGuestCartItem(item: GuestCartItem): GuestCartItem[] {
const items = readGuestCart();
const existing = items.find((line) => line.id === item.id);
const next = existing
? items.map((line) =>
line.id === item.id ? { ...line, quantity: line.quantity + item.quantity } : line,
)
: [...items, item];
writeStorage(next);
return next;
}
export function updateGuestCartItem(id: string, quantity: number): GuestCartItem[] {
const items = readGuestCart();
const next =
quantity <= 0
? items.filter((line) => line.id !== id)
: items.map((line) => (line.id === id ? { ...line, quantity } : line));
writeStorage(next);
return next;
}
export function removeGuestCartItem(id: string): GuestCartItem[] {
const next = readGuestCart().filter((line) => line.id !== id);
writeStorage(next);
return next;
}
+148
View File
@@ -0,0 +1,148 @@
import { featuredCategories, type CategoryFrame } from "@/lib/categories";
import type { GalleryItem } from "@/data/site";
import { gallery, site } from "@/data/site";
import {
catalogAspectFor,
imagesForSlot,
slotFor,
toCssAspectRatio,
type SlotPayload,
type StaticImage,
} from "@/lib/static-images";
export type HomeCategory = CategoryFrame & {
image?: string | null;
href?: string;
};
export type StoryStep = {
en: string;
fa: string;
text: string;
src: string;
alt: string;
position: string;
href?: string;
};
const defaultStory: StoryStep[] = [
{
en: "Gold",
fa: "فلز",
text: "کار از طلا آغاز می‌شود. زنجیر، حلقه و پایه از همین فلز است.",
src: "/assets/images/541932677_18076083281511213_2998824066892089068_n.jpg",
alt: "زنجیر طلا با قفل، روی روبان سرمه‌ای",
position: "center",
href: "/products",
},
{
en: "Stone",
fa: "سنگ",
text: "الماس، مروارید و سنگ رنگی هر کدام جای خود را دارند.",
src: "/assets/images/834767155_18125669717511213_5824315146052276957_n.jpg",
alt: "گوشواره‌های زمرد و الماس روی گل شیپوری سرخ",
position: "center 42%",
href: "/products",
},
{
en: "Line",
fa: "طرح",
text: "خط‌ها ساده و دقیق‌اند. نشست سنگ و تناسب، همان چیزی است که دیده می‌شود.",
src: "/assets/images/842977415_18126295883511213_701670290850293570_n.jpg",
alt: "گردنبند دو رج با آویز برگ‌مانند الماس روی پارچه قهوه‌ای",
position: "center 40%",
href: "/products",
},
{
en: "Wear",
fa: "پوشیدن",
text: "قطعه برای پوشیدن و ماندن ساخته می‌شود.",
src: "/assets/images/498025374_9837247912996594_6060652491414854135_n.jpg",
alt: "سه انگشتر مروارید با هاله نگین، در طلا و نقره",
position: "32% 36%",
href: "/products",
},
];
function aspectStyle(slots: SlotPayload[], key: string) {
const fromApi = slotFor(slots, key)?.aspectRatio;
const ratio = toCssAspectRatio(fromApi || catalogAspectFor(key));
return ratio ? ({ aspectRatio: ratio } as const) : undefined;
}
export function resolveHomeCategories(slots: SlotPayload[]): HomeCategory[] {
const images = imagesForSlot(slots, "categories");
if (!images.length) {
return featuredCategories.map((category) => ({ ...category }));
}
return images.map((image, index) => {
const fallback = featuredCategories[index];
return {
fa: image.titleFa || fallback?.fa || "دسته",
en: image.titleEn || fallback?.en || "",
image: image.url,
href: image.linkUrl || undefined,
};
});
}
export function resolveFourBanner(slots: SlotPayload[]): StoryStep[] {
const images = imagesForSlot(slots, "four-banner");
return defaultStory.map((fallback, index) => {
const image = images[index];
if (!image?.url) return fallback;
return {
en: image.titleEn || fallback.en,
fa: image.titleFa || fallback.fa,
text: image.subtext || fallback.text,
src: image.url,
alt: image.titleFa || image.titleEn || fallback.alt,
position: fallback.position,
href: image.linkUrl || fallback.href,
};
});
}
export function resolveMasonry(slots: SlotPayload[]): GalleryItem[] {
const images = imagesForSlot(slots, "masonry-image");
if (!images.length) return gallery;
return images.map((image) => ({
src: image.url || "",
title: image.titleFa || image.titleEn || "جواهر ایوبی فخیم",
category: image.subtext || "مجموعه",
alt: image.titleFa || image.titleEn || "قطعه جواهر گالری ایوبی فخیم",
href: image.linkUrl || undefined,
}));
}
export function resolveInstagramSlot(slots: SlotPayload[], fallbackHref: string) {
const image = imagesForSlot(slots, "instagram-image")[0] as StaticImage | undefined;
return {
src: image?.url || "/assets/images/instagram.jpeg",
alt: image?.titleFa || image?.titleEn || "برشی از صفحه اینستاگرام گالری ایوبی فخیم",
kicker: image?.titleEn || "Follow us",
title: image?.titleFa || "ما را در اینستاگرام دنبال کنید",
lead: image?.subtext || "قطعه‌های تازه گالری را در صفحه اینستاگرام ببینید.",
href: image?.linkUrl || fallbackHref,
linkUrl: image?.linkUrl || undefined,
style: aspectStyle(slots, "instagram-image"),
};
}
export function resolveLastBanner(slots: SlotPayload[]) {
const image = imagesForSlot(slots, "last-banner")[0] as StaticImage | undefined;
return {
src: image?.url || "/assets/images/825269750_18124978682511213_3830069416169726999_n.jpg",
alt: image?.titleFa || image?.titleEn || "گردنبند تنیسی با آویز مستطیل الماس روی ابریشم سرخ",
kicker: image?.titleEn || site.nameEn,
title: image?.titleFa || site.tagline,
subtext: image?.subtext || "",
href: image?.linkUrl || undefined,
style: aspectStyle(slots, "last-banner"),
};
}
export function fourBannerAspect(slots: SlotPayload[]) {
return aspectStyle(slots, "four-banner");
}
+79
View File
@@ -0,0 +1,79 @@
import { cache } from "react";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 60): Promise<T> {
try {
const response = await fetch(`${meshkeeApiBase}${path}`, {
next: { revalidate },
});
if (!response.ok) return fallback;
return (await response.json()) as T;
} catch {
return fallback;
}
}
export function tenantPath(suffix: string) {
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
}
export const getBusinessInfo = cache(async () => {
return meshkeeGet<BusinessInfo>(tenantPath("/website/business-info"), emptyBusinessInfo);
});
export const getFavicon = cache(async () => {
return meshkeeGet<FaviconInfo>(tenantPath("/website/favicon"), {
faviconUrl: null,
logoUrl: null,
logoDarkUrl: null,
hasDedicatedFavicon: false,
});
});
export type FaviconInfo = {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
};
export type BusinessInfo = {
name?: string;
nameFa?: string;
about?: string;
vision?: string;
logoUrl?: string | null;
logoDarkUrl?: string | null;
emails?: unknown[];
phoneNumbers?: unknown[];
socialMedia?: {
instagram?: string;
telegram?: string;
whatsapp?: string;
youtube?: string;
aparat?: string;
linkedin?: string;
};
addresses?: unknown[];
schema?: {
enabled?: boolean;
jsonLd?: Record<string, unknown> | null;
};
metaTags?: {
html?: string;
name?: string;
content?: string;
property?: string;
httpEquiv?: string;
}[];
trustBadges?: { kind?: string; embedHtml?: string }[];
};
const emptyBusinessInfo: BusinessInfo = {
metaTags: [],
trustBadges: [],
phoneNumbers: [],
emails: [],
addresses: [],
};
+10
View File
@@ -0,0 +1,10 @@
{
"baseUrl": "https://ayoubi.jewelry",
"staticPages": [
{ "path": "/", "changefreq": "daily", "priority": 1.0 },
{ "path": "/products", "changefreq": "weekly", "priority": 0.8 },
{ "path": "/blog", "changefreq": "weekly", "priority": 0.7 },
{ "path": "/about", "changefreq": "monthly", "priority": 0.6 },
{ "path": "/contact", "changefreq": "monthly", "priority": 0.5 }
]
}
+42
View File
@@ -0,0 +1,42 @@
export function faSlug(input: string | null | undefined): string {
if (!input) return "-";
return (
input
.normalize("NFC")
.trim()
.replace(/[\/\\?%*:|"'<>.,()[\]]/g, "")
.replace(/\s+/g, "-")
.slice(0, 80) || "-"
);
}
/** Safe for HTTP Location headers (ASCII-only path segments). */
export function encodePathSegment(value: string): string {
return encodeURIComponent(value);
}
export function productHref(id: string, nameFa?: string | null, title?: string | null): string {
return `/products/${id}/${encodePathSegment(faSlug(nameFa || title))}`;
}
export function categoryHref(id: string, nameFa?: string | null, name?: string | null): string {
return `/products/category/${id}/${encodePathSegment(faSlug(nameFa || name))}`;
}
export function blogHref(slug: string): string {
return `/blog/${encodePathSegment(slug)}`;
}
export function portfolioHref(slug: string): string {
return `/portfolio/${encodePathSegment(slug)}`;
}
export function sameSlug(incoming: string, canonical: string): boolean {
let decoded = incoming;
try {
decoded = decodeURIComponent(incoming);
} catch {
decoded = incoming;
}
return decoded.normalize("NFC") === canonical.normalize("NFC");
}
+117
View File
@@ -0,0 +1,117 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
export type StaticImage = {
url?: string | null;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
};
export type StaticImageSlot = {
key: string;
label: string;
kind: "single" | "list";
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
/**
* Catalog for `GET /meshkee/static-image-slots`.
* Keep aspectRatio / recommendedWidth in sync with the on-page layout —
* if a frame’s size or ratio changes in development, update the matching
* entry here in the same change.
*/
export const STATIC_IMAGE_SLOTS: StaticImageSlot[] = [
{
key: "categories",
label: "دسته‌های صفحه اصلی",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 320,
},
{
key: "four-banner",
label: "بنر چهارتایی (از فلز تا پوشیدن)",
kind: "list",
pageKey: "home",
aspectRatio: "3:4",
itemCount: 4,
recommendedWidth: 1440,
},
{
key: "masonry-image",
label: "تصاویر ماسونری (اندازه آزاد)",
kind: "list",
pageKey: "home",
aspectRatio: "3:4",
itemCount: null,
recommendedWidth: 960,
},
{
key: "instagram-image",
label: "تصویر اینستاگرام",
kind: "single",
pageKey: "home",
aspectRatio: "4:5",
itemCount: 1,
recommendedWidth: 480,
},
{
key: "last-banner",
label: "بنر پایانی (پارالاکس)",
kind: "single",
pageKey: "home",
aspectRatio: "16:9",
itemCount: 1,
recommendedWidth: 1600,
},
{
key: "about-portrait",
label: "تصویر درباره ما",
kind: "single",
pageKey: "about",
aspectRatio: "3:4",
itemCount: 1,
recommendedWidth: 900,
},
];
export type SlotPayload = {
key?: string;
kind?: "single" | "list" | string;
aspectRatio?: string | null;
itemCount?: number | null;
images?: StaticImage[];
};
export async function getPageSlots(pageKey: string) {
const data = await meshkeeGet<{ items?: SlotPayload[] }>(
tenantPath(`/website/static-images?pageKey=${encodeURIComponent(pageKey)}`),
{ items: [] },
);
return data.items ?? [];
}
export function slotFor(slots: SlotPayload[], key: string) {
return slots.find((item) => item.key === key);
}
export function imagesForSlot(slots: SlotPayload[], key: string) {
const slot = slotFor(slots, key);
return (slot?.images ?? []).filter((image) => Boolean(image.url));
}
/** CSS `aspect-ratio` value from Meshkee `16:9` strings. */
export function toCssAspectRatio(value?: string | number | null) {
if (value === null || value === undefined || value === "") return undefined;
return String(value).replace(":", "/");
}
export function catalogAspectFor(key: string) {
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key)?.aspectRatio;
}
+38
View File
@@ -0,0 +1,38 @@
import type { SpecRow, TechnicalInfoForm, TechnicalInfoValue } from "@/lib/types";
export function buildSpecRows(
form: TechnicalInfoForm | null | undefined,
values: TechnicalInfoValue[],
): SpecRow[] {
if (!form || values.length === 0) return [];
const fieldsById = new Map(form.fields.map((field) => [field.id, field]));
const rows: SpecRow[] = [];
for (const value of values) {
const field = fieldsById.get(value.fieldId);
if (!field) continue;
const label = field.labelFa || field.label || field.key || value.fieldId;
let display = "";
if (value.textValue) {
display = value.textValue;
} else if (value.optionId && field.options) {
const option = field.options.find((item) => item.id === value.optionId);
display = option ? option.labelFa || option.label || option.id : "";
} else if (value.optionIds && field.options) {
display = value.optionIds
.map((id) => field.options?.find((item) => item.id === id))
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.map((item) => item.labelFa || item.label || item.id)
.join("، ");
}
if (display) rows.push({ key: field.id, label, value: display });
}
return rows.sort(
(a, b) => (fieldsById.get(a.key)?.sortOrder ?? 0) - (fieldsById.get(b.key)?.sortOrder ?? 0),
);
}
+153
View File
@@ -0,0 +1,153 @@
export type PagedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
export type ApiCategory = {
id: string;
parentId: string | null;
name: string;
nameFa: string;
slug: string;
sortOrder?: number;
isActive?: boolean;
seoMetaTitle?: string | null;
seoMetaDescription?: string | null;
};
export type ProductImage = {
mediaId?: string;
url: string;
};
export type ApiProduct = {
id: string;
title: string;
nameFa: string;
summary?: string;
descriptionHtml?: string;
slug: string;
categoryId?: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
brand?: string | null;
tags?: string[];
seoMetaTitle?: string | null;
seoMetaDescription?: string | null;
thumbnailUrl?: string | null;
image?: string | null;
thumbnail?: string | null;
images?: ProductImage[];
commentCount?: number;
store?: {
variantCount?: number;
minPrice?: number | null;
maxPrice?: number | null;
inStock?: boolean;
};
schema?: { jsonLd?: Record<string, unknown> | null };
};
export type StoreItemSelection = {
variationId?: string;
variationName?: string;
optionId?: string;
value?: string;
};
export type StoreVariant = {
id: string;
label: string;
selections?: StoreItemSelection[];
price: number | null;
discountedPrice: number | null;
stockQuantity: number | null;
};
export type StoreItemVariants = {
id: string;
productId: string;
variants: StoreVariant[];
};
export type ApiBlog = {
id: string;
title?: string;
nameFa?: string;
summary?: string;
descriptionHtml?: string;
contentHtml?: string;
slug: string;
thumbnailUrl?: string | null;
image?: string | null;
categoryNameFa?: string | null;
publishedAt?: string;
createdAt?: string;
seoMetaTitle?: string | null;
seoMetaDescription?: string | null;
schema?: { jsonLd?: Record<string, unknown> | null };
};
export type ApiPortfolio = {
id: string;
title?: string;
nameFa?: string;
summary?: string;
descriptionHtml?: string;
contentHtml?: string;
slug: string;
thumbnailUrl?: string | null;
image?: string | null;
images?: ProductImage[];
projectUrl?: string | null;
publishedAt?: string;
createdAt?: string;
seoMetaTitle?: string | null;
seoMetaDescription?: string | null;
};
export type EntityComment = {
id: string;
authorName?: string;
text?: string;
createdAt?: string;
};
export type AuthUser = {
id: string;
name?: string;
nameFa?: string;
firstName?: string;
lastName?: string;
cellNumber?: string;
isAdmin?: boolean;
role?: string;
};
export type TechnicalInfoField = {
id: string;
label?: string;
labelFa?: string;
key?: string;
sortOrder?: number;
options?: { id: string; label?: string; labelFa?: string }[];
};
export type TechnicalInfoForm = {
fields: TechnicalInfoField[];
};
export type TechnicalInfoValue = {
fieldId: string;
textValue?: string | null;
optionId?: string | null;
optionIds?: string[] | null;
};
export type SpecRow = {
key: string;
label: string;
value: string;
};