Compare commits

..
14 Commits
Author SHA1 Message Date
Alireza HassaniandCursor e625354dc9 Add installment calculator and Latin digits in English product text.
Wire the product CTA to store-loan-methods calculate so shoppers can pick a term and see the plan, and render English model runs under Montserrat so fanum fonts stop painting 0-9 as Persian digits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 00:21:40 +03:30
amirhosein.ashourloo 7b8b5fd723 Fix logo 2026-09-20 00:26:38 +03:30
amirhosein.ashourlooandClaude Sonnet 5 7ee43e3e79 Header logo 32px; drop fixed row heights for pt-5
The first row's h-[70px] / lg:h-25 only existed to give the old large
logo room, so they are replaced with pt-5. Logo is h-8 (32px).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 00:19:39 +03:30
amirhosein.ashourlooandClaude Sonnet 5 7089078b4c Use bundled logo from public/ instead of the API
Header, footer and og:image now read public/images/logo.png via
LOGO_SRC in config.ts. The API's /website/favicon returned another
business's mark, so getFavicon()/BrandAssets are removed and the tab
icon is the bundled favicon.png. Footer logo is now 70px like the
header (108px was sized for the old square mark; the new 200x48
wordmark would be 450px wide).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 00:13:53 +03:30
amirhosein.ashourlooandClaude Sonnet 5 8e82370b64 Shrink desktop header logo to 70px
Header row height (lg:h-32 -> lg:h-25) and the logo's own height
(h-[108px] -> h-[70px]) both come down together so the row doesn't
carry leftover empty space around the smaller mark. Footer logo is
unchanged (still 108px) — the two are sized independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:56:27 +03:30
websites-deploy 990e1999b9 Use Next.js standalone output for lean production runtime.
Deploy can drop full node_modules and run .next/standalone/server.js (~100MB RSS).
2026-09-08 06:54:07 +00:00
amirhosein.ashourlooandClaude Sonnet 5 e44fc90d96 Make blog cards square + clickable, add fallback article detail pages
- Blog card thumbnails (homepage magazine carousel, /blog listing, and
  the "more posts" carousel) are now square instead of 16:9.
- Fallback blog cards (src/data/fallbackBlogs.ts) were previously
  plain, non-clickable divs since /blogs is still empty on this
  tenant and no real detail page existed for them. They now link to a
  real local /blog/{id}/{slug} page via getFallbackBlogById() — image
  and title are both inside the link. That page shows the real
  title/image/category we have plus a "coming soon" note instead of
  fabricating article body content.
- Homepage magazine section's fallback posts now link to their own
  detail page instead of generically to /blog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 14:55:58 +03:30
amirhosein.ashourlooandClaude Sonnet 5 4ffdf03727 Rewrite cart as client-only guest cart per AI_PROMPT.md spec
Replaces the previous server-cart implementation (which wrongly called
the customer-dashboard-only /businesses/{id}/cart* API) with the
documented guest-cart architecture:

- src/lib/guestCart.ts: storage key/cookie name, cookie attributes,
  line shape, and encodeGuestCart() copied verbatim from AI_PROMPT.md
- CartProvider/CartButton: pure client state (localStorage + mirrored
  cookie), no bearer auth, no businessId
- CartButton's "ادامه" builds the exact documented Continue URL,
  branching on hasCustomerSessionCookie() to either
  customer.<domain>/checkout/cart?guestCart=<encoded> or
  .../login?redirect=<encoded checkout path>
- Auth cookie corrected to the real documented name
  (meshkee_customer_access_token, possibly chunked) via a shared
  reassembleChunkedCookie() helper used by both auth.ts (server) and
  authUser.ts (client)
- ProductBuyPanel/product pages switched to the documented
  GET .../store-items/by-product/{id} endpoint (getStoreItemByProduct)
- isInStock() treats stockQuantity === null as unlimited stock, not
  out-of-stock, per spec
- AGENTS.md updated to describe this architecture and remove the
  incorrect server-cart documentation

Manually verified in-browser: badge count, popup lines/qty/remove/
total all update correctly against injected guest-cart data; tsc,
eslint, and next build all pass clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:29:30 +03:30
amirhosein.ashourlooandClaude Sonnet 5 d624ad8a32 Add shopping cart, store-items pages, contact form toast; skip workshops+portfolio
Neither workshops nor portfolio is in this tenant's enabledModules
(["products","blog","finance"]) - same reasoning as before, page/nav entry
withheld until either is actually enabled.

Shopping cart (new):
- CartProvider (client context): live cart state, add/update/remove/refresh,
  reads AUTH_TOKEN_COOKIE, calls the real /businesses/{id}/cart* endpoints
  (bearer-only, no guest cart - by design, not a gap)
- CartButton: header/bottom-nav icon with a live count badge and a popup
  preview; "ادامه" hands off to the customer portal's own cart/checkout,
  since this storefront has no checkout UI (address/payment) to hand off to
  internally - out of scope for this pass
- ProductBuyPanel: quantity stepper, wired to CartProvider.addItem() instead
  of its own ad-hoc fetch+token logic, toast on success
- Cart/CartItem types are a best-effort guess at an undocumented response
  shape (OpenAPI gives no field-level schema) - unverified against real data
  since store-items are still empty; read every field defensively

Store-items pages (new): /store-items (flat listing of purchasable variants,
always priced unlike /products) and /store-items/{id} (detail - no slug,
resolves the variant's productId and reuses the product's own gallery/
description/technical-info/comments/related-products, just with that
variant preselected).

Contact form: now shows a toast + inline success text and resets while
staying on screen, instead of swapping to a separate "thank you" panel.

New shared providers: ToastProvider (useToast) and CartProvider, both
mounted once in layout.tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 08:56:04 +03:30
amirhosein.ashourlooandClaude Sonnet 5 e676397bae Fix hero slider pagination dots showing swiper's black/blue defaults
.swiper-pagination-bullet{background:#fff} had the same specificity as
swiper's own .swiper-pagination-bullet{background:var(...)} rule and lost
the cascade depending on chunk order, so dots rendered swiper's actual
defaults (black inactive, iOS-blue active) instead of white.

Fix: set swiper's own theming variables (--swiper-pagination-bullet-
inactive-color, --swiper-pagination-color, etc.) scoped to .hero-dots
instead of fighting the background property directly - this is swiper's
documented way to theme bullets and is cascade-order-proof. The active
pill shape (22px, 4px radius) has no variable of its own, so that rule
stays as a plain override, scoped under .hero-dots for higher specificity
instead of !important.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 08:01:06 +03:30
amirhosein.ashourlooandClaude Sonnet 5 0ffbd953b3 Show real titleFa captions, 3x logo size, fix category hover border
- CategoryCarousel: caption under each circle now comes from the slot
  image's own titleFa (as the business typed it) instead of the resolved
  category's API name - identity resolution via linkUrl slug is still used
  for the href, just not for the visible label
- Replace the hover ring (box-shadow) with a real 1px border - the ring
  rendered oddly against Swiper's transformed slide track, looking like it
  "leaked" into the row above; a border participates in normal box layout
  and doesn't have that problem (border-box sizing means zero layout shift)
- Header/footer logo: h-9 (36px) -> h-[108px], 3x on request; header row
  height bumped to fit. Note: the currently-uploaded (wrong-business) logo
  has a lot of baked-in padding, so it still reads small even at 3x - that's
  the asset, not the sizing
- Re-verified the static-image-slot wiring end to end against the original
  spec (url->img, linkUrl->href, aspectRatio from API, placeholder on
  empty/fail) - all still holds

Also add a Deployment section to AGENTS.md: this repo's whole point is
CI/CD from origin/main, and local commits sitting unpushed (as 3 commits
were, until now) look indistinguishable from unfixed bugs to whoever is
testing the deployed site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 07:53:20 +03:30
amirhosein.ashourlooandClaude Sonnet 5 2249a07dee Fix category-slot matching: identity by linkUrl slug, not array position
The homepage category carousel and installments highlights matched
slot.images[i] to categoryList[i] by array index, assuming the business's
CMS upload order lines up with this site's own category sort order. It
doesn't - live data showed a laptop photo landing on "موبایل" and "لوازم
برقی" appearing on the site despite no photo ever being uploaded for it,
purely from index luck. titleFa isn't reliable either: the same laptop image
was titled "تبلت" in the CMS, contradicting its own linkUrl.

