Connect the storefront to Meshkee catalog, blogs, and static image slots.

Wire homepage specials, product/blog pages, instant search, and the customer portal so the live pharmacy site can be managed from the dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 23:51:41 +03:30
co-authored by Cursor
parent cd2f9dfc9d
commit 66e604c426
39 changed files with 1900 additions and 188 deletions
+14
View File
@@ -0,0 +1,14 @@
---
description: Keep Meshkee static-image slot catalog in sync with homepage image sizes
alwaysApply: true
---
# Static image slots
Homepage banners, slider, categories, and about image are Meshkee static-image slots (`lib/static-images.ts`, published at `GET /meshkee/static-image-slots`).
If you change an image slot’s **size, aspect ratio, column count, or key** on the homepage, update `STATIC_IMAGE_SLOTS` in the same change so the dashboard static-image reader matches the live layout. `aspectRatio` must look like `16:9`.
Do not invent upload/CMS APIs. Fetch `GET /tenants/{domain}/website/static-images`. Use `slot.images` (`list` = all, `single` = first). `url` → img, `linkUrl` → wrap in a link. Empty or failed → keep the current placeholder.
Keep `CONTEXT.md` slot docs in sync when keys or ratios change.
+23
View File
@@ -0,0 +1,23 @@
Domain: arjmandpharmacy.com
Documentation for backend is:https://api.meshkee.com/docs/website
Login or user icon should go to customer.{domainName}.com
Create product page and details >> connect to store items
Details page:
- use light box gallery
- zoom image on hover
- has two tabs in bottom, description and comments. Comment is for authenticated user if not show a login link button or box
- add quantity adjuster and add to shopping card button
Create blog page and details >> connect to blog
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
First carousel >> special-sale
Second carousel >> multi-vitamin
Third carousel >> body-building-supplement
update context md for better use in other Ai and systems - update it on every prompt (If required)
+19
View File
@@ -0,0 +1,19 @@
Wire this page to arjmandpharmacy.com static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- two-banner-below-slider > two slot
- categories > duplicatable
- two-banner-row → two slot
- three-banner-row → three slot
- about us image - one slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
View File
+68
View File
@@ -0,0 +1,68 @@
# Arjmand Pharmacy — AI context
Persian RTL pharmacy storefront for **arjmandpharmacy.com** (داروخانه دکتر ارجمند). Next.js App Router.
## 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: `arjmandpharmacy.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN` / `WEBSITE_DOMAIN` / `NEXT_PUBLIC_MESHKEE_DOMAIN`)
- Customer portal: `https://customer.arjmandpharmacy.com` (`NEXT_PUBLIC_CUSTOMER_PORTAL_URL`)
- Public reads: `/tenants/{domain}/...` (no auth). Cart/orders/favorites: `/businesses/{businessId}/...` + Bearer JWT.
- Do not invent CMS, upload, or dashboard APIs.
Login / user icon (and add-to-cart if not logged in) go 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` |
| `/blog/[slug]` | Blog details |
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
| `/api/search?q=` | Instant store-item search (`name`, min 3 chars) |
| `/api/cart/items` | Proxies authenticated add-to-cart |
## Homepage carousels (store specials)
`GET /tenants/{domain}/store-specials` matched by `key`:
1. `special-sale`
2. `multi-vitamin`
3. `body-building-supplement`
If a special has no `items`, keep the current sample products. Use `variants[0].id` as the product URL.
## Catalog
- Mega menu: product categories **level 1 and 2** (`GET /tenants/{domain}/categories?entityType=product`). Links: `/products?categoryId={id}`.
- Instant search: header input, activate when query length **> 2**, `store-items?name=`.
- 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.
- Add to cart: `POST /businesses/{businessId}/cart/items` with `{ storeItemVariantId, quantity }`. No token → customer portal.
## Static image slots
Homepage images: `GET /tenants/{domain}/website/static-images?pageKey=home`. Catalog: `GET /meshkee/static-image-slots`.
| key | kind | itemCount |
| --- | --- | --- |
| `slider` | list | null |
| `two-banner-below-slider` | list | 2 |
| `categories` | list | null |
| `two-banner-row` | list | 2 |
| `three-banner-row` | list | 3 |
| `about-us-image` | single | 1 |
`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 `lib/static-images.ts` in the same change.
## Local fallbacks
- Slider: `src/assets/images/slides`, else `/placeholders/slider-*.svg`
- Logo: `src/assets/images/logo`, else `/placeholders/logo.svg`
- Other art / empty catalog: `/public/placeholders/*.svg` and sample products/posts in `lib/data.ts`
+33
View File
@@ -0,0 +1,33 @@
import { customerPortalUrl, meshkeeApiBase } from "@/lib/config";
import { getBusinessId } from "@/lib/meshkee";
export async function POST(request: Request) {
const token = request.headers.get("authorization");
if (!token) {
return Response.json({ error: "login_required", redirect: customerPortalUrl }, { status: 401 });
}
const businessId = await getBusinessId();
if (!businessId) {
return Response.json({ error: "tenant_missing" }, { status: 500 });
}
const body = await request.json();
try {
const response = await fetch(`${meshkeeApiBase}/businesses/${encodeURIComponent(businessId)}/cart/items`, {
method: "POST",
headers: {
Authorization: token,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const text = await response.text();
return new Response(text, {
status: response.status,
headers: { "Content-Type": response.headers.get("content-type") ?? "application/json" },
});
} catch {
return Response.json({ error: "cart_failed" }, { status: 502 });
}
}
+19
View File
@@ -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: [] });
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { ProductTabs } from "@/components/product/ProductTabs";
import { blogImage, fetchBlog } from "@/lib/blog";
import { fetchComments } from "@/lib/comments";
import { formatFaDate } from "@/lib/format";
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await fetchBlog(slug);
return { title: post ? `${post.title} | مجله سلامت` : "مقاله" };
}
export default async function BlogDetailsPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetchBlog(slug);
if (!post) notFound();
const comments = await fetchComments("blog", post.id);
return (
<article className="pt-8">
<Container className="max-w-3xl">
<p className="text-[13px] text-muted">
<Link href="/" className="hover:text-primary">خانه</Link>
<span className="mx-2">/</span>
<Link href="/blog" className="hover:text-primary">مجله سلامت</Link>
</p>
<h1 className="mt-3 text-[24px] font-bold leading-10 text-navy">{post.title}</h1>
<p className="mt-2 text-[13px] text-muted">{formatFaDate(post.publishedAt || post.createdAt)}</p>
<div className="mt-5 overflow-hidden rounded-2xl bg-sky">
<Placeholder src={blogImage(post)} alt={post.title} className="aspect-[16/8] w-full object-cover" />
</div>
{post.abstract ? <p className="mt-6 text-[15px] leading-8 text-ink">{post.abstract}</p> : null}
{post.mainTextHtml ? (
<div className="rich-text mt-6 text-[14px] leading-8 text-ink" dangerouslySetInnerHTML={{ __html: post.mainTextHtml }} />
) : null}
<ProductTabs showDescription={false} comments={comments} />
</Container>
</article>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { blogHref, blogImage, fetchBlogs } from "@/lib/blog";
import { faDigits, formatFaDate } from "@/lib/format";
export const revalidate = 60;
export const metadata: Metadata = {
title: "مجله سلامت | داروخانه دکتر ارجمند",
};
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 pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
return (
<section className="pt-8">
<Container>
<p className="text-[13px] text-muted">
<Link href="/" className="hover:text-primary">خانه</Link>
<span className="mx-2">/</span>
مجله سلامت
</p>
<h1 className="mt-2 text-[22px] font-bold text-navy">مجله سلامت</h1>
{result.items.length === 0 ? (
<div className="mt-8 rounded-2xl bg-white px-6 py-16 text-center">
<p className="text-[15px] font-bold text-navy">مطلبی منتشر نشده است</p>
<p className="mt-2 text-[13px] text-muted">پس از انتشار مقاله در پنل Meshkee، اینجا نمایش داده می‌شود.</p>
</div>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.items.map((post) => (
<article key={post.id} className="overflow-hidden rounded-2xl bg-white">
<Link href={blogHref(post)} className="block overflow-hidden bg-sky">
<Placeholder src={blogImage(post)} className="aspect-[16/10] w-full object-cover" />
</Link>
<div className="p-4">
<p className="text-[12px] text-muted">{formatFaDate(post.publishedAt || post.createdAt)}</p>
<h2 className="mt-2 text-[15px] font-bold leading-7 text-navy">
<Link href={blogHref(post)}>{post.title}</Link>
</h2>
{post.abstract ? <p className="mt-2 line-clamp-3 text-[13px] leading-6 text-muted">{post.abstract}</p> : null}
</div>
</article>
))}
</div>
)}
{pageCount > 1 && result.items.length > 0 ? (
<div className="mt-8 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-primary text-white" : "bg-white text-navy"}`}
>
{faDigits(number)}
</Link>
);
})}
</div>
) : null}
</Container>
</section>
);
}
+16 -4
View File
@@ -53,8 +53,20 @@ body {
scrollbar-width: none;
}
input,
button,
textarea {
font-family: inherit;
.rich-text {
overflow-wrap: anywhere;
}
.rich-text :where(p, ul, ol, h2, h3, h4) {
margin-top: 0.85em;
}
.rich-text :where(ul, ol) {
padding-inline-start: 1.25rem;
}
.rich-text img {
height: auto;
max-width: 100%;
border-radius: 12px;
}
+14 -3
View File
@@ -1,6 +1,11 @@
import type { Metadata } from "next";
import { Vazirmatn } from "next/font/google";
import "./globals.css";
import { TopBar } from "@/components/layout/TopBar";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { getNavCategories } from "@/lib/categories";
import { getLogoSrc } from "@/lib/media";
const vazir = Vazirmatn({
subsets: ["arabic", "latin"],
@@ -15,7 +20,9 @@ export const metadata: Metadata = {
"خرید آنلاین دارو، مکمل، محصولات بهداشتی و آرایشی از داروخانه دکتر ارجمند در قم، سالاریه.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const [logoSrc, categories] = await Promise.all([Promise.resolve(getLogoSrc()), getNavCategories()]);
return (
<html lang="fa" dir="rtl" className={vazir.variable}>
<head>
@@ -26,8 +33,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
rel="stylesheet"
/>
</head>
<body className={`${vazir.className} min-h-screen antialiased`}>{children}</body>
<body className={`${vazir.className} min-h-screen antialiased`}>
<TopBar />
<Header logoSrc={logoSrc} categories={categories} />
<main className="pb-12">{children}</main>
<Footer logoSrc={logoSrc} />
</body>
</html>
);
}
+16
View File
@@ -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 async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+93 -40
View File
@@ -1,6 +1,3 @@
import { TopBar } from "@/components/layout/TopBar";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { Hero } from "@/components/home/Hero";
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
import { SiteFeatures } from "@/components/home/SiteFeatures";
@@ -9,47 +6,103 @@ import { BannerRow } from "@/components/home/BannerRow";
import { AboutDoctor } from "@/components/home/AboutDoctor";
import { BlogCarousel } from "@/components/home/BlogCarousel";
import { FaqPreview } from "@/components/home/FaqPreview";
import { specialSale, topSelling, twoBanners, tripleBanners, yeganehProducts } from "@/lib/data";
import { getLogoSrc, getSlides } from "@/lib/media";
import { categories, heroBanners, specialSale, twoBanners, tripleBanners, yeganehProducts } from "@/lib/data";
import { getSlides } from "@/lib/media";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
import { fetchBlogs } from "@/lib/blog";
import { fetchSpecialProducts } from "@/lib/store";
export default function HomePage() {
const slides = getSlides();
const logoSrc = getLogoSrc();
export const revalidate = 60;
const placeholderSlides = [
{ id: "slide-1", image: "/placeholders/slider-1.svg", href: "#" },
{ id: "slide-2", image: "/placeholders/slider-2.svg", href: "#" },
{ id: "slide-3", image: "/placeholders/slider-3.svg", href: "#" },
];
export default async function HomePage() {
const [slots, sale, vitamins, bodybuilding, blogs] = await Promise.all([
fetchStaticImageSlots("home"),
fetchSpecialProducts("special-sale"),
fetchSpecialProducts("multi-vitamin"),
fetchSpecialProducts("body-building-supplement"),
fetchBlogs({ pageSize: 8 }),
]);
const localSlides = getSlides();
const slider = overlaySlotImages(
pickSlot(slots, "slider"),
localSlides.length ? localSlides : placeholderSlides,
"slider",
);
const belowSlider = overlaySlotImages(
pickSlot(slots, "two-banner-below-slider"),
heroBanners.map((banner) => ({ id: banner.id, image: banner.image, href: banner.href })),
"two-banner-below-slider",
);
const categoryItems = overlaySlotImages(
pickSlot(slots, "categories"),
categories.map((category) => ({
id: category.id,
image: category.image,
href: category.href,
title: category.title,
})),
"categories",
);
const twoBannerRow = overlaySlotImages(
pickSlot(slots, "two-banner-row"),
twoBanners.map((banner) => ({ id: banner.id, image: banner.image, href: banner.href })),
"two-banner-row",
);
const threeBannerRow = overlaySlotImages(
pickSlot(slots, "three-banner-row"),
tripleBanners.map((banner) => ({ id: banner.id, image: banner.image, href: banner.href })),
"three-banner-row",
);
const aboutImage = overlaySlotImages(
pickSlot(slots, "about-us-image"),
[{ id: "about", image: "/placeholders/doctor.svg" }],
"about-us-image",
);
return (
<>
<TopBar />
<Header logoSrc={logoSrc} />
<main className="pb-12">
<Hero slides={slides} />
<FeaturedCategories />
<SiteFeatures />
<ProductCarousel
id="special-sale"
title="فروش ویژه"
subtitle="تخفیف‌های منتخب امروز داروخانه"
products={specialSale}
accent="sale"
/>
<BannerRow banners={twoBanners} columns={2} />
<ProductCarousel
id="top-selling"
title="پرفروش‌ترین محصولات"
subtitle="پیشنهاد ویژه مشتریان"
products={topSelling}
/>
<BannerRow banners={tripleBanners} columns={3} />
<ProductCarousel
id="yeganeh"
title="محصولات برند یگانه"
subtitle="مکمل‌های منتخب Yeganeh"
products={yeganehProducts}
/>
<AboutDoctor />
<BlogCarousel />
<FaqPreview />
</main>
<Footer logoSrc={logoSrc} />
<Hero
slides={slider.items}
banners={belowSlider.items}
sliderAspectRatio={slider.aspectRatio}
bannerAspectRatio={belowSlider.aspectRatio}
/>
<FeaturedCategories items={categoryItems.items} aspectRatio={categoryItems.aspectRatio} />
<SiteFeatures />
<ProductCarousel
id="special-sale"
title={sale.special?.title || "فروش ویژه"}
subtitle="تخفیف‌های منتخب امروز داروخانه"
products={sale.products.length ? sale.products : specialSale}
href="/products"
accent="sale"
/>
<BannerRow banners={twoBannerRow.items} columns={2} aspectRatio={twoBannerRow.aspectRatio} />
<ProductCarousel
id="multi-vitamin"
title={vitamins.special?.title || "مولتی ویتامین‌ها"}
subtitle="مکمل‌های ویتامینه منتخب"
products={vitamins.products.length ? vitamins.products : yeganehProducts}
href="/products"
/>
<BannerRow banners={threeBannerRow.items} columns={3} aspectRatio={threeBannerRow.aspectRatio} />
<ProductCarousel
id="body-building-supplement"
title={bodybuilding.special?.title || "مکمل‌های بدنسازی"}
subtitle="پروتئین، کراتین و مکمل ورزشی"
products={bodybuilding.products.length ? bodybuilding.products : specialSale}
href="/products"
/>
<AboutDoctor image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
<BlogCarousel posts={blogs.items} />
<FaqPreview />
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Container } from "@/components/Container";
import { AddToCart } from "@/components/product/AddToCart";
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="pt-8">
<Container>
<p className="text-[13px] text-muted">
<Link href="/" className="hover:text-primary">خانه</Link>
<span className="mx-2">/</span>
<Link href="/products" className="hover:text-primary">محصولات</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-primary">{brand}</p> : null}
<h1 className="mt-2 text-[22px] font-bold leading-9 text-navy">{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-navy">
{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-ok" : "text-sale"}`}>
{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-primary bg-sky text-navy" : "border-line bg-white text-muted"}`}
>
{item.label || variantTitle(item)}
</Link>
))}
</div>
) : null}
{inStock ? <AddToCart variantId={variant.id} maxQuantity={variant.stockQuantity} /> : null}
</div>
</div>
<ProductTabs descriptionHtml={catalog?.descriptionHtml || catalog?.summary} comments={comments} />
</Container>
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
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: "محصولات | داروخانه دکتر ارجمند",
};
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: [] }))])
.find((item) => item.id === categoryId)?.title;
return (
<section className="pt-8">
<Container>
<div className="mb-6">
<p className="text-[13px] text-muted">
<Link href="/" className="hover:text-primary">خانه</Link>
<span className="mx-2">/</span>
محصولات
</p>
<h1 className="mt-2 text-[22px] font-bold text-navy">
{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 bg-white px-6 py-16 text-center">
<p className="text-[15px] font-bold text-navy">محصولی برای نمایش وجود ندارد</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-primary text-white" : "bg-white text-navy"}`}
>
{faDigits(number)}
</Link>
);
})}
</div>
) : null}
</Container>
</section>
);
}
+24 -2
View File
@@ -1,12 +1,34 @@
"use client";
import { useEffect, useRef, useState } from "react";
type PlaceholderProps = {
src: string;
fallbackSrc?: string;
alt?: string;
className?: string;
};
export function Placeholder({ src, alt = "", className }: PlaceholderProps) {
export function Placeholder({ src, fallbackSrc, alt = "", className }: PlaceholderProps) {
const [current, setCurrent] = useState(src);
const failed = useRef(false);
useEffect(() => {
failed.current = false;
setCurrent(src);
}, [src]);
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className={className} />
<img
src={current}
alt={alt}
className={className}
onError={() => {
if (failed.current || !fallbackSrc || fallbackSrc === current) return;
failed.current = true;
setCurrent(fallbackSrc);
}}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Placeholder } from "@/components/Placeholder";
import { parseAspectRatio } from "@/lib/static-images";
type SlotMediaProps = {
src: string;
alt?: string;
href?: string;
fallbackSrc?: string;
aspectRatio?: string;
className?: string;
imgClassName?: string;
fill?: boolean;
};
function wrap(href: string | undefined, children: ReactNode, className?: string) {
if (!href) return <div className={className}>{children}</div>;
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} className={className}>
{children}
</a>
);
}
export function SlotMedia({
src,
alt = "",
href,
fallbackSrc,
aspectRatio,
className = "",
imgClassName = "absolute inset-0 h-full w-full object-cover",
fill = false,
}: SlotMediaProps) {
const image = <Placeholder src={src} fallbackSrc={fallbackSrc} alt={alt} className={imgClassName} />;
if (fill) {
return wrap(href, image, `absolute inset-0 ${className}`.trim());
}
const ratio = parseAspectRatio(aspectRatio);
return wrap(
href,
<span className="relative block h-full w-full overflow-hidden" style={ratio ? { aspectRatio: ratio } : undefined}>
{image}
</span>,
className,
);
}
+12 -3
View File
@@ -1,14 +1,23 @@
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotMedia } from "@/components/SlotMedia";
import { about } from "@/lib/data";
import type { SlotMediaItem } from "@/lib/static-images";
export function AboutDoctor({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
const src = image?.image ?? "/placeholders/doctor.svg";
export function AboutDoctor() {
return (
<section id="about" className="pt-10">
<Container>
<div className="grid items-center gap-8 overflow-hidden rounded-2xl bg-white p-5 sm:p-8 lg:grid-cols-[0.9fr_1.1fr]">
<div className="overflow-hidden rounded-2xl bg-sky">
<Placeholder src="/placeholders/doctor.svg" className="aspect-[4/5] w-full object-cover lg:aspect-[4/4.4]" />
<SlotMedia
src={src}
href={image?.href}
fallbackSrc={image?.fallbackSrc ?? "/placeholders/doctor.svg"}
aspectRatio={aspectRatio}
className={!aspectRatio ? "aspect-[4/5] lg:aspect-[4/4.4]" : ""}
/>
</div>
<div>
<p className="text-[13px] font-medium text-primary">{about.subtitle}</p>
+20 -9
View File
@@ -1,18 +1,29 @@
import Link from "next/link";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotMedia } from "@/components/SlotMedia";
import type { SlotMediaItem } from "@/lib/static-images";
type Banner = { id: string; image: string; href: string };
export function BannerRow({ banners, columns }: { banners: Banner[]; columns: 2 | 3 }) {
export function BannerRow({
banners,
columns,
aspectRatio,
}: {
banners?: SlotMediaItem[];
columns: 2 | 3;
aspectRatio?: string;
}) {
return (
<section className="pt-8">
<Container>
<div className={`grid gap-3 ${columns === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
{banners.map((banner) => (
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
<Placeholder src={banner.image} className="h-[100px] w-full object-cover sm:h-[118px]" />
</Link>
{(banners ?? []).map((banner) => (
<SlotMedia
key={banner.id}
src={banner.image}
href={banner.href}
fallbackSrc={banner.fallbackSrc}
aspectRatio={aspectRatio}
className={`rounded-xl bg-sky ${!aspectRatio ? "h-[100px] sm:h-[118px]" : ""}`}
/>
))}
</div>
</Container>
+25 -6
View File
@@ -5,10 +5,26 @@ import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { blogs } from "@/lib/data";
import { blogs as sampleBlogs } from "@/lib/data";
import { blogHref, blogImage, type BlogPost } from "@/lib/blog";
import { formatFaDate } from "@/lib/format";
export function BlogCarousel() {
export function BlogCarousel({ posts }: { posts?: BlogPost[] }) {
const scroller = useRef<HTMLDivElement>(null);
const list =
posts && posts.length
? posts.map((post) => ({
id: post.id,
title: post.title,
excerpt: post.abstract || "",
date: formatFaDate(post.publishedAt || post.createdAt),
image: blogImage(post),
href: blogHref(post),
}))
: sampleBlogs.map((post) => ({
...post,
href: "/blog",
}));
return (
<section id="blog" className="pt-10">
@@ -18,7 +34,7 @@ export function BlogCarousel() {
<h2 className="text-[18px] font-bold text-navy">آخرین مطالب مجله سلامت</h2>
<p className="mt-1 text-[13px] text-muted">نکته‌های کاربردی از داروسازان داروخانه</p>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => scroller.current?.scrollBy({ left: 320, behavior: "smooth" })}
@@ -35,18 +51,21 @@ export function BlogCarousel() {
>
<ChevronLeft className="size-4" />
</button>
<Link href="/blog" className="hidden text-[13px] text-primary sm:block">
مشاهده همه
</Link>
</div>
</div>
<div ref={scroller} className="no-scrollbar flex snap-x gap-4 overflow-x-auto">
{blogs.map((post) => (
{list.map((post) => (
<article key={post.id} className="w-[280px] shrink-0 snap-start overflow-hidden rounded-2xl bg-white sm:w-[320px]">
<Link href="#" className="block overflow-hidden bg-sky">
<Link href={post.href} className="block overflow-hidden bg-sky">
<Placeholder src={post.image} className="aspect-[16/10] w-full object-cover" />
</Link>
<div className="p-4">
<p className="text-[12px] text-muted">{post.date}</p>
<h3 className="mt-2 text-[14px] font-bold leading-7 text-navy">
<Link href="#">{post.title}</Link>
<Link href={post.href}>{post.title}</Link>
</h3>
<p className="mt-2 line-clamp-2 text-[12.5px] leading-6 text-muted">{post.excerpt}</p>
</div>
+53 -13
View File
@@ -1,9 +1,45 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotMedia } from "@/components/SlotMedia";
import { categories } from "@/lib/data";
import type { SlotMediaItem } from "@/lib/static-images";
function CardWrap({ href, children }: { href?: string; children: ReactNode }) {
const className =
"group flex flex-col items-center gap-2 rounded-2xl bg-white p-3 text-center transition hover:-translate-y-0.5";
if (!href) return <div className={className}>{children}</div>;
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} className={className}>
{children}
</a>
);
}
export function FeaturedCategories({
items,
aspectRatio,
}: {
items?: SlotMediaItem[];
aspectRatio?: string;
}) {
const list: SlotMediaItem[] =
items && items.length > 0
? items
: categories.map((category) => ({
id: category.id,
image: category.image,
href: category.href,
title: category.title,
}));
export function FeaturedCategories() {
return (
<section id="categories" className="pt-10">
<Container>
@@ -14,19 +50,23 @@ export function FeaturedCategories() {
</div>
</div>
<div className="grid grid-cols-4 gap-3 md:grid-cols-8">
{categories.map((category) => (
<Link
key={category.id}
href={category.href}
className="group flex flex-col items-center gap-2 rounded-2xl bg-white p-3 text-center transition hover:-translate-y-0.5"
>
{list.map((category) => (
<CardWrap key={category.id} href={category.href}>
<span className="grid size-[72px] place-items-center overflow-hidden rounded-2xl bg-sky sm:size-[84px]">
<Placeholder src={category.image} className="size-full object-cover" />
<SlotMedia
src={category.image}
fallbackSrc={category.fallbackSrc}
aspectRatio={aspectRatio}
className="size-full overflow-hidden rounded-2xl"
imgClassName="absolute inset-0 size-full object-cover"
/>
</span>
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
{category.title}
</span>
</Link>
{category.title ? (
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
{category.title}
</span>
) : null}
</CardWrap>
))}
</div>
</Container>
+38 -24
View File
@@ -1,15 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { heroBanners } from "@/lib/data";
import type { Slide } from "@/lib/media";
import { SlotMedia } from "@/components/SlotMedia";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
export function Hero({ slides }: { slides: Slide[] }) {
type HeroProps = {
slides: SlotMediaItem[];
banners: SlotMediaItem[];
sliderAspectRatio?: string;
bannerAspectRatio?: string;
};
export function Hero({ slides = [], banners = [], sliderAspectRatio, bannerAspectRatio }: HeroProps) {
const [index, setIndex] = useState(0);
const sliderRatio = parseAspectRatio(sliderAspectRatio);
useEffect(() => {
if (slides.length <= 1) return;
@@ -27,24 +34,26 @@ export function Hero({ slides }: { slides: Slide[] }) {
return (
<section className="pt-5">
<Container>
<div className="relative overflow-hidden rounded-2xl bg-sky">
<div className="relative aspect-[16/6] sm:aspect-[16/5.2]">
{slides.map((slide, i) => (
<Link
key={slide.id}
href={slide.href}
className={`absolute inset-0 transition-opacity duration-500 ${i === index ? "opacity-100" : "pointer-events-none opacity-0"}`}
>
<Placeholder src={slide.image} className="h-full w-full object-cover" />
</Link>
))}
</div>
<div
className={`relative overflow-hidden rounded-2xl bg-sky ${sliderRatio ? "" : "aspect-[16/6] sm:aspect-[16/5.2]"}`}
style={sliderRatio ? { aspectRatio: sliderRatio } : undefined}
>
{slides.map((slide, i) => (
<SlotMedia
key={slide.id}
fill
src={slide.image}
href={slide.href}
fallbackSrc={slide.fallbackSrc}
className={`transition-opacity duration-500 ${i === index ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
))}
{slides.length > 1 ? (
<>
<button
type="button"
onClick={() => go(-1)}
className="absolute top-1/2 right-3 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
className="absolute top-1/2 right-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
aria-label="اسلاید قبلی"
>
<ChevronRight className="size-5" />
@@ -52,12 +61,12 @@ export function Hero({ slides }: { slides: Slide[] }) {
<button
type="button"
onClick={() => go(1)}
className="absolute top-1/2 left-3 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
className="absolute top-1/2 left-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
aria-label="اسلاید بعدی"
>
<ChevronLeft className="size-5" />
</button>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5">
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
{slides.map((slide, i) => (
<button
key={slide.id}
@@ -73,10 +82,15 @@ export function Hero({ slides }: { slides: Slide[] }) {
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{heroBanners.map((banner) => (
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
<Placeholder src={banner.image} className="h-[92px] w-full object-cover sm:h-[110px]" />
</Link>
{banners.map((banner) => (
<SlotMedia
key={banner.id}
src={banner.image}
href={banner.href}
fallbackSrc={banner.fallbackSrc}
aspectRatio={bannerAspectRatio}
className={`overflow-hidden rounded-xl bg-sky ${!bannerAspectRatio ? "h-[92px] sm:h-[110px]" : ""}`}
/>
))}
</div>
</Container>
+1 -1
View File
@@ -20,7 +20,7 @@ export function ProductCarousel({
id,
title,
subtitle,
href = "#",
href = "/products",
products,
accent = "default",
}: Props) {
+32 -31
View File
@@ -6,7 +6,6 @@ import {
ChevronDown,
Heart,
Menu,
Search,
ShoppingBag,
UserRound,
X,
@@ -14,9 +13,12 @@ import {
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { MegaMenu } from "@/components/layout/MegaMenu";
import { categories, navLinks, site } from "@/lib/data";
import { InstantSearch } from "@/components/layout/InstantSearch";
import { customerPortalUrl } from "@/lib/config";
import { navLinks, site } from "@/lib/data";
import type { NavCategory } from "@/lib/categories";
export function Header({ logoSrc }: { logoSrc: string }) {
export function Header({ logoSrc, categories }: { logoSrc: string; categories: NavCategory[] }) {
const [megaOpen, setMegaOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileCats, setMobileCats] = useState(false);
@@ -37,32 +39,22 @@ export function Header({ logoSrc }: { logoSrc: string }) {
<Placeholder src={logoSrc} alt={site.name} className="h-11 w-auto object-contain" />
</Link>
<form className="relative hidden min-w-0 flex-1 md:block">
<input
type="search"
placeholder="جستجوی دارو، مکمل و محصولات بهداشتی..."
className="h-11 w-full rounded-xl border border-line bg-mist pr-4 pl-11 text-[13px] outline-none transition focus:border-primary focus:bg-white"
/>
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted" />
</form>
<InstantSearch />
<div className="ms-auto flex items-center gap-1">
<Link
href="#"
<a
href={customerPortalUrl}
className="hidden items-center gap-2 rounded-xl px-3 py-2 text-[13px] text-ink hover:bg-mist lg:flex"
>
<UserRound className="size-5 text-navy" />
ورود / ثبت‌نام
</Link>
<Link href="#" className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقه‌مندی">
</a>
<a href={customerPortalUrl} className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقه‌مندی">
<Heart className="size-5 text-navy" />
</Link>
<Link href="#" className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
</a>
<a href={customerPortalUrl} className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
<ShoppingBag className="size-5 text-navy" />
<span className="absolute top-1.5 left-1.5 grid size-4 place-items-center rounded-full bg-primary text-[10px] text-white">
۰
</span>
</Link>
</a>
</div>
</Container>
@@ -81,7 +73,7 @@ export function Header({ logoSrc }: { logoSrc: string }) {
دسته‌بندی‌ها
<ChevronDown className={`size-4 transition ${megaOpen ? "rotate-180" : ""}`} />
</button>
<MegaMenu open={megaOpen} />
<MegaMenu open={megaOpen} categories={categories} />
</div>
{navLinks.map((link) => (
@@ -111,18 +103,14 @@ export function Header({ logoSrc }: { logoSrc: string }) {
<X className="size-5" />
</button>
</div>
<form className="px-4 pt-4">
<input
type="search"
placeholder="جستجو..."
className="h-11 w-full rounded-xl border border-line bg-mist px-3 text-[13px] outline-none"
/>
</form>
<div className="px-4 pt-4">
<InstantSearch compact />
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3">
<button
type="button"
className="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-right text-[14px] font-medium text-navy"
onClick={() => setMobileCats((v) => !v)}
onClick={() => setMobileCats((value) => !value)}
>
دسته‌بندی‌ها
<ChevronDown className={`size-4 ${mobileCats ? "rotate-180" : ""}`} />
@@ -131,9 +119,19 @@ export function Header({ logoSrc }: { logoSrc: string }) {
<ul className="mb-2 space-y-1 pr-3">
{categories.map((cat) => (
<li key={cat.id}>
<Link href={cat.href} className="block rounded-lg px-3 py-1.5 text-[13px] text-muted">
<Link href={cat.href} className="block rounded-lg px-3 py-1.5 text-[13px] font-medium text-navy" onClick={() => setMobileOpen(false)}>
{cat.title}
</Link>
{cat.children.map((child) => (
<Link
key={child.id}
href={child.href}
className="block rounded-lg px-3 py-1.5 pr-5 text-[13px] text-muted"
onClick={() => setMobileOpen(false)}
>
{child.title}
</Link>
))}
</li>
))}
</ul>
@@ -148,6 +146,9 @@ export function Header({ logoSrc }: { logoSrc: string }) {
{link.title}
</Link>
))}
<a href={customerPortalUrl} className="mt-2 block rounded-xl px-3 py-2.5 text-[14px] text-primary">
ورود / ثبت‌نام
</a>
</nav>
</aside>
</div>
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
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 }: { compact?: boolean }) {
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]);
return (
<div ref={box} className={`relative ${compact ? "w-full" : "hidden min-w-0 flex-1 md:block"}`}>
<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 border border-line bg-mist pr-4 pl-11 text-[13px] outline-none transition focus:border-primary focus:bg-white"
/>
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted" />
{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,38,133,0.12)]">
{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-mist"
onClick={() => setOpen(false)}
>
<span className="grid size-12 shrink-0 overflow-hidden rounded-lg bg-sky">
<Placeholder
src={item.productImage || "/placeholders/product.svg"}
className="size-full object-cover"
/>
</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>
)}
</div>
) : null}
</div>
);
}
+37 -29
View File
@@ -1,41 +1,49 @@
"use client";
import Link from "next/link";
import { categories } from "@/lib/data";
import { Placeholder } from "@/components/Placeholder";
import type { NavCategory } from "@/lib/categories";
export function MegaMenu({ open }: { open: boolean }) {
export function MegaMenu({ open, categories }: { open: boolean; categories: NavCategory[] }) {
if (!open) return null;
const list = categories.length ? categories : [];
return (
<div className="absolute top-full right-0 z-50 mt-2 w-[min(920px,calc(100vw-2rem))] rounded-2xl border border-line bg-white p-5 shadow-[0_16px_40px_rgba(0,38,133,0.12)]">
<div className="grid grid-cols-2 gap-5 md: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-navy"
>
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
<Placeholder src={category.image} className="size-full object-cover" />
</span>
{category.title}
</Link>
<ul className="space-y-1.5 pr-1">
{category.children.map((child) => (
<li key={child.title}>
<Link
href={child.href}
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
>
{child.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
{list.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">
{list.map((category) => (
<div key={category.id}>
<Link
href={category.href}
className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-navy"
>
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
<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-sky hover:text-primary"
>
{child.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Minus, Plus, ShoppingBag } from "lucide-react";
import { customerPortalUrl } from "@/lib/config";
export function AddToCart({ variantId, maxQuantity }: { variantId: string; maxQuantity?: number | null }) {
const [quantity, setQuantity] = useState(1);
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const cap = maxQuantity && maxQuantity > 0 ? maxQuantity : 99;
const change = (delta: number) => {
setQuantity((current) => Math.min(cap, Math.max(1, current + delta)));
};
const add = async () => {
const token =
window.localStorage.getItem("accessToken") ||
window.localStorage.getItem("meshkeeToken") ||
window.localStorage.getItem("token");
if (!token) {
window.location.href = customerPortalUrl;
return;
}
setStatus("loading");
try {
const response = await fetch("/api/cart/items", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ storeItemVariantId: variantId, quantity }),
});
if (response.status === 401) {
window.location.href = customerPortalUrl;
return;
}
setStatus(response.ok ? "ok" : "error");
} catch {
setStatus("error");
}
};
return (
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex h-12 items-center rounded-xl border border-line bg-white">
<button type="button" className="grid size-12 place-items-center text-navy" onClick={() => change(-1)} aria-label="کاهش">
<Minus className="size-4" />
</button>
<span className="min-w-[2rem] text-center text-[15px] font-bold text-navy">{quantity}</span>
<button type="button" className="grid size-12 place-items-center text-navy" onClick={() => change(1)} aria-label="افزایش">
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={add}
disabled={status === "loading"}
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-primary text-[14px] font-bold text-white transition hover:bg-primary-hover disabled:opacity-70"
>
<ShoppingBag className="size-4" />
{status === "loading" ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
{status === "ok" ? <p className="text-[12px] text-ok">به سبد اضافه شد.</p> : null}
{status === "error" ? <p className="text-[12px] text-sale">افزودن به سبد انجام نشد.</p> : null}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"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-sky"
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-cover transition-transform duration-150"
/>
<span
className="pointer-events-none absolute inset-0 bg-cover 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-primary" : "border-line"}`}
>
<Placeholder src={image} className="size-full object-cover" />
</button>
))}
</div>
) : null}
</div>
{lightbox ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-navy/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-navy" aria-label="بستن">
<X className="size-5" />
</button>
<img
src={current}
alt={alt}
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
onClick={(event) => event.stopPropagation()}
/>
</div>
) : null}
</>
);
}
+89
View File
@@ -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 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-sky text-navy" : "text-muted"}`}
>
توضیحات
</button>
<button
type="button"
onClick={() => setTab("comments")}
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "comments" ? "bg-sky text-navy" : "text-muted"}`}
>
نظرات
</button>
</div>
) : (
<h2 className="text-[16px] font-bold text-navy">نظرات</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-mist p-4">
<p className="text-[13px] font-bold text-navy">{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-sky p-5">
<p className="text-[14px] font-bold text-navy">برای ثبت نظر وارد شوید</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-primary px-5 text-[13px] font-bold text-white"
>
ورود / ثبت‌نام
</a>
</div>
)}
</div>
)}
</section>
);
}
+22 -10
View File
@@ -5,19 +5,25 @@ 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 bg-white p-3">
<Link href="#" className="relative overflow-hidden rounded-xl bg-sky">
<Link href={href} className="relative overflow-hidden rounded-xl bg-sky">
{product.discount ? (
<span className="absolute top-2 right-2 z-10 rounded-md bg-sale px-1.5 py-0.5 text-[11px] font-bold text-white">
{faDigits(product.discount)}٪
</span>
) : null}
<Placeholder src={product.image} className="aspect-square w-full object-cover transition group-hover:scale-[1.03]" />
<Placeholder src={product.image} fallbackSrc="/placeholders/product.svg" className="aspect-square w-full object-cover transition group-hover:scale-[1.03]" />
</Link>
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{product.brand}</p>
{product.brand ? (
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{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="#">{product.title}</Link>
<Link href={href}>{product.title}</Link>
</h3>
<div className="mt-auto pt-3">
{product.oldPrice ? (
@@ -27,16 +33,22 @@ export function ProductCard({ product }: { product: Product }) {
)}
<div className="mt-1 flex items-end justify-between gap-2">
<p className="text-[14px] font-bold text-navy">
{formatPrice(product.price)}
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
{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>
<button
type="button"
<Link
href={href}
className="grid size-9 place-items-center rounded-xl bg-primary text-white transition hover:bg-primary-hover"
aria-label="افزودن به سبد خرید"
aria-label="مشاهده محصول"
>
<ShoppingBag className="size-4" />
</button>
</Link>
</div>
</div>
</article>
+46
View File
@@ -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)}`;
}
+65
View File
@@ -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());
}
+16
View File
@@ -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 ?? [];
}
+16
View File
@@ -0,0 +1,16 @@
export const websiteDomain =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
process.env.WEBSITE_DOMAIN ??
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ??
"arjmandpharmacy.com";
export const meshkeeApiBase = (
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
process.env.MESHKEE_API_BASE ??
process.env.NEXT_PUBLIC_MESHKEE_API_URL ??
"https://api.meshkee.com/api/v1"
).replace(/\/$/, "");
export const customerPortalUrl = (
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? `https://customer.${websiteDomain}`
).replace(/\/$/, "");
+16 -13
View File
@@ -2,10 +2,12 @@ export type Product = {
id: string;
title: string;
brand: string;
price: number;
price?: number;
oldPrice?: number;
discount?: number;
image: string;
href?: string;
inStock?: boolean;
};
export type Category = {
@@ -30,10 +32,11 @@ export const site = {
};
export const navLinks = [
{ title: "فروش ویژه", href: "#special-sale" },
{ title: "درباره ما", href: "#about" },
{ title: "سوالات متداول", href: "#faq" },
{ title: "تماس با ما", href: "#contact" },
{ title: "فروش ویژه", href: "/#special-sale" },
{ title: "مجله سلامت", href: "/blog" },
{ title: "درباره ما", href: "/#about" },
{ title: "سوالات متداول", href: "/#faq" },
{ title: "تماس با ما", href: "/#contact" },
];
export const categories: Category[] = [
@@ -385,15 +388,15 @@ export const faqs = [
export const footerLinks = {
shop: [
{ title: "فروش ویژه", href: "#special-sale" },
{ title: "پرفروش‌ترین‌ها", href: "#top-selling" },
{ title: "محصولات یگانه", href: "#yeganeh" },
{ title: "دسته‌بندی‌ها", href: "#categories" },
{ title: "فروش ویژه", href: "/#special-sale" },
{ title: "مولتی ویتامین", href: "/#multi-vitamin" },
{ title: "مکمل بدنسازی", href: "/#body-building-supplement" },
{ title: "همه محصولات", href: "/products" },
],
pages: [
{ title: "درباره ما", href: "#about" },
{ title: "سوالات متداول", href: "#faq" },
{ title: "تماس با ما", href: "#contact" },
{ title: "مجله سلامت", href: "#blog" },
{ title: "درباره ما", href: "/#about" },
{ title: "سوالات متداول", href: "/#faq" },
{ title: "تماس با ما", href: "/#contact" },
{ title: "مجله سلامت", href: "/blog" },
],
};
+7
View File
@@ -5,3 +5,10 @@ export function faDigits(value: string | number) {
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");
}
+28
View File
@@ -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) : "";
}
+205
View File
@@ -0,0 +1,205 @@
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export type SlotKind = "single" | "list";
export type SlotImage = {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number | null;
};
export type StaticImageSlotCatalogItem = {
key: string;
label: string;
kind: SlotKind;
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
export type StaticImageSlot = StaticImageSlotCatalogItem & {
images: SlotImage[];
};
export type SlotMediaItem = {
id: string;
image: string;
href?: string;
title?: string;
fallbackSrc?: string;
};
/**
* Homepage slot catalog published at GET /meshkee/static-image-slots.
* Ratios match the live homepage layout at max-w-[1280px] (inner 1232px).
* If a section’s size or aspect ratio changes, update this catalog — the
* Meshkee dashboard reads it as the static-image source of truth.
*/
export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
{
key: "slider",
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "40:13",
itemCount: null,
recommendedWidth: 1280,
},
{
key: "two-banner-below-slider",
label: "Two banners below slider",
kind: "list",
pageKey: "home",
aspectRatio: "61:11",
itemCount: 2,
recommendedWidth: 610,
},
{
key: "categories",
label: "Featured categories",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 168,
},
{
key: "two-banner-row",
label: "Two banner row",
kind: "list",
pageKey: "home",
aspectRatio: "305:59",
itemCount: 2,
recommendedWidth: 610,
},
{
key: "three-banner-row",
label: "Three banner row",
kind: "list",
pageKey: "home",
aspectRatio: "403:118",
itemCount: 3,
recommendedWidth: 403,
},
{
key: "about-us-image",
label: "About us image",
kind: "single",
pageKey: "home",
aspectRatio: "10:11",
itemCount: 1,
recommendedWidth: 560,
},
];
export function catalogSlot(key: string) {
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
}
export function parseAspectRatio(ratio?: string | null) {
if (!ratio) return undefined;
const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
if (!match) return undefined;
return `${match[1]} / ${match[2]}`;
}
export function pickSlot(slots: StaticImageSlot[], key: string) {
return slots.find((slot) => slot.key === key);
}
function normalizeImages(images: SlotImage[] | undefined) {
return [...(images ?? [])]
.filter((image) => Boolean(image?.url))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
if (!slot) return [];
const images = normalizeImages(slot.images);
if (slot.kind === "single") return images.slice(0, 1);
return images;
}
function toMedia(image: SlotImage, fallbackSrc?: string): SlotMediaItem {
return {
id: String(image.id),
image: image.url,
href: image.linkUrl?.trim() || undefined,
title: image.titleFa?.trim() || image.titleEn?.trim() || undefined,
fallbackSrc,
};
}
export function overlaySlotImages(
slot: StaticImageSlot | undefined,
fallbacks: SlotMediaItem[],
key: string,
): { items: SlotMediaItem[]; aspectRatio?: string } {
const catalog = catalogSlot(key);
const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
const kind = slot?.kind ?? catalog?.kind ?? "list";
const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
if (kind === "single") {
const image = apiImages[0];
const fallback = fallbacks[0];
return {
aspectRatio,
items: image ? [toMedia(image, fallback?.image)] : fallbacks.slice(0, 1),
};
}
if (apiImages.length === 0) {
return {
aspectRatio,
items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
};
}
if (itemCount) {
return {
aspectRatio,
items: Array.from({ length: itemCount }, (_, index) => {
const image = apiImages[index];
const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
return image ? toMedia(image, fallback?.image) : fallback;
}).filter(Boolean),
};
}
return {
aspectRatio,
items: apiImages.map((image, index) => toMedia(image, fallbacks[index]?.image)),
};
}
function readItems(payload: unknown): StaticImageSlot[] {
if (Array.isArray(payload)) return payload as StaticImageSlot[];
if (payload && typeof payload === "object") {
const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
return record.items ?? record.slots ?? [];
}
return [];
}
export async function fetchStaticImageSlots(pageKey = "home"): Promise<StaticImageSlot[]> {
const url = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
try {
const response = await fetch(url, {
next: { revalidate: 60 },
});
if (!response.ok) return [];
return readItems(await response.json());
} catch {
return [];
}
}
+193
View File
@@ -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 || "";
}