Compare commits
10
Commits
0ffbd953b3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e625354dc9 | ||
|
|
7b8b5fd723 | ||
|
|
7ee43e3e79 | ||
|
|
7089078b4c | ||
|
|
8e82370b64 | ||
|
|
990e1999b9 | ||
|
|
e44fc90d96 | ||
|
|
4ffdf03727 | ||
|
|
d624ad8a32 | ||
|
|
e676397bae |
@@ -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
Binary file not shown.
@@ -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 |
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user