Fix: resolve each image's category from the `category` query param on its
own linkUrl (categorySlugFromLinkUrl() in slug.ts, matched against
ApiCategory.slug via the new findNodeBySlug()). The homepage carousel is now
fully slot-driven - it iterates slot.images (in the CMS's own sortOrder) and
simply omits any category with no matching image, per explicit product
direction, rather than falling back to a placeholder. Installments keeps its
curated HIGHLIGHT_IDS list with a placeholder for uncovered categories,
since that page always shows the same fixed set, but now uses the same
identity-based lookup instead of position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 07:36:24 +03:30
amirhosein.ashourlooandClaude Sonnet 5 db7d00e473 Add about/contact/installments pages, fix category slot bugs, wire live branding
- Enlarge category carousel thumbnails (90px -> 104-140px responsive) and
  confirm the slider/categories/banner static-image slots are all reading
  correctly from the CMS (verified live, not a wiring bug)
- Fix installments page dropping "هندزفری و هدفون": it's a level-3 category,
  not level-2, so level2.find() silently missed it - use findNode() over the
  whole tree instead
- Extend gen-category-placeholders.mjs to generate a placeholder for every
  category at every level (was level-2 only), needed for the fix above
- Add /about, /contact, /installments pages
  - About: real copy transcribed from the live moalem.shop theme, real
    product photos from the API, text-only brand strip (no scraped logos)
  - Contact: ContactForm.tsx posts to the real POST /contact-submissions,
    branch cards with key-less Google Maps embeds
  - Installments: flat brand-colored hero (no photo/gradient), category
    highlights reusing the same categories slot images as the homepage
- Replace the placeholder header/footer phone number with the real one, add
  real branch addresses and Instagram/Telegram links (src/data/site.ts),
  pulled from the live theme's footer
- Wire logo/favicon to GET /website/favicon (getFavicon() in api.ts) instead
  of the static bundled files, so a dashboard upload now shows automatically;
  layout.tsx metadata is now generateMetadata() to support this. The tenant's
  uploaded logo/favicon currently belongs to a different business - the
  wiring is correct and will self-correct once that's fixed in the dashboard

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 07:22:54 +03:30
amirhosein.ashourlooandClaude Sonnet 5 09a409b3e7 Update banners.psd design source
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:02:15 +03:30
79 changed files with 2742 additions and 271 deletions
+189 -29
View File
@@ -19,17 +19,19 @@ fallbacks should update it.
empty or failing module degrades one section instead of the page. empty or failing module degrades one section instead of the page.
- Never invent CMS/admin endpoints. If OpenAPI and any prose conflict, OpenAPI wins. - Never invent CMS/admin endpoints. If OpenAPI and any prose conflict, OpenAPI wins.
### What the tenant actually returns today (2026-08-31) ### What the tenant actually returns today (2026-09-06)
| Endpoint | State | | Endpoint | State |
|---|---| |---|---|
| `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **`workshops` is not enabled.** Do not build a workshops page/nav entry until it is. | | `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **neither `workshops` nor `portfolio` is enabled.** Do not build a page/nav entry for either until it is; `/portfolios` and `/workshops` both return empty lists today regardless, but the real signal is `enabledModules`, not an empty list (an enabled-but-unfilled module still gets its page, per "Fallbacks" below). |
| `/products` | **479 items**, real titles, images, categories | | `/products` | **479 items**, real titles, images, categories |
| `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). | | `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). |
| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`. `/store-items/by-product/{id}` returns a single `{ storeItem }` (or null) — for a product's full variant list use `/store-items?productId=&pageSize=100` (`{ items }`) instead, see `getStoreItemsByProduct` in `src/lib/api.ts`. | | `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`, and `/store-items` (the `getStoreItems()` list used by `/store-items`) has nothing to page through either. `/store-items/by-product/{id}` → `{ storeItem: { variants: [...] } \| null }` is the documented source for a product's full variant list — `getStoreItemByProduct()` in `src/lib/api.ts`. |
| `/website/sliders`, `/website/static-images`, `/website/category-groups` | **empty** | | Cart (`/businesses/{id}/cart*`) | **Off-limits from this storefront by design**, not a data gap — see "Shopping cart" below. Never call it. |
| `/website/sliders`, `/website/category-groups` | **empty** |
| `/website/static-images` | **filled** — `slider` (2), `categories` (8 of 15), `one/two/three-banner-bg` all have real images. Verified live and rendering correctly; if a future check finds otherwise, suspect a stale build/deploy before suspecting the wiring. |
| `/blogs` | **empty** (module enabled) | | `/blogs` | **empty** (module enabled) |
| `/website/business-info`, `/website/favicon` | present but blank (no logo, no phone, no socials) | | `/website/business-info` | phones/addresses/socialMedia still empty; `logoUrl`/`faviconUrl` point to a **different business's** mark and are deliberately unused (see "Brand") |
| `/products/by-id/{id}/technical-info` | `{ form: null, values: [] }` — empty on every product checked so far | | `/products/by-id/{id}/technical-info` | `{ form: null, values: [] }` — empty on every product checked so far |
| `/comments` | **empty**, but `POST` works (no bearer required by the API itself) | | `/comments` | **empty**, but `POST` works (no bearer required by the API itself) |
@@ -38,18 +40,31 @@ fallbacks should update it.
| Gap | Fallback | Remove when | | Gap | Fallback | Remove when |
|---|---|---| |---|---|---|
| No prices | `src/lib/demoPricing.ts`, gated by `NEXT_PUBLIC_DEMO_PRICING=1` — deterministic price per product id | store-items exist | | No prices | `src/lib/demoPricing.ts`, gated by `NEXT_PUBLIC_DEMO_PRICING=1` — deterministic price per product id | store-items exist |
| No sliders | `src/app/page.tsx`'s local `FALLBACK_SLIDES` (banners downloaded from the live moalem.shop theme into `public/images/banners/`) — used only when the `slider` static-image slot is also empty, see "Static-image slots" below | `/website/sliders` or the `slider` slot returns items | | No blog posts | `src/data/fallbackBlogs.ts` — the real posts from moalem.shop/articles, shown on the homepage and `/blog` as square-thumbnail cards that link to a real local `/blog/{id}/{slug}` page (`getFallbackBlogById()`); that page shows title/category/image plus a "coming soon" note since only those three fields are real, no scraped body content | `/blogs` returns items |
| No blog posts | `src/data/fallbackBlogs.ts` — the real posts from moalem.shop/articles, shown on the homepage and `/blog` as non-clickable cards (no real detail page exists for them) | `/blogs` returns items | | No category art past slot position 8 | placeholder SVGs in `public/images/categories/{id}.svg`, generated by `scripts/gen-category-placeholders.mjs` for every category at every level, not just level-2 | the `categories` slot has an image for that position |
| No category art | placeholder SVGs in `public/images/categories/{id}.svg`, generated by `scripts/gen-category-placeholders.mjs` — overridden per-slide by the `categories` static-image slot when it has images | real thumbnails are dropped in, or the `categories` slot is filled |
| No branding in API | logo/favicon copied from the live theme into `public/` | `logoUrl` is set on the tenant |
| No banner art (`one/two/three-banner-bg`) | `src/components/PromoBanners.tsx` renders a flat dashed placeholder per slot cell, labelled with the recommended size/ratio | the matching static-image slot is filled |
## Brand ## Brand
Taken from the live moalem.shop theme, not invented: Colors taken from the live moalem.shop theme, not invented:
primary `#E51E22`, dark `#B10123`, ink `#2E2E2E`, body `#7C7C7C`. primary `#E51E22`, dark `#B10123`, ink `#2E2E2E`, body `#7C7C7C`.
Fonts: IRANYekan (fa, local `public/fonts`) + Montserrat (latin, next/font). Fonts: IRANYekan (fa, local `public/fonts`) + Montserrat (latin, next/font).
- Logo: **bundled, not from the API** — `public/images/logo.png` (200x48
wordmark, supplied by the business), referenced via `LOGO_SRC`/`LOGO_WIDTH`/
`LOGO_HEIGHT` in `src/lib/config.ts` by `Header.tsx`, `Footer.tsx` and the
og:image in `layout.tsx`. This was an explicit decision (2026-09-20): the
API's `/website/favicon` returned another business's mark, so
`getFavicon()` was removed entirely — don't re-add it. Tab icon is the
bundled `public/favicon.png`. To change the logo, replace the file (keep
the same name; update `LOGO_WIDTH`/`LOGO_HEIGHT` if its shape changes).
- Header (desktop) logo renders at `h-8` (32px), footer logo at `h-[70px]` — sized
independently, on request. The header's first row has no fixed height, just `pt-5`.
- Contact data (phone, mobile, branch addresses, Instagram/Telegram) in
`src/data/site.ts` is real, pulled from the live theme's footer — `GET
/website/business-info` still returns empty `phoneNumbers`/`addresses`/
`socialMedia` for this tenant, so these stay hand-maintained here until
that's filled in. Update both together when it is.
## Layout conventions ## Layout conventions
- Content width `--container-boxed: 1200px`, applied with the `boxed` utility. - Content width `--container-boxed: 1200px`, applied with the `boxed` utility.
@@ -84,25 +99,83 @@ Login itself happens off-site, on the customer portal (`customer.{domain}`) —
this storefront never renders a login form. For the header/bottom-nav account this storefront never renders a login form. For the header/bottom-nav account
menu to show "نام عزیز" instead of "ورود | ثبت‌نام", the customer portal needs menu to show "نام عزیز" instead of "ورود | ثبت‌نام", the customer portal needs
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`) to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`) named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, `"meshkee_customer_access_token"`
holding the bearer access token. Until that exists, `getSession()` in — the documented name from AI_PROMPT.md's "Shared login" contract, not a
`src/lib/auth.ts` always resolves to `null` and the anonymous view is all guess) holding the bearer access token, URL-encoded and possibly **chunked**
anyone will see — this is expected, not a bug, and nothing else breaks. into `${name}_0`, `${name}_1`, … with `${name}_n` holding the chunk count
when the JWT is too long for one cookie. Until the dashboard sets it,
`getSession()` in `src/lib/auth.ts` always resolves to `null` and the
anonymous view is all anyone will see — this is expected, not a bug, and
nothing else breaks.
- `reassembleChunkedCookie()` in `src/lib/authUser.ts` — pure, takes a
`(name) => value` reader so both server and client call the same
reassembly logic: tries the bare cookie first, then falls back to the
`_n` + `_0..n-1` chunks.
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads - `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
the cookie and calls `GET /auth/me`. the (possibly chunked) cookie via `reassembleChunkedCookie()`, URL-decodes
- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to it, and calls `GET /auth/me`.
import from client components. **Never import `auth.ts` from a client - `src/lib/authUser.ts` — pure helpers safe to import from client components:
component** — it pulls in `next/headers` and breaks the build; that's the `displayName`, `isAdmin`, `reassembleChunkedCookie`, and
entire reason this file is split out. `hasCustomerSessionCookie()` (reads `document.cookie` — used by
`CartButton.tsx`'s Continue logic, see "Shopping cart"). **Never import
`auth.ts` from a client component** — it pulls in `next/headers` and
breaks the build; that's the entire reason this file is split out.
- Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively — - Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively —
the OpenAPI schema doesn't document a role field on the user object, so the OpenAPI schema doesn't document a role field on the user object, so
this is a best guess. Update it once the real shape is confirmed. this is a best guess. Update it once the real shape is confirmed.
- Cart ("افزودن به سبد خرید" in `ProductBuyPanel.tsx`) needs a real
`storeItemVariantId`, which only exists once a product has store-items — ## Shopping cart
today that's none of them, so the button always shows "این محصول هنوز برای
خرید آنلاین فعال نشده است" regardless of login state. That's correct given **Guest cart only — this storefront never calls the server cart API.**
the data, not a bug either. Per AI_PROMPT.md's "Shopping cart" section, `/businesses/{id}/cart*` (and
`/cart/checkout`) are customer-dashboard-only; the site's whole cart is a
local, unauthenticated mini-cart that hands off to the dashboard on
"ادامه". Do not add a `businessId` prop, a bearer-authed cart call, or a
checkout/login/address/payment page here — that is explicitly out of scope
per the same doc.
- `src/lib/guestCart.ts` — the entire contract, copied verbatim from
AI_PROMPT.md, not invented: storage key **and** cookie name
`meshkee-guest-cart` (`GUEST_CART_STORAGE_KEY` in `config.ts`), persisted
to both `localStorage` and a `Domain=.{domain}; Path=/; SameSite=Lax;
Max-Age=~30d` cookie (skipped when `encodeURIComponent(json)` exceeds
~3500 chars — the URL param carries it instead on Continue). Each line is
`{ id, name, slug, price, originalPrice?, image, quantity }` where `id`
**must** be the `storeItemVariantId` — adding an id already in the cart
increments `quantity` instead of duplicating the line. `encodeGuestCart()`
is the exact base64url-of-JSON function from the doc — must stay
byte-for-byte identical since the customer dashboard decodes it.
- `src/components/providers/CartProvider.tsx` — thin React context wrapping
`guestCart.ts` (`items`, `count`, `total`, `addItem`/`updateItem`/
`removeItem`) so every consumer re-renders on change. No `user` or
`businessId` prop — works identically for anonymous and signed-in
shoppers, since the cart never touches the server. Reads localStorage
post-mount only (via `queueMicrotask`), never during the initial render,
so SSR markup (always empty) doesn't mismatch.
- `src/components/CartButton.tsx` — the header/bottom-nav icon: live count
badge, a popup with per-line qty/remove/total. "ادامه" builds the Continue
URL exactly per spec: `encodeGuestCart(items)` in the `guestCart` query
param, to `${CUSTOMER_PORTAL_URL}/checkout/cart?guestCart=<encoded>` when
`hasCustomerSessionCookie()` is true, else to
`${CUSTOMER_PORTAL_URL}/login?redirect=<url-encoded checkout path>`. This
storefront has no checkout/address/payment UI of its own — that whole flow
is the customer dashboard's, by design; don't build one here without an
explicit ask. `placement="up"` (bottom nav) renders the popup `fixed`
against the viewport rather than `absolute` against the button's own
narrow `flex-1` column — an `absolute` inset there would squeeze the whole
preview into ~1/4 of the screen width instead of spanning near it.
- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" — variants come from
`getStoreItemByProduct()` (`GET .../store-items/by-product/{productId}`).
In-stock means `stockQuantity === null` (**unlimited**, not
out-of-stock — see `isInStock()` in `src/lib/format.ts`) or `> 0`; with
more than one in-stock variant the shopper must pick one before the button
enables. No login is required to add — only "ادامه" cares about auth.
Today every product has zero store-items, so the button always shows "این
محصول هنوز برای خرید آنلاین فعال نشده است" and the quantity stepper never
renders — correct given the data, not a bug. Once store-items exist, add
→ badge update → popup line is the same guest-cart path already wired and
manually verified with injected `localStorage` data.
## Static-image slots ## Static-image slots
@@ -112,7 +185,7 @@ each is consumed:
| Key | Kind | Consumed by | | Key | Kind | Consumed by |
|---|---|---| |---|---|---|
| `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images | | `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images |
| `categories` | list | `CategoryCarousel.tsx` — images matched **positionally** to the category list (business fills them in the same order); a category past the end of the slot keeps its SVG placeholder | | `categories` | list | `CategoryCarousel.tsx` — **fully slot-driven**: iterates `slot.images` and resolves each one's category via the `category` query param on its own `linkUrl` (`categorySlugFromLinkUrl()` in `src/lib/slug.ts`, matched against `ApiCategory.slug` with `findNodeBySlug()`). A category with no matching image simply isn't shown — **no placeholder fallback here**, per an explicit product decision (2026-09-06). Position and `titleFa` are **not** used for matching — both were tried and found unreliable in practice (see the history note below) |
| `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` | | `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` |
| `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` | | `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` |
| `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` | | `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` |
@@ -122,21 +195,78 @@ what the component actually renders. If you change a banner's size or ratio,
update its slot entry in the same commit — that file is the only thing update its slot entry in the same commit — that file is the only thing
telling the dashboard (and the designer) what to upload. telling the dashboard (and the designer) what to upload.
**Why `categories` matches by `linkUrl` slug, not position:** the first version
matched `slot.images[i]` to `categoryList[i]` by array index, assuming the
business's upload order lines up with this site's category sort order. It
doesn't, and there's no contract that it ever would — live data showed a
laptop photo (`linkUrl` correctly said `?category=laptop`) landing on
"موبایل" and "لوازم برقی" appearing with no photo uploaded for it at all,
purely because of where it fell in the array. The visible caption under each
circle is that image's own `titleFa`, taken as typed — even on the one
sample where it disagreed with the photo (that same laptop image was titled
"تبلت"), it's still the business's own editorial text for the tile, and the
`href` (built from the `linkUrl` slug, resolved to this site's own canonical
`/products/category/{id}/{slug}` rather than linking off-site to the literal
`linkUrl`) is what actually has to be correct for navigation to work.
**Category circle hover:** use a real `border` (1px, transparent → `border-brand`
on `group-hover`), not a `ring`/box-shadow. A ring rendered inside a Swiper
slide painted like it was "leaking" into the row above — box-shadow doesn't
participate in normal layout the way a border does, and interacts oddly with
Swiper's transformed `.swiper-wrapper` track. `border` with this site's
global `box-sizing: border-box` reset adds zero layout shift, so it's the
safer default for any future hover-outline effect inside a carousel.
Homepage banner placement (mirrors the logilook.com reference given for this Homepage banner placement (mirrors the logilook.com reference given for this
layout): hero → categories → hot deals → **three-banner-bg** → موبایل layout): hero → categories → hot deals → **three-banner-bg** → موبایل
carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** → carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** →
کنسول بازی → مجله. کنسول بازی → مجله.
## Product / blog pages ## Static pages
- `/products` — full catalog, `?name=` search, `?page=`. - `/about` — real copy transcribed from the live moalem.shop theme's
about-us module (verified against its cached HTML, not invented); brand
strip is plain text wordmarks (no scraped logo images — avoids a
trademark/asset-licensing headache for names we don't have real logo files
for) with real product photos pulled from the API.
- `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions`
(no bearer required); branch cards use `BRANCHES` from `src/data/site.ts`
plus a key-less Google Maps `output=embed` iframe per branch (no API key
needed for a query-based embed). On success: a toast (`useToast()`), inline
success text, and the form **resets and stays on screen** rather than being
swapped for a separate "thank you" panel, so another message can be sent
right away.
- `/installments` — flat, brand-colored hero (no photo, no gradient — this
site stays flat) instead of the reference design's photographic banner;
category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike
the homepage carousel — a highlight with no matching `categories`-slot
image (by `linkUrl` slug, same lookup as the carousel) keeps its SVG
placeholder rather than disappearing, since this page always shows the
same curated set. One reference category ("خانه هوشمند") has no equivalent
on this tenant and is swapped for "لوازم برقی".
## Product / store-item / blog pages
- `/products` — full catalog, `?name=` search, `?page=`. Includes items with
no price/stock yet (most of the catalog today).
- `/products/category/{id}/{slug}` — category listing; merges every leaf in - `/products/category/{id}/{slug}` — category listing; merges every leaf in
the subtree (see the leaf-category note above) and paginates in memory. the subtree (see the leaf-category note above) and paginates in memory.
- `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox + - `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox +
hover-zoom via a background-position overlay, desktop only), hover-zoom via a background-position overlay, desktop only),
`ProductBuyPanel.tsx` (variants, price, auth-aware cart), `ProductInfoTabs.tsx` `ProductBuyPanel.tsx` (variants, quantity, auth-aware cart), `ProductInfoTabs.tsx`
(توضیحات / مشخصات فنی / دیدگاه کاربران), related products from (توضیحات / مشخصات فنی / دیدگاه کاربران), related products from
`relatedProducts` on the by-id response. `relatedProducts` on the by-id response.
- `/store-items` — flat listing of every purchasable **variant**
(`getStoreItems()`), unlike `/products`: every card here always has a real
price and stock count, by definition of what a store-item is.
- `/store-items/{id}` — detail for one variant. A store-item has no slug and
no content of its own (no gallery/description/comments in the API) — this
page resolves the variant's `productId` and reuses the **product's** real
content (`ProductGallery`, `ProductInfoTabs`, `relatedProducts`), the same
components the product page uses, just entered from a variant-first URL
with that variant preselected in `ProductBuyPanel` (`initialVariantId`).
Comments are attached to the product (`entityType: "product"`), not the
variant — the comments API has no `storeItem` entity type.
- `/blog`, `/blog/{id}/{slug}` — same shape, no variants/tabs; just content + - `/blog`, `/blog/{id}/{slug}` — same shape, no variants/tabs; just content +
`CommentSection.tsx`. Fallback posts (see table above) only exist in the `CommentSection.tsx`. Fallback posts (see table above) only exist in the
listing grid, not as real detail pages. listing grid, not as real detail pages.
@@ -152,8 +282,17 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
- Product `/products/{id}/{nameFaSlug}` - Product `/products/{id}/{nameFaSlug}`
- Category `/products/category/{id}/{nameFaSlug}` - Category `/products/category/{id}/{nameFaSlug}`
- Blog `/blog/{id}/{titleSlug}` - Blog `/blog/{id}/{titleSlug}`
- Store-item `/store-items/{id}` — no slug segment; `StoreItem` has no `slug` field.
- Helpers live in `src/lib/slug.ts` — build links with those, never by hand. - Helpers live in `src/lib/slug.ts` — build links with those, never by hand.
## Toasts
`src/components/providers/ToastProvider.tsx`, mounted once in `layout.tsx`
alongside `CartProvider`. `useToast().showToast(message, type?)` from any
client component — used by the contact form and `ProductBuyPanel`'s
add-to-cart. Sits above the mobile bottom nav (`bottom-20`, `lg:bottom-6`);
don't reposition it without checking that bar's height.
## Published for the platform ## Published for the platform
- `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button - `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button
@@ -177,3 +316,24 @@ npm run dev # localhost:3000
npm run build npm run build
node scripts/gen-category-placeholders.mjs # refresh category thumb placeholders node scripts/gen-category-placeholders.mjs # refresh category thumb placeholders
``` ```
## Deployment
`origin` is `gitea.meshkee.com/meshkee-websites/moalem-shop.git`, `main` branch.
Whatever serves the business's preview/live URL deploys from `origin/main` —
**committing locally is not enough**, push too. A whole session's worth of
work (2026-09-06: about/contact/installments pages, the category-slot
identity fix, live logo/favicon wiring) once sat 3 commits ahead of
`origin/main` unpushed, and the business reported bugs that were already
fixed locally — they were looking at the old deployed code. Check
`git status` for "ahead of origin" before telling anyone a fix is live.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
BIN
View File
Binary file not shown.
+2
View File
@@ -1,6 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
output: "standalone",
images: { images: {
remotePatterns: [ remotePatterns: [
{ protocol: "https", hostname: "c804387.parspack.net" }, { protocol: "https", hostname: "c804387.parspack.net" },
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کابل و رابط">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کابل و را</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1227</text>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="پاور بانک">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">پاور بانک</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1228</text>
</svg>

After

Width:  |  Height:  |  Size: 581 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کارت حافظه">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کارت حافظ</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1229</text>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="شارژر موبایل">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">شارژر موب</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1230</text>
</svg>

After

Width:  |  Height:  |  Size: 587 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="اسپیکر سیمی و بلوتوثی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">اسپیکر سی</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1231</text>
</svg>

After

Width:  |  Height:  |  Size: 603 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هندزفری و هدفون">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هندزفری و</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1232</text>
</svg>

After

Width:  |  Height:  |  Size: 592 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هدفون بلوتوثی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هدفون بلو</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1233</text>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هدفون سیمی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هدفون سیم</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1234</text>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="قاب و کیف گوشی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">قاب و کیف</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1235</text>
</svg>

After

Width:  |  Height:  |  Size: 588 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="محافظ صفحه نمایش">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">محافظ صفح</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1236</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی تبلت">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1237</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="محافظ صفحه نمایش">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">محافظ صفح</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1238</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="قاب و کیف تبلت">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">قاب و کیف</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1239</text>
</svg>

After

Width:  |  Height:  |  Size: 588 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هارد اکسترنال">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هارد اکست</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1240</text>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="فلش مموری">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">فلش مموری</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1241</text>
</svg>

After

Width:  |  Height:  |  Size: 581 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="باطری">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">باطری</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1242</text>
</svg>

After

Width:  |  Height:  |  Size: 567 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="گارد هندزفری">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">گارد هندز</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1243</text>
</svg>

After

Width:  |  Height:  |  Size: 587 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="تبدیل">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">تبدیل</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1244</text>
</svg>

After

Width:  |  Height:  |  Size: 567 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="ماوس">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">ماوس</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1245</text>
</svg>

After

Width:  |  Height:  |  Size: 563 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی لپ تاپ">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1246</text>
</svg>

After

Width:  |  Height:  |  Size: 597 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کول پد">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کول پد</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1247</text>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هندزفری بلوتوثی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هندزفری ب</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1248</text>
</svg>

After

Width:  |  Height:  |  Size: 593 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هندزفری گردنی">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هندزفری گ</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1249</text>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هارد اینترنال">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هارد اینت</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1250</text>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="گوشی های کارکرده">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">گوشی های </text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1252</text>
</svg>

After

Width:  |  Height:  |  Size: 593 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی کنسول">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1257</text>
</svg>

After

Width:  |  Height:  |  Size: 596 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="دوربین عکاسی کانن">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">دوربین عک</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1262</text>
</svg>

After

Width:  |  Height:  |  Size: 596 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="رینگ لایت">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">رینگ لایت</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1265</text>
</svg>

After

Width:  |  Height:  |  Size: 581 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="نگهدارنده و پایه">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">نگهدارنده</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1266</text>
</svg>

After

Width:  |  Height:  |  Size: 595 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="میکروفون">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">میکروفون</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1267</text>
</svg>

After

Width:  |  Height:  |  Size: 579 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="خطوط کارکرده">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">خطوط کارک</text>
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1270</text>
</svg>

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

+13 -7
View File
@@ -1,6 +1,9 @@
// Writes one placeholder thumbnail per top-level product category into // Writes one placeholder thumbnail per product category (every level, not
// public/images/categories/{id}.svg. They are meant to be replaced with real // just level-2) into public/images/categories/{id}.svg. They are meant to be
// artwork one file at a time — the carousel reads the same paths either way. // replaced with real artwork one file at a time — the carousel and any other
// page that links a specific category id (e.g. /installments) read the same
// paths either way, so every id needs a file, not just the level-2 ones the
// homepage carousel shows by default.
// Run: node scripts/gen-category-placeholders.mjs // Run: node scripts/gen-category-placeholders.mjs
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
@@ -12,14 +15,17 @@ const res = await fetch(
); );
const { items } = await res.json(); const { items } = await res.json();
const roots = items.filter((c) => c.parentId === null).map((c) => c.id); // Root categories (parentId === null) have no icon of their own on this
const level2 = items.filter((c) => c.parentId && roots.includes(c.parentId)); // site — nothing links directly to "کالای دیجیتال" itself — so only their
// descendants get a placeholder.
const roots = new Set(items.filter((c) => c.parentId === null).map((c) => c.id));
const withIcons = items.filter((c) => !roots.has(c.id));
await mkdir(OUT, { recursive: true }); await mkdir(OUT, { recursive: true });
const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
for (const cat of level2) { for (const cat of withIcons) {
const label = escape(cat.nameFa || cat.name); const label = escape(cat.nameFa || cat.name);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="${label}"> const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="${label}">
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/> <circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
@@ -31,4 +37,4 @@ for (const cat of level2) {
await writeFile(new URL(`${cat.id}.svg`, OUT), svg, "utf8"); await writeFile(new URL(`${cat.id}.svg`, OUT), svg, "utf8");
} }
console.log(`wrote ${level2.length} placeholders to public/images/categories/`); console.log(`wrote ${withIcons.length} placeholders to public/images/categories/`);
+141
View File
@@ -0,0 +1,141 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import { CheckIcon } from "@/components/icons";
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
export const metadata: Metadata = {
title: "درباره ما",
description:
"فروشگاه اینترنتی موبایل معلم، بیش از دو دهه تجربه در عرضه موبایل، لپ‌تاپ، کنسول بازی و لوازم جانبی دیجیتال با ضمانت اصالت کالا.",
};
// Real copy transcribed from the live moalem.shop theme's about-us module —
// see the paragraph match confirmed against the cached homepage HTML.
const INTRO =
"فروشگاه اینترنتی موبایل معلم با گستره‌ای از کالاهای متنوع در زمینه دیجیتال، برای کاربران خود «تجربه‌ای لذت‌بخش از یک خرید اینترنتی» را تداعی می‌کند. ارسال سریع، ضمانت بهترین قیمت و تضمین اصل بودن کالا، سه اصل اولیه‌ای است که ما از نخستین روز سعی کرده‌ایم به آن پایبند باشیم. فروشگاه موبایل معلم فعالیت خود را از سال ۱۳۸۰ با دو دهه تجربه در صنف موبایل آغاز نمود. این شرکت مفتخر است که با بهره‌گیری از کادری متخصص و مجرب، توانسته گام‌های بلندی در زمینه تامین و پخش تلفن همراه، لپ‌تاپ، کنسول بازی، دوربین دیجیتال و انواع تجهیزات و اکسسوری کالای دیجیتال بردارد و سهم شایسته‌ای در حوزه موبایل و رایانه را به خود اختصاص دهد.";
const ACTIVITIES = [
"عرضه انواع گوشی‌های موبایل و تبلت برندهای اصلی",
"عرضه و فروش انواع لپ‌تاپ‌های ایسوس و لنوو",
"عرضه انواع کنسول بازی و دوربین‌های دیجیتال",
"فروش و عرضه انواع اکسسوری موبایل در بندهای مختلف",
"ارائه پشتیبانی و خدمات پس از فروش مناسب با نیاز مشتریان",
"تعمیرات تخصصی انواع لپ‌تاپ، موبایل و…",
"فروش اقساطی تمامی کالاهای دیجیتال",
"فروش آنلاین به همکاران عزیز و مشتریان گرامی",
];
const BRANDS = ["Samsung", "Apple", "Xiaomi", "Huawei", "Asus", "Lenovo", "Nokia"];
export default function AboutPage() {
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "درباره ما" }]} />
<h1 className="mb-8 text-xl font-bold text-ink md:text-2xl">درباره ما</h1>
{/* Intro */}
<section className="grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-2 md:p-10">
<div>
<span className="inline-flex items-center rounded-full bg-brand px-3 py-1.5 text-[12px] font-bold text-white">
بیش از ۱۰۰+ فروش موفق
</span>
<h2 className="mt-4 text-lg font-bold text-ink md:text-xl">فروشگاه موبایل معلم را بیشتر بشناسید</h2>
<p className="mt-4 text-[13.5px] leading-8 text-body">{INTRO}</p>
</div>
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-white">
<Image
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/e5dfb9fe-8910-4ce7-a43a-6a66e327bbf6.jpg"
alt="گوشی موبایل از فروشگاه موبایل معلم"
fill
sizes="(max-width: 768px) 100vw, 500px"
className="object-contain p-6"
/>
</div>
</section>
{/* Brand strip */}
<section className="my-10 flex flex-wrap items-center justify-center gap-x-10 gap-y-4 opacity-60 grayscale">
{BRANDS.map((b) => (
<span key={b} className="text-lg font-bold text-ink-soft">
{b}
</span>
))}
</section>
{/* Activities */}
<section className="grid grid-cols-1 items-center gap-8 md:grid-cols-2">
<div className="relative order-2 aspect-square w-full overflow-hidden rounded-2xl bg-surface md:order-1">
<Image
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/a089678c-b194-4d2c-bf4d-b920208ac2e7.jpg"
alt="ساعت هوشمند از فروشگاه موبایل معلم"
fill
sizes="(max-width: 768px) 100vw, 500px"
className="object-contain p-6"
/>
</div>
<div className="order-1 md:order-2">
<h2 className="text-lg font-bold text-ink md:text-xl">از ما بیشتر بدانید</h2>
<p className="mt-2 text-[13px] text-body">زمینه فعالیت شرکت به شرح زیر می‌باشد:</p>
<ul className="mt-5 space-y-3">
{ACTIVITIES.map((item, i) => (
<li key={i} className="flex items-start gap-2.5 text-[13.5px] leading-7 text-ink-soft">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-brand" />
{item}
</li>
))}
</ul>
<Link
href="/contact"
className="mt-6 inline-flex items-center gap-2 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا با ما تماس بگیرید
</Link>
</div>
</section>
{/* Magazine highlight */}
<section className="mt-14 md:mt-20">
<h2 className="mb-5 text-lg font-bold text-ink md:text-xl">از مجله معلم بخوانید</h2>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<ul className="divide-y divide-line rounded-xl border border-line bg-white">
{FALLBACK_BLOGS.slice(0, 3).map((post) => (
<li key={post.id} className="flex gap-3 p-4">
<span className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-surface">
<Image src={post.image} alt={post.title} fill sizes="64px" className="object-cover" />
</span>
<div className="min-w-0">
<span className="text-[11px] font-bold text-brand">{post.category}</span>
<p className="clamp-2 mt-1 text-[13px] leading-6 text-ink-soft">{post.title}</p>
</div>
</li>
))}
</ul>
<div className="relative overflow-hidden rounded-xl">
<span className="relative block aspect-[16/10] w-full bg-surface lg:aspect-auto lg:h-full">
<Image
src={FALLBACK_BLOGS[0].image}
alt={FALLBACK_BLOGS[0].title}
fill
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
{/* Flat overlay, not a gradient — this site's design keeps to flat fills. */}
<span className="absolute inset-x-0 bottom-0 bg-black/55 p-5">
<span className="rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{FALLBACK_BLOGS[0].category}
</span>
<p className="clamp-2 mt-2 text-[14px] font-bold leading-7 text-white">
{FALLBACK_BLOGS[0].title}
</p>
</span>
</span>
</div>
</div>
</section>
</main>
);
}
+51
View File
@@ -7,6 +7,7 @@ import BlogCarousel, { type BlogCardData } from "@/components/BlogCarousel";
import { getBlogById, getBlogs, getComments, safe } from "@/lib/api"; import { getBlogById, getBlogs, getComments, safe } from "@/lib/api";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { blogHref } from "@/lib/slug"; import { blogHref } from "@/lib/slug";
import { FALLBACK_BLOGS, getFallbackBlogById } from "@/data/fallbackBlogs";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -14,6 +15,12 @@ export async function generateMetadata({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const fallback = getFallbackBlogById(id);
if (fallback) {
return { title: fallback.title, openGraph: { images: [{ url: fallback.image }] } };
}
try { try {
const { blog } = await getBlogById(id); const { blog } = await getBlogById(id);
const title = blog.title ?? blog.nameFa ?? ""; const title = blog.title ?? blog.nameFa ?? "";
@@ -27,9 +34,53 @@ export async function generateMetadata({
} }
} }
// A fallback post (see src/data/fallbackBlogs.ts) has a real id/title/image
// but no scraped body content yet — /blogs is still empty on this tenant.
// Its detail page still exists so the magazine cards can link somewhere
// real, it just shows what data we actually have instead of the full
// article body.
function FallbackBlogDetail({
post,
others,
}: {
post: { id: string; title: string; image: string; category: string };
others: typeof FALLBACK_BLOGS;
}) {
const morePosts: BlogCardData[] = others
.filter((p) => p.id !== post.id)
.slice(0, 4)
.map((p) => ({ id: p.id, href: blogHref(p.id, p.title), title: p.title, image: p.image, category: p.category }));
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "مجله معلم", href: "/blog" }, { label: post.category }]} />
<article className="mx-auto max-w-3xl">
<span className="text-[12px] font-bold text-brand">{post.category}</span>
<h1 className="mt-2 text-xl font-bold text-ink md:text-2xl">{post.title}</h1>
<span className="relative mt-5 block aspect-[16/9] w-full overflow-hidden rounded-xl bg-surface">
<Image src={post.image} alt={post.title} fill sizes="768px" className="object-cover" priority />
</span>
<p className="mt-6 rounded-lg bg-surface px-4 py-3 text-sm leading-7 text-body">
متن کامل این مقاله به‌زودی از طریق پنل مدیریت اضافه می‌شود.
</p>
</article>
{morePosts.length > 0 && <BlogCarousel posts={morePosts} />}
</main>
);
}
export default async function BlogDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function BlogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const fallback = getFallbackBlogById(id);
if (fallback) {
return <FallbackBlogDetail post={fallback} others={FALLBACK_BLOGS} />;
}
let blog; let blog;
try { try {
({ blog } = await getBlogById(id)); ({ blog } = await getBlogById(id));
+8 -6
View File
@@ -40,17 +40,19 @@ export default async function BlogListPage({
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{usingFallback {usingFallback
? FALLBACK_BLOGS.map((post) => ( ? FALLBACK_BLOGS.map((post) => (
// No real detail page exists yet for these — see AGENTS.md fallback <Link
// table — so the card is informational, not a link. key={post.id}
<div key={post.id} className="overflow-hidden rounded-xl border border-line bg-white"> href={blogHref(post.id, post.title)}
<span className="relative block aspect-[16/9] w-full bg-surface"> className="overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative block aspect-square w-full bg-surface">
<Image src={post.image} alt={post.title} fill sizes="360px" className="object-cover" /> <Image src={post.image} alt={post.title} fill sizes="360px" className="object-cover" />
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand"> <span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{post.category} {post.category}
</span> </span>
</span> </span>
<p className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{post.title}</p> <p className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{post.title}</p>
</div> </Link>
)) ))
: result.items.map((post) => { : result.items.map((post) => {
const title = post.title ?? post.nameFa ?? ""; const title = post.title ?? post.nameFa ?? "";
@@ -62,7 +64,7 @@ export default async function BlogListPage({
href={blogHref(post.id, title)} href={blogHref(post.id, title)}
className="overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]" className="overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
> >
<span className="relative block aspect-[16/9] w-full bg-surface"> <span className="relative block aspect-square w-full bg-surface">
{image && <Image src={image} alt={title} fill sizes="360px" className="object-cover" />} {image && <Image src={image} alt={title} fill sizes="360px" className="object-cover" />}
{category && ( {category && (
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand"> <span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Breadcrumbs from "@/components/Breadcrumbs";
import ContactForm from "@/components/ContactForm";
import { InstagramIcon, MailIcon, MapPinIcon, PhoneIcon, TelegramIcon } from "@/components/icons";
import { BRANCHES, MOBILE, MOBILE_HREF, PHONE, PHONE_HREF, SOCIAL } from "@/data/site";
export const metadata: Metadata = {
title: "تماس با ما",
description: "راه‌های تماس با فروشگاه موبایل معلم — شماره تماس، آدرس شعب و فرم ارسال پیام.",
};
export default function ContactPage() {
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "تماس با ما" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">تماس با ما</h1>
<p className="mb-8 text-[13px] text-body">
سوالی دارید؟ فرم زیر را پر کنید یا مستقیم با ما تماس بگیرید.
</p>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border border-line bg-white p-6 md:p-8">
<h2 className="mb-5 text-[15px] font-bold text-ink">فرم ارسال پیام</h2>
<ContactForm />
</div>
<div className="space-y-5">
<div className="rounded-2xl border border-line bg-white p-6">
<h2 className="mb-4 text-[15px] font-bold text-ink">راه‌های ارتباطی</h2>
<ul className="space-y-4 text-[13px]">
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<PhoneIcon className="size-4.5" />
</span>
<a href={PHONE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
{PHONE}
</a>
</li>
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<PhoneIcon className="size-4.5" />
</span>
<a href={MOBILE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
{MOBILE}
</a>
</li>
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<MailIcon className="size-4.5" />
</span>
<span className="text-body">پاسخگویی از ۹ صبح تا ۹ شب</span>
</li>
</ul>
<div className="mt-5 flex items-center gap-2 border-t border-line pt-5">
<a
href={SOCIAL.instagram}
target="_blank"
rel="noreferrer"
aria-label="اینستاگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<InstagramIcon className="size-4.5" />
</a>
<a
href={SOCIAL.telegram}
target="_blank"
rel="noreferrer"
aria-label="تلگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<TelegramIcon className="size-4.5" />
</a>
</div>
</div>
{BRANCHES.map((branch) => (
<div key={branch.title} className="overflow-hidden rounded-2xl border border-line bg-white">
<div className="flex items-start gap-3 p-5">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<MapPinIcon className="size-4.5" />
</span>
<div>
<p className="text-[13px] font-bold text-ink">{branch.title}</p>
<p className="mt-1 text-[12.5px] leading-6 text-body">{branch.address}</p>
</div>
</div>
<iframe
title={`نقشه ${branch.title}`}
src={`https://www.google.com/maps?q=${encodeURIComponent(branch.mapQuery)}&output=embed`}
className="h-48 w-full border-0 grayscale"
loading="lazy"
/>
</div>
))}
</div>
</div>
</main>
);
}
+20 -9
View File
@@ -60,20 +60,31 @@
padding-inline: 1rem; padding-inline: 1rem;
} }
/* Latin runs (model names, prices in EN digits) inside Farsi copy. */ /* Latin runs (model names, SKUs) inside Farsi copy — Montserrat keeps
real 0-9 digits; IRANYekan fanum would otherwise paint them as ۰-۹. */
.en { .en {
font-family: var(--font-en), var(--font-fa), sans-serif; font-family: var(--font-en), var(--font-fa), sans-serif;
unicode-bidi: isolate;
} }
/* Swiper: the package ships its own CSS, these are the site-level overrides. */ /* Swiper theming: its own CSS reads these custom properties when drawing
.swiper-pagination-bullet { bullets (background: var(--swiper-pagination-bullet-inactive-color, #000)
background: #ffffff; etc.) — setting a plain .swiper-pagination-bullet{background} rule has the
opacity: 0.55; same specificity as swiper's own rule and lost the cascade depending on
width: 8px; chunk order, which is why the dots were showing swiper's black/blue
height: 8px; defaults instead of white. Setting the variables is the way swiper itself
documents for overriding bullet colors, and it's cascade-order-proof. */
.hero-dots {
--swiper-pagination-bullet-inactive-color: #ffffff;
--swiper-pagination-bullet-inactive-opacity: 0.55;
--swiper-pagination-color: #ffffff;
--swiper-pagination-bullet-width: 8px;
--swiper-pagination-bullet-height: 8px;
} }
.swiper-pagination-bullet-active { /* The active pill shape has no swiper variable of its own — .hero-dots
opacity: 1; raises specificity (0,2,0) above swiper's base bullet rule (0,1,0) so
this wins without !important. */
.hero-dots .swiper-pagination-bullet-active {
width: 22px; width: 22px;
border-radius: 4px; border-radius: 4px;
} }
+144
View File
@@ -0,0 +1,144 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import TrustBadges from "@/components/ui/TrustBadges";
import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons";
import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api";
import { buildCategoryTree, findNode } from "@/lib/categoryTree";
import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
export const metadata: Metadata = {
title: "فروش اقساطی",
description: "خرید اقساطی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم دیجیتال تا ۱۴ ماه، فقط با یک برگ چک.",
};
// Categories this page highlights, mapped to real ids from
// GET /tenants/moalem.shop/categories — "لوازم برقی" stands in for
// "خانه هوشمند" from the reference design since this tenant has no such
// category; every other label matches a real one.
const HIGHLIGHT_IDS = ["1251", "1254", "1253", "1256", "1261", "1232", "1260"];
const TRUST_ITEMS = [
{ icon: TruckIcon, title: "ارسال رایگان کالا", desc: "برای سفارش‌های بالای ۵ میلیون تومان" },
{ icon: ShieldIcon, title: "تضمین کیفیت", desc: "ضمانت اصالت تمامی کالاها" },
{ icon: LockIcon, title: "حریم خصوصی", desc: "عدم ذخیره‌سازی اطلاعات شما" },
{ icon: RotateIcon, title: "برگشت پول", desc: "برای اولین سفارش شما" },
{ icon: WalletIcon, title: "ضمانت با چک", desc: "بدون نیاز به ضامن" },
];
export default async function InstallmentsPage() {
const [roots, slots] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
safe(getStaticImageSlots("home"), []),
]);
const categoriesSlot = findSlot(slots, "categories");
// Match by the slug in each image's own linkUrl (…?category=mobile), not
// by array position — position assumes the business's upload order
// matches this page's HIGHLIGHT_IDS order, which isn't a real contract and
// was proven wrong in practice (a laptop photo landing on "موبایل"). See
// AGENTS.md "Static-image slots".
const artBySlug = new Map<string, string>();
for (const img of categoriesSlot?.images ?? []) {
const slug = categorySlugFromLinkUrl(img.linkUrl);
if (slug) artBySlug.set(slug, img.url);
}
const highlights = HIGHLIGHT_IDS.map((id) => {
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
// (1232) is a level-3 category, not level-2.
const cat = findNode(roots, id);
if (!cat) return null;
const art = artBySlug.get(cat.slug) || `/images/categories/${id}.svg`;
return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) };
}).filter((x): x is NonNullable<typeof x> => Boolean(x));
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "فروش اقساطی" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">فروش اقساطی</h1>
<p className="mb-8 text-[13px] text-body">خرید به‌صرفه و مطمئن</p>
{/* Flat hero banner — brand-colored, no photo, no gradient. */}
<section className="overflow-hidden rounded-2xl bg-ink p-8 text-white md:p-12">
<p className="en text-sm font-bold tracking-widest text-white/60">INSTALLMENTS</p>
<p className="mt-2 text-6xl font-black leading-none text-brand md:text-8xl">۱۴ ماه</p>
<h2 className="mt-4 text-xl font-bold md:text-2xl">فروش اقساطی تمامی محصولات</h2>
<p className="mt-1 text-[14px] text-white/70">راهی مطمئن برای خرید آسان و اقساطی</p>
<Link
href="/products"
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا خرید کنید
<ChevronLeft className="size-4" />
</Link>
</section>
{/* Goal + intro */}
<section className="mt-10 grid grid-cols-1 items-center gap-8 md:grid-cols-[200px_1fr]">
<div className="text-center md:text-start">
<p className="text-7xl font-black leading-none text-brand">۱۴</p>
<p className="en text-sm font-bold tracking-widest text-body">MONTH</p>
</div>
<div>
<h2 className="text-lg font-bold text-ink md:text-xl">هدف ما، فروش اقساطی به‌صورت منصفانه و متنوع</h2>
<p className="mt-3 text-[13.5px] leading-8 text-body">
ما فروش اقساطی لوازم دیجیتال ارائه می‌دهیم؛ شامل گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و سایر
محصولات روز. پرداخت اقساط به‌صورت اقساط تا ۱۴ ماه انجام می‌شود و تنها تضمین موردنیاز، چک است.
هدف ما فراهم‌کردن خرید آسان، سریع و مطمئن برای مشتریان است تا بدون فشار مالی، دستگاه موردنظرشان
را تهیه کنند.
</p>
</div>
</section>
{/* Category highlights */}
{highlights.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-bold text-ink md:text-xl">نقد و اقساط ۱۴ ماهه</h2>
<p className="mt-1 text-[13px] text-body">
با شرایطی آسان، هر کالای دیجیتالی و گجت هوشمند مورد علاقه‌تان را به‌صورت اقساطی بخرید
</p>
<div className="mt-6 grid grid-cols-3 gap-6 sm:grid-cols-4 lg:grid-cols-7">
{highlights.map((h) => (
<Link key={h.id} href={h.href} className="group flex flex-col items-center gap-2">
<span className="relative size-[84px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[96px]">
<Image src={h.art} alt={h.label} fill sizes="96px" className="object-contain p-3" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
{h.label}
</span>
</Link>
))}
</div>
</section>
)}
{/* Second promo block */}
<section className="mt-14 grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-[1fr_220px] md:p-10">
<div>
<h2 className="text-lg font-bold text-ink md:text-xl">
فروش اقساطی تمامی اجناس و پرداخت حداکثر تا ۱۴ ماه
</h2>
<p className="mt-3 text-[13.5px] leading-8 text-body">
برای سهولت بیشتر، تنها با ۲۰٪ پیش‌پرداخت می‌توانید محصول مورد نظرتان را دریافت کنید. باقی مبلغ
در بلندمدت تا ۱۴ ماه تسویه می‌شود و کارمزدها هم کاملاً منصفانه و شفاف هستند. با این شرایط، خرید
راحت، سریع و بدون فشار مالی انجام می‌شود.
</p>
<Link
href="/contact"
className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا ثبت‌نام بگیرید
<ChevronLeft className="size-4" />
</Link>
</div>
<p className="text-center text-8xl font-black leading-none text-brand md:text-9xl">۱۴</p>
</section>
<section className="mt-14">
<TrustBadges items={TRUST_ITEMS} />
</section>
</main>
);
}
+19 -8
View File
@@ -10,7 +10,9 @@ import { getCategories, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree"; import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { SITE_NAME } from "@/data/site"; import { SITE_NAME } from "@/data/site";
import { SITE_URL } from "@/lib/config"; import { LOGO_HEIGHT, LOGO_SRC, LOGO_WIDTH, SITE_URL } from "@/lib/config";
import { ToastProvider } from "@/components/providers/ToastProvider";
import { CartProvider } from "@/components/providers/CartProvider";
// Montserrat is the Latin face the live moalem.shop theme pairs with IRANYekan. // Montserrat is the Latin face the live moalem.shop theme pairs with IRANYekan.
const montserrat = Montserrat({ const montserrat = Montserrat({
@@ -20,26 +22,31 @@ const montserrat = Montserrat({
display: "swap", display: "swap",
}); });
export const metadata: Metadata = { const BASE_DESCRIPTION =
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.";
// Tab icon and og:image use the bundled brand files in /public — on purpose
// not GET /website/favicon (that endpoint served another business's mark).
export function generateMetadata(): Metadata {
return {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
title: { title: {
default: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`, default: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
template: `%s | ${SITE_NAME}`, template: `%s | ${SITE_NAME}`,
}, },
description: description: BASE_DESCRIPTION,
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
icons: [{ url: "/favicon.png", type: "image/png" }], icons: [{ url: "/favicon.png", type: "image/png" }],
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fa_IR", locale: "fa_IR",
siteName: SITE_NAME, siteName: SITE_NAME,
title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`, title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
description: description: BASE_DESCRIPTION,
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
url: SITE_URL, url: SITE_URL,
images: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }], images: [{ url: LOGO_SRC, width: LOGO_WIDTH, height: LOGO_HEIGHT, alt: SITE_NAME }],
}, },
}; };
}
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categories, user] = await Promise.all([ const [categories, user] = await Promise.all([
@@ -51,6 +58,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}> <html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col"> <body className="flex min-h-full flex-col">
<ToastProvider>
<CartProvider>
<TopBar /> <TopBar />
<Header menu={menu} user={user} /> <Header menu={menu} user={user} />
<main className="flex-1 pb-6">{children}</main> <main className="flex-1 pb-6">{children}</main>
@@ -58,6 +67,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
{/* Spacer so the fixed mobile bar never covers the end of the footer. */} {/* Spacer so the fixed mobile bar never covers the end of the footer. */}
<div className="h-[68px] lg:hidden" /> <div className="h-[68px] lg:hidden" />
<MobileBottomNav categories={menu} user={user} /> <MobileBottomNav categories={menu} user={user} />
</CartProvider>
</ToastProvider>
</body> </body>
</html> </html>
); );
+3 -5
View File
@@ -14,7 +14,7 @@ import {
getStoreSpecials, getStoreSpecials,
safe, safe,
} from "@/lib/api"; } from "@/lib/api";
import { buildCategoryTree, carouselCategories, findNode, subtreeIds } from "@/lib/categoryTree"; import { buildCategoryTree, findNode, subtreeIds } from "@/lib/categoryTree";
import { productToCard, specialToCards } from "@/lib/cards"; import { productToCard, specialToCards } from "@/lib/cards";
import { blogHref, categoryHref } from "@/lib/slug"; import { blogHref, categoryHref } from "@/lib/slug";
import { HOME_CAROUSELS } from "@/data/site"; import { HOME_CAROUSELS } from "@/data/site";
@@ -57,8 +57,6 @@ export default async function HomePage() {
safe(getStaticImageSlots("home"), []), safe(getStaticImageSlots("home"), []),
]); ]);
const carouselCats = carouselCategories(categories);
// Empty or failing slots fall through to each section's own placeholder — // Empty or failing slots fall through to each section's own placeholder —
// findSlot() already returns undefined for an empty/missing key. // findSlot() already returns undefined for an empty/missing key.
const sliderSlot = findSlot(imageSlots, "slider"); const sliderSlot = findSlot(imageSlots, "slider");
@@ -124,7 +122,7 @@ export default async function HomePage() {
})) }))
: FALLBACK_BLOGS.map((b) => ({ : FALLBACK_BLOGS.map((b) => ({
id: b.id, id: b.id,
href: "/blog", href: blogHref(b.id, b.title),
title: b.title, title: b.title,
image: b.image, image: b.image,
category: b.category, category: b.category,
@@ -141,7 +139,7 @@ export default async function HomePage() {
<> <>
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی</h1> <h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی</h1>
<HeroSlider slot={sliderSlot} slides={slides} /> <HeroSlider slot={sliderSlot} slides={slides} />
<CategoryCarousel categories={carouselCats} slot={categoriesSlot} /> <CategoryCarousel roots={categories} slot={categoriesSlot} />
<HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} /> <HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
<ThreeBanners slot={threeBannerSlot} /> <ThreeBanners slot={threeBannerSlot} />
{mobileCarousel && ( {mobileCarousel && (
+9 -7
View File
@@ -10,7 +10,7 @@ import {
getComments, getComments,
getProductById, getProductById,
getProductTechnicalInfo, getProductTechnicalInfo,
getStoreItemsByProduct, getStoreItemByProduct,
safe, safe,
} from "@/lib/api"; } from "@/lib/api";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
@@ -50,12 +50,13 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const { product, relatedProducts } = data; const { product, relatedProducts } = data;
const title = product.nameFa || product.title; const title = product.nameFa || product.title;
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([ const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemsByProduct(product.id), { items: [] }), safe(getStoreItemByProduct(product.id), { storeItem: null }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }), safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }), safe(getComments("product", product.id), { items: [] }),
getSession(), getSession(),
]); ]);
const variants = storeItem?.variants ?? [];
const images = [ const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []), ...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
@@ -76,10 +77,11 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
]} ]}
/> />
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]"> <ProductBuyPanel
<ProductGallery images={images} title={title} /> product={product}
<ProductBuyPanel product={product} items={storeItems} user={user} /> variants={variants}
</div> gallery={<ProductGallery images={images} title={title} />}
/>
<ProductInfoTabs <ProductInfoTabs
title={title} title={title}
+125
View File
@@ -0,0 +1,125 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductGallery from "@/components/ProductGallery";
import ProductBuyPanel from "@/components/ProductBuyPanel";
import ProductInfoTabs from "@/components/ProductInfoTabs";
import ProductCard from "@/components/ui/ProductCard";
import SectionHeader from "@/components/ui/SectionHeader";
import {
getComments,
getProductById,
getProductTechnicalInfo,
getStoreItemById,
getStoreItemByProduct,
safe,
} from "@/lib/api";
import { getSession } from "@/lib/auth";
import { buildSpecRows } from "@/lib/technicalInfo";
import { productToCard } from "@/lib/cards";
import { categoryHref } from "@/lib/slug";
// A store-item is a specific purchasable variant, not its own content
// entity — there's no slug, no separate gallery/description/comments for
// it. This page resolves the variant's productId and reuses the product's
// real content (images, description, technical specs, comments), the same
// way ProductBuyPanel's own variant switcher already works — just entered
// from a variant-first URL with that variant preselected.
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const { variant } = await getStoreItemById(id);
const title = variant.productNameFa || variant.productTitle;
return {
title,
description: title.slice(0, 160),
openGraph: variant.productImage ? { images: [{ url: variant.productImage }] } : undefined,
};
} catch {
return {};
}
}
export default async function StoreItemDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let variant;
try {
({ variant } = await getStoreItemById(id));
} catch {
notFound();
}
let data;
try {
data = await getProductById(variant.productId);
} catch {
notFound();
}
const { product, relatedProducts } = data;
const title = product.nameFa || product.title;
const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemByProduct(product.id), { storeItem: null }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
getSession(),
]);
const variants = storeItem?.variants ?? [];
const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
...product.images.map((img) => img.url),
].filter((url, i, all) => all.indexOf(url) === i);
const specRows = buildSpecRows(technicalInfo.form, technicalInfo.values);
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs
items={[
{ label: "کالاهای موجود", href: "/store-items" },
...(product.categoryId && product.categoryNameFa
? [{ label: product.categoryNameFa, href: categoryHref(product.categoryId, product.categoryNameFa) }]
: []),
{ label: title },
]}
/>
<ProductBuyPanel
product={product}
variants={variants}
initialVariantId={variant.id}
gallery={<ProductGallery images={images} title={title} />}
/>
<ProductInfoTabs
title={title}
descriptionHtml={product.descriptionHtml}
specRows={specRows}
comments={comments}
productId={product.id}
user={user}
/>
{relatedProducts.length > 0 && (
<div className="mt-14 md:mt-20">
<SectionHeader
title="محصولات مشابه"
href={product.categoryId ? categoryHref(product.categoryId, product.categoryNameFa) : "/products"}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4">
{relatedProducts.map((p) => (
<ProductCard key={p.id} product={productToCard(p)} />
))}
</div>
</div>
)}
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import Breadcrumbs from "@/components/Breadcrumbs";
import ProductCard from "@/components/ui/ProductCard";
import Pagination from "@/components/ui/Pagination";
import { getStoreItems, safe } from "@/lib/api";
import { storeItemToCard } from "@/lib/cards";
const PAGE_SIZE = 24;
export const metadata: Metadata = {
title: "کالاهای موجود",
description: "فهرست کامل کالاهای قابل خرید فروشگاه موبایل معلم با قیمت و موجودی نهایی.",
};
// Unlike /products (the full catalog, including items with no price yet),
// every card here always has a real price and stock count — this lists
// /store-items directly, the purchasable-variant layer. Empty on this
// tenant today (see AGENTS.md "What the tenant actually returns").
export default async function StoreItemsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; name?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const name = params.name?.trim();
const result = await safe(getStoreItems({ page, pageSize: PAGE_SIZE, name }), {
items: [],
total: 0,
page,
pageSize: PAGE_SIZE,
});
const buildHref = (p: number) => {
const qs = new URLSearchParams();
if (name) qs.set("name", name);
if (p > 1) qs.set("page", String(p));
const query = qs.toString();
return `/store-items${query ? `?${query}` : ""}`;
};
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "کالاهای موجود" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">
{name ? `نتایج جستجوی «${name}»` : "کالاهای موجود"}
</h1>
<p className="mb-8 text-[13px] text-body">{result.total.toLocaleString("fa-IR")} کالا</p>
{result.items.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:gap-5 lg:grid-cols-4 xl:grid-cols-5">
{result.items.map((item) => (
<ProductCard key={item.id} product={storeItemToCard(item)} />
))}
</div>
) : (
<p className="rounded-xl border border-dashed border-line bg-surface py-16 text-center text-sm text-body">
کالایی برای نمایش ثبت نشده است.
</p>
)}
<Pagination page={page} pageSize={PAGE_SIZE} total={result.total} buildHref={buildHref} />
</main>
);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default function BlogCarousel({ posts }: { posts: BlogCardData[] }) {
href={post.href} href={post.href}
className="flex h-full flex-col overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]" className="flex h-full flex-col overflow-hidden rounded-xl border border-line bg-white transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
> >
<span className="relative block aspect-[16/9] w-full bg-surface"> <span className="relative block aspect-square w-full bg-surface">
{post.image && ( {post.image && (
<Image <Image
src={post.image} src={post.image}
+5 -2
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import MixedText from "./MixedText";
import { ChevronLeft } from "./icons"; import { ChevronLeft } from "./icons";
export interface Crumb { export interface Crumb {
@@ -14,10 +15,12 @@ export default function Breadcrumbs({ items }: { items: Crumb[] }) {
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />} {i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
{item.href ? ( {item.href ? (
<Link href={item.href} className="transition hover:text-brand"> <Link href={item.href} className="transition hover:text-brand">
{item.label} <MixedText>{item.label}</MixedText>
</Link> </Link>
) : ( ) : (
<span className="text-ink-soft">{item.label}</span> <span className="text-ink-soft">
<MixedText>{item.label}</MixedText>
</span>
)} )}
</span> </span>
))} ))}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { CartIcon as CartGlyph } from "./icons";
import { useCart } from "./providers/CartProvider";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import { encodeGuestCart } from "@/lib/guestCart";
import { hasCustomerSessionCookie } from "@/lib/authUser";
import { formatPrice, toFaDigits } from "@/lib/format";
// Header/bottom-nav mini-cart per AI_PROMPT.md "Shopping cart": badge with
// the live item count, a popup previewing the (local-only) cart, and a
// "ادامه" button that hands the guest cart off to the customer dashboard —
// this storefront has no login/checkout/address/payment pages of its own,
// by design (see AGENTS.md).
export default function CartButton({
className = "",
labelClassName = "",
showLabel = false,
placement = "down",
wrapperClassName = "relative",
}: {
className?: string;
labelClassName?: string;
showLabel?: boolean;
/** "up" for the mobile bottom nav, where the button sits at the screen edge. */
placement?: "down" | "up";
/** The bottom nav needs `flex-1` on this wrapper (not just the button) to
* match its three sibling nav items' width — the header doesn't. */
wrapperClassName?: string;
}) {
const { items, count, total, updateItem, removeItem } = useCart();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
function handleContinue() {
const encoded = encodeGuestCart(items);
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
const url = hasCustomerSessionCookie()
? `${CUSTOMER_PORTAL_URL}${checkoutPath}`
: `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(checkoutPath)}`;
window.location.href = url;
}
return (
<div ref={ref} className={wrapperClassName}>
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label="سبد خرید"
className={className}
>
<span className="relative inline-flex">
<CartGlyph className="size-5" />
{count > 0 && (
<span className="absolute -left-2 -top-2 flex size-4 items-center justify-center rounded-full bg-white text-[10px] font-bold text-brand">
{toFaDigits(count > 9 ? "9+" : count)}
</span>
)}
</span>
{showLabel && <span className={labelClassName}>سبد خرید</span>}
</button>
{open && (
<div
// "up" (mobile bottom nav) uses `fixed` against the viewport, not
// `absolute` against this button's own flex-1 column (~1/4 of a
// 4-item row) — an inset anchored to that narrow column would
// squeeze the whole cart preview into it instead of spanning
// near-full width above the bar.
className={`z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl ${
placement === "up" ? "fixed inset-x-4 bottom-[76px]" : "absolute left-0 top-full mt-2 w-80"
}`}
>
<div className="border-b border-line px-4 py-3">
<h3 className="text-[14px] font-bold text-ink">سبد خرید</h3>
</div>
{items.length === 0 ? (
<p className="p-6 text-center text-[13px] text-body">سبد خرید شما خالی است.</p>
) : (
<>
<ul className="max-h-80 divide-y divide-line overflow-auto">
{items.map((item) => (
<li key={item.id} className="flex items-center gap-3 px-4 py-3">
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
{item.image && (
<Image src={item.image} alt={item.name} fill sizes="48px" className="object-contain" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{item.name}</span>
<span className="mt-1 flex items-center gap-1.5">
<button
onClick={() => updateItem(item.id, item.quantity - 1)}
aria-label="کاهش تعداد"
className="flex size-5 items-center justify-center rounded border border-line text-[12px] text-ink-soft transition hover:border-brand hover:text-brand"
>
−
</button>
<span className="w-5 text-center text-[11px] text-body">{toFaDigits(item.quantity)}</span>
<button
onClick={() => updateItem(item.id, item.quantity + 1)}
aria-label="افزایش تعداد"
className="flex size-5 items-center justify-center rounded border border-line text-[12px] text-ink-soft transition hover:border-brand hover:text-brand"
>
+
</button>
</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-2">
<span className="text-[12px] font-bold text-ink">{formatPrice(item.price)}</span>
<button
onClick={() => removeItem(item.id)}
aria-label="حذف از سبد خرید"
className="text-[11px] text-body underline-offset-2 transition hover:text-brand hover:underline"
>
حذف
</button>
</span>
</li>
))}
</ul>
<div className="border-t border-line p-4">
<div className="mb-3 flex items-center justify-between text-[13px]">
<span className="text-body">جمع کل</span>
<span className="font-bold text-ink">
{formatPrice(total)}
<span className="mr-1 font-normal text-body">تومان</span>
</span>
</div>
<button
onClick={handleContinue}
className="flex h-11 w-full items-center justify-center rounded-lg bg-brand text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
ادامه
</button>
</div>
</>
)}
</div>
)}
</div>
);
}
+45 -26
View File
@@ -6,24 +6,36 @@ import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css"; import "swiper/css";
import "swiper/css/free-mode"; import "swiper/css/free-mode";
import { categoryHref } from "@/lib/slug"; import { findNodeBySlug } from "@/lib/categoryTree";
import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
import type { CategoryNode, StaticImageSlot } from "@/lib/types"; import type { CategoryNode, StaticImageSlot } from "@/lib/types";
// Circular category thumbnails. The "categories" static-image slot has no // Fully slot-driven: each image in the "categories" slot carries its own
// per-category id, so its images (kind: list, duplicatable) are matched // linkUrl (e.g. "...?category=mobile"), whose `category` param is the real
// positionally to the category list, in the same order the business fills // DB slug — that's what identifies which category a photo is FOR (used to
// them in — each still links to its real category page, only the art swaps. // build the real, in-site link target). The visible caption is the
// A category past the end of the slot, or the whole thing when the slot is // business's own titleFa for that image, taken as typed — a category the
// empty, keeps its placeholder at public/images/categories/{id}.svg. // business hasn't uploaded a photo for simply isn't shown here, no
// placeholder, per the "only what's in the panel" brief.
// See AGENTS.md "Static-image slots" for the mismatch this replaced.
export default function CategoryCarousel({ export default function CategoryCarousel({
categories, roots,
slot, slot,
}: { }: {
categories: CategoryNode[]; roots: CategoryNode[];
slot?: StaticImageSlot; slot?: StaticImageSlot;
}) { }) {
if (categories.length === 0) return null; const items = (slot?.images ?? [])
const slotImages = slot?.images ?? []; .map((img) => {
const catSlug = categorySlugFromLinkUrl(img.linkUrl);
const cat = catSlug ? findNodeBySlug(roots, catSlug) : undefined;
if (!cat) return null;
const label = img.titleFa || img.titleEn || cat.nameFa || cat.name;
return { key: img.id, art: img.url, label, href: categoryHref(cat.id, cat.nameFa, cat.name) };
})
.filter((x): x is { key: string; art: string; label: string; href: string } => Boolean(x));
if (items.length === 0) return null;
return ( return (
<section className="boxed mt-8" aria-label="دسته‌بندی محصولات"> <section className="boxed mt-8" aria-label="دسته‌بندی محصولات">
@@ -31,30 +43,37 @@ export default function CategoryCarousel({
modules={[FreeMode]} modules={[FreeMode]}
dir="rtl" dir="rtl"
freeMode freeMode
slidesPerView={3.5} slidesPerView={2.8}
spaceBetween={12} spaceBetween={12}
breakpoints={{ breakpoints={{
480: { slidesPerView: 4.5 }, 480: { slidesPerView: 3.6 },
768: { slidesPerView: 6.5 }, 768: { slidesPerView: 5 },
1024: { slidesPerView: 9 }, 1024: { slidesPerView: 7 },
}} }}
> >
{categories.map((cat, i) => { {items.map(({ key, art, label, href }) => (
const label = cat.nameFa || cat.name; <SwiperSlide key={key}>
const art = slotImages[i]?.url || `/images/categories/${cat.id}.svg`; <Link href={href} className="group flex flex-col items-center gap-2">
return ( {/* A real border, not ring/box-shadow — box-shadow rings can
<SwiperSlide key={cat.id}> paint oddly against Swiper's transformed slide track
<Link href={categoryHref(cat.id, cat.nameFa, cat.name)} className="group flex flex-col items-center gap-2"> (looked like the border "leaking" into the row above).
<span className="relative size-[90px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft"> border-box sizing (this site's global reset) means the
<Image src={art} alt={label} fill sizes="90px" className="object-contain p-2" /> 1px border adds no layout shift. */}
<span className="relative size-[104px] overflow-hidden rounded-full border border-transparent bg-surface transition-all duration-300 group-hover:border-brand group-hover:shadow-md sm:size-[124px] lg:size-[140px]">
<Image
src={art}
alt={label}
fill
sizes="140px"
className="rounded-full object-contain p-3 transition-transform duration-300 group-hover:scale-110"
/>
</span> </span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand"> <span className="text-center text-[13.5px] text-ink transition group-hover:text-brand">
{label} {label}
</span> </span>
</Link> </Link>
</SwiperSlide> </SwiperSlide>
); ))}
})}
</Swiper> </Swiper>
</section> </section>
); );
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { submitContactForm } from "@/lib/api";
import { useToast } from "./providers/ToastProvider";
import { CheckIcon } from "./icons";
type Status = "idle" | "submitting" | "sent" | "error";
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
// documented endpoint, no bearer required. On success: a toast, inline
// success text, and the form resets and stays put (not swapped for a
// separate "thank you" panel) so another message can be sent right away.
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const { showToast } = useToast();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
const name = String(data.get("name") || "").trim();
const title = String(data.get("title") || "").trim();
const text = String(data.get("text") || "").trim();
const email = String(data.get("email") || "").trim();
const cellNumber = String(data.get("cellNumber") || "").trim();
if (!name || !title || !text) return;
setStatus("submitting");
try {
await submitContactForm({
name,
title,
text,
email: email || undefined,
cellNumber: cellNumber || undefined,
});
form.reset();
setStatus("sent");
showToast("پیام شما با موفقیت ارسال شد");
} catch {
setStatus("error");
}
}
const input =
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
return (
<form onSubmit={handleSubmit} onChange={() => status === "sent" && setStatus("idle")} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="name" required placeholder="نام و نام خانوادگی" className={input} />
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="email" type="email" placeholder="ایمیل (اختیاری)" className={input} />
<input name="title" required placeholder="موضوع پیام" className={input} />
</div>
<textarea
name="text"
required
rows={5}
placeholder="متن پیام شما…"
className="w-full rounded-xl border border-line bg-white p-4 text-sm outline-none transition focus:border-brand"
/>
{status === "error" && (
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p>
)}
{status === "sent" && (
<p className="flex items-center gap-1.5 text-[12.5px] font-bold text-brand">
<CheckIcon className="size-4" />
پیام شما ارسال شد. همکاران ما در اسرع وقت با شما تماس می‌گیرند.
</p>
)}
<button
type="submit"
disabled={status === "submitting"}
className="flex h-12 w-full items-center justify-center rounded-xl bg-brand text-sm font-bold text-white transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-10"
>
{status === "submitting" ? "در حال ارسال…" : "ارسال پیام"}
</button>
</form>
);
}
+31 -3
View File
@@ -1,9 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE } from "@/data/site"; import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE, SOCIAL } from "@/data/site";
import { LOGO_HEIGHT, LOGO_SRC, LOGO_WIDTH } from "@/lib/config";
import { categoryHref } from "@/lib/slug"; import { categoryHref } from "@/lib/slug";
import type { CategoryNode } from "@/lib/types"; import type { CategoryNode } from "@/lib/types";
import { PhoneIcon } from "./icons"; import { InstagramIcon, PhoneIcon, TelegramIcon } from "./icons";
import { toFaDigits } from "@/lib/format"; import { toFaDigits } from "@/lib/format";
export default function Footer({ categories }: { categories: CategoryNode[] }) { export default function Footer({ categories }: { categories: CategoryNode[] }) {
@@ -11,7 +12,13 @@ export default function Footer({ categories }: { categories: CategoryNode[] }) {
<footer className="mt-14 border-t border-line bg-surface"> <footer className="mt-14 border-t border-line bg-surface">
<div className="boxed grid gap-8 py-10 md:grid-cols-4"> <div className="boxed grid gap-8 py-10 md:grid-cols-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} /> <Image
src={LOGO_SRC}
alt={SITE_NAME}
width={LOGO_WIDTH}
height={LOGO_HEIGHT}
className="h-[70px] object-contain"
/>
<p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p> <p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p>
<a <a
href={PHONE_HREF} href={PHONE_HREF}
@@ -20,6 +27,27 @@ export default function Footer({ categories }: { categories: CategoryNode[] }) {
<PhoneIcon className="size-4" /> <PhoneIcon className="size-4" />
{PHONE} {PHONE}
</a> </a>
<div className="mt-4 flex items-center gap-2">
<a
href={SOCIAL.instagram}
target="_blank"
rel="noreferrer"
aria-label="اینستاگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<InstagramIcon className="size-4.5" />
</a>
<a
href={SOCIAL.telegram}
target="_blank"
rel="noreferrer"
aria-label="تلگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<TelegramIcon className="size-4.5" />
</a>
</div>
</div> </div>
<div> <div>
+20 -13
View File
@@ -5,15 +5,22 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import SearchBox from "./SearchBox"; import SearchBox from "./SearchBox";
import AccountMenu from "./AccountMenu"; import AccountMenu from "./AccountMenu";
import { CartIcon, ChevronDown, GridIcon, PhoneIcon } from "./icons"; import CartButton from "./CartButton";
import { ChevronDown, GridIcon, PhoneIcon } from "./icons";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site"; import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME } from "@/data/site";
import { CUSTOMER_PORTAL_URL } from "@/lib/config"; import { LOGO_HEIGHT, LOGO_SRC, LOGO_WIDTH } from "@/lib/config";
import { categoryHref } from "@/lib/slug"; import { categoryHref } from "@/lib/slug";
import type { AuthUser, CategoryNode } from "@/lib/types"; import type { AuthUser, CategoryNode } from "@/lib/types";
// Two rows: logo + search + account/cart, then the product mega menu and the // Two rows: logo + search + account/cart, then the product mega menu and the
// main nav — the same split the reference storefront uses. // main nav — the same split the reference storefront uses.
export default function Header({ menu, user }: { menu: CategoryNode[]; user: AuthUser | null }) { export default function Header({
menu,
user,
}: {
menu: CategoryNode[];
user: AuthUser | null;
}) {
const [megaOpen, setMegaOpen] = useState(false); const [megaOpen, setMegaOpen] = useState(false);
return ( return (
@@ -21,9 +28,16 @@ export default function Header({ menu, user }: { menu: CategoryNode[]; user: Aut
<div className="boxed"> <div className="boxed">
{/* Mobile keeps the header down to the search box alone — account, {/* Mobile keeps the header down to the search box alone — account,
cart and browsing live in the fixed bottom bar instead. */} cart and browsing live in the fixed bottom bar instead. */}
<div className="flex h-[70px] items-center gap-4"> <div className="flex items-center gap-4 pt-5">
<Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}> <Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}>
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} priority /> <Image
src={LOGO_SRC}
alt={SITE_NAME}
width={LOGO_WIDTH}
height={LOGO_HEIGHT}
className="h-8 w-auto object-contain"
priority
/>
</Link> </Link>
<div className="flex-1"> <div className="flex-1">
@@ -32,14 +46,7 @@ export default function Header({ menu, user }: { menu: CategoryNode[]; user: Aut
<div className="mr-auto hidden items-center gap-2 lg:flex"> <div className="mr-auto hidden items-center gap-2 lg:flex">
<AccountMenu user={user} /> <AccountMenu user={user} />
<Link <CartButton className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark" />
href={`${CUSTOMER_PORTAL_URL}/cart`}
className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark"
aria-label="سبد خرید"
>
<CartIcon className="size-5" />
<span className="font-bold">۰</span>
</Link>
</div> </div>
</div> </div>
+431
View File
@@ -0,0 +1,431 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { CheckIcon, CloseIcon, WalletIcon } from "./icons";
import { clampCreditAmount, fetchStoreLoanMethods, fetchStoreLoanPlan } from "@/lib/loanMethods";
import { formatPrice, toFaDigits } from "@/lib/format";
import type { StoreLoanMethod, StoreLoanPlan } from "@/lib/types";
type Props = {
open: boolean;
totalValue: number | null;
onClose: () => void;
};
function formatDueDate(iso: string) {
try {
return new Date(iso).toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch {
return iso;
}
}
export default function LoanCalculatorModal({ open, totalValue, onClose }: Props) {
const [methods, setMethods] = useState<StoreLoanMethod[]>([]);
const [enabled, setEnabled] = useState(true);
const [loadingMethods, setLoadingMethods] = useState(false);
const [methodId, setMethodId] = useState<string | null>(null);
const [months, setMonths] = useState(0);
const [creditAmount, setCreditAmount] = useState(0);
const [plan, setPlan] = useState<StoreLoanPlan | null>(null);
const [calculating, setCalculating] = useState(false);
const [error, setError] = useState<string | null>(null);
const method = useMemo(
() => methods.find((m) => m.id === methodId) ?? null,
[methods, methodId],
);
const monthOptions = method?.returnMonthOptions?.length
? method.returnMonthOptions
: method
? Array.from(
{ length: Math.floor((method.returnMonthsMax - method.returnMonthsMin) / method.returnMonthsStep) + 1 },
(_, i) => method.returnMonthsMin + i * method.returnMonthsStep,
)
: [];
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Load methods when opened.
useEffect(() => {
if (!open) return;
const controller = new AbortController();
let cancelled = false;
setLoadingMethods(true);
setError(null);
setPlan(null);
fetchStoreLoanMethods(controller.signal)
.then((data) => {
if (cancelled) return;
setEnabled(data.enabled);
setMethods(data.items);
if (data.items.length === 1) {
setMethodId(data.items[0]!.id);
} else if (data.items.length > 1) {
setMethodId(null);
}
})
.catch((err: unknown) => {
if (cancelled || (err as Error).name === "AbortError") return;
setEnabled(false);
setMethods([]);
setError("دریافت روش‌های اقساط ممکن نشد.");
})
.finally(() => {
if (!cancelled) setLoadingMethods(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [open]);
// When method or price changes, seed credit + months.
useEffect(() => {
if (!method || totalValue === null) return;
const credit = clampCreditAmount(
totalValue,
totalValue,
method.minAmount,
method.maxAmount,
);
setCreditAmount(credit);
const options = method.returnMonthOptions?.length
? method.returnMonthOptions
: [method.returnMonthsMin];
setMonths(options[0] ?? method.returnMonthsMin);
setPlan(null);
}, [method, totalValue]);
// Calculate whenever inputs are ready.
useEffect(() => {
if (!open || !method || totalValue === null || months < 1 || creditAmount < 1) return;
if (totalValue < method.minShoppingAmount) {
setPlan(null);
setError(
`حداقل مبلغ خرید برای این طرح ${formatPrice(method.minShoppingAmount)} تومان است.`,
);
return;
}
const controller = new AbortController();
let cancelled = false;
const timer = setTimeout(() => {
setCalculating(true);
setError(null);
fetchStoreLoanPlan(
{
methodId: method.id,
totalValue,
creditAmount,
months,
},
controller.signal,
)
.then((data) => {
if (cancelled) return;
setPlan(data.plan);
setError(null);
})
.catch((err: unknown) => {
if (cancelled || (err as Error).name === "AbortError") return;
setPlan(null);
setError((err as Error).message || "محاسبه اقساط ناموفق بود.");
})
.finally(() => {
if (!cancelled) setCalculating(false);
});
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
controller.abort();
};
}, [open, method, totalValue, creditAmount, months]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open || !mounted) return null;
const needsMethodPick = methods.length > 1 && !methodId;
const priceLabel = totalValue !== null ? formatPrice(totalValue) : "—";
return createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-ink/45 p-0 sm:items-center sm:p-4"
onClick={onClose}
role="presentation"
>
<div
className="flex max-h-[92vh] w-full max-w-lg flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:rounded-3xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="loan-calc-title"
>
<div className="flex items-start justify-between gap-3 border-b border-line px-5 py-4">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-[12px] font-bold text-brand">
<WalletIcon className="size-4" />
محاسبه اقساط
</p>
<h2 id="loan-calc-title" className="mt-1 text-lg font-bold text-ink">
این محصول را اقساطی بخرید
</h2>
<p className="mt-0.5 text-[13px] text-body">
مبلغ کالا: <span className="font-bold text-ink">{priceLabel}</span>
{totalValue !== null && <span className="mr-1">تومان</span>}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<CloseIcon className="size-4.5" />
</button>
</div>
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-5">
{loadingMethods && (
<p className="py-8 text-center text-sm text-body">در حال دریافت روش‌های اقساط…</p>
)}
{!loadingMethods && (!enabled || methods.length === 0) && (
<p className="rounded-xl bg-surface px-4 py-6 text-center text-sm text-body">
در حال حاضر فروش اقساطی برای این فروشگاه فعال نیست.
</p>
)}
{!loadingMethods && needsMethodPick && (
<section>
<h3 className="mb-3 text-sm font-bold text-ink">انتخاب طرح اقساط</h3>
<div className="space-y-2">
{methods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMethodId(m.id)}
className="flex w-full items-center gap-3 rounded-2xl border border-line p-3 text-start transition hover:border-brand"
>
<span className="relative size-12 shrink-0 overflow-hidden rounded-xl bg-surface">
{m.imageUrl ? (
<Image src={m.imageUrl} alt="" fill sizes="48px" className="object-cover" />
) : (
<span className="flex size-full items-center justify-center text-brand">
<WalletIcon className="size-5" />
</span>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold text-ink">{m.nameFa || m.nameEn}</span>
{m.description && (
<span className="mt-0.5 block text-[12px] text-body">{m.description}</span>
)}
</span>
</button>
))}
</div>
</section>
)}
{!loadingMethods && method && (
<>
{methods.length > 1 && (
<button
type="button"
onClick={() => {
setMethodId(null);
setPlan(null);
}}
className="text-[12.5px] font-bold text-brand"
>
تغییر طرح: {method.nameFa || method.nameEn}
</button>
)}
{methods.length === 1 && (
<p className="rounded-xl bg-brand-soft px-3 py-2.5 text-[13px] font-bold text-brand">
{method.nameFa || method.nameEn}
{method.description ? ` — ${method.description}` : ""}
</p>
)}
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-bold text-ink">مدت بازپرداخت</h3>
<span className="text-sm font-black text-brand">
{toFaDigits(months)} ماه
</span>
</div>
<div className="flex flex-wrap gap-2">
{monthOptions.map((m) => (
<button
key={m}
type="button"
onClick={() => setMonths(m)}
className={`h-10 min-w-12 rounded-xl px-3 text-sm font-bold transition ${
months === m
? "bg-brand text-white"
: "border border-line text-ink hover:border-brand hover:text-brand"
}`}
>
{toFaDigits(m)}
</button>
))}
</div>
<p className="mt-2 text-[12px] text-body">
سود سالانه {toFaDigits(method.interest)}٪ · اعتبار از{" "}
{formatPrice(method.minAmount)} تا {formatPrice(method.maxAmount)} تومان
</p>
</section>
{totalValue !== null && totalValue > method.minAmount && (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-bold text-ink">مبلغ اعتبار (قسطی)</h3>
<span className="text-sm font-bold text-ink">
{formatPrice(creditAmount)}
<span className="mr-1 text-[11px] font-normal text-body">تومان</span>
</span>
</div>
<input
type="range"
min={Math.min(method.minAmount, totalValue)}
max={Math.min(method.maxAmount, totalValue)}
step={100_000}
value={creditAmount}
onChange={(e) =>
setCreditAmount(
clampCreditAmount(
Number(e.target.value),
totalValue,
method.minAmount,
method.maxAmount,
),
)
}
className="w-full accent-[var(--color-brand)]"
/>
<div className="mt-1 flex justify-between text-[11px] text-body">
<span>نقدی: {formatPrice(Math.max(0, totalValue - creditAmount))} تومان</span>
<span>حداکثر اعتبار</span>
</div>
</section>
)}
{error && (
<p className="rounded-xl bg-brand-soft px-3 py-2.5 text-[13px] text-brand" role="status">
{error}
</p>
)}
{calculating && !plan && (
<p className="text-center text-sm text-body">در حال محاسبه…</p>
)}
{plan && !error && (
<section className="space-y-3">
<h3 className="text-sm font-bold text-ink">جزئیات وام</h3>
<div className="grid grid-cols-2 gap-2">
<SummaryCard label="پرداخت نقدی" value={plan.cashPayment} />
<SummaryCard label="مبلغ اعتبار" value={plan.creditAmount} />
<SummaryCard label="کارمزد / سود" value={plan.interestFee} />
<SummaryCard label="جمع بازپرداخت" value={plan.totalRepay} emphasize />
</div>
<div className="overflow-hidden rounded-2xl border border-line">
<div className="flex items-center justify-between bg-surface px-3 py-2.5 text-[12.5px] font-bold text-ink">
<span>جدول اقساط</span>
<span>{toFaDigits(plan.installments.length)} قسط</span>
</div>
<ul className="max-h-56 divide-y divide-line overflow-y-auto">
{plan.installments.map((item) => (
<li
key={item.index}
className="flex items-center justify-between gap-3 px-3 py-2.5 text-[12.5px]"
>
<span className="flex items-center gap-2 text-body">
<span className="flex size-6 items-center justify-center rounded-full bg-brand-soft text-[11px] font-bold text-brand">
{toFaDigits(item.index)}
</span>
{formatDueDate(item.dueDate)}
</span>
<span className="shrink-0 font-bold text-ink">
{formatPrice(item.amount)}
<span className="mr-1 text-[10px] font-normal text-body">تومان</span>
</span>
</li>
))}
</ul>
</div>
</section>
)}
</>
)}
</div>
{plan && !error && (
<div className="border-t border-line px-5 py-4">
<p className="flex items-center justify-center gap-1.5 text-[12.5px] text-body">
<CheckIcon className="size-4 text-brand" />
برای نهایی کردن خرید اقساطی با فروشگاه هماهنگ کنید
</p>
</div>
)}
</div>
</div>,
document.body,
);
}
function SummaryCard({
label,
value,
emphasize,
}: {
label: string;
value: number;
emphasize?: boolean;
}) {
return (
<div
className={`rounded-2xl px-3 py-3 ${
emphasize ? "col-span-2 bg-brand text-white" : "bg-surface text-ink"
}`}
>
<p className={`text-[11px] ${emphasize ? "text-white/80" : "text-body"}`}>{label}</p>
<p className="mt-0.5 text-sm font-black">
{formatPrice(value)}
<span className={`mr-1 text-[10px] font-normal ${emphasize ? "text-white/80" : "text-body"}`}>
تومان
</span>
</p>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { splitMixedText } from "@/lib/mixedText";
export default function MixedText({
children,
className,
}: {
children: string;
className?: string;
}) {
const nodes: ReactNode[] = splitMixedText(children).map((seg, i) =>
seg.type === "en" ? (
<span key={i} className="en" dir="ltr" lang="en">
{seg.text}
</span>
) : (
<span key={i}>{seg.text}</span>
),
);
if (className) return <span className={className}>{nodes}</span>;
return <>{nodes}</>;
}
+8 -5
View File
@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CartIcon, ChevronDown, GridIcon, UserIcon } from "./icons"; import { ChevronDown, GridIcon, UserIcon } from "./icons";
import CartButton from "./CartButton";
import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config"; import { BUSINESS_PORTAL_URL, CUSTOMER_PORTAL_URL } from "@/lib/config";
import { categoryHref } from "@/lib/slug"; import { categoryHref } from "@/lib/slug";
import { displayName, isAdmin } from "@/lib/authUser"; import { displayName, isAdmin } from "@/lib/authUser";
@@ -130,10 +131,12 @@ export default function MobileBottomNav({
خانه خانه
</Link> </Link>
<Link href={`${CUSTOMER_PORTAL_URL}/cart`} className={`${item} text-ink`}> <CartButton
<CartIcon className="size-6" /> placement="up"
سبد خرید wrapperClassName="relative flex-1"
</Link> className={`${item} w-full text-ink`}
showLabel
/>
<button <button
onClick={() => setSheetOpen((v) => !v)} onClick={() => setSheetOpen((v) => !v)}
+141 -72
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import { useState, type ReactNode } from "react";
import { useState } from "react"; import { CartIcon, CheckIcon, ChevronLeft, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "./icons";
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons"; import LoanCalculatorModal from "./LoanCalculatorModal";
import { formatPrice } from "@/lib/format"; import MixedText from "./MixedText";
import { AUTH_TOKEN_COOKIE, CUSTOMER_PORTAL_URL } from "@/lib/config"; import { useCart } from "./providers/CartProvider";
import { useToast } from "./providers/ToastProvider";
import { formatPrice, isInStock, toFaDigits } from "@/lib/format";
import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing"; import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing";
import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types"; import type { ApiProduct, StoreSpecialVariant } from "@/lib/types";
const trustPoints = [ const trustPoints = [
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" }, { icon: ShieldIcon, title: "ضمانت اصالت کالا" },
@@ -14,22 +16,39 @@ const trustPoints = [
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" }, { icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" },
]; ];
// Add-to-cart follows AI_PROMPT.md "Shopping cart" exactly: variants come
// from GET .../store-items/by-product/{id}, "in stock" means
// stockQuantity === null (unlimited) or > 0, a shopper with more than one
// in-stock variant must pick one before adding, and the cart line's `id` is
// the variant id (storeItemVariantId) — never the product id. No login is
// required to add; the mini-cart is a local guest cart (see
// CartProvider/guestCart.ts) — only "ادامه" in CartButton cares about auth.
export default function ProductBuyPanel({ export default function ProductBuyPanel({
product, product,
items = [], variants = [],
user, initialVariantId,
gallery,
}: { }: {
product: ApiProduct; product: ApiProduct;
items?: StoreItem[]; variants?: StoreSpecialVariant[];
user: AuthUser | null; /** Preselect a specific variant — used when entering via its own
* /store-items/{id} URL rather than the product page. */
initialVariantId?: string;
/** Optional gallery slot — when provided, the installment CTA spans the
* full product row (gallery + buy columns). */
gallery?: ReactNode;
}) { }) {
const [selectedId, setSelectedId] = useState(items[0]?.id); const inStockVariants = variants.filter((v) => isInStock(v.stockQuantity));
const [adding, setAdding] = useState(false); const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
const [quantity, setQuantity] = useState(1);
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [error, setError] = useState<string | null>(null); const [loanOpen, setLoanOpen] = useState(false);
const selected = items.find((i) => i.id === selectedId) ?? items[0]; const { addItem } = useCart();
const { showToast } = useToast();
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
const title = product.nameFa || product.title; const title = product.nameFa || product.title;
const image = product.thumbnailUrl || product.image || product.thumbnail;
const variationName = selected?.selections[0]?.variationName; const variationName = selected?.selections[0]?.variationName;
let price: number | null = null; let price: number | null = null;
@@ -46,77 +65,71 @@ export default function ProductBuyPanel({
oldPrice = demo.oldPrice; oldPrice = demo.oldPrice;
} }
const percent = price !== null && oldPrice !== null && oldPrice > price const percent =
price !== null && oldPrice !== null && oldPrice > price
? Math.round(((oldPrice - price) / oldPrice) * 100) ? Math.round(((oldPrice - price) / oldPrice) * 100)
: 0; : 0;
// Purchasing needs a real store-item variant (cart items are added by // A shopper with more than one in-stock variant must choose before adding
// storeItemVariantId, not productId) — until this product has one, there // — the variant picker below doubles as that requirement, so "ready to
// is nothing a bearer token could add to a cart, logged in or not. // add" just means a selection exists.
const purchasable = items.length > 0 && Boolean(selected); const purchasable = inStockVariants.length > 0 && Boolean(selected);
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock; const maxQuantity = selected?.stockQuantity == null ? 99 : Math.max(1, selected.stockQuantity);
async function handleAddToCart() { function handleAddToCart() {
if (!selected || !user) return; if (!selected) return;
setAdding(true); addItem({
setError(null); id: selected.id,
try { name: title,
const token = document.cookie slug: product.slug,
.split("; ") price: selected.discountedPrice ?? selected.price ?? 0,
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`)) originalPrice: selected.discountedPrice != null ? (selected.price ?? undefined) : undefined,
?.split("=")[1]; image,
if (!token) throw new Error("no-token"); quantity,
});
const res = await fetch(
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ storeItemVariantId: selected.storeItemId, quantity: 1 }),
}
);
if (!res.ok) throw new Error(String(res.status));
setAdded(true); setAdded(true);
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
setTimeout(() => setAdded(false), 1800); setTimeout(() => setAdded(false), 1800);
} catch {
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
} finally {
setAdding(false);
}
} }
return ( const buyColumn = (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10">
<div> <div>
<h1 className="text-xl font-bold text-ink md:text-2xl">{title}</h1> <h1 className="text-xl font-bold text-ink md:text-2xl">
<MixedText>{title}</MixedText>
</h1>
{product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>} {product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
{product.summary && ( {product.summary && (
<div className="mt-5"> <div className="mt-5">
<p className="mb-1.5 text-sm font-bold text-ink">توضیح کوتاه:</p> <p className="mb-1.5 text-sm font-bold text-ink">توضیح کوتاه:</p>
<p className="text-sm leading-7 text-body">{product.summary}</p> <p className="text-sm leading-7 text-body">
<MixedText>{product.summary}</MixedText>
</p>
</div> </div>
)} )}
{items.length > 0 && ( {inStockVariants.length > 0 && (
<div className="mt-5"> <div className="mt-5">
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p> <p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{items.map((item) => { {inStockVariants.map((variant) => {
const rawValue = item.selections[0]?.value; const rawValue = variant.selections[0]?.value;
const colorHex = rawValue?.startsWith("#") ? rawValue : null; const colorHex = rawValue?.startsWith("#") ? rawValue : null;
return ( return (
<button <button
key={item.id} key={variant.id}
type="button" type="button"
onClick={() => setSelectedId(item.id)} onClick={() => {
disabled={item.stockQuantity <= 0} setSelectedId(variant.id);
title={item.label} setQuantity(1);
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${ }}
title={variant.label}
className={`flex items-center justify-center transition ${
colorHex colorHex
? `size-9 rounded-full border-2 ${item.id === selectedId ? "border-brand" : "border-line"}` ? `size-9 rounded-full border-2 ${variant.id === selectedId ? "border-brand" : "border-line"}`
: `h-10 rounded-lg border px-4 text-sm font-bold ${ : `h-10 rounded-lg border px-4 text-sm font-bold ${
item.id === selectedId variant.id === selectedId
? "border-brand bg-brand text-white" ? "border-brand bg-brand text-white"
: "border-line text-ink hover:border-brand hover:text-brand" : "border-line text-ink hover:border-brand hover:text-brand"
}` }`
@@ -125,7 +138,7 @@ export default function ProductBuyPanel({
{colorHex ? ( {colorHex ? (
<span className="block size-6 rounded-full" style={{ background: colorHex }} /> <span className="block size-6 rounded-full" style={{ background: colorHex }} />
) : ( ) : (
item.label variant.label
)} )}
</button> </button>
); );
@@ -149,10 +162,10 @@ export default function ProductBuyPanel({
<div className="mb-4 flex items-center justify-between border-b border-line pb-4"> <div className="mb-4 flex items-center justify-between border-b border-line pb-4">
<span <span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${ className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${
inStock ? "bg-brand-soft text-brand" : "bg-surface text-body" purchasable ? "bg-brand-soft text-brand" : "bg-surface text-body"
}`} }`}
> >
{inStock ? "آماده ارسال" : "ناموجود"} {purchasable ? "آماده ارسال" : "ناموجود"}
</span> </span>
<TruckIcon className="size-5 text-body" /> <TruckIcon className="size-5 text-body" />
</div> </div>
@@ -186,19 +199,34 @@ export default function ProductBuyPanel({
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body"> <p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
این محصول هنوز برای خرید آنلاین فعال نشده است. این محصول هنوز برای خرید آنلاین فعال نشده است.
</p> </p>
) : !user ? (
<Link
href={CUSTOMER_PORTAL_URL}
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-brand text-sm font-bold text-white transition hover:bg-brand-dark"
>
برای خرید وارد شوید
</Link>
) : ( ) : (
<div className="flex items-stretch gap-2">
<div className="flex h-12 items-center rounded-lg border border-line">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
disabled={quantity <= 1}
aria-label="کاهش تعداد"
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
>
−
</button>
<span className="w-8 text-center text-sm font-bold text-ink">{toFaDigits(quantity)}</span>
<button
type="button"
onClick={() => setQuantity((q) => Math.min(maxQuantity, q + 1))}
disabled={quantity >= maxQuantity}
aria-label="افزایش تعداد"
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
>
+
</button>
</div>
<button <button
type="button" type="button"
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={!inStock || adding} className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition ${
className={`flex h-12 w-full items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark" added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`} }`}
> >
@@ -210,12 +238,12 @@ export default function ProductBuyPanel({
) : ( ) : (
<> <>
<CartIcon className="size-4.5" /> <CartIcon className="size-4.5" />
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"} افزودن به سبد خرید
</> </>
)} )}
</button> </button>
</div>
)} )}
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
</div> </div>
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5"> <div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5">
@@ -229,4 +257,45 @@ export default function ProductBuyPanel({
</div> </div>
</div> </div>
); );
const installmentRow = (
<button
type="button"
onClick={() => setLoanOpen(true)}
disabled={price === null}
className="group flex w-full items-center gap-4 overflow-hidden rounded-2xl bg-brand px-5 py-4 text-start text-white shadow-[0_8px_24px_-8px_rgba(229,30,34,0.55)] transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-60 sm:px-6 sm:py-5"
>
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-white text-brand sm:size-14">
<WalletIcon className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-base font-black leading-7 sm:text-lg">این محصول را اقساطی بخرید</span>
<span className="mt-0.5 block text-[12.5px] font-bold text-white/85 sm:text-[13px]">
انتخاب مدت بازپرداخت و مشاهده جزئیات اقساط
</span>
</span>
<ChevronLeft className="size-5 shrink-0 transition group-hover:-translate-x-0.5 sm:size-6" />
</button>
);
return (
<>
{gallery ? (
<div className="space-y-6 md:space-y-8">
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
{gallery}
{buyColumn}
</div>
{installmentRow}
</div>
) : (
<div className="space-y-6">
{buyColumn}
{installmentRow}
</div>
)}
<LoanCalculatorModal open={loanOpen} totalValue={price} onClose={() => setLoanOpen(false)} />
</>
);
} }
+12 -4
View File
@@ -2,6 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import CommentSection from "./CommentSection"; import CommentSection from "./CommentSection";
import MixedText from "./MixedText";
import { wrapLatinInHtml } from "@/lib/mixedText";
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types"; import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
type Tab = "description" | "technical" | "reviews"; type Tab = "description" | "technical" | "reviews";
@@ -49,11 +51,13 @@ export default function ProductInfoTabs({
<div className="pt-8"> <div className="pt-8">
{tab === "description" && ( {tab === "description" && (
<div> <div>
<h2 className="mb-4 text-lg font-bold text-ink md:text-xl">معرفی {title}</h2> <h2 className="mb-4 text-lg font-bold text-ink md:text-xl">
معرفی <MixedText>{title}</MixedText>
</h2>
{descriptionHtml ? ( {descriptionHtml ? (
<div <div
className="rich-text text-sm leading-8 text-body" className="rich-text text-sm leading-8 text-body"
dangerouslySetInnerHTML={{ __html: descriptionHtml }} dangerouslySetInnerHTML={{ __html: wrapLatinInHtml(descriptionHtml) }}
/> />
) : ( ) : (
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p> <p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
@@ -67,8 +71,12 @@ export default function ProductInfoTabs({
<dl className="divide-y divide-line overflow-hidden rounded-xl border border-line"> <dl className="divide-y divide-line overflow-hidden rounded-xl border border-line">
{specRows.map((row) => ( {specRows.map((row) => (
<div key={row.key} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface"> <div key={row.key} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
<dt className="text-body">{row.label}</dt> <dt className="text-body">
<dd className="font-bold text-ink">{row.value}</dd> <MixedText>{row.label}</MixedText>
</dt>
<dd className="font-bold text-ink">
<MixedText>{row.value}</MixedText>
</dd>
</div> </div>
))} ))}
</dl> </dl>
+4 -1
View File
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { SearchIcon } from "./icons"; import { SearchIcon } from "./icons";
import MixedText from "./MixedText";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { productHref } from "@/lib/slug"; import { productHref } from "@/lib/slug";
import type { ApiProduct } from "@/lib/types"; import type { ApiProduct } from "@/lib/types";
@@ -110,7 +111,9 @@ export default function SearchBox() {
)} )}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="clamp-2 text-[13px] leading-6">{p.nameFa || p.title}</span> <span className="clamp-2 text-[13px] leading-6">
<MixedText>{p.nameFa || p.title}</MixedText>
</span>
</span> </span>
{p.store.minPrice !== null && ( {p.store.minPrice !== null && (
<span className="shrink-0 text-[12px] font-bold text-ink"> <span className="shrink-0 text-[12px] font-bold text-ink">
+43
View File
@@ -110,3 +110,46 @@ export const RotateIcon = ({ className }: P) => (
<path d="M17.5 3.5V7H14M6.5 20.5V17H10" /> <path d="M17.5 3.5V7H14M6.5 20.5V17H10" />
</svg> </svg>
); );
export const LockIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
export const WalletIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3.5" y="6" width="17" height="13" rx="2" />
<path d="M3.5 10h17" />
<circle cx="16.5" cy="14" r="1.2" />
</svg>
);
export const MapPinIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.4" />
</svg>
);
export const MailIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3.5 6.5 12 13l8.5-6.5" />
</svg>
);
export const InstagramIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
export const TelegramIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M21 4 3 11.4l6 2.1M21 4l-3.3 15L9 13.5M21 4 9 13.5v5.4l3-3.3" />
</svg>
);
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import {
addGuestCartItem,
readGuestCart,
removeGuestCartItem,
updateGuestCartItem,
type GuestCartItem,
} from "@/lib/guestCart";
interface CartContextValue {
items: GuestCartItem[];
count: number;
total: number;
addItem: (item: GuestCartItem) => void;
updateItem: (id: string, quantity: number) => void;
removeItem: (id: string) => void;
}
const CartContext = createContext<CartContextValue | null>(null);
// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — no server
// call, no auth required. State lives in localStorage (mirrored to a cookie
// for the customer dashboard to read); this provider is just a thin React
// wrapper around src/lib/guestCart.ts so every consumer re-renders on change.
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<GuestCartItem[]>([]);
// Reading localStorage during the initial render would mismatch the
// server-rendered (empty) markup, so the real cart loads post-mount —
// deferred to a microtask so the effect body itself never calls setState
// synchronously.
useEffect(() => {
queueMicrotask(() => setItems(readGuestCart()));
}, []);
const addItem = useCallback((item: GuestCartItem) => {
setItems(addGuestCartItem(item));
}, []);
const updateItem = useCallback((id: string, quantity: number) => {
setItems(updateGuestCartItem(id, quantity));
}, []);
const removeItem = useCallback((id: string) => {
setItems(removeGuestCartItem(id));
}, []);
const count = items.reduce((sum, item) => sum + item.quantity, 0);
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<CartContext.Provider value={{ items, count, total, addItem, updateItem, removeItem }}>
{children}
</CartContext.Provider>
);
}
export function useCart(): CartContextValue {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}
@@ -0,0 +1,59 @@
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { CheckIcon } from "../icons";
interface ToastItem {
id: number;
message: string;
type: "success" | "error";
}
interface ToastContextValue {
showToast: (message: string, type?: "success" | "error") => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
// Minimal global toast — used by the contact form and cart actions. Sits
// above the mobile bottom nav (bottom-20) and drops down on lg where that
// bar doesn't exist.
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback((message: string, type: "success" | "error" = "success") => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 3500);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div
className="pointer-events-none fixed inset-x-0 bottom-20 z-[100] flex flex-col items-center gap-2 px-4 lg:bottom-6"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex max-w-sm items-center gap-2 rounded-xl px-4 py-3 text-[13px] font-bold text-white shadow-lg ${
toast.type === "error" ? "bg-ink" : "bg-brand"
}`}
>
{toast.type === "success" && <CheckIcon className="size-4 shrink-0" />}
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import MixedText from "@/components/MixedText";
import { formatPrice, toFaDigits } from "@/lib/format"; import { formatPrice, toFaDigits } from "@/lib/format";
import type { ProductCardData } from "@/lib/types"; import type { ProductCardData } from "@/lib/types";
@@ -34,7 +35,7 @@ export default function ProductCard({ product }: { product: ProductCardData }) {
</span> </span>
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft"> <span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
{title} <MixedText>{title}</MixedText>
</span> </span>
{/* The old-price line always occupies its row — rendered empty when there {/* The old-price line always occupies its row — rendered empty when there
+26
View File
@@ -0,0 +1,26 @@
import type { ComponentType } from "react";
export interface TrustBadge {
icon: ComponentType<{ className?: string }>;
title: string;
desc: string;
}
// Shared trust-point row used on /about and /installments — same visual
// language as the badges in ProductBuyPanel, just laid out wider.
export default function TrustBadges({ items }: { items: TrustBadge[] }) {
return (
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item) => (
<div
key={item.title}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-white p-5 text-center"
>
<item.icon className="size-7 text-brand" />
<span className="text-[13px] font-bold text-ink">{item.title}</span>
<span className="text-[11.5px] leading-5 text-body">{item.desc}</span>
</div>
))}
</div>
);
}
+8
View File
@@ -1,6 +1,10 @@
// The blog module is enabled on this tenant but GET /blogs is still empty. // The blog module is enabled on this tenant but GET /blogs is still empty.
// These posts are the real ones published on moalem.shop/articles, kept as a // These posts are the real ones published on moalem.shop/articles, kept as a
// fallback so the magazine section has content until the posts are imported. // fallback so the magazine section has content until the posts are imported.
// Each one still gets a real local detail page at /blog/{id}/{slug} (see
// getFallbackBlogById() below) — only title/image/category are real, there
// is no scraped body content yet, so that page shows just those plus a
// "more articles" list rather than fabricating a body.
// Delete this file once /tenants/moalem.shop/blogs returns items. // Delete this file once /tenants/moalem.shop/blogs returns items.
export interface FallbackBlog { export interface FallbackBlog {
id: string; id: string;
@@ -73,3 +77,7 @@ export const FALLBACK_BLOGS: FallbackBlog[] = [
category: "راهنمای خرید", category: "راهنمای خرید",
}, },
]; ];
export function getFallbackBlogById(id: string): FallbackBlog | undefined {
return FALLBACK_BLOGS.find((post) => post.id === id);
}
+26 -2
View File
@@ -9,8 +9,32 @@ export const PROMO = {
href: "/installments", href: "/installments",
}; };
export const PHONE = "۰۲۱-۳۳۹۰۰۰۰۰"; // Real contact data — pulled from the live moalem.shop theme (footer +
export const PHONE_HREF = "tel:+982133900000"; // support badge), not invented. website/business-info on the API is still
// blank for phones/addresses/social, so this stays hand-maintained here
// until that's filled in; update both together when it is.
export const PHONE = "۰۲۵-۳۲۱۰۶";
export const PHONE_HREF = "tel:+982532106";
export const MOBILE = "۰۹۳۵۷۵۳۲۱۰۶";
export const MOBILE_HREF = "tel:+989357532106";
export const SOCIAL = {
instagram: "https://instagram.com/mobilemoalem",
telegram: "https://t.me/mobilemoalem",
};
export const BRANCHES = [
{
title: "شعبه ۱",
address: "قم، تقاطع عماریاسر، جنب پاساژ الزهرا، موبایل معلم",
mapQuery: "قم تقاطع عماریاسر جنب پاساژ الزهرا موبایل معلم",
},
{
title: "شعبه ۲",
address: "قم، عماریاسر، نبش بازار سلام، فروشگاه معلم پلاس",
mapQuery: "قم عماریاسر نبش بازار سلام فروشگاه معلم پلاس",
},
];
export const MAIN_NAV = [ export const MAIN_NAV = [
{ label: "برندها", href: "/brands" }, { label: "برندها", href: "/brands" },
+54 -6
View File
@@ -12,6 +12,9 @@ import type {
SliderItem, SliderItem,
StaticImageSlot, StaticImageSlot,
StoreItem, StoreItem,
StoreItemVariants,
StoreLoanCalculateResponse,
StoreLoanMethodsResponse,
StoreSpecial, StoreSpecial,
Tenant, Tenant,
TechnicalInfoForm, TechnicalInfoForm,
@@ -159,12 +162,37 @@ export function getProductTechnicalInfo(
return apiGet(t(`/products/by-id/${productId}/technical-info`)); return apiGet(t(`/products/by-id/${productId}/technical-info`));
} }
// Not /store-items/by-product/{id} — that one returns a single { storeItem } // The documented source for a product's purchasable variants (AI_PROMPT.md
// (or null), not the full variant list a buy panel needs. The paginated // "Shopping cart", step 1) — { storeItem: null } when the product has no
// /store-items list, filtered by productId, is the one that returns every // store-item at all. storeItem.variants is what the buy panel and the
// purchasable variant. // add-to-cart flow both read; never call /store-items?productId= for this,
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> { // that's a different, unrelated listing (see getStoreItems below).
return apiGet(t("/store-items"), { productId, pageSize: 100 }); export function getStoreItemByProduct(
productId: string
): Promise<{ storeItem: StoreItemVariants | null }> {
return apiGet(t(`/store-items/by-product/${productId}`));
}
// The full, flat catalog of purchasable variants — every card here always
// has a real price/stock, unlike /products which includes items with
// neither. Empty on this tenant today (see AGENTS.md "What the tenant
// actually returns").
export function getStoreItems(
params: {
page?: number;
pageSize?: number;
categoryId?: string;
brandId?: string;
productId?: string;
name?: string;
inStock?: boolean;
} = {}
): Promise<PagedResult<StoreItem>> {
return apiGet<PagedResult<StoreItem>>(t("/store-items"), params);
}
export function getStoreItemById(variantId: string): Promise<{ variant: StoreItem }> {
return apiGet(t(`/store-items/${variantId}`));
} }
export function getComments( export function getComments(
@@ -198,6 +226,26 @@ export function submitContactForm(
return apiPost(t("/contact-submissions"), data); return apiPost(t("/contact-submissions"), data);
} }
// Installments module — AI_PROMPT.md "Installments & credit methods".
// Hide the CTA when enabled:false or items is empty.
export function getStoreLoanMethods(): Promise<StoreLoanMethodsResponse> {
return apiGet(t("/store-loan-methods"));
}
export function calculateStoreLoan(input: {
methodId: string;
totalValue: number;
creditAmount: number;
months: number;
}): Promise<StoreLoanCalculateResponse> {
return apiPost(t("/store-loan-methods/calculate"), input);
}
// No cart functions here on purpose — AI_PROMPT.md "Hard rules" #3 and
// "Shopping cart" are explicit that `/businesses/{id}/cart*` is
// customer-dashboard-only. The mini-cart is local-only; see
// src/lib/guestCart.ts.
export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined { export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const slot = slots.find((s) => s.key === key); const slot = slots.find((s) => s.key === key);
if (!slot || slot.images.length === 0) return undefined; if (!slot || slot.images.length === 0) return undefined;
+9 -6
View File
@@ -1,18 +1,21 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getMe, safe } from "./api"; import { getMe, safe } from "./api";
import { AUTH_TOKEN_COOKIE } from "./config"; import { AUTH_TOKEN_COOKIE } from "./config";
import { reassembleChunkedCookie } from "./authUser";
import type { AuthUser } from "./types"; import type { AuthUser } from "./types";
export { displayName, isAdmin } from "./authUser"; export { displayName, isAdmin } from "./authUser";
// Server-side "am I logged in" check for the header/account menu. See the // Server-side "am I logged in" check for the header/account menu. Reads the
// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency // cookie the customer dashboard writes on login (see AUTH_TOKEN_COOKIE in
// this relies on. Resolves to null whenever the cookie is absent or /auth/me // config.ts and AI_PROMPT.md "Shared login") — this storefront never issues
// fails — never throws, so a missing session degrades to the anonymous view. // it. Resolves to null whenever the cookie is absent or /auth/me fails —
// never throws, so a missing session degrades to the anonymous view.
export async function getSession(): Promise<AuthUser | null> { export async function getSession(): Promise<AuthUser | null> {
const store = await cookies(); const store = await cookies();
const token = store.get(AUTH_TOKEN_COOKIE)?.value; const raw = reassembleChunkedCookie((name) => store.get(name)?.value, AUTH_TOKEN_COOKIE);
if (!token) return null; if (!raw) return null;
const token = decodeURIComponent(raw);
const result = await safe(getMe(token), null); const result = await safe(getMe(token), null);
return result?.user ?? null; return result?.user ?? null;
+42
View File
@@ -1,5 +1,6 @@
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts, // Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
// which reads next/headers) — safe to import from client components. // which reads next/headers) — safe to import from client components.
import { AUTH_TOKEN_COOKIE } from "./config";
import type { AuthUser } from "./types"; import type { AuthUser } from "./types";
// "علی عزیز" — first name (or the fullest name field available) + عزیز. // "علی عزیز" — first name (or the fullest name field available) + عزیز.
@@ -14,3 +15,44 @@ export function displayName(user: AuthUser): string {
export function isAdmin(user: AuthUser): boolean { export function isAdmin(user: AuthUser): boolean {
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner"; return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
} }
// Per AI_PROMPT.md "Shared login": the access-token cookie "may be chunked
// as name_n + name_0…" when the JWT is too long for one cookie — name_n
// holds the chunk count, name_0.._n-1 the pieces, joined in order. Shared by
// the server (auth.ts, reading next/headers) and the client (CartButton's
// Continue, reading document.cookie) — both just need to hand this a
// `(name) => value | undefined` reader for whichever cookie source they have.
export function reassembleChunkedCookie(
getCookie: (name: string) => string | undefined,
baseName: string
): string | undefined {
const direct = getCookie(baseName);
if (direct) return direct;
const count = Number(getCookie(`${baseName}_n`));
if (!Number.isInteger(count) || count <= 0) return undefined;
const parts: string[] = [];
for (let i = 0; i < count; i++) {
const part = getCookie(`${baseName}_${i}`);
if (!part) return undefined; // a chunk is missing — treat as absent rather than truncated
parts.push(part);
}
return parts.join("");
}
function readCookieFromDocument(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
return document.cookie
.split("; ")
.find((c) => c.startsWith(`${name}=`))
?.slice(name.length + 1);
}
// Client-side "am I logged in" check — just the cookie's presence, per
// AI_PROMPT.md ("if the access-token cookie exists, treat them as logged
// in"). Used by CartButton to decide the Continue URL without needing the
// full session fetch getSession() does server-side.
export function hasCustomerSessionCookie(): boolean {
return Boolean(reassembleChunkedCookie(readCookieFromDocument, AUTH_TOKEN_COOKIE));
}
+22 -3
View File
@@ -1,7 +1,7 @@
import { discountPercent } from "./format"; import { discountPercent, isInStock } from "./format";
import { productHref } from "./slug"; import { productHref } from "./slug";
import { DEMO_PRICING, demoPrice } from "./demoPricing"; import { DEMO_PRICING, demoPrice } from "./demoPricing";
import type { ApiProduct, ProductCardData, StoreSpecial } from "./types"; import type { ApiProduct, ProductCardData, StoreItem, StoreSpecial } from "./types";
export function productToCard(product: ApiProduct): ProductCardData { export function productToCard(product: ApiProduct): ProductCardData {
const base = { const base = {
@@ -29,6 +29,25 @@ export function productToCard(product: ApiProduct): ProductCardData {
return { ...base, price: null, oldPrice: null, percent: 0, inStock: false }; return { ...base, price: null, oldPrice: null, percent: 0, inStock: false };
} }
// Unlike products, every store-item always has a real price/stock — that's
// the whole point of the /store-items endpoint — so this never touches
// demoPricing. Links to the item's own /store-items/{id} detail page, not
// the parent product's page.
export function storeItemToCard(item: StoreItem): ProductCardData {
const price = item.discountedPrice ?? item.price;
const oldPrice = item.discountedPrice !== null ? item.price : null;
return {
id: item.id,
href: `/store-items/${item.id}`,
title: item.productNameFa || item.productTitle,
image: item.productImage,
price,
oldPrice,
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
inStock: item.stockQuantity > 0,
};
}
// A curated store-special group carries its own variant pricing, so the // A curated store-special group carries its own variant pricing, so the
// discount shown there is the real one rather than a product-level guess. // discount shown there is the real one rather than a product-level guess.
export function specialToCards(special: StoreSpecial): ProductCardData[] { export function specialToCards(special: StoreSpecial): ProductCardData[] {
@@ -46,7 +65,7 @@ export function specialToCards(special: StoreSpecial): ProductCardData[] {
price, price,
oldPrice, oldPrice,
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0, percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
inStock: (variant?.stockQuantity ?? 0) > 0, inStock: isInStock(variant?.stockQuantity),
}; };
}); });
} }
+9
View File
@@ -35,6 +35,15 @@ export function findNode(nodes: CategoryNode[], id: string): CategoryNode | unde
return undefined; return undefined;
} }
export function findNodeBySlug(nodes: CategoryNode[], slug: string): CategoryNode | undefined {
for (const node of nodes) {
if (node.slug === slug) return node;
const hit = findNodeBySlug(node.children, slug);
if (hit) return hit;
}
return undefined;
}
// Products are filed against leaf categories, so a parent like "لوازم جانبی" // Products are filed against leaf categories, so a parent like "لوازم جانبی"
// matches almost nothing on its own — a carousel for it has to ask for the // matches almost nothing on its own — a carousel for it has to ask for the
// whole subtree. Capped for the homepage's small carousels; a full category // whole subtree. Capped for the homepage's small carousels; a full category
+19 -8
View File
@@ -7,14 +7,25 @@ export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
// "مدیریت وبسایت" account-menu item shown to admins. // "مدیریت وبسایت" account-menu item shown to admins.
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`; export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
// Login happens on the customer portal (a separate app), not on this site. // Bundled brand mark (public/images/logo.png, 200x48 wordmark) — deliberately
// For the account menu to know a visitor is signed in, that portal needs to // not read from the API's /website/favicon; see AGENTS.md "Brand".
// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not export const LOGO_SRC = "/images/logo.png";
// HttpOnly) after login, so requests to this site carry it too. Until it export const LOGO_WIDTH = 150;
// does, getSession() in src/lib/auth.ts always resolves to null and the export const LOGO_HEIGHT = 48;
// header simply shows the anonymous "ورود | ثبت‌نام" link — nothing breaks,
// the logged-in state just never activates. // Written by the customer dashboard on login, on the shared parent domain
export const AUTH_TOKEN_COOKIE = "meshkee_token"; // (".{SITE_DOMAIN}", not HttpOnly) — this storefront only ever reads it, per
// the official AI_PROMPT.md "Shared login" contract. May be chunked into
// `${AUTH_TOKEN_COOKIE}_0`, `_1`, … with `${AUTH_TOKEN_COOKIE}_n` holding the
// chunk count, when the JWT is too long for one cookie — see readAuthToken()
// in src/lib/auth.ts / src/lib/authUser.ts, never read this raw.
export const AUTH_TOKEN_COOKIE = "meshkee_customer_access_token";
// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — never a
// server cart. Same storage key is used for both localStorage and the
// mirrored cookie, and must match the customer dashboard's own key exactly
// so it can read a guest cart handed off via the Continue button.
export const GUEST_CART_STORAGE_KEY = "meshkee-guest-cart";
// Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color). // Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color).
export const BRAND = { export const BRAND = {
+7
View File
@@ -15,6 +15,13 @@ export function discountPercent(price: number, oldPrice: number): number {
return Math.round(((oldPrice - price) / oldPrice) * 100); return Math.round(((oldPrice - price) / oldPrice) * 100);
} }
// Per AI_PROMPT.md "Shopping cart" step 3: a variant's stockQuantity of
// `null` means unlimited stock (in stock), not zero/out of stock — never
// treat it as falsy.
export function isInStock(stockQuantity: number | null | undefined): boolean {
return stockQuantity === null || (stockQuantity ?? 0) > 0;
}
// The static-image-slot API documents aspectRatio as "16:9", but CSS's // The static-image-slot API documents aspectRatio as "16:9", but CSS's
// aspect-ratio property needs a slash ("16/9"), not a colon. // aspect-ratio property needs a slash ("16/9"), not a colon.
export function toCssAspectRatio(value: string | number | null | undefined): string | undefined { export function toCssAspectRatio(value: string | number | null | undefined): string | undefined {
+95
View File
@@ -0,0 +1,95 @@
// The mini-cart per AI_PROMPT.md "Shopping cart": local-only, never the
// server cart (`/businesses/{id}/cart*` is off-limits from this site). Must
// match the customer dashboard's own guest-cart contract exactly — storage
// key, cookie attributes, line shape, and the encoding function below are
// all copied verbatim from that spec, not invented.
import { GUEST_CART_STORAGE_KEY, SITE_DOMAIN } from "./config";
export interface GuestCartItem {
id: string; // storeItemVariantId — never a product id or store-item id
name: string;
slug: string;
price: number;
originalPrice?: number;
image: string | null;
quantity: number;
}
// Verbatim from AI_PROMPT.md — base64url of the UTF-8 JSON, no padding.
export function encodeGuestCart(items: GuestCartItem[]): string {
const json = JSON.stringify(items);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Cookie mirror is capped ~3500 chars (skip it and rely on the URL param
// instead) — the spec's own number, not a guess.
const COOKIE_SIZE_LIMIT = 3500;
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // ~30 days
function readLocalStorage(): GuestCartItem[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(GUEST_CART_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function writeStorage(items: GuestCartItem[]): void {
if (typeof window === "undefined") return;
const json = JSON.stringify(items);
try {
window.localStorage.setItem(GUEST_CART_STORAGE_KEY, json);
} catch {
// localStorage can throw (private mode, quota) — the cookie mirror below
// still gets a chance to persist the cart.
}
const encoded = encodeURIComponent(json);
if (encoded.length > COOKIE_SIZE_LIMIT) return; // rely on the URL param on Continue instead
try {
document.cookie = `${GUEST_CART_STORAGE_KEY}=${encoded}; Domain=.${SITE_DOMAIN}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
} catch {
// Cookie writes can fail under strict privacy settings — localStorage is
// the primary store, this is just the cross-subdomain mirror.
}
}
export function readGuestCart(): GuestCartItem[] {
return readLocalStorage();
}
// `id` is the storeItemVariantId — the one field the dashboard resolves to
// stock/price server-side. If it's already in the cart, increment quantity
// instead of adding a second line (per AI_PROMPT.md step 6).
export function addGuestCartItem(item: GuestCartItem): GuestCartItem[] {
const items = readGuestCart();
const existing = items.find((i) => i.id === item.id);
const next = existing
? items.map((i) => (i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i))
: [...items, item];
writeStorage(next);
return next;
}
export function updateGuestCartItem(id: string, quantity: number): GuestCartItem[] {
const items = readGuestCart();
const next =
quantity <= 0 ? items.filter((i) => i.id !== id) : items.map((i) => (i.id === id ? { ...i, quantity } : i));
writeStorage(next);
return next;
}
export function removeGuestCartItem(id: string): GuestCartItem[] {
const next = readGuestCart().filter((i) => i.id !== id);
writeStorage(next);
return next;
}
+53
View File
@@ -0,0 +1,53 @@
import { API_BASE, SITE_DOMAIN } from "./config";
import type { StoreLoanCalculateResponse, StoreLoanMethodsResponse } from "./types";
const base = `${API_BASE}/tenants/${SITE_DOMAIN}/store-loan-methods`;
/** Browser-side list — same contract as GET .../store-loan-methods. */
export async function fetchStoreLoanMethods(
signal?: AbortSignal,
): Promise<StoreLoanMethodsResponse> {
const res = await fetch(base, { signal });
if (!res.ok) throw new Error(`loan methods failed (${res.status})`);
return res.json() as Promise<StoreLoanMethodsResponse>;
}
/** Browser-side calculate — POST .../store-loan-methods/calculate. */
export async function fetchStoreLoanPlan(
input: {
methodId: string;
totalValue: number;
creditAmount: number;
months: number;
},
signal?: AbortSignal,
): Promise<StoreLoanCalculateResponse> {
const res = await fetch(`${base}/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
let message = `calculate failed (${res.status})`;
try {
const body = (await res.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
/* ignore */
}
throw new Error(message);
}
return res.json() as Promise<StoreLoanCalculateResponse>;
}
export function clampCreditAmount(
value: number,
totalValue: number,
minAmount: number,
maxAmount: number,
): number {
const upper = Math.min(Math.round(maxAmount), Math.round(totalValue));
const lower = Math.min(Math.round(minAmount), upper);
return Math.min(upper, Math.max(lower, Math.round(value)));
}
+44
View File
@@ -0,0 +1,44 @@
// IRANYekan *fanum* maps Latin digit codepoints (0-9) to Persian glyphs.
// Montserrat (`.en`) keeps real Latin digits — so any Latin-letter run that
// also carries model numbers (HP R5, 8GB, EF2126WM…) must render under `.en`.
// Pure digit runs (e.g. "13" inside «آیفون 13 پرو») stay on the FA font and
// correctly show as Persian digits.
const EN_RUN = /[A-Za-z0-9][A-Za-z0-9\s\-_|./+#%]*/g;
export type TextSegment = { type: "fa" | "en"; text: string };
export function splitMixedText(text: string): TextSegment[] {
const segments: TextSegment[] = [];
let cursor = 0;
let match: RegExpExecArray | null;
const re = new RegExp(EN_RUN.source, "g");
while ((match = re.exec(text)) !== null) {
let start = match.index;
let end = start + match[0].length;
while (end > start && /\s/.test(text[end - 1]!)) end--;
const chunk = text.slice(start, end);
if (!/[A-Za-z]/.test(chunk)) continue;
if (start > cursor) segments.push({ type: "fa", text: text.slice(cursor, start) });
segments.push({ type: "en", text: chunk });
cursor = end;
}
if (cursor < text.length) segments.push({ type: "fa", text: text.slice(cursor) });
return segments;
}
/** Wrap Latin runs in a plain string as `.en` spans (text is assumed already HTML-safe). */
export function wrapLatinRuns(text: string): string {
return splitMixedText(text)
.map((seg) =>
seg.type === "en" ? `<span class="en" dir="ltr" lang="en">${seg.text}</span>` : seg.text,
)
.join("");
}
/** Walk HTML text nodes (content between tags) and wrap Latin runs. */
export function wrapLatinInHtml(html: string): string {
return html.replace(/(^|>)([^<]+)/g, (_, prefix: string, text: string) => prefix + wrapLatinRuns(text));
}
+15
View File
@@ -22,3 +22,18 @@ export function categoryHref(id: string, nameFa?: string | null, name?: string |
export function blogHref(id: string, title?: string | null): string { export function blogHref(id: string, title?: string | null): string {
return `/blog/${id}/${faSlug(title)}`; return `/blog/${id}/${faSlug(title)}`;
} }
// Static-image-slot items carry a business-picked linkUrl like
// "https://moalem.shop/products?category=mobile" — the `category` query
// param is the DB slug (ApiCategory.slug), the one reliable way to identify
// which category an uploaded photo is actually for. Position in the list
// and the item's own titleFa are NOT reliable — see AGENTS.md "Static-image
// slots" for the real mismatch this was built to fix.
export function categorySlugFromLinkUrl(linkUrl: string | null | undefined): string | null {
if (!linkUrl) return null;
try {
return new URL(linkUrl).searchParams.get("category");
} catch {
return null;
}
}
+61 -1
View File
@@ -165,11 +165,21 @@ export interface StoreSpecialVariant {
selections: StoreItemSelection[]; selections: StoreItemSelection[];
price: number | null; price: number | null;
discountedPrice: number | null; discountedPrice: number | null;
stockQuantity: number; stockQuantity: number | null;
isFestival: boolean; isFestival: boolean;
sortOrder: number; sortOrder: number;
} }
// GET /store-items/by-product/{productId} → { storeItem: StoreItemVariants |
// null } — the documented source for a product's purchasable variants (see
// AI_PROMPT.md "Shopping cart"). `stockQuantity: null` means unlimited
// stock, not out of stock — check `=== 0`, not falsiness.
export interface StoreItemVariants {
id: string;
productId: string;
variants: StoreSpecialVariant[];
}
export interface StoreSpecialItem { export interface StoreSpecialItem {
id: string; id: string;
productId: string; productId: string;
@@ -300,3 +310,53 @@ export interface AuthUser {
isAdmin?: boolean; isAdmin?: boolean;
role?: string; role?: string;
} }
// The mini-cart is local-only (see AGENTS.md "Shopping cart" and
// src/lib/guestCart.ts for GuestCartItem) — there is no server Cart/CartItem
// type here on purpose, calling /businesses/{id}/cart* from this site is
// against the documented contract.
// GET /tenants/{domain}/store-loan-methods — hide UI when enabled:false / empty.
export interface StoreLoanMethod {
id: string;
nameFa: string;
nameEn: string;
description: string | null;
imageUrl: string | null;
imageAspectRatio: string;
minShoppingAmount: number;
minAmount: number;
maxAmount: number;
interest: number;
returnMonthsMin: number;
returnMonthsMax: number;
returnMonthsStep: number;
returnMonthOptions: number[];
sortOrder: number;
}
export interface StoreLoanMethodsResponse {
enabled: boolean;
items: StoreLoanMethod[];
}
export interface StoreLoanInstallment {
index: number;
dueDate: string;
amount: number;
}
export interface StoreLoanPlan {
totalValue: number;
creditAmount: number;
cashPayment: number;
interestFee: number;
totalRepay: number;
months: number;
installments: StoreLoanInstallment[];
}
export interface StoreLoanCalculateResponse {
method: StoreLoanMethod;
plan: StoreLoanPlan;
}