Compare commits

..
10 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
38 changed files with 1806 additions and 295 deletions
+125 -36
View File
@@ -23,14 +23,15 @@ fallbacks should update it.
| 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`. |
| 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/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. | | `/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` | phones/addresses/socialMedia still empty; `logoUrl`/`faviconUrl` are now set but point to a **different business's** mark (see "Brand") | | `/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) |
@@ -39,9 +40,8 @@ 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 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 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 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 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 |
| Wrong logo/favicon in API | `getFavicon()` is wired live (see "Brand") but the uploaded asset itself is wrong; no code fallback needed once the dashboard upload is fixed | the correct asset is uploaded |
## Brand ## Brand
@@ -49,19 +49,16 @@ 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/favicon: `getFavicon()` in `src/lib/api.ts` reads - Logo: **bundled, not from the API** — `public/images/logo.png` (200x48
`GET /tenants/moalem.shop/website/favicon` — the documented single source — wordmark, supplied by the business), referenced via `LOGO_SRC`/`LOGO_WIDTH`/
and `Header.tsx`/`Footer.tsx`/`generateMetadata()` in `layout.tsx` all use `LOGO_HEIGHT` in `src/lib/config.ts` by `Header.tsx`, `Footer.tsx` and the
it, falling back to the bundled `/images/logo.png` / `/favicon.png` only og:image in `layout.tsx`. This was an explicit decision (2026-09-20): the
when the API field is null. **As of 2026-09-06 the dashboard has a logo/ API's `/website/favicon` returned another business's mark, so
favicon uploaded, but it's the wrong business's** (a home-appliances store's `getFavicon()` was removed entirely — don't re-add it. Tab icon is the
mark, not موبایل معلم) — the wiring is correct and will pick up the right bundled `public/favicon.png`. To change the logo, replace the file (keep
asset automatically the moment it's replaced in the dashboard; don't the same name; update `LOGO_WIDTH`/`LOGO_HEIGHT` if its shape changes).
"fix" this by hardcoding the local fallback again. - Header (desktop) logo renders at `h-8` (32px), footer logo at `h-[70px]` — sized
- Header/footer logo renders at `h-[108px]` (3x the original `h-9`/36px, on independently, on request. The header's first row has no fixed height, just `pt-5`.
request). If the wrong logo above still looks small at that size, it's
because that specific PNG has a lot of baked-in transparent padding around
the mark — `object-contain` can't fix that, only the replacement asset can.
- Contact data (phone, mobile, branch addresses, Instagram/Telegram) in - Contact data (phone, mobile, branch addresses, Instagram/Telegram) in
`src/data/site.ts` is real, pulled from the live theme's footer — `GET `src/data/site.ts` is real, pulled from the live theme's footer — `GET
/website/business-info` still returns empty `phoneNumbers`/`addresses`/ /website/business-info` still returns empty `phoneNumbers`/`addresses`/
@@ -102,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
@@ -177,7 +232,10 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
- `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions` - `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions`
(no bearer required); branch cards use `BRANCHES` from `src/data/site.ts` (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 plus a key-less Google Maps `output=embed` iframe per branch (no API key
needed for a query-based embed). 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 - `/installments` — flat, brand-colored hero (no photo, no gradient — this
site stays flat) instead of the reference design's photographic banner; site stays flat) instead of the reference design's photographic banner;
category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike
@@ -187,16 +245,28 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
same curated set. One reference category ("خانه هوشمند") has no equivalent same curated set. One reference category ("خانه هوشمند") has no equivalent
on this tenant and is swapped for "لوازم برقی". on this tenant and is swapped for "لوازم برقی".
## Product / blog pages ## Product / store-item / blog pages
- `/products` — full catalog, `?name=` search, `?page=`. - `/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.
@@ -212,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
@@ -248,3 +327,13 @@ identity fix, live logo/favicon wiring) once sat 3 commits ahead of
`origin/main` unpushed, and the business reported bugs that were already `origin/main` unpushed, and the business reported bugs that were already
fixed locally — they were looking at the old deployed code. Check fixed locally — they were looking at the old deployed code. Check
`git status` for "ahead of origin" before telling anyone a fix is live. `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" },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

+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">
+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;
} }
+21 -28
View File
@@ -6,13 +6,13 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import TopBar from "@/components/TopBar"; import TopBar from "@/components/TopBar";
import MobileBottomNav from "@/components/MobileBottomNav"; import MobileBottomNav from "@/components/MobileBottomNav";
import { getCategories, getFavicon, safe } from "@/lib/api"; 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";
const NO_BRAND_ASSETS = { faviconUrl: null, logoUrl: null, logoDarkUrl: null, hasDedicatedFavicon: false }; 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({
@@ -25,13 +25,9 @@ const montserrat = Montserrat({
const BASE_DESCRIPTION = const BASE_DESCRIPTION =
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم."; "خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.";
// generateMetadata (not a static export) because the tab icon comes from // Tab icon and og:image use the bundled brand files in /public — on purpose
// GET /tenants/{domain}/website/favicon — once the business uploads a real // not GET /website/favicon (that endpoint served another business's mark).
// one in the dashboard this picks it up with no code change. Falls back to export function generateMetadata(): Metadata {
// the bundled /favicon.png only while that API field is empty.
export async function generateMetadata(): Promise<Metadata> {
const brand = await safe(getFavicon(), NO_BRAND_ASSETS);
return { return {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
title: { title: {
@@ -39,7 +35,7 @@ export async function generateMetadata(): Promise<Metadata> {
template: `%s | ${SITE_NAME}`, template: `%s | ${SITE_NAME}`,
}, },
description: BASE_DESCRIPTION, description: BASE_DESCRIPTION,
icons: [{ url: brand.faviconUrl || "/favicon.png", type: "image/png" }], icons: [{ url: "/favicon.png", type: "image/png" }],
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fa_IR", locale: "fa_IR",
@@ -47,35 +43,32 @@ export async function generateMetadata(): Promise<Metadata> {
title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`, title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
description: BASE_DESCRIPTION, description: BASE_DESCRIPTION,
url: SITE_URL, url: SITE_URL,
// The dashboard logo is a square mark; the bundled fallback is a wide images: [{ url: LOGO_SRC, width: LOGO_WIDTH, height: LOGO_HEIGHT, alt: SITE_NAME }],
// wordmark — the two aren't the same shape, so their og:image sizes
// can't share one hardcoded pair.
images: brand.logoUrl
? [{ url: brand.logoUrl, width: 512, height: 512, alt: SITE_NAME }]
: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
}, },
}; };
} }
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categories, user, brand] = await Promise.all([ const [categories, user] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []), safe(getCategories().then(buildCategoryTree), []),
getSession(), getSession(),
safe(getFavicon(), NO_BRAND_ASSETS),
]); ]);
const menu = carouselCategories(categories); const menu = carouselCategories(categories);
const logoUrl = brand.logoUrl || "/images/logo.png";
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">
<TopBar /> <ToastProvider>
<Header menu={menu} user={user} logoUrl={logoUrl} /> <CartProvider>
<main className="flex-1 pb-6">{children}</main> <TopBar />
<Footer categories={menu} logoUrl={logoUrl} /> <Header menu={menu} user={user} />
{/* Spacer so the fixed mobile bar never covers the end of the footer. */} <main className="flex-1 pb-6">{children}</main>
<div className="h-[68px] lg:hidden" /> <Footer categories={menu} />
<MobileBottomNav categories={menu} user={user} /> {/* Spacer so the fixed mobile bar never covers the end of the footer. */}
<div className="h-[68px] lg:hidden" />
<MobileBottomNav categories={menu} user={user} />
</CartProvider>
</ToastProvider>
</body> </body>
</html> </html>
); );
+1 -1
View File
@@ -122,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,
+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>
);
}
+22 -24
View File
@@ -2,22 +2,28 @@
import { useState } from "react"; import { useState } from "react";
import { submitContactForm } from "@/lib/api"; import { submitContactForm } from "@/lib/api";
import { useToast } from "./providers/ToastProvider";
import { CheckIcon } from "./icons";
type Status = "idle" | "submitting" | "sent" | "error"; type Status = "idle" | "submitting" | "sent" | "error";
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real, // Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
// documented endpoint, no bearer required. // 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() { export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
const { showToast } = useToast();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const form = new FormData(e.currentTarget); const form = e.currentTarget;
const name = String(form.get("name") || "").trim(); const data = new FormData(form);
const title = String(form.get("title") || "").trim(); const name = String(data.get("name") || "").trim();
const text = String(form.get("text") || "").trim(); const title = String(data.get("title") || "").trim();
const email = String(form.get("email") || "").trim(); const text = String(data.get("text") || "").trim();
const cellNumber = String(form.get("cellNumber") || "").trim(); const email = String(data.get("email") || "").trim();
const cellNumber = String(data.get("cellNumber") || "").trim();
if (!name || !title || !text) return; if (!name || !title || !text) return;
setStatus("submitting"); setStatus("submitting");
@@ -29,33 +35,19 @@ export default function ContactForm() {
email: email || undefined, email: email || undefined,
cellNumber: cellNumber || undefined, cellNumber: cellNumber || undefined,
}); });
form.reset();
setStatus("sent"); setStatus("sent");
e.currentTarget.reset(); showToast("پیام شما با موفقیت ارسال شد");
} catch { } catch {
setStatus("error"); setStatus("error");
} }
} }
if (status === "sent") {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-line bg-surface p-10 text-center">
<p className="text-[15px] font-bold text-ink">پیام شما با موفقیت ارسال شد.</p>
<p className="text-[13px] text-body">همکاران ما در اسرع وقت با شما تماس می‌گیرند.</p>
<button
onClick={() => setStatus("idle")}
className="mt-2 rounded-lg border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
ارسال پیام دیگر
</button>
</div>
);
}
const input = const input =
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand"; "h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} onChange={() => status === "sent" && setStatus("idle")} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="name" required placeholder="نام و نام خانوادگی" className={input} /> <input name="name" required placeholder="نام و نام خانوادگی" className={input} />
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} /> <input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
@@ -75,6 +67,12 @@ export default function ContactForm() {
{status === "error" && ( {status === "error" && (
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p> <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 <button
type="submit" type="submit"
+6 -13
View File
@@ -1,30 +1,23 @@
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, SOCIAL } 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 { InstagramIcon, PhoneIcon, TelegramIcon } from "./icons"; import { InstagramIcon, PhoneIcon, TelegramIcon } from "./icons";
import { toFaDigits } from "@/lib/format"; import { toFaDigits } from "@/lib/format";
export default function Footer({ export default function Footer({ categories }: { categories: CategoryNode[] }) {
categories,
logoUrl,
}: {
categories: CategoryNode[];
logoUrl: string;
}) {
return ( return (
<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">
{/* h-[108px] w-auto so a square dashboard logo and the wide
fallback wordmark both render undistorted — see Header.tsx. */}
<Image <Image
src={logoUrl} src={LOGO_SRC}
alt={SITE_NAME} alt={SITE_NAME}
width={280} width={LOGO_WIDTH}
height={108} height={LOGO_HEIGHT}
className="h-[108px] w-auto object-contain" 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
+10 -22
View File
@@ -5,9 +5,10 @@ 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";
@@ -16,11 +17,9 @@ import type { AuthUser, CategoryNode } from "@/lib/types";
export default function Header({ export default function Header({
menu, menu,
user, user,
logoUrl,
}: { }: {
menu: CategoryNode[]; menu: CategoryNode[];
user: AuthUser | null; user: AuthUser | null;
logoUrl: string;
}) { }) {
const [megaOpen, setMegaOpen] = useState(false); const [megaOpen, setMegaOpen] = useState(false);
@@ -28,19 +27,15 @@ export default function Header({
<header className="sticky top-0 z-40 w-full border-b border-line bg-white"> <header className="sticky top-0 z-40 w-full border-b border-line bg-white">
<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. The row cart and browsing live in the fixed bottom bar instead. */}
is taller on lg so a prominent 108px logo (3x the old 36px) has <div className="flex items-center gap-4 pt-5">
room without cramming the search box/buttons beside it. */}
<div className="flex h-[70px] items-center gap-4 lg:h-32">
<Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}> <Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}>
{/* h-[108px] w-auto (not a fixed box) so a square dashboard logo
mark and the wide fallback wordmark both render undistorted. */}
<Image <Image
src={logoUrl} src={LOGO_SRC}
alt={SITE_NAME} alt={SITE_NAME}
width={280} width={LOGO_WIDTH}
height={108} height={LOGO_HEIGHT}
className="h-[108px] w-auto object-contain" className="h-8 w-auto object-contain"
priority priority
/> />
</Link> </Link>
@@ -51,14 +46,7 @@ export default function Header({
<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)}
+162 -93
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 =
? Math.round(((oldPrice - price) / oldPrice) * 100) price !== null && oldPrice !== null && oldPrice > price
: 0; ? Math.round(((oldPrice - price) / oldPrice) * 100)
: 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( setAdded(true);
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`, showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
{ setTimeout(() => setAdded(false), 1800);
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);
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,36 +199,51 @@ 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>
) : ( ) : (
<button <div className="flex items-stretch gap-2">
type="button" <div className="flex h-12 items-center rounded-lg border border-line">
onClick={handleAddToCart} <button
disabled={!inStock || adding} type="button"
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 ${ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark" 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"
{added ? ( >
<> −
<CheckIcon className="size-4.5" /> </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))}
<CartIcon className="size-4.5" /> disabled={quantity >= maxQuantity}
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"} 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> +
</button>
</div>
<button
type="button"
onClick={handleAddToCart}
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
{added ? (
<>
<CheckIcon className="size-4.5" />
افزوده شد
</>
) : (
<>
<CartIcon className="size-4.5" />
افزودن به سبد خرید
</>
)}
</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">
+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
+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);
}
+54 -14
View File
@@ -4,7 +4,6 @@ import type {
ApiCategory, ApiCategory,
ApiProduct, ApiProduct,
AuthUser, AuthUser,
BrandAssets,
BusinessInfo, BusinessInfo,
ContactFormInput, ContactFormInput,
EntityComment, EntityComment,
@@ -13,6 +12,9 @@ import type {
SliderItem, SliderItem,
StaticImageSlot, StaticImageSlot,
StoreItem, StoreItem,
StoreItemVariants,
StoreLoanCalculateResponse,
StoreLoanMethodsResponse,
StoreSpecial, StoreSpecial,
Tenant, Tenant,
TechnicalInfoForm, TechnicalInfoForm,
@@ -87,13 +89,6 @@ export function getBusinessInfo(): Promise<BusinessInfo> {
return apiGet<BusinessInfo>(t("/website/business-info")); return apiGet<BusinessInfo>(t("/website/business-info"));
} }
// Single documented source for header/footer logos and the browser-tab
// icon — see AGENTS.md "Branding". Prefer this over reading logoUrl off
// getTenant()/getBusinessInfo() directly.
export function getFavicon(): Promise<BrandAssets> {
return apiGet<BrandAssets>(t("/website/favicon"));
}
export function getSliders(): Promise<SliderItem[]> { export function getSliders(): Promise<SliderItem[]> {
return apiGet<{ items: SliderItem[] }>(t("/website/sliders")).then((r) => r.items); return apiGet<{ items: SliderItem[] }>(t("/website/sliders")).then((r) => r.items);
} }
@@ -167,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(
@@ -206,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),
}; };
}); });
} }
+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));
}
+61 -11
View File
@@ -23,16 +23,6 @@ export interface Tenant {
faviconUrl: string | null; faviconUrl: string | null;
} }
// GET /tenants/{domain}/website/favicon — the documented single source for
// header/footer logos and the browser-tab icon (falls back to the light
// logo when hasDedicatedFavicon is false).
export interface BrandAssets {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
}
// This tenant has no emails/phones/addresses filled in yet, so the loose // This tenant has no emails/phones/addresses filled in yet, so the loose
// shapes are read defensively at render time. // shapes are read defensively at render time.
export interface BusinessInfo { export interface BusinessInfo {
@@ -175,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;
@@ -310,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;
}