Wire Meshkee catalog: products, blog, mega menu, and product search.
Catalog-only (no cart); search targets store items and products pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cfa21cbee9
commit
8e14748b48
@@ -16,8 +16,8 @@ Create blog page and details >> connect to blog
|
|||||||
Product Menu should be a mega menu with level 1 and 2 of categories
|
Product Menu should be a mega menu with level 1 and 2 of categories
|
||||||
Search input should be instant search - activate when type more than 2 char
|
Search input should be instant search - activate when type more than 2 char
|
||||||
|
|
||||||
First carousel >> special-sale
|
First carousel >> consumeables
|
||||||
Second carousel >> multi-vitamin
|
Second carousel >> refrigrator-parts
|
||||||
Third carousel >> body-building-supplement
|
Third carousel >> washing-machine-parts
|
||||||
|
|
||||||
update context md for better use in other Ai and systems - update it on every prompt (If required)
|
update context md for better use in other Ai and systems - update it on every prompt (If required)
|
||||||
+73
-77
@@ -3,84 +3,95 @@
|
|||||||
Persian RTL storefront for **brb-store.com** (فروشگاه و تعمیرگاه لوازم خانگی برادران برقعی، قم).
|
Persian RTL storefront for **brb-store.com** (فروشگاه و تعمیرگاه لوازم خانگی برادران برقعی، قم).
|
||||||
Next.js App Router rebuild of the original WillaMall/BeTheme site. Keep this file current — other AI tools read it first.
|
Next.js App Router rebuild of the original WillaMall/BeTheme site. Keep this file current — other AI tools read it first.
|
||||||
|
|
||||||
Live DNS for `www.brb-store.com` currently serves a different tenant; source of truth for copy, images, and palette is the archived original at `https://www.brb-store.ir/` (Wayback 2022) plus `Ai-data/prompt.txt`.
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Next.js 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4
|
- Next.js 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4
|
||||||
- Locale: `fa`, direction: `rtl`
|
- Locale: `fa`, direction: `rtl`
|
||||||
- Fonts: Vazirmatn (Persian) + Montserrat (English kickers). Original site used **BYekan-Edited** + **IRANSans** + **Montserrat**; those files were not recoverable from Wayback (`WillaMall_SP/assets/Fonts/...` 404).
|
- Fonts: Vazirmatn (Persian) + Montserrat (English kickers)
|
||||||
- Icons: Lucide, tinted with brand red (original used WillaMall `WMi-*` icon font, not archived).
|
- Icons: Lucide
|
||||||
- Dev: `npm run dev`
|
- Dev: `npm run dev`
|
||||||
|
|
||||||
## Brand
|
## Brand
|
||||||
|
|
||||||
| Token | Value | Source |
|
| Token | Value |
|
||||||
| --- | --- | --- |
|
|
||||||
| Brand red | `#e7432b` | BeTheme `color-red` |
|
|
||||||
| Brand dark | `#c93621` | hover |
|
|
||||||
| Ink | `#1a1a1a` | |
|
|
||||||
| Muted | `#6c6363` | original about text |
|
|
||||||
| Paper | `#f6f3f1` | |
|
|
||||||
| Footer copy bar | `#efefef` | original `.footer_copy` |
|
|
||||||
|
|
||||||
Logo: oval **BRB** (black top / red bottom, “Borgheei Brothers”) at `public/images/brand/logo.png` and `src/assets/images/logo/logo.png`. Favicon from original `.ico`.
|
|
||||||
|
|
||||||
Design language comes from the original BeTheme Carpenter homepage, not a generic banner stack:
|
|
||||||
|
|
||||||
- Full-bleed kitchen photography with a **white 92% caption card** (the original slider’s 430px white panel), red 3px bar, Montserrat kicker.
|
|
||||||
- «قطعات ما» header: red bar + English kicker, title, short catalog note.
|
|
||||||
- Parts grid: **4 columns**, image cards with a **frosted glass** caption box on the photo (`backdrop-filter`), hover zoom + search icon.
|
|
||||||
- Red (`#e7432b`) as accent — numbers, icons, underlines — not a wash over every photo.
|
|
||||||
- Story: فروش / قطعات / تعمیرات, ۳۰+ years, two Qom branches, wholesale.
|
|
||||||
- Homepage **blog carousel** after the three-banner row: snap-scroll cards, arrows, link to `/blog`. Copy lives in `lib/data.ts` (`blogPosts`) until Meshkee.
|
|
||||||
- Original **category hover carousel** (`CategoryShowcase`): centered «دسته بندی ها» header, tall photo cards, dark bottom bar that expands on hover with «تماس با ما». Data: `showcaseCategories`.
|
|
||||||
|
|
||||||
Product tiles stay on **white backgrounds**. Lifestyle extras: `public/images/lifestyle/`.
|
|
||||||
|
|
||||||
## Routes (this pass)
|
|
||||||
|
|
||||||
Clone **only** these pages:
|
|
||||||
|
|
||||||
| Path | Content |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | Homepage + static image slots |
|
| Brand red | `#e7432b` |
|
||||||
| `/about` | About us (original `/AboutUs`) |
|
| Brand dark | `#c93621` |
|
||||||
| `/contact` | Contact us (original `/ContactUs`) |
|
| Ink | `#161616` |
|
||||||
| `/blog` | Article list (local placeholders until Meshkee blog is wired) |
|
| Muted | `#6c6363` |
|
||||||
| `/blog/[slug]` | Article detail |
|
| Paper | `#f4f0ec` |
|
||||||
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
|
|
||||||
|
|
||||||
Account / user icon → `https://customer.brb-store.com` (`NEXT_PUBLIC_CUSTOMER_PORTAL_URL`). Do not build a local login page.
|
Logo: `public/images/brand/logo.png`. Dark glossy caption bars: `lib/glossy.ts`.
|
||||||
|
|
||||||
|
## Meshkee API
|
||||||
|
|
||||||
|
- Docs: https://api.meshkee.com/docs/website
|
||||||
|
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
|
||||||
|
- Base: `https://api.meshkee.com/api/v1`
|
||||||
|
- Tenant domain: `brb-store.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN` / `WEBSITE_DOMAIN`)
|
||||||
|
- Customer portal: `https://customer.brb-store.com` (`NEXT_PUBLIC_CUSTOMER_PORTAL_URL`)
|
||||||
|
- Public reads: `/tenants/{domain}/...` (no auth).
|
||||||
|
- Do not invent CMS, upload, or dashboard APIs.
|
||||||
|
|
||||||
|
This site is **catalog-only** (no shopping cart). Login / user icon goes to the customer portal. Do not build a local login page.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Path | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Homepage + static image slots + store specials + latest blogs |
|
||||||
|
| `/products` | Store items (`GET /tenants/{domain}/store-items`) |
|
||||||
|
| `/products?categoryId=` | Same, filtered |
|
||||||
|
| `/products/[variantId]` | Store item variant + product description/gallery + comments |
|
||||||
|
| `/blog` | `GET /tenants/{domain}/blogs` (falls back to `lib/data.ts` `blogPosts`) |
|
||||||
|
| `/blog/[slug]` | Blog details + comments |
|
||||||
|
| `/about` | About us |
|
||||||
|
| `/contact` | Contact us |
|
||||||
|
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
|
||||||
|
| `/api/search?q=` | Instant product search (`store-items?name=`, min 3 chars) |
|
||||||
|
|
||||||
## Homepage order
|
## Homepage order
|
||||||
|
|
||||||
`Hero` → `Services` (3 icons) → `CategoryShowcase` → `AboutPreview` → five-banner (2-col + 3-col) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
|
`Hero` → `Services` → three product carousels → `CategoryShowcase` → `AboutPreview` → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
|
||||||
|
|
||||||
Caption bars on photos use dark glossy glass (`lib/glossy.ts` `glossyBar`). Hover the card to expand the bar and show `subtext`.
|
## Homepage carousels (store specials)
|
||||||
|
|
||||||
## Static image slots (wired)
|
`GET /tenants/{domain}/store-specials` matched by `key`:
|
||||||
|
|
||||||
`GET /tenants/brb-store.com/website/static-images?pageKey=home`. Catalog: `GET /meshkee/static-image-slots` from `STATIC_IMAGE_SLOTS` in `lib/static-images.ts`. Client: `overlaySlotImages` / `pickSlot`. Empty/fail → local placeholder. `url` → `<img src>`, `linkUrl` → wrap in a link, `titleFa` / `titleEn` / `subtext` → caption. Use `slot.aspectRatio`.
|
1. `consumeables` — اقلام مصرفی
|
||||||
|
2. `refrigrator-parts` — قطعات یخچال
|
||||||
|
3. `washing-machine-parts` — قطعات ماشین لباسشویی
|
||||||
|
|
||||||
If a slot’s CSS size or ratio changes, update `aspectRatio` and `recommendedWidth` in `STATIC_IMAGE_SLOTS` in the same change.
|
If a special has no `items`, keep sample products from `lib/data.ts`. Use `variants[0].id` as the product URL.
|
||||||
|
|
||||||
| key | kind | itemCount | ratio | Local fallback |
|
## Catalog
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `slider` | list | null | 2:1 | `slides` peek carousel, center `1280px` |
|
|
||||||
| `three-icon-image` | list | 3 | 1:1 | Lucide icons if slot empty |
|
|
||||||
| `categories` | list | null | 14:23 | `showcaseCategories` tall cards |
|
|
||||||
| `about-us-image` | single | 1 | 4:3 | `public/images/about/store.jpg` |
|
|
||||||
| `five-banner-section` | list | 5 | 2:1 | `twoBannerRow` + `threeBannerRow` |
|
|
||||||
| `two-banner-row` | list | 2 | 2:1 | `bannersBelowSlider` (after blog) |
|
|
||||||
| `full-row-image` | single | 1 | 32:9 | `public/images/banners/wide.jpg` |
|
|
||||||
| `two-branch-image` | list | 2 | 2:1 | `branches` photos |
|
|
||||||
|
|
||||||
Do not invent upload APIs.
|
- Mega menu: product categories **level 1 and 2** (`GET /tenants/{domain}/categories?entityType=product`). Links: `/products?categoryId={id}`.
|
||||||
|
- Instant search: header input searches **products** when query length **> 2** (`/api/search` → `store-items?name=`). Enter or “مشاهده همه” → `/products?q=`.
|
||||||
|
- Product pages are display-only (price, gallery, description, comments). No cart / add-to-cart.
|
||||||
|
- Details gallery: product `images[].url` + thumbnail. Click = lightbox, hover = zoom.
|
||||||
|
- Details tabs: description (`descriptionHtml`) and comments (`GET /comments?entityType=product&entityId={productId}`). Unauthenticated users see a login box to the customer portal.
|
||||||
|
|
||||||
## Original business data (valid — do not invent replacements)
|
## Static image slots
|
||||||
|
|
||||||
**About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances (vacuum, meat grinder, fan, juicer, mill, steam iron, kettle, mixer, blender, microwave, …). Wholesale and retail, Iran and abroad. Open to manufacturers/traders.
|
`GET /tenants/{domain}/website/static-images?pageKey=home`. Catalog: `GET /meshkee/static-image-slots` from `STATIC_IMAGE_SLOTS` in `lib/static-images.ts`.
|
||||||
|
|
||||||
|
| key | kind | itemCount |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `slider` | list | null |
|
||||||
|
| `three-icon-image` | list | 3 |
|
||||||
|
| `categories` | list | null |
|
||||||
|
| `about-us-image` | single | 1 |
|
||||||
|
| `five-banner-section` | list | 5 |
|
||||||
|
| `two-banner-row` | list | 2 |
|
||||||
|
| `full-row-image` | single | 1 |
|
||||||
|
| `two-branch-image` | list | 2 |
|
||||||
|
|
||||||
|
`url` → img, `linkUrl` → wrap in a link, empty/fail → placeholder. Use `slot.aspectRatio`. If a slot’s size/ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change.
|
||||||
|
|
||||||
|
## Original business data (do not invent replacements)
|
||||||
|
|
||||||
|
**About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances. Wholesale and retail.
|
||||||
|
|
||||||
**Branches**
|
**Branches**
|
||||||
|
|
||||||
@@ -89,26 +100,11 @@ Do not invent upload APIs.
|
|||||||
|
|
||||||
**Phones:** 0912-309-3407، 0912-651-7619، 025-36604454، 025-32404070، تلفکس 0253-6638866
|
**Phones:** 0912-309-3407، 0912-651-7619، 025-36604454، 025-32404070، تلفکس 0253-6638866
|
||||||
**Telegram:** https://t.me/borgheeibrothers
|
**Telegram:** https://t.me/borgheeibrothers
|
||||||
**Email display:** Alireza borghei20@gmail.com (`alirezaborghei20@gmail.com`)
|
**Email:** `alirezaborghei20@gmail.com`
|
||||||
**Map:** 34.6564, 50.8696
|
**Map:** 34.6564, 50.8696
|
||||||
|
|
||||||
**Part categories (homepage):** vacuum, juicer/blender/mill, steam cleaner, meat grinder, iron, microwave/toaster, kettle, fan/evaporative cooler, food processor/chopper/hand blender/mixer, battery, bearing, collector.
|
## Local fallbacks
|
||||||
|
|
||||||
Appliance mega-menu (household + industrial) is copied from the original header; links currently go to `/#parts` until the catalog is connected.
|
- Copy / nav / sample products / local blog: `lib/data.ts`
|
||||||
|
- Empty catalog art: `/public/placeholders/*.svg`
|
||||||
## Meshkee
|
- Downloaded originals: `public/images/`
|
||||||
|
|
||||||
Docs: https://api.meshkee.com/docs/website
|
|
||||||
OpenAPI: https://api.meshkee.com/docs/website/openapi.json
|
|
||||||
Base: `https://api.meshkee.com/api/v1`
|
|
||||||
Tenant domain: `brb-store.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN` / `WEBSITE_DOMAIN`).
|
|
||||||
|
|
||||||
Wired: homepage static images (`Ai-data/prompt-images-slot.txt`).
|
|
||||||
|
|
||||||
Later: `Ai-data/prompt-connect-backend.txt` — products, blog, portfolio, customer portal.
|
|
||||||
|
|
||||||
Public reads only. Do not invent CMS/upload/dashboard APIs. Product images are white-bg and will be read from the backend.
|
|
||||||
|
|
||||||
## Local content
|
|
||||||
|
|
||||||
All copy and nav live in `lib/data.ts`. Downloaded originals are under `public/images/`. Sample lifestyle photos: `assets/sample images/` (copied to `public/images/lifestyle/`).
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const query = url.searchParams.get("q")?.trim() ?? "";
|
||||||
|
if (query.length <= 2) {
|
||||||
|
return Response.json({ items: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/store-items?name=${encodeURIComponent(query)}&pageSize=8`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
return Response.json(await response.json());
|
||||||
|
} catch {
|
||||||
|
return Response.json({ items: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { PageHero } from "@/components/layout/PageHero";
|
import { PageHero } from "@/components/layout/PageHero";
|
||||||
import { blogPosts, getBlogPost } from "@/lib/data";
|
import { ProductTabs } from "@/components/product/ProductTabs";
|
||||||
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
import { blogImage, fetchBlog } from "@/lib/blog";
|
||||||
|
import { fetchComments } from "@/lib/comments";
|
||||||
|
import { getBlogPost } from "@/lib/data";
|
||||||
|
import { formatFaDate } from "@/lib/format";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export const revalidate = 60;
|
||||||
return blogPosts.map((post) => ({ slug: post.slug }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -15,9 +18,11 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const post = getBlogPost(slug);
|
const apiPost = await fetchBlog(slug);
|
||||||
if (!post) return { title: "مقاله" };
|
if (apiPost) return { title: apiPost.title, description: apiPost.abstract ?? undefined };
|
||||||
return { title: post.title, description: post.excerpt };
|
const local = getBlogPost(slug);
|
||||||
|
if (!local) return { title: "مقاله" };
|
||||||
|
return { title: local.title, description: local.excerpt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogPostPage({
|
export default async function BlogPostPage({
|
||||||
@@ -26,6 +31,36 @@ export default async function BlogPostPage({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const apiPost = await fetchBlog(slug);
|
||||||
|
|
||||||
|
if (apiPost) {
|
||||||
|
const comments = await fetchComments("blog", apiPost.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} />
|
||||||
|
<section className="py-16 sm:py-24">
|
||||||
|
<Container className="max-w-3xl">
|
||||||
|
<p className="font-latin text-[12px] tracking-[0.18em] text-muted">
|
||||||
|
{formatFaDate(apiPost.publishedAt || apiPost.createdAt)}
|
||||||
|
</p>
|
||||||
|
{apiPost.abstract ? <p className="mt-6 text-[16px] leading-9 text-muted">{apiPost.abstract}</p> : null}
|
||||||
|
{apiPost.mainTextHtml ? (
|
||||||
|
<div
|
||||||
|
className="rich-text mt-8 text-[16px] leading-9 text-muted"
|
||||||
|
dangerouslySetInnerHTML={{ __html: apiPost.mainTextHtml }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ProductTabs showDescription={false} comments={comments} />
|
||||||
|
<Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand">
|
||||||
|
بازگشت به مقالات
|
||||||
|
<span className="h-px w-10 bg-brand" />
|
||||||
|
</Link>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const post = getBlogPost(slug);
|
const post = getBlogPost(slug);
|
||||||
if (!post) notFound();
|
if (!post) notFound();
|
||||||
|
|
||||||
@@ -40,6 +75,9 @@ export default async function BlogPostPage({
|
|||||||
<p key={paragraph}>{paragraph}</p>
|
<p key={paragraph}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-8 overflow-hidden rounded-2xl">
|
||||||
|
<Placeholder src={post.image} alt={post.title} className="aspect-[16/9] w-full object-cover" />
|
||||||
|
</div>
|
||||||
<Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand">
|
<Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand">
|
||||||
بازگشت به مقالات
|
بازگشت به مقالات
|
||||||
<span className="h-px w-10 bg-brand" />
|
<span className="h-px w-10 bg-brand" />
|
||||||
|
|||||||
+58
-1
@@ -3,19 +3,75 @@ import Link from "next/link";
|
|||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { PageHero } from "@/components/layout/PageHero";
|
import { PageHero } from "@/components/layout/PageHero";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
import { blogHref, blogImage, fetchBlogs } from "@/lib/blog";
|
||||||
import { blogPosts } from "@/lib/data";
|
import { blogPosts } from "@/lib/data";
|
||||||
|
import { faDigits, formatFaDate } from "@/lib/format";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "مقالات",
|
title: "مقالات",
|
||||||
description: "نکتههایی از تعمیرگاه و بورس قطعات لوازم خانگی برادران برقعی.",
|
description: "نکتههایی از تعمیرگاه و بورس قطعات لوازم خانگی برادران برقعی.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default async function BlogPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const { page: pageParam } = await searchParams;
|
||||||
|
const page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
const result = await fetchBlogs({ page, pageSize: 12 });
|
||||||
|
const usingApi = result.items.length > 0;
|
||||||
|
const pageCount = usingApi
|
||||||
|
? Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)))
|
||||||
|
: 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero kicker="Blog" title="مقالات" image="/images/slides/slide-2.jpg" />
|
<PageHero kicker="Blog" title="مقالات" image="/images/slides/slide-2.jpg" />
|
||||||
<section className="py-16 sm:py-24">
|
<section className="py-16 sm:py-24">
|
||||||
<Container>
|
<Container>
|
||||||
|
{usingApi ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{result.items.map((post) => (
|
||||||
|
<Link key={post.id} href={blogHref(post)} className="group block">
|
||||||
|
<div className="overflow-hidden rounded-2xl">
|
||||||
|
<Placeholder
|
||||||
|
src={blogImage(post)}
|
||||||
|
alt={post.title}
|
||||||
|
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">
|
||||||
|
{formatFaDate(post.publishedAt || post.createdAt)}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2>
|
||||||
|
{post.abstract ? (
|
||||||
|
<p className="mt-2 line-clamp-2 text-[14px] leading-7 text-muted">{post.abstract}</p>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className="mt-10 flex justify-center gap-2">
|
||||||
|
{Array.from({ length: pageCount }, (_, index) => {
|
||||||
|
const number = index + 1;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={number}
|
||||||
|
href={`/blog?page=${number}`}
|
||||||
|
className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`}
|
||||||
|
>
|
||||||
|
{faDigits(number)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{blogPosts.map((post) => (
|
{blogPosts.map((post) => (
|
||||||
<Link key={post.slug} href={`/blog/${post.slug}`} className="group block">
|
<Link key={post.slug} href={`/blog/${post.slug}`} className="group block">
|
||||||
@@ -32,6 +88,7 @@ export default function BlogPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -77,3 +77,33 @@ textarea,
|
|||||||
button {
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rich-text h1,
|
||||||
|
.rich-text h2,
|
||||||
|
.rich-text h3 {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text p {
|
||||||
|
margin-bottom: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text ul,
|
||||||
|
.rich-text ol {
|
||||||
|
margin: 0.75em 0;
|
||||||
|
padding-inline-start: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text a {
|
||||||
|
color: var(--brand);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
+5
-2
@@ -4,6 +4,7 @@ import "./globals.css";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { TopBar } from "@/components/layout/TopBar";
|
import { TopBar } from "@/components/layout/TopBar";
|
||||||
|
import { getNavCategories } from "@/lib/categories";
|
||||||
import { site } from "@/lib/data";
|
import { site } from "@/lib/data";
|
||||||
|
|
||||||
const vazir = Vazirmatn({
|
const vazir = Vazirmatn({
|
||||||
@@ -30,12 +31,14 @@ export const metadata: Metadata = {
|
|||||||
icons: { icon: "/favicon.ico" },
|
icons: { icon: "/favicon.ico" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const categories = await getNavCategories();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={`${vazir.variable} ${montserrat.variable}`}>
|
<html lang="fa" dir="rtl" className={`${vazir.variable} ${montserrat.variable}`}>
|
||||||
<body className={`${vazir.className} min-h-screen antialiased`}>
|
<body className={`${vazir.className} min-h-screen antialiased`}>
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<Header />
|
<Header categories={categories} />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+33
-2
@@ -4,25 +4,38 @@ import { BlogCarousel } from "@/components/home/BlogCarousel";
|
|||||||
import { Branches } from "@/components/home/Branches";
|
import { Branches } from "@/components/home/Branches";
|
||||||
import { CategoryShowcase } from "@/components/home/CategoryShowcase";
|
import { CategoryShowcase } from "@/components/home/CategoryShowcase";
|
||||||
import { Hero } from "@/components/home/Hero";
|
import { Hero } from "@/components/home/Hero";
|
||||||
|
import { ProductCarousel } from "@/components/home/ProductCarousel";
|
||||||
import { Services } from "@/components/home/Services";
|
import { Services } from "@/components/home/Services";
|
||||||
import { Stats } from "@/components/home/Stats";
|
import { Stats } from "@/components/home/Stats";
|
||||||
import { WideBanner } from "@/components/home/WideBanner";
|
import { WideBanner } from "@/components/home/WideBanner";
|
||||||
import {
|
import {
|
||||||
about,
|
about,
|
||||||
bannersBelowSlider,
|
bannersBelowSlider,
|
||||||
|
blogPosts,
|
||||||
branches,
|
branches,
|
||||||
|
consumablesFallback,
|
||||||
|
refrigeratorPartsFallback,
|
||||||
services,
|
services,
|
||||||
showcaseCategories,
|
showcaseCategories,
|
||||||
slides,
|
slides,
|
||||||
threeBannerRow,
|
threeBannerRow,
|
||||||
twoBannerRow,
|
twoBannerRow,
|
||||||
|
washingMachinePartsFallback,
|
||||||
} from "@/lib/data";
|
} from "@/lib/data";
|
||||||
|
import { fetchBlogs } from "@/lib/blog";
|
||||||
|
import { fetchSpecialProducts } from "@/lib/store";
|
||||||
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
|
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const slots = await fetchStaticImageSlots("home");
|
const [slots, consumables, refrigerator, washing, blogs] = await Promise.all([
|
||||||
|
fetchStaticImageSlots("home"),
|
||||||
|
fetchSpecialProducts("consumeables"),
|
||||||
|
fetchSpecialProducts("refrigrator-parts"),
|
||||||
|
fetchSpecialProducts("washing-machine-parts"),
|
||||||
|
fetchBlogs({ pageSize: 8 }),
|
||||||
|
]);
|
||||||
|
|
||||||
const slider = overlaySlotImages(
|
const slider = overlaySlotImages(
|
||||||
pickSlot(slots, "slider"),
|
pickSlot(slots, "slider"),
|
||||||
@@ -122,11 +135,29 @@ export default async function HomePage() {
|
|||||||
<>
|
<>
|
||||||
<Hero slides={slider.items} aspectRatio={slider.aspectRatio} />
|
<Hero slides={slider.items} aspectRatio={slider.aspectRatio} />
|
||||||
<Services items={icons.items} />
|
<Services items={icons.items} />
|
||||||
|
<ProductCarousel
|
||||||
|
id="consumables"
|
||||||
|
title={consumables.special?.title || "اقلام مصرفی"}
|
||||||
|
subtitle="قطعات مصرفی پرفروش برای تعمیرگاه و مصرفکننده"
|
||||||
|
products={consumables.products.length ? consumables.products : consumablesFallback}
|
||||||
|
/>
|
||||||
|
<ProductCarousel
|
||||||
|
id="refrigerator-parts"
|
||||||
|
title={refrigerator.special?.title || "قطعات یخچال"}
|
||||||
|
subtitle="قطعات یخچال و فریزر"
|
||||||
|
products={refrigerator.products.length ? refrigerator.products : refrigeratorPartsFallback}
|
||||||
|
/>
|
||||||
|
<ProductCarousel
|
||||||
|
id="washing-machine-parts"
|
||||||
|
title={washing.special?.title || "قطعات ماشین لباسشویی"}
|
||||||
|
subtitle="قطعات لباسشویی خانگی و صنعتی"
|
||||||
|
products={washing.products.length ? washing.products : washingMachinePartsFallback}
|
||||||
|
/>
|
||||||
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
|
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
|
||||||
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
|
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
|
||||||
<BannerRow banners={fiveBanners.items.slice(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} />
|
<BannerRow banners={fiveBanners.items.slice(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} />
|
||||||
<BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} />
|
<BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} />
|
||||||
<BlogCarousel />
|
<BlogCarousel posts={blogs.items} fallback={blogPosts} />
|
||||||
<BannerRow banners={twoBanners.items} columns={2} aspectRatio={twoBanners.aspectRatio} />
|
<BannerRow banners={twoBanners.items} columns={2} aspectRatio={twoBanners.aspectRatio} />
|
||||||
<Stats />
|
<Stats />
|
||||||
<WideBanner image={fullRow.items[0]} aspectRatio={fullRow.aspectRatio} />
|
<WideBanner image={fullRow.items[0]} aspectRatio={fullRow.aspectRatio} />
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { ProductGallery } from "@/components/product/ProductGallery";
|
||||||
|
import { ProductTabs } from "@/components/product/ProductTabs";
|
||||||
|
import { fetchComments } from "@/lib/comments";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
brandName,
|
||||||
|
fetchCatalogProductByTitle,
|
||||||
|
fetchStoreItemByProduct,
|
||||||
|
fetchStoreVariant,
|
||||||
|
galleryUrls,
|
||||||
|
variantTitle,
|
||||||
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const variant = await fetchStoreVariant(id);
|
||||||
|
return {
|
||||||
|
title: variant ? variantTitle(variant) : "محصول",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const variant = await fetchStoreVariant(id);
|
||||||
|
if (!variant) notFound();
|
||||||
|
|
||||||
|
const [catalog, storeItem, comments] = await Promise.all([
|
||||||
|
fetchCatalogProductByTitle(variant.productNameFa || variant.productTitle, variant.productId),
|
||||||
|
variant.productId ? fetchStoreItemByProduct(variant.productId) : Promise.resolve({ storeItem: undefined }),
|
||||||
|
variant.productId ? fetchComments("product", variant.productId) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const images = galleryUrls(catalog, variant.productImage);
|
||||||
|
const price = variant.discountedPrice ?? variant.price;
|
||||||
|
const oldPrice = variant.discountedPrice != null ? variant.price : null;
|
||||||
|
const title = catalog?.nameFa || variantTitle(variant);
|
||||||
|
const brand = brandName(catalog);
|
||||||
|
const variants = storeItem.storeItem?.variants ?? [];
|
||||||
|
const inStock = variant.stockQuantity == null || variant.stockQuantity > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-10 sm:py-14">
|
||||||
|
<Container>
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
<Link href="/" className="hover:text-brand">
|
||||||
|
خانه
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link href="/products" className="hover:text-brand">
|
||||||
|
محصولات
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-8 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<ProductGallery images={images} alt={title} />
|
||||||
|
<div>
|
||||||
|
{brand ? <p className="text-[13px] font-medium text-brand">{brand}</p> : null}
|
||||||
|
<h1 className="mt-2 text-[22px] font-extrabold leading-9 text-ink">{title}</h1>
|
||||||
|
{variant.label && variant.label !== title ? (
|
||||||
|
<p className="mt-2 text-[13px] text-muted">{variant.label}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-end gap-3">
|
||||||
|
{oldPrice ? <p className="text-[13px] text-muted line-through">{formatPrice(oldPrice)} تومان</p> : null}
|
||||||
|
<p className="text-[22px] font-bold text-ink">
|
||||||
|
{price != null ? (
|
||||||
|
<>
|
||||||
|
{formatPrice(price)}
|
||||||
|
<span className="mr-1 text-[13px] font-medium text-muted">تومان</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[16px] font-medium text-muted">برای قیمت تماس بگیرید</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-2 text-[13px] ${inStock ? "text-emerald-600" : "text-brand"}`}>
|
||||||
|
{inStock ? "موجود در فروشگاه" : "ناموجود"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{variants.length > 1 ? (
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{variants.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/products/${encodeURIComponent(item.id)}`}
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`}
|
||||||
|
>
|
||||||
|
{item.label || variantTitle(item)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="mt-6 inline-flex h-12 items-center justify-center rounded-xl bg-brand px-6 text-[14px] font-bold text-white hover:bg-brand-dark"
|
||||||
|
>
|
||||||
|
استعلام و سفارش
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductTabs descriptionHtml={catalog?.descriptionHtml || catalog?.summary} comments={comments} />
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
|
import { fetchStoreItems, variantToProduct } from "@/lib/store";
|
||||||
|
import { getNavCategories } from "@/lib/categories";
|
||||||
|
import { faDigits } from "@/lib/format";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "محصولات",
|
||||||
|
description: "قطعات و لوازم خانگی فروشگاه برادران برقعی",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProductsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ page?: string; categoryId?: string; q?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const page = Math.max(1, Number(params.page) || 1);
|
||||||
|
const categoryId = params.categoryId;
|
||||||
|
const q = params.q?.trim();
|
||||||
|
const [result, nav] = await Promise.all([
|
||||||
|
fetchStoreItems({ page, pageSize: 20, categoryId, name: q && q.length > 2 ? q : undefined }),
|
||||||
|
getNavCategories(),
|
||||||
|
]);
|
||||||
|
const products = result.items.map(variantToProduct);
|
||||||
|
const pageCount = Math.max(1, Math.ceil((result.total || products.length) / (result.pageSize || 20)));
|
||||||
|
const categoryTitle = nav
|
||||||
|
.flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))])
|
||||||
|
.find((item) => item.id === categoryId)?.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-10 sm:py-14">
|
||||||
|
<Container>
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
<Link href="/" className="hover:text-brand">
|
||||||
|
خانه
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
محصولات
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-[22px] font-extrabold text-ink">
|
||||||
|
{categoryTitle || (q ? `جستجو: ${q}` : "همه محصولات")}
|
||||||
|
</h1>
|
||||||
|
{result.total ? <p className="mt-1 text-[13px] text-muted">{faDigits(result.total)} کالا</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-line bg-white px-6 py-16 text-center">
|
||||||
|
<p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p>
|
||||||
|
<p className="mt-2 text-[13px] text-muted">بهزودی موجودی فروشگاه از پنل Meshkee اینجا دیده میشود.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{products.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<div className="mt-8 flex justify-center gap-2">
|
||||||
|
{Array.from({ length: pageCount }, (_, index) => {
|
||||||
|
const number = index + 1;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (categoryId) query.set("categoryId", categoryId);
|
||||||
|
if (q) query.set("q", q);
|
||||||
|
query.set("page", String(number));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={number}
|
||||||
|
href={`/products?${query.toString()}`}
|
||||||
|
className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`}
|
||||||
|
>
|
||||||
|
{faDigits(number)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,26 @@ import Link from "next/link";
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
import { blogPosts } from "@/lib/data";
|
import { blogHref, blogImage, type BlogPost } from "@/lib/blog";
|
||||||
|
import { formatFaDate } from "@/lib/format";
|
||||||
|
|
||||||
export function BlogCarousel() {
|
type LocalPost = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
date: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BlogCarousel({
|
||||||
|
posts = [],
|
||||||
|
fallback = [],
|
||||||
|
}: {
|
||||||
|
posts?: BlogPost[];
|
||||||
|
fallback?: LocalPost[];
|
||||||
|
}) {
|
||||||
const scroller = useRef<HTMLDivElement>(null);
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
const usingApi = posts.length > 0;
|
||||||
|
|
||||||
const scrollByCard = (direction: number) => {
|
const scrollByCard = (direction: number) => {
|
||||||
const node = scroller.current;
|
const node = scroller.current;
|
||||||
@@ -56,7 +72,28 @@ export function BlogCarousel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto">
|
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto">
|
||||||
{blogPosts.map((post) => (
|
{usingApi
|
||||||
|
? posts.map((post) => (
|
||||||
|
<article key={post.id} className="w-[min(85vw,340px)] shrink-0 snap-start">
|
||||||
|
<Link href={blogHref(post)} className="group block">
|
||||||
|
<div className="overflow-hidden rounded-2xl">
|
||||||
|
<Placeholder
|
||||||
|
src={blogImage(post)}
|
||||||
|
alt={post.title}
|
||||||
|
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">
|
||||||
|
{formatFaDate(post.publishedAt || post.createdAt)}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3>
|
||||||
|
{post.abstract ? (
|
||||||
|
<p className="mt-2 line-clamp-2 text-[13px] leading-7 text-muted">{post.abstract}</p>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
: fallback.map((post) => (
|
||||||
<article key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start">
|
<article key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start">
|
||||||
<Link href={`/blog/${post.slug}`} className="group block">
|
<Link href={`/blog/${post.slug}`} className="group block">
|
||||||
<div className="overflow-hidden rounded-2xl">
|
<div className="overflow-hidden rounded-2xl">
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Container } from "@/components/Container";
|
||||||
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
|
import type { Product } from "@/lib/data";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
href?: string;
|
||||||
|
products: Product[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductCarousel({ id, title, subtitle, href = "/products", products }: Props) {
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollBy = (dir: number) => {
|
||||||
|
scroller.current?.scrollBy({ left: dir * -280, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!products.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="py-10">
|
||||||
|
<Container>
|
||||||
|
<div className="mb-5 flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-[3px] w-8 bg-brand" />
|
||||||
|
<p className="section-kicker">Parts</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-[22px] font-extrabold sm:text-[28px]">{title}</h2>
|
||||||
|
{subtitle ? <p className="mt-1 text-[13px] text-muted">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
|
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
|
||||||
|
aria-label="قبلی"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollBy(1)}
|
||||||
|
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
|
||||||
|
aria-label="بعدی"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<Link href={href} className="hidden rounded-xl bg-ink px-5 py-2.5 text-[13px] text-white hover:bg-brand sm:inline-flex">
|
||||||
|
مشاهده همه
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1">
|
||||||
|
{products.map((product) => (
|
||||||
|
<div key={product.id} className="w-[210px] shrink-0 snap-start sm:w-[230px]">
|
||||||
|
<ProductCard product={product} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,15 +5,16 @@ import Link from "next/link";
|
|||||||
import { ChevronDown, Menu, UserRound, X } from "lucide-react";
|
import { ChevronDown, Menu, UserRound, X } from "lucide-react";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
import { HeaderSearch } from "@/components/layout/HeaderSearch";
|
import { InstantSearch } from "@/components/layout/InstantSearch";
|
||||||
|
import { MegaMenu } from "@/components/layout/MegaMenu";
|
||||||
import { customerPortalUrl } from "@/lib/config";
|
import { customerPortalUrl } from "@/lib/config";
|
||||||
import { applianceGroups, categories, navLinks, site } from "@/lib/data";
|
import { navLinks, site } from "@/lib/data";
|
||||||
|
import type { NavCategory } from "@/lib/categories";
|
||||||
|
|
||||||
export function Header() {
|
export function Header({ categories = [] }: { categories?: NavCategory[] }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [partsOpen, setPartsOpen] = useState(false);
|
const [megaOpen, setMegaOpen] = useState(false);
|
||||||
const [appliancesOpen, setAppliancesOpen] = useState(false);
|
const [mobileCats, setMobileCats] = useState(false);
|
||||||
const [mobileParts, setMobileParts] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-line bg-white/95 backdrop-blur">
|
<header className="sticky top-0 z-40 border-b border-line bg-white/95 backdrop-blur">
|
||||||
@@ -38,83 +39,26 @@ export function Header() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
onMouseEnter={() => setAppliancesOpen(true)}
|
onMouseEnter={() => setMegaOpen(true)}
|
||||||
onMouseLeave={() => setAppliancesOpen(false)}
|
onMouseLeave={() => setMegaOpen(false)}
|
||||||
>
|
>
|
||||||
<button type="button" className="flex items-center gap-1 px-3 py-2 text-[14px] hover:text-brand">
|
<Link href="/products" className="flex items-center gap-1 px-3 py-2 text-[14px] hover:text-brand">
|
||||||
لوازم برقی
|
محصولات
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
{appliancesOpen ? (
|
|
||||||
<div className="absolute right-0 top-full w-[560px] border border-line bg-white p-6 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
|
||||||
<div className="grid grid-cols-2 gap-8">
|
|
||||||
{applianceGroups.map((group) => (
|
|
||||||
<div key={group.title}>
|
|
||||||
<p className="mb-3 border-r-2 border-brand pr-2 text-[13px] font-bold">{group.title}</p>
|
|
||||||
<ul className="space-y-1.5 text-[13px] text-muted">
|
|
||||||
{group.items.map((item) => (
|
|
||||||
<li key={item}>
|
|
||||||
<Link href="/#parts" className="hover:text-brand">
|
|
||||||
{item}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
onMouseEnter={() => setPartsOpen(true)}
|
|
||||||
onMouseLeave={() => setPartsOpen(false)}
|
|
||||||
>
|
|
||||||
<Link href="/#parts" className="flex items-center gap-1 px-3 py-2 text-[14px] hover:text-brand">
|
|
||||||
قطعات
|
|
||||||
<ChevronDown className="size-3.5" />
|
<ChevronDown className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
{partsOpen ? (
|
<MegaMenu open={megaOpen} categories={categories} />
|
||||||
<div className="absolute right-0 top-full w-[720px] border border-line bg-white p-5 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Link key={category.id} href="/#parts" className="group flex items-center gap-2 p-1.5 hover:bg-paper">
|
|
||||||
<Placeholder
|
|
||||||
src={category.image}
|
|
||||||
alt=""
|
|
||||||
className="size-11 shrink-0 object-cover"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-[12px] font-medium leading-5 group-hover:text-brand">
|
|
||||||
{category.title.replace("قطعات ", "")}
|
|
||||||
</span>
|
|
||||||
<span className="font-latin block text-[9px] tracking-[0.16em] text-muted uppercase">
|
|
||||||
{category.english}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{navLinks
|
{navLinks
|
||||||
.filter((link) => link.href !== "/" && link.href !== "/#parts")
|
.filter((link) => link.href !== "/" && link.href !== "/products")
|
||||||
.map((link) => (
|
.map((link) => (
|
||||||
<Link
|
<Link key={link.href} href={link.href} className="px-3 py-2 text-[14px] hover:text-brand">
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
className="px-3 py-2 text-[14px] hover:text-brand"
|
|
||||||
>
|
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<HeaderSearch className="ms-auto hidden max-w-xs flex-1 md:block" />
|
<InstantSearch className="ms-auto hidden max-w-xs flex-1 md:block" />
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={customerPortalUrl}
|
href={customerPortalUrl}
|
||||||
@@ -139,30 +83,40 @@ export function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 overflow-y-auto p-4 text-[15px]">
|
<div className="flex flex-1 flex-col gap-1 overflow-y-auto p-4 text-[15px]">
|
||||||
<HeaderSearch className="mb-3" />
|
<InstantSearch compact className="mb-3 w-full" />
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<Link key={link.href} href={link.href} className="py-2" onClick={() => setOpen(false)}>
|
<Link key={link.href} href={link.href} className="py-2" onClick={() => setOpen(false)}>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
<Link href="/products" className="py-2" onClick={() => setOpen(false)}>
|
||||||
|
محصولات
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-between py-2"
|
className="flex items-center justify-between py-2"
|
||||||
onClick={() => setMobileParts((value) => !value)}
|
onClick={() => setMobileCats((value) => !value)}
|
||||||
>
|
>
|
||||||
دستهبندی قطعات
|
دستهبندیها
|
||||||
<ChevronDown className={`size-4 transition ${mobileParts ? "rotate-180" : ""}`} />
|
<ChevronDown className={`size-4 transition ${mobileCats ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{mobileParts
|
{mobileCats
|
||||||
? categories.map((category) => (
|
? categories.map((category) => (
|
||||||
<Link
|
<div key={category.id} className="space-y-1 pe-2">
|
||||||
key={category.id}
|
<Link href={category.href} className="block py-1.5 text-[13px] font-medium" onClick={() => setOpen(false)}>
|
||||||
href="/#parts"
|
|
||||||
className="py-1.5 pe-2 text-[13px] text-muted"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{category.title}
|
{category.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
{category.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.id}
|
||||||
|
href={child.href}
|
||||||
|
className="block py-1 text-[12px] text-muted"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{child.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
<a href={customerPortalUrl} className="mt-4 rounded-xl bg-brand px-4 py-3 text-center text-white">
|
<a href={customerPortalUrl} className="mt-4 rounded-xl bg-brand px-4 py-3 text-center text-white">
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { FormEvent } from "react";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export function HeaderSearch({ className = "" }: { className?: string }) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
router.push("/#parts");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={onSubmit} className={`relative ${className}`}>
|
|
||||||
<input
|
|
||||||
name="q"
|
|
||||||
placeholder="جستجوی قطعه..."
|
|
||||||
className="h-11 w-full rounded-xl bg-paper pe-4 ps-10 text-[13px] outline-none placeholder:text-muted/70 focus:bg-white focus:ring-1 focus:ring-brand"
|
|
||||||
/>
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted" />
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { variantTitle, type StoreVariant } from "@/lib/store";
|
||||||
|
|
||||||
|
export function InstantSearch({ compact = false, className = "" }: { compact?: boolean; className?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [items, setItems] = useState<StoreVariant[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (event: MouseEvent) => {
|
||||||
|
if (!box.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const value = query.trim();
|
||||||
|
if (value.length <= 2) {
|
||||||
|
setItems([]);
|
||||||
|
setOpen(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
|
||||||
|
const data = (await response.json()) as { items?: StoreVariant[] };
|
||||||
|
setItems(data.items ?? []);
|
||||||
|
setOpen(true);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const goToProducts = (event?: FormEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
const value = query.trim();
|
||||||
|
if (value.length <= 2) return;
|
||||||
|
setOpen(false);
|
||||||
|
router.push(`/products?q=${encodeURIComponent(value)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={box} className={`relative ${className || (compact ? "w-full" : "hidden min-w-0 flex-1 md:block")}`}>
|
||||||
|
<form onSubmit={goToProducts}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onFocus={() => {
|
||||||
|
if (query.trim().length > 2) setOpen(true);
|
||||||
|
}}
|
||||||
|
placeholder={compact ? "جستجوی محصول..." : "جستجوی محصول و قطعه..."}
|
||||||
|
className="h-11 w-full rounded-xl bg-paper pe-4 ps-10 text-[13px] outline-none placeholder:text-muted/70 focus:bg-white focus:ring-1 focus:ring-brand"
|
||||||
|
/>
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-50 overflow-hidden rounded-2xl border border-line bg-white shadow-[0_16px_40px_rgba(0,0,0,0.1)]">
|
||||||
|
{loading ? (
|
||||||
|
<p className="px-4 py-3 text-[13px] text-muted">در حال جستجو...</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-[13px] text-muted">نتیجهای پیدا نشد.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="max-h-[360px] overflow-y-auto py-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const price = item.discountedPrice ?? item.price;
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link
|
||||||
|
href={`/products/${encodeURIComponent(item.id)}`}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 hover:bg-paper"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="grid size-12 shrink-0 overflow-hidden rounded-lg bg-paper">
|
||||||
|
<Placeholder
|
||||||
|
src={item.productImage || "/placeholders/product.svg"}
|
||||||
|
className="size-full object-contain"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="line-clamp-1 text-[13px] text-ink">{variantTitle(item)}</span>
|
||||||
|
<span className="mt-0.5 block text-[12px] text-muted">
|
||||||
|
{price != null ? `${formatPrice(price)} تومان` : "تماس بگیرید"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToProducts()}
|
||||||
|
className="block w-full border-t border-line px-4 py-3 text-center text-[13px] font-medium text-brand hover:bg-paper"
|
||||||
|
>
|
||||||
|
مشاهده همه نتایج در محصولات
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
import type { NavCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
export function MegaMenu({ open, categories }: { open: boolean; categories: NavCategory[] }) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-full right-0 z-50 mt-1 w-[min(920px,calc(100vw-2rem))] border border-line bg-white p-5 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted">دستهبندیها بهزودی اضافه میشوند.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div key={category.id}>
|
||||||
|
<Link href={category.href} className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-ink hover:text-brand">
|
||||||
|
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-paper">
|
||||||
|
<Placeholder src={category.image || "/placeholders/category.svg"} className="size-full object-cover" />
|
||||||
|
</span>
|
||||||
|
{category.title}
|
||||||
|
</Link>
|
||||||
|
{category.children.length ? (
|
||||||
|
<ul className="space-y-1.5 pr-1">
|
||||||
|
{category.children.map((child) => (
|
||||||
|
<li key={child.id}>
|
||||||
|
<Link
|
||||||
|
href={child.href}
|
||||||
|
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-paper hover:text-brand"
|
||||||
|
>
|
||||||
|
{child.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
|
||||||
|
export function ProductGallery({ images, alt }: { images: string[]; alt: string }) {
|
||||||
|
const gallery = images.length ? images : ["/placeholders/product.svg"];
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
const [origin, setOrigin] = useState("50% 50%");
|
||||||
|
const [zoomed, setZoomed] = useState(false);
|
||||||
|
const current = gallery[active] ?? gallery[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative block w-full overflow-hidden rounded-2xl bg-paper"
|
||||||
|
onClick={() => setLightbox(true)}
|
||||||
|
onMouseEnter={() => setZoomed(true)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setZoomed(false);
|
||||||
|
setOrigin("50% 50%");
|
||||||
|
}}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
const x = ((event.clientX - box.left) / box.width) * 100;
|
||||||
|
const y = ((event.clientY - box.top) / box.height) * 100;
|
||||||
|
setOrigin(`${x}% ${y}%`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Placeholder
|
||||||
|
src={current}
|
||||||
|
fallbackSrc="/placeholders/product.svg"
|
||||||
|
alt={alt}
|
||||||
|
className="aspect-square w-full object-contain transition-transform duration-150"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 bg-contain bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${current})`,
|
||||||
|
transform: zoomed ? "scale(1.85)" : "scale(1)",
|
||||||
|
transformOrigin: origin,
|
||||||
|
opacity: zoomed ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{gallery.length > 1 ? (
|
||||||
|
<div className="mt-3 flex gap-2 overflow-x-auto">
|
||||||
|
{gallery.map((image, index) => (
|
||||||
|
<button
|
||||||
|
key={`${image}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(index)}
|
||||||
|
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-brand" : "border-line"}`}
|
||||||
|
>
|
||||||
|
<Placeholder src={image} className="size-full object-contain" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightbox ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/80 p-4" onClick={() => setLightbox(false)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-4 left-4 grid size-10 place-items-center rounded-full bg-white text-ink"
|
||||||
|
aria-label="بستن"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={current}
|
||||||
|
alt={alt}
|
||||||
|
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { customerPortalUrl } from "@/lib/config";
|
||||||
|
import { formatFaDate } from "@/lib/format";
|
||||||
|
import type { CommentItem } from "@/lib/comments";
|
||||||
|
|
||||||
|
export function ProductTabs({
|
||||||
|
descriptionHtml,
|
||||||
|
comments,
|
||||||
|
showDescription = true,
|
||||||
|
}: {
|
||||||
|
descriptionHtml?: string | null;
|
||||||
|
comments: CommentItem[];
|
||||||
|
showDescription?: boolean;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<"description" | "comments">(showDescription ? "description" : "comments");
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setToken(
|
||||||
|
window.localStorage.getItem("accessToken") ||
|
||||||
|
window.localStorage.getItem("meshkeeToken") ||
|
||||||
|
window.localStorage.getItem("token"),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-10 rounded-2xl border border-line bg-white p-5 sm:p-7">
|
||||||
|
{showDescription ? (
|
||||||
|
<div className="flex gap-2 border-b border-line pb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("description")}
|
||||||
|
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "description" ? "bg-paper text-ink" : "text-muted"}`}
|
||||||
|
>
|
||||||
|
توضیحات
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("comments")}
|
||||||
|
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "comments" ? "bg-paper text-ink" : "text-muted"}`}
|
||||||
|
>
|
||||||
|
نظرات
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-[16px] font-bold text-ink">نظرات</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "description" ? (
|
||||||
|
descriptionHtml ? (
|
||||||
|
<div className="rich-text mt-5 text-[14px] leading-8 text-ink" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||||
|
) : (
|
||||||
|
<p className="mt-5 text-[13px] text-muted">توضیحاتی برای این محصول ثبت نشده است.</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted">نظری ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
comments.map((comment, index) => (
|
||||||
|
<article key={comment.id ?? index} className="rounded-xl bg-paper p-4">
|
||||||
|
<p className="text-[13px] font-bold text-ink">{comment.authorName || "کاربر"}</p>
|
||||||
|
{comment.createdAt ? <p className="mt-1 text-[11px] text-muted">{formatFaDate(comment.createdAt)}</p> : null}
|
||||||
|
<p className="mt-2 text-[13px] leading-7 text-ink">{comment.text}</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{token ? (
|
||||||
|
<p className="text-[13px] text-muted">برای مدیریت نظرات از حساب کاربری خود استفاده کنید.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl bg-paper p-5">
|
||||||
|
<p className="text-[14px] font-bold text-ink">برای ثبت نظر وارد شوید</p>
|
||||||
|
<p className="mt-1 text-[13px] text-muted">نظرات فقط برای کاربران واردشده فعال است.</p>
|
||||||
|
<a
|
||||||
|
href={customerPortalUrl}
|
||||||
|
className="mt-4 inline-flex h-11 items-center rounded-xl bg-brand px-5 text-[13px] font-bold text-white hover:bg-brand-dark"
|
||||||
|
>
|
||||||
|
ورود / ثبتنام
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowUpLeft } from "lucide-react";
|
||||||
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
|
import { faDigits, formatPrice } from "@/lib/format";
|
||||||
|
import type { Product } from "@/lib/data";
|
||||||
|
|
||||||
|
export function ProductCard({ product }: { product: Product }) {
|
||||||
|
const href = product.href ?? `/products/${encodeURIComponent(product.id)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="group flex h-full flex-col rounded-2xl border border-line bg-white p-3">
|
||||||
|
<Link href={href} className="relative overflow-hidden rounded-xl bg-paper">
|
||||||
|
{product.discount ? (
|
||||||
|
<span className="absolute top-2 right-2 z-10 rounded-md bg-brand px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||||
|
{faDigits(product.discount)}٪
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Placeholder
|
||||||
|
src={product.image}
|
||||||
|
fallbackSrc="/placeholders/product.svg"
|
||||||
|
className="aspect-square w-full object-contain transition group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
{product.brand ? (
|
||||||
|
<p className="font-latin mt-3 text-[11px] tracking-wide text-brand">{product.brand}</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 h-[16px]" />
|
||||||
|
)}
|
||||||
|
<h3 className="mt-1 line-clamp-2 min-h-[40px] text-[13px] leading-5 text-ink">
|
||||||
|
<Link href={href}>{product.title}</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto pt-3">
|
||||||
|
{product.oldPrice ? (
|
||||||
|
<p className="text-[11px] text-muted line-through">{formatPrice(product.oldPrice)} تومان</p>
|
||||||
|
) : (
|
||||||
|
<p className="h-[16px]" />
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-end justify-between gap-2">
|
||||||
|
<p className="text-[14px] font-bold text-ink">
|
||||||
|
{product.price != null ? (
|
||||||
|
<>
|
||||||
|
{formatPrice(product.price)}
|
||||||
|
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] font-medium text-muted">تماس بگیرید</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="grid size-9 place-items-center rounded-xl bg-brand text-white transition hover:bg-brand-dark"
|
||||||
|
aria-label="مشاهده محصول"
|
||||||
|
>
|
||||||
|
<ArrowUpLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export type BlogPost = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
abstract?: string | null;
|
||||||
|
mainTextHtml?: string | null;
|
||||||
|
titleImageUrl?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
commentCount?: number;
|
||||||
|
author?: { name?: string } | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagedBlogs = {
|
||||||
|
items: BlogPost[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("page", String(params.page ?? 1));
|
||||||
|
query.set("pageSize", String(params.pageSize ?? 12));
|
||||||
|
return meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: params.pageSize ?? 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBlog(slug: string) {
|
||||||
|
const data = await meshkeeGet<{ blog?: BlogPost }>(tenantPath(`/blogs/${encodeURIComponent(slug)}`), {});
|
||||||
|
return data.blog ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogImage(post: BlogPost) {
|
||||||
|
return post.titleImageUrl || "/placeholders/blog.svg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogHref(post: BlogPost) {
|
||||||
|
return `/blog/${encodeURIComponent(post.slug)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export type ApiCategory = {
|
||||||
|
id: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
nameFa?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavCategory = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
image?: string;
|
||||||
|
children: { id: string; title: string; href: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function titleOf(category: ApiCategory) {
|
||||||
|
return category.nameFa?.trim() || category.name?.trim() || "دستهبندی";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrefOf(category: ApiCategory) {
|
||||||
|
return `/products?categoryId=${encodeURIComponent(category.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProductCategories() {
|
||||||
|
const data = await meshkeeGet<{ items?: ApiCategory[] }>(tenantPath("/categories?entityType=product"), {
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
return (data.items ?? []).filter((category) => category.isActive !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCategoryTree(flat: ApiCategory[]): NavCategory[] {
|
||||||
|
const byId = new Map(flat.map((category) => [String(category.id), category]));
|
||||||
|
const children = new Map<string, ApiCategory[]>();
|
||||||
|
|
||||||
|
for (const category of flat) {
|
||||||
|
const parentId = category.parentId ? String(category.parentId) : "";
|
||||||
|
const key = parentId && byId.has(parentId) ? parentId : "root";
|
||||||
|
const list = children.get(key) ?? [];
|
||||||
|
list.push(category);
|
||||||
|
children.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sort = (list: ApiCategory[]) =>
|
||||||
|
[...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || titleOf(a).localeCompare(titleOf(b), "fa"));
|
||||||
|
|
||||||
|
return sort(children.get("root") ?? []).map((root) => ({
|
||||||
|
id: String(root.id),
|
||||||
|
title: titleOf(root),
|
||||||
|
href: hrefOf(root),
|
||||||
|
children: sort(children.get(String(root.id)) ?? []).map((child) => ({
|
||||||
|
id: String(child.id),
|
||||||
|
title: titleOf(child),
|
||||||
|
href: hrefOf(child),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNavCategories() {
|
||||||
|
return buildCategoryTree(await fetchProductCategories());
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export type CommentItem = {
|
||||||
|
id?: string;
|
||||||
|
authorName?: string | null;
|
||||||
|
text?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchComments(entityType: "product" | "blog", entityId: string) {
|
||||||
|
const data = await meshkeeGet<{ items?: CommentItem[] }>(
|
||||||
|
tenantPath(`/comments?entityType=${entityType}&entityId=${encodeURIComponent(entityId)}`),
|
||||||
|
{ items: [] },
|
||||||
|
);
|
||||||
|
return data.items ?? [];
|
||||||
|
}
|
||||||
+26
-10
@@ -1,3 +1,15 @@
|
|||||||
|
export type Product = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
brand: string;
|
||||||
|
price?: number;
|
||||||
|
oldPrice?: number;
|
||||||
|
discount?: number;
|
||||||
|
image: string;
|
||||||
|
href?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
name: "فروشگاه برادران برقعی",
|
name: "فروشگاه برادران برقعی",
|
||||||
nameEn: "Burgheii Brothers Store",
|
nameEn: "Burgheii Brothers Store",
|
||||||
@@ -56,7 +68,7 @@ export const branches = [
|
|||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ href: "/", label: "خانه" },
|
{ href: "/", label: "خانه" },
|
||||||
{ href: "/#parts", label: "قطعات" },
|
{ href: "/products", label: "محصولات" },
|
||||||
{ href: "/blog", label: "مقالات" },
|
{ href: "/blog", label: "مقالات" },
|
||||||
{ href: "/about", label: "درباره ما" },
|
{ href: "/about", label: "درباره ما" },
|
||||||
{ href: "/contact", label: "ارتباط با ما" },
|
{ href: "/contact", label: "ارتباط با ما" },
|
||||||
@@ -370,17 +382,21 @@ export const threeBannerRow = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Placeholder products with white backgrounds until Meshkee store items are wired. */
|
/** Placeholder products with white backgrounds until Meshkee store items are wired. */
|
||||||
export const sampleProducts = [
|
export const sampleProducts: Product[] = [
|
||||||
{ id: "p-vacuum", title: "قطعات جاروبرقی", image: "/images/categories/vacuum.jpg" },
|
{ id: "p-vacuum", title: "قطعات جاروبرقی", brand: "Vacuum", image: "/images/categories/vacuum.jpg", href: "/products" },
|
||||||
{ id: "p-blender", title: "قطعات مخلوط کن", image: "/images/categories/blender.jpg" },
|
{ id: "p-blender", title: "قطعات مخلوط کن", brand: "Blender", image: "/images/categories/blender.jpg", href: "/products" },
|
||||||
{ id: "p-grinder", title: "تیغ و شبکه چرخ گوشت", image: "/images/categories/meat-grinder.jpg" },
|
{ id: "p-grinder", title: "تیغ و شبکه چرخ گوشت", brand: "Grinder", image: "/images/categories/meat-grinder.jpg", href: "/products" },
|
||||||
{ id: "p-microwave", title: "قطعات ماکروویو", image: "/images/categories/microwave.png" },
|
{ id: "p-microwave", title: "قطعات ماکروویو", brand: "Microwave", image: "/images/categories/microwave.png", href: "/products" },
|
||||||
{ id: "p-kettle", title: "قطعات چای ساز", image: "/images/categories/kettle.jpg" },
|
{ id: "p-kettle", title: "قطعات چای ساز", brand: "Kettle", image: "/images/categories/kettle.jpg", href: "/products" },
|
||||||
{ id: "p-iron", title: "قطعات اتو", image: "/images/categories/iron.jpg" },
|
{ id: "p-iron", title: "قطعات اتو", brand: "Iron", image: "/images/categories/iron.jpg", href: "/products" },
|
||||||
{ id: "p-bearing", title: "بلبرینگ", image: "/images/categories/bearing.jpg" },
|
{ id: "p-bearing", title: "بلبرینگ", brand: "Bearing", image: "/images/categories/bearing.jpg", href: "/products" },
|
||||||
{ id: "p-collector", title: "کلکتور", image: "/images/categories/collector.jpg" },
|
{ id: "p-collector", title: "کلکتور", brand: "Collector", image: "/images/categories/collector.jpg", href: "/products" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const consumablesFallback = sampleProducts.slice(0, 4);
|
||||||
|
export const refrigeratorPartsFallback = sampleProducts.slice(2, 6);
|
||||||
|
export const washingMachinePartsFallback = sampleProducts.slice(4, 8);
|
||||||
|
|
||||||
export const blogPosts = [
|
export const blogPosts = [
|
||||||
{
|
{
|
||||||
slug: "meat-grinder-blades",
|
slug: "meat-grinder-blades",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function faDigits(value: string | number) {
|
||||||
|
return String(value).replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(value: number) {
|
||||||
|
return faDigits(value.toLocaleString("en-US"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFaDate(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return date.toLocaleDateString("fa-IR");
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tenant = { id: number | string; nameFa?: string; name?: string; domain?: string };
|
||||||
|
|
||||||
|
export async function getTenant() {
|
||||||
|
return meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBusinessId() {
|
||||||
|
const tenant = await getTenant();
|
||||||
|
return tenant?.id != null ? String(tenant.id) : "";
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
import type { Product } from "@/lib/data";
|
||||||
|
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export type StoreVariant = {
|
||||||
|
id: string;
|
||||||
|
storeItemId?: string;
|
||||||
|
productId?: string;
|
||||||
|
productTitle?: string | null;
|
||||||
|
productNameFa?: string | null;
|
||||||
|
productImage?: string | null;
|
||||||
|
productTotalStock?: number | null;
|
||||||
|
label?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
discountedPrice?: number | null;
|
||||||
|
stockQuantity?: number | null;
|
||||||
|
isFestival?: boolean;
|
||||||
|
selections?: { variationName?: string; value?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSpecial = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
items?: StoreSpecialProduct[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSpecialProduct = {
|
||||||
|
id: string;
|
||||||
|
productId?: string;
|
||||||
|
productTitle?: string | null;
|
||||||
|
productNameFa?: string | null;
|
||||||
|
productImage?: string | null;
|
||||||
|
variants?: StoreVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CatalogProduct = {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
nameFa?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
summary?: string | null;
|
||||||
|
descriptionHtml?: string | null;
|
||||||
|
brand?: { name?: string; nameFa?: string } | string | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
images?: Array<string | { url?: string | null }>;
|
||||||
|
categoryNameFa?: string | null;
|
||||||
|
categoryName?: string | null;
|
||||||
|
commentCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Paged<T> = {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLACEHOLDER = "/placeholders/product.svg";
|
||||||
|
|
||||||
|
function asNumber(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discountPercent(price?: number | null, discounted?: number | null) {
|
||||||
|
if (!price || !discounted || discounted >= price) return undefined;
|
||||||
|
return Math.round((1 - discounted / price) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inStock(quantity?: number | null) {
|
||||||
|
return quantity == null || quantity > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variantTitle(variant: StoreVariant) {
|
||||||
|
return variant.productNameFa?.trim() || variant.productTitle?.trim() || variant.label?.trim() || "محصول";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variantToProduct(variant: StoreVariant): Product {
|
||||||
|
const price = asNumber(variant.discountedPrice) ?? asNumber(variant.price);
|
||||||
|
const oldPrice = asNumber(variant.discountedPrice) != null ? asNumber(variant.price) ?? undefined : undefined;
|
||||||
|
return {
|
||||||
|
id: String(variant.id),
|
||||||
|
href: `/products/${encodeURIComponent(String(variant.id))}`,
|
||||||
|
title: variantTitle(variant),
|
||||||
|
brand: variant.selections?.map((item) => item.value).filter(Boolean).join(" / ") || variant.label || "",
|
||||||
|
price: price ?? undefined,
|
||||||
|
oldPrice,
|
||||||
|
discount: discountPercent(asNumber(variant.price), asNumber(variant.discountedPrice)),
|
||||||
|
image: variant.productImage || PLACEHOLDER,
|
||||||
|
inStock: inStock(variant.stockQuantity ?? variant.productTotalStock),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function specialItemToProduct(item: StoreSpecialProduct): Product | null {
|
||||||
|
const variant = item.variants?.[0];
|
||||||
|
if (!variant) return null;
|
||||||
|
return variantToProduct({
|
||||||
|
...variant,
|
||||||
|
productId: item.productId ?? variant.productId,
|
||||||
|
productTitle: item.productTitle ?? variant.productTitle,
|
||||||
|
productNameFa: item.productNameFa ?? variant.productNameFa,
|
||||||
|
productImage: item.productImage ?? variant.productImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStoreItems(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
name?: string;
|
||||||
|
} = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("page", String(params.page ?? 1));
|
||||||
|
query.set("pageSize", String(params.pageSize ?? 20));
|
||||||
|
if (params.categoryId) query.set("categoryId", params.categoryId);
|
||||||
|
if (params.name) query.set("name", params.name);
|
||||||
|
return meshkeeGet<Paged<StoreVariant>>(tenantPath(`/store-items?${query.toString()}`), {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: params.pageSize ?? 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStoreVariant(variantId: string) {
|
||||||
|
const data = await meshkeeGet<{ variant?: StoreVariant }>(
|
||||||
|
tenantPath(`/store-items/${encodeURIComponent(variantId)}`),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return data.variant ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStoreItemByProduct(productId: string) {
|
||||||
|
return meshkeeGet<{ storeItem?: { id?: string; productId?: string; variants?: StoreVariant[] } }>(
|
||||||
|
tenantPath(`/store-items/by-product/${encodeURIComponent(productId)}`),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStoreSpecials() {
|
||||||
|
const data = await meshkeeGet<{ items?: StoreSpecial[] }>(tenantPath("/store-specials"), { items: [] });
|
||||||
|
return data.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSpecialProducts(key: string) {
|
||||||
|
const specials = await fetchStoreSpecials();
|
||||||
|
const special = specials.find((item) => item.key === key);
|
||||||
|
const products = (special?.items ?? []).map(specialItemToProduct).filter((item): item is Product => Boolean(item));
|
||||||
|
return { special, products };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function galleryUrls(product?: CatalogProduct | null, fallback?: string | null) {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const image of product?.images ?? []) {
|
||||||
|
const url = typeof image === "string" ? image : image?.url;
|
||||||
|
if (url) urls.push(url);
|
||||||
|
}
|
||||||
|
for (const url of [product?.image, product?.thumbnailUrl, product?.thumbnail, fallback]) {
|
||||||
|
if (url && !urls.includes(url)) urls.push(url);
|
||||||
|
}
|
||||||
|
return urls.length ? urls : [PLACEHOLDER];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) {
|
||||||
|
if (!title?.trim()) return null;
|
||||||
|
const data = await meshkeeGet<Paged<CatalogProduct>>(
|
||||||
|
tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=8`),
|
||||||
|
{ items: [], total: 0, page: 1, pageSize: 8 },
|
||||||
|
);
|
||||||
|
const match =
|
||||||
|
data.items.find((item) => String(item.id) === String(productId)) ??
|
||||||
|
data.items[0] ??
|
||||||
|
null;
|
||||||
|
if (!match?.slug) return match;
|
||||||
|
const detail = await meshkeeGet<{ product?: CatalogProduct }>(
|
||||||
|
tenantPath(`/products/${encodeURIComponent(match.slug)}`),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return detail.product ?? match;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandName(product?: CatalogProduct | null) {
|
||||||
|
const brand = product?.brand;
|
||||||
|
if (!brand) return "";
|
||||||
|
if (typeof brand === "string") return brand;
|
||||||
|
return brand.nameFa || brand.name || "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400" viewBox="0 0 640 400" fill="none">
|
||||||
|
<rect width="640" height="400" fill="#f4f0ec"/>
|
||||||
|
<rect x="80" y="70" width="480" height="260" rx="20" fill="#fff" stroke="#e4ddd8"/>
|
||||||
|
<path d="M140 140h280M140 180h200M140 220h240" stroke="#6c6363" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<circle cx="500" cy="280" r="28" fill="#e7432b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80" fill="none">
|
||||||
|
<rect width="80" height="80" rx="16" fill="#f4f0ec"/>
|
||||||
|
<path d="M24 52V28h32v24H24z" stroke="#e7432b" stroke-width="2.5"/>
|
||||||
|
<path d="M32 36h16M32 44h10" stroke="#6c6363" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400" fill="none">
|
||||||
|
<rect width="400" height="400" fill="#f4f0ec"/>
|
||||||
|
<rect x="120" y="110" width="160" height="180" rx="16" stroke="#e7432b" stroke-width="3" fill="#fff"/>
|
||||||
|
<path d="M150 170h100M150 200h70M150 230h90" stroke="#6c6363" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
Reference in New Issue
Block a user