diff --git a/.cursor/rules/static-image-slots.mdc b/.cursor/rules/static-image-slots.mdc new file mode 100644 index 0000000..5f52cf0 --- /dev/null +++ b/.cursor/rules/static-image-slots.mdc @@ -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. diff --git a/Ai-data/prompt-connect-backend.txt b/Ai-data/prompt-connect-backend.txt new file mode 100644 index 0000000..731d341 --- /dev/null +++ b/Ai-data/prompt-connect-backend.txt @@ -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) \ No newline at end of file diff --git a/Ai-data/prompt-images-slot.txt b/Ai-data/prompt-images-slot.txt new file mode 100644 index 0000000..45b551a --- /dev/null +++ b/Ai-data/prompt-images-slot.txt @@ -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 → . linkUrl → wrap in . 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) \ No newline at end of file diff --git a/prompt.txt b/Ai-data/prompt.txt similarity index 100% rename from prompt.txt rename to Ai-data/prompt.txt diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8b2a6ec --- /dev/null +++ b/CONTEXT.md @@ -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` diff --git a/app/api/cart/items/route.ts b/app/api/cart/items/route.ts new file mode 100644 index 0000000..fc91e92 --- /dev/null +++ b/app/api/cart/items/route.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 }); + } +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..a6c2a60 --- /dev/null +++ b/app/api/search/route.ts @@ -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: [] }); + } +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..73d9c0a --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -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 { + 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 ( +
+ +

+ خانه + / + مجله سلامت +

+

{post.title}

+

{formatFaDate(post.publishedAt || post.createdAt)}

+
+ +
+ {post.abstract ?

{post.abstract}

: null} + {post.mainTextHtml ? ( +
+ ) : null} + + +
+ ); +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..a17a589 --- /dev/null +++ b/app/blog/page.tsx @@ -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 ( +
+ +

+ خانه + / + مجله سلامت +

+

مجله سلامت

+ + {result.items.length === 0 ? ( +
+

مطلبی منتشر نشده است

+

پس از انتشار مقاله در پنل Meshkee، اینجا نمایش داده می‌شود.

+
+ ) : ( +
+ {result.items.map((post) => ( +
+ + + +
+

{formatFaDate(post.publishedAt || post.createdAt)}

+

+ {post.title} +

+ {post.abstract ?

{post.abstract}

: null} +
+
+ ))} +
+ )} + + {pageCount > 1 && result.items.length > 0 ? ( +
+ {Array.from({ length: pageCount }, (_, index) => { + const number = index + 1; + return ( + + {faDigits(number)} + + ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 53ad06d..eaa6fe9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -53,8 +53,20 @@ body { scrollbar-width: none; } -input, -button, -textarea { - font-family: inherit; +.rich-text { + overflow-wrap: anywhere; +} + +.rich-text :where(p, ul, ol, h2, h3, h4) { + margin-top: 0.85em; +} + +.rich-text :where(ul, ol) { + padding-inline-start: 1.25rem; +} + +.rich-text img { + height: auto; + max-width: 100%; + border-radius: 12px; } diff --git a/app/layout.tsx b/app/layout.tsx index dfb7013..bbd2696 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,11 @@ import type { Metadata } from "next"; import { Vazirmatn } from "next/font/google"; import "./globals.css"; +import { TopBar } from "@/components/layout/TopBar"; +import { Header } from "@/components/layout/Header"; +import { Footer } from "@/components/layout/Footer"; +import { getNavCategories } from "@/lib/categories"; +import { getLogoSrc } from "@/lib/media"; const vazir = Vazirmatn({ subsets: ["arabic", "latin"], @@ -15,7 +20,9 @@ export const metadata: Metadata = { "خرید آنلاین دارو، مکمل، محصولات بهداشتی و آرایشی از داروخانه دکتر ارجمند در قم، سالاریه.", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const [logoSrc, categories] = await Promise.all([Promise.resolve(getLogoSrc()), getNavCategories()]); + return ( @@ -26,8 +33,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) rel="stylesheet" /> - {children} + + +
+
{children}
+