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:
co-authored by
Cursor
parent
cd2f9dfc9d
commit
66e604c426
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+68
@@ -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`
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProductCarousel({
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
href = "#",
|
||||
href = "/products",
|
||||
products,
|
||||
accent = "default",
|
||||
}: Props) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) : "";
|
||||
}
|
||||
@@ -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
@@ -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 || "";
|
||||
}
|
||||
Reference in New Issue
Block a user