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;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
.rich-text {
|
||||||
button,
|
overflow-wrap: anywhere;
|
||||||
textarea {
|
}
|
||||||
font-family: inherit;
|
|
||||||
|
.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 type { Metadata } from "next";
|
||||||
import { Vazirmatn } from "next/font/google";
|
import { Vazirmatn } from "next/font/google";
|
||||||
import "./globals.css";
|
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({
|
const vazir = Vazirmatn({
|
||||||
subsets: ["arabic", "latin"],
|
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 (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={vazir.variable}>
|
<html lang="fa" dir="rtl" className={vazir.variable}>
|
||||||
<head>
|
<head>
|
||||||
@@ -26,8 +33,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</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>
|
</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 });
|
||||||
|
}
|
||||||
+82
-29
@@ -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 { Hero } from "@/components/home/Hero";
|
||||||
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
|
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
|
||||||
import { SiteFeatures } from "@/components/home/SiteFeatures";
|
import { SiteFeatures } from "@/components/home/SiteFeatures";
|
||||||
@@ -9,47 +6,103 @@ import { BannerRow } from "@/components/home/BannerRow";
|
|||||||
import { AboutDoctor } from "@/components/home/AboutDoctor";
|
import { AboutDoctor } from "@/components/home/AboutDoctor";
|
||||||
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
||||||
import { FaqPreview } from "@/components/home/FaqPreview";
|
import { FaqPreview } from "@/components/home/FaqPreview";
|
||||||
import { specialSale, topSelling, twoBanners, tripleBanners, yeganehProducts } from "@/lib/data";
|
import { categories, heroBanners, specialSale, twoBanners, tripleBanners, yeganehProducts } from "@/lib/data";
|
||||||
import { getLogoSrc, getSlides } from "@/lib/media";
|
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() {
|
export const revalidate = 60;
|
||||||
const slides = getSlides();
|
|
||||||
const logoSrc = getLogoSrc();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar />
|
<Hero
|
||||||
<Header logoSrc={logoSrc} />
|
slides={slider.items}
|
||||||
<main className="pb-12">
|
banners={belowSlider.items}
|
||||||
<Hero slides={slides} />
|
sliderAspectRatio={slider.aspectRatio}
|
||||||
<FeaturedCategories />
|
bannerAspectRatio={belowSlider.aspectRatio}
|
||||||
|
/>
|
||||||
|
<FeaturedCategories items={categoryItems.items} aspectRatio={categoryItems.aspectRatio} />
|
||||||
<SiteFeatures />
|
<SiteFeatures />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
id="special-sale"
|
id="special-sale"
|
||||||
title="فروش ویژه"
|
title={sale.special?.title || "فروش ویژه"}
|
||||||
subtitle="تخفیفهای منتخب امروز داروخانه"
|
subtitle="تخفیفهای منتخب امروز داروخانه"
|
||||||
products={specialSale}
|
products={sale.products.length ? sale.products : specialSale}
|
||||||
|
href="/products"
|
||||||
accent="sale"
|
accent="sale"
|
||||||
/>
|
/>
|
||||||
<BannerRow banners={twoBanners} columns={2} />
|
<BannerRow banners={twoBannerRow.items} columns={2} aspectRatio={twoBannerRow.aspectRatio} />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
id="top-selling"
|
id="multi-vitamin"
|
||||||
title="پرفروشترین محصولات"
|
title={vitamins.special?.title || "مولتی ویتامینها"}
|
||||||
subtitle="پیشنهاد ویژه مشتریان"
|
subtitle="مکملهای ویتامینه منتخب"
|
||||||
products={topSelling}
|
products={vitamins.products.length ? vitamins.products : yeganehProducts}
|
||||||
|
href="/products"
|
||||||
/>
|
/>
|
||||||
<BannerRow banners={tripleBanners} columns={3} />
|
<BannerRow banners={threeBannerRow.items} columns={3} aspectRatio={threeBannerRow.aspectRatio} />
|
||||||
<ProductCarousel
|
<ProductCarousel
|
||||||
id="yeganeh"
|
id="body-building-supplement"
|
||||||
title="محصولات برند یگانه"
|
title={bodybuilding.special?.title || "مکملهای بدنسازی"}
|
||||||
subtitle="مکملهای منتخب Yeganeh"
|
subtitle="پروتئین، کراتین و مکمل ورزشی"
|
||||||
products={yeganehProducts}
|
products={bodybuilding.products.length ? bodybuilding.products : specialSale}
|
||||||
|
href="/products"
|
||||||
/>
|
/>
|
||||||
<AboutDoctor />
|
<AboutDoctor image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
|
||||||
<BlogCarousel />
|
<BlogCarousel posts={blogs.items} />
|
||||||
<FaqPreview />
|
<FaqPreview />
|
||||||
</main>
|
|
||||||
<Footer logoSrc={logoSrc} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
type PlaceholderProps = {
|
||||||
src: string;
|
src: string;
|
||||||
|
fallbackSrc?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
className?: 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 (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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 { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
import { about } from "@/lib/data";
|
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 (
|
return (
|
||||||
<section id="about" className="pt-10">
|
<section id="about" className="pt-10">
|
||||||
<Container>
|
<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="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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium text-primary">{about.subtitle}</p>
|
<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 { 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,
|
||||||
export function BannerRow({ banners, columns }: { banners: Banner[]; columns: 2 | 3 }) {
|
columns,
|
||||||
|
aspectRatio,
|
||||||
|
}: {
|
||||||
|
banners?: SlotMediaItem[];
|
||||||
|
columns: 2 | 3;
|
||||||
|
aspectRatio?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="pt-8">
|
<section className="pt-8">
|
||||||
<Container>
|
<Container>
|
||||||
<div className={`grid gap-3 ${columns === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
|
<div className={`grid gap-3 ${columns === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
|
||||||
{banners.map((banner) => (
|
{(banners ?? []).map((banner) => (
|
||||||
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
|
<SlotMedia
|
||||||
<Placeholder src={banner.image} className="h-[100px] w-full object-cover sm:h-[118px]" />
|
key={banner.id}
|
||||||
</Link>
|
src={banner.image}
|
||||||
|
href={banner.href}
|
||||||
|
fallbackSrc={banner.fallbackSrc}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
className={`rounded-xl bg-sky ${!aspectRatio ? "h-[100px] sm:h-[118px]" : ""}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -5,10 +5,26 @@ import Link from "next/link";
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
import { 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 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 (
|
return (
|
||||||
<section id="blog" className="pt-10">
|
<section id="blog" className="pt-10">
|
||||||
@@ -18,7 +34,7 @@ export function BlogCarousel() {
|
|||||||
<h2 className="text-[18px] font-bold text-navy">آخرین مطالب مجله سلامت</h2>
|
<h2 className="text-[18px] font-bold text-navy">آخرین مطالب مجله سلامت</h2>
|
||||||
<p className="mt-1 text-[13px] text-muted">نکتههای کاربردی از داروسازان داروخانه</p>
|
<p className="mt-1 text-[13px] text-muted">نکتههای کاربردی از داروسازان داروخانه</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scroller.current?.scrollBy({ left: 320, behavior: "smooth" })}
|
onClick={() => scroller.current?.scrollBy({ left: 320, behavior: "smooth" })}
|
||||||
@@ -35,18 +51,21 @@ export function BlogCarousel() {
|
|||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<Link href="/blog" className="hidden text-[13px] text-primary sm:block">
|
||||||
|
مشاهده همه
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref={scroller} className="no-scrollbar flex snap-x gap-4 overflow-x-auto">
|
<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]">
|
<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" />
|
<Placeholder src={post.image} className="aspect-[16/10] w-full object-cover" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className="text-[12px] text-muted">{post.date}</p>
|
<p className="text-[12px] text-muted">{post.date}</p>
|
||||||
<h3 className="mt-2 text-[14px] font-bold leading-7 text-navy">
|
<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>
|
</h3>
|
||||||
<p className="mt-2 line-clamp-2 text-[12.5px] leading-6 text-muted">{post.excerpt}</p>
|
<p className="mt-2 line-clamp-2 text-[12.5px] leading-6 text-muted">{post.excerpt}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,45 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
import { categories } from "@/lib/data";
|
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 (
|
return (
|
||||||
<section id="categories" className="pt-10">
|
<section id="categories" className="pt-10">
|
||||||
<Container>
|
<Container>
|
||||||
@@ -14,19 +50,23 @@ export function FeaturedCategories() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-3 md:grid-cols-8">
|
<div className="grid grid-cols-4 gap-3 md:grid-cols-8">
|
||||||
{categories.map((category) => (
|
{list.map((category) => (
|
||||||
<Link
|
<CardWrap key={category.id} href={category.href}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="grid size-[72px] place-items-center overflow-hidden rounded-2xl bg-sky sm:size-[84px]">
|
<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>
|
||||||
|
{category.title ? (
|
||||||
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
|
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
|
||||||
{category.title}
|
{category.title}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
) : null}
|
||||||
|
</CardWrap>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
+36
-22
@@ -1,15 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { SlotMedia } from "@/components/SlotMedia";
|
||||||
import { heroBanners } from "@/lib/data";
|
import { parseAspectRatio } from "@/lib/static-images";
|
||||||
import type { Slide } from "@/lib/media";
|
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 [index, setIndex] = useState(0);
|
||||||
|
const sliderRatio = parseAspectRatio(sliderAspectRatio);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slides.length <= 1) return;
|
if (slides.length <= 1) return;
|
||||||
@@ -27,24 +34,26 @@ export function Hero({ slides }: { slides: Slide[] }) {
|
|||||||
return (
|
return (
|
||||||
<section className="pt-5">
|
<section className="pt-5">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-sky">
|
<div
|
||||||
<div className="relative aspect-[16/6] sm:aspect-[16/5.2]">
|
className={`relative overflow-hidden rounded-2xl bg-sky ${sliderRatio ? "" : "aspect-[16/6] sm:aspect-[16/5.2]"}`}
|
||||||
{slides.map((slide, i) => (
|
style={sliderRatio ? { aspectRatio: sliderRatio } : undefined}
|
||||||
<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" />
|
{slides.map((slide, i) => (
|
||||||
</Link>
|
<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"}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
{slides.length > 1 ? (
|
{slides.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(-1)}
|
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="اسلاید قبلی"
|
aria-label="اسلاید قبلی"
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-5" />
|
<ChevronRight className="size-5" />
|
||||||
@@ -52,12 +61,12 @@ export function Hero({ slides }: { slides: Slide[] }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(1)}
|
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="اسلاید بعدی"
|
aria-label="اسلاید بعدی"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-5" />
|
<ChevronLeft className="size-5" />
|
||||||
</button>
|
</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) => (
|
{slides.map((slide, i) => (
|
||||||
<button
|
<button
|
||||||
key={slide.id}
|
key={slide.id}
|
||||||
@@ -73,10 +82,15 @@ export function Hero({ slides }: { slides: Slide[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{heroBanners.map((banner) => (
|
{banners.map((banner) => (
|
||||||
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
|
<SlotMedia
|
||||||
<Placeholder src={banner.image} className="h-[92px] w-full object-cover sm:h-[110px]" />
|
key={banner.id}
|
||||||
</Link>
|
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>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function ProductCarousel({
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
href = "#",
|
href = "/products",
|
||||||
products,
|
products,
|
||||||
accent = "default",
|
accent = "default",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Heart,
|
Heart,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
UserRound,
|
UserRound,
|
||||||
X,
|
X,
|
||||||
@@ -14,9 +13,12 @@ import {
|
|||||||
import { Container } from "@/components/Container";
|
import { Container } from "@/components/Container";
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
import { Placeholder } from "@/components/Placeholder";
|
||||||
import { MegaMenu } from "@/components/layout/MegaMenu";
|
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 [megaOpen, setMegaOpen] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [mobileCats, setMobileCats] = 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" />
|
<Placeholder src={logoSrc} alt={site.name} className="h-11 w-auto object-contain" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<form className="relative hidden min-w-0 flex-1 md:block">
|
<InstantSearch />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="ms-auto flex items-center gap-1">
|
<div className="ms-auto flex items-center gap-1">
|
||||||
<Link
|
<a
|
||||||
href="#"
|
href={customerPortalUrl}
|
||||||
className="hidden items-center gap-2 rounded-xl px-3 py-2 text-[13px] text-ink hover:bg-mist lg:flex"
|
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" />
|
<UserRound className="size-5 text-navy" />
|
||||||
ورود / ثبتنام
|
ورود / ثبتنام
|
||||||
</Link>
|
</a>
|
||||||
<Link href="#" className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقهمندی">
|
<a href={customerPortalUrl} className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقهمندی">
|
||||||
<Heart className="size-5 text-navy" />
|
<Heart className="size-5 text-navy" />
|
||||||
</Link>
|
</a>
|
||||||
<Link href="#" className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
|
<a href={customerPortalUrl} className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
|
||||||
<ShoppingBag className="size-5 text-navy" />
|
<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">
|
</a>
|
||||||
۰
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
@@ -81,7 +73,7 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
|||||||
دستهبندیها
|
دستهبندیها
|
||||||
<ChevronDown className={`size-4 transition ${megaOpen ? "rotate-180" : ""}`} />
|
<ChevronDown className={`size-4 transition ${megaOpen ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
<MegaMenu open={megaOpen} />
|
<MegaMenu open={megaOpen} categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
@@ -111,18 +103,14 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
|||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form className="px-4 pt-4">
|
<div className="px-4 pt-4">
|
||||||
<input
|
<InstantSearch compact />
|
||||||
type="search"
|
</div>
|
||||||
placeholder="جستجو..."
|
|
||||||
className="h-11 w-full rounded-xl border border-line bg-mist px-3 text-[13px] outline-none"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" : ""}`} />
|
<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">
|
<ul className="mb-2 space-y-1 pr-3">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<li key={cat.id}>
|
<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}
|
{cat.title}
|
||||||
</Link>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -148,6 +146,9 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
|||||||
{link.title}
|
{link.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
<a href={customerPortalUrl} className="mt-2 block rounded-xl px-3 py-2.5 text-[14px] text-primary">
|
||||||
|
ورود / ثبتنام
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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,29 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { categories } from "@/lib/data";
|
|
||||||
import { Placeholder } from "@/components/Placeholder";
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const list = categories.length ? categories : [];
|
||||||
|
|
||||||
return (
|
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="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">
|
{list.length === 0 ? (
|
||||||
{categories.map((category) => (
|
<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}>
|
<div key={category.id}>
|
||||||
<Link
|
<Link
|
||||||
href={category.href}
|
href={category.href}
|
||||||
className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-navy"
|
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">
|
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
|
||||||
<Placeholder src={category.image} className="size-full object-cover" />
|
<Placeholder src={category.image || "/placeholders/category.svg"} className="size-full object-cover" />
|
||||||
</span>
|
</span>
|
||||||
{category.title}
|
{category.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
{category.children.length ? (
|
||||||
<ul className="space-y-1.5 pr-1">
|
<ul className="space-y-1.5 pr-1">
|
||||||
{category.children.map((child) => (
|
{category.children.map((child) => (
|
||||||
<li key={child.title}>
|
<li key={child.id}>
|
||||||
<Link
|
<Link
|
||||||
href={child.href}
|
href={child.href}
|
||||||
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
|
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
|
||||||
@@ -33,9 +39,11 @@ export function MegaMenu({ open }: { open: boolean }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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";
|
import type { Product } from "@/lib/data";
|
||||||
|
|
||||||
export function ProductCard({ product }: { product: Product }) {
|
export function ProductCard({ product }: { product: Product }) {
|
||||||
|
const href = product.href ?? `/products/${encodeURIComponent(product.id)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="group flex h-full flex-col rounded-2xl bg-white p-3">
|
<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 ? (
|
{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">
|
<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)}٪
|
{faDigits(product.discount)}٪
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
</Link>
|
||||||
|
{product.brand ? (
|
||||||
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{product.brand}</p>
|
<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">
|
<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>
|
</h3>
|
||||||
<div className="mt-auto pt-3">
|
<div className="mt-auto pt-3">
|
||||||
{product.oldPrice ? (
|
{product.oldPrice ? (
|
||||||
@@ -27,16 +33,22 @@ export function ProductCard({ product }: { product: Product }) {
|
|||||||
)}
|
)}
|
||||||
<div className="mt-1 flex items-end justify-between gap-2">
|
<div className="mt-1 flex items-end justify-between gap-2">
|
||||||
<p className="text-[14px] font-bold text-navy">
|
<p className="text-[14px] font-bold text-navy">
|
||||||
|
{product.price != null ? (
|
||||||
|
<>
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
|
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] font-medium text-muted">تماس بگیرید</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={href}
|
||||||
className="grid size-9 place-items-center rounded-xl bg-primary text-white transition hover:bg-primary-hover"
|
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" />
|
<ShoppingBag className="size-4" />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
price: number;
|
price?: number;
|
||||||
oldPrice?: number;
|
oldPrice?: number;
|
||||||
discount?: number;
|
discount?: number;
|
||||||
image: string;
|
image: string;
|
||||||
|
href?: string;
|
||||||
|
inStock?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
@@ -30,10 +32,11 @@ export const site = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ title: "فروش ویژه", href: "#special-sale" },
|
{ title: "فروش ویژه", href: "/#special-sale" },
|
||||||
{ title: "درباره ما", href: "#about" },
|
{ title: "مجله سلامت", href: "/blog" },
|
||||||
{ title: "سوالات متداول", href: "#faq" },
|
{ title: "درباره ما", href: "/#about" },
|
||||||
{ title: "تماس با ما", href: "#contact" },
|
{ title: "سوالات متداول", href: "/#faq" },
|
||||||
|
{ title: "تماس با ما", href: "/#contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const categories: Category[] = [
|
export const categories: Category[] = [
|
||||||
@@ -385,15 +388,15 @@ export const faqs = [
|
|||||||
|
|
||||||
export const footerLinks = {
|
export const footerLinks = {
|
||||||
shop: [
|
shop: [
|
||||||
{ title: "فروش ویژه", href: "#special-sale" },
|
{ title: "فروش ویژه", href: "/#special-sale" },
|
||||||
{ title: "پرفروشترینها", href: "#top-selling" },
|
{ title: "مولتی ویتامین", href: "/#multi-vitamin" },
|
||||||
{ title: "محصولات یگانه", href: "#yeganeh" },
|
{ title: "مکمل بدنسازی", href: "/#body-building-supplement" },
|
||||||
{ title: "دستهبندیها", href: "#categories" },
|
{ title: "همه محصولات", href: "/products" },
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
{ title: "درباره ما", href: "#about" },
|
{ title: "درباره ما", href: "/#about" },
|
||||||
{ title: "سوالات متداول", href: "#faq" },
|
{ title: "سوالات متداول", href: "/#faq" },
|
||||||
{ title: "تماس با ما", href: "#contact" },
|
{ title: "تماس با ما", href: "/#contact" },
|
||||||
{ title: "مجله سلامت", href: "#blog" },
|
{ title: "مجله سلامت", href: "/blog" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,3 +5,10 @@ export function faDigits(value: string | number) {
|
|||||||
export function formatPrice(value: number) {
|
export function formatPrice(value: number) {
|
||||||
return faDigits(value.toLocaleString("en-US"));
|
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