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>
@@ -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`
|
||||
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 268 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
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,
|
||||
icons: {
|
||||
icon: "/Ayoobi-logo.png",
|
||||
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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,105 @@
|
||||
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} />
|
||||
|
||||
<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>
|
||||
|
||||
<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__brand">
|
||||
<h1 className="coming-soon__name">{site.nameEn}</h1>
|
||||
<p className="coming-soon__name-fa">{site.nameFa}</p>
|
||||
</div>
|
||||
|
||||
<p className="coming-soon__tagline">{site.tagline}</p>
|
||||
|
||||
<div className="coming-soon__meta">
|
||||
<section className="visit" aria-labelledby="visit-title">
|
||||
<div className="wrap visit__grid">
|
||||
<div>
|
||||
<span className="coming-soon__label">آدرس</span>
|
||||
<p className="coming-soon__address">{site.address}</p>
|
||||
<h2 id="visit-title">بازدید از گالری</h2>
|
||||
<p className="lead">قطعهها را در گالری از نزدیک ببینید.</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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<Link className="text-link" href="/contact">
|
||||
هماهنگی بازدید
|
||||
</Link>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) }} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")}</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));
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(/ /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} از مجموعه گالری ایوبی فخیم. برای دیدن از نزدیک، بازدید را هماهنگ کنید.`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||