Compare commits
@@ -19,17 +19,19 @@ fallbacks should update it.
|
||||
empty or failing module degrades one section instead of the page.
|
||||
- Never invent CMS/admin endpoints. If OpenAPI and any prose conflict, OpenAPI wins.
|
||||
|
||||
### What the tenant actually returns today (2026-08-31)
|
||||
### What the tenant actually returns today (2026-09-06)
|
||||
|
||||
| Endpoint | State |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `/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`. |
|
||||
| `/website/sliders`, `/website/static-images`, `/website/category-groups` | **empty** |
|
||||
| `/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/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) |
|
||||
| `/website/business-info`, `/website/favicon` | present but blank (no logo, no phone, no socials) |
|
||||
| `/website/business-info` | phones/addresses/socialMedia still empty; `logoUrl`/`faviconUrl` point to a **different business's** mark and are deliberately unused (see "Brand") |
|
||||
| `/products/by-id/{id}/technical-info` | `{ form: null, values: [] }` — empty on every product checked so far |
|
||||
| `/comments` | **empty**, but `POST` works (no bearer required by the API itself) |
|
||||
|
||||
@@ -38,18 +40,31 @@ fallbacks should update it.
|
||||
| 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 sliders | `src/app/page.tsx`'s local `FALLBACK_SLIDES` (banners downloaded from the live moalem.shop theme into `public/images/banners/`) — used only when the `slider` static-image slot is also empty, see "Static-image slots" below | `/website/sliders` or the `slider` slot returns items |
|
||||
| No blog posts | `src/data/fallbackBlogs.ts` — the real posts from moalem.shop/articles, shown on the homepage and `/blog` as non-clickable cards (no real detail page exists for them) | `/blogs` returns items |
|
||||
| No category art | placeholder SVGs in `public/images/categories/{id}.svg`, generated by `scripts/gen-category-placeholders.mjs` — overridden per-slide by the `categories` static-image slot when it has images | real thumbnails are dropped in, or the `categories` slot is filled |
|
||||
| No branding in API | logo/favicon copied from the live theme into `public/` | `logoUrl` is set on the tenant |
|
||||
| No banner art (`one/two/three-banner-bg`) | `src/components/PromoBanners.tsx` renders a flat dashed placeholder per slot cell, labelled with the recommended size/ratio | the matching static-image slot is filled |
|
||||
| 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 |
|
||||
|
||||
## Brand
|
||||
|
||||
Taken from the live moalem.shop theme, not invented:
|
||||
Colors taken from the live moalem.shop theme, not invented:
|
||||
primary `#E51E22`, dark `#B10123`, ink `#2E2E2E`, body `#7C7C7C`.
|
||||
Fonts: IRANYekan (fa, local `public/fonts`) + Montserrat (latin, next/font).
|
||||
|
||||
- Logo: **bundled, not from the API** — `public/images/logo.png` (200x48
|
||||
wordmark, supplied by the business), referenced via `LOGO_SRC`/`LOGO_WIDTH`/
|
||||
`LOGO_HEIGHT` in `src/lib/config.ts` by `Header.tsx`, `Footer.tsx` and the
|
||||
og:image in `layout.tsx`. This was an explicit decision (2026-09-20): the
|
||||
API's `/website/favicon` returned another business's mark, so
|
||||
`getFavicon()` was removed entirely — don't re-add it. Tab icon is the
|
||||
bundled `public/favicon.png`. To change the logo, replace the file (keep
|
||||
the same name; update `LOGO_WIDTH`/`LOGO_HEIGHT` if its shape changes).
|
||||
- Header (desktop) logo renders at `h-8` (32px), footer logo at `h-[70px]` — sized
|
||||
independently, on request. The header's first row has no fixed height, just `pt-5`.
|
||||
- Contact data (phone, mobile, branch addresses, Instagram/Telegram) in
|
||||
`src/data/site.ts` is real, pulled from the live theme's footer — `GET
|
||||
/website/business-info` still returns empty `phoneNumbers`/`addresses`/
|
||||
`socialMedia` for this tenant, so these stay hand-maintained here until
|
||||
that's filled in. Update both together when it is.
|
||||
|
||||
## Layout conventions
|
||||
|
||||
- Content width `--container-boxed: 1200px`, applied with the `boxed` utility.
|
||||
@@ -84,25 +99,83 @@ Login itself happens off-site, on the customer portal (`customer.{domain}`) —
|
||||
this storefront never renders a login form. For the header/bottom-nav account
|
||||
menu to show "نام عزیز" instead of "ورود | ثبتنام", the customer portal needs
|
||||
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
|
||||
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`)
|
||||
holding the bearer access token. Until that exists, `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.
|
||||
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, `"meshkee_customer_access_token"`
|
||||
— the documented name from AI_PROMPT.md's "Shared login" contract, not a
|
||||
guess) holding the bearer access token, URL-encoded and possibly **chunked**
|
||||
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
|
||||
the cookie and calls `GET /auth/me`.
|
||||
- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to
|
||||
import from client components. **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.
|
||||
the (possibly chunked) cookie via `reassembleChunkedCookie()`, URL-decodes
|
||||
it, and calls `GET /auth/me`.
|
||||
- `src/lib/authUser.ts` — pure helpers safe to import from client components:
|
||||
`displayName`, `isAdmin`, `reassembleChunkedCookie`, and
|
||||
`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 —
|
||||
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.
|
||||
- Cart ("افزودن به سبد خرید" in `ProductBuyPanel.tsx`) needs a real
|
||||
`storeItemVariantId`, which only exists once a product has store-items —
|
||||
today that's none of them, so the button always shows "این محصول هنوز برای
|
||||
خرید آنلاین فعال نشده است" regardless of login state. That's correct given
|
||||
the data, not a bug either.
|
||||
|
||||
## Shopping cart
|
||||
|
||||
**Guest cart only — this storefront never calls the server cart API.**
|
||||
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
|
||||
|
||||
@@ -112,7 +185,7 @@ each is consumed:
|
||||
| Key | Kind | Consumed by |
|
||||
|---|---|---|
|
||||
| `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images |
|
||||
| `categories` | list | `CategoryCarousel.tsx` — images matched **positionally** to the category list (business fills them in the same order); a category past the end of the slot keeps its SVG placeholder |
|
||||
| `categories` | list | `CategoryCarousel.tsx` — **fully slot-driven**: iterates `slot.images` and resolves each one's category via the `category` query param on its own `linkUrl` (`categorySlugFromLinkUrl()` in `src/lib/slug.ts`, matched against `ApiCategory.slug` with `findNodeBySlug()`). A category with no matching image simply isn't shown — **no placeholder fallback here**, per an explicit product decision (2026-09-06). Position and `titleFa` are **not** used for matching — both were tried and found unreliable in practice (see the history note below) |
|
||||
| `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` |
|
||||
| `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` |
|
||||
| `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` |
|
||||
@@ -122,21 +195,78 @@ what the component actually renders. If you change a banner's size or ratio,
|
||||
update its slot entry in the same commit — that file is the only thing
|
||||
telling the dashboard (and the designer) what to upload.
|
||||
|
||||
**Why `categories` matches by `linkUrl` slug, not position:** the first version
|
||||
matched `slot.images[i]` to `categoryList[i]` by array index, assuming the
|
||||
business's upload order lines up with this site's category sort order. It
|
||||
doesn't, and there's no contract that it ever would — live data showed a
|
||||
laptop photo (`linkUrl` correctly said `?category=laptop`) landing on
|
||||
"موبایل" and "لوازم برقی" appearing with no photo uploaded for it at all,
|
||||
purely because of where it fell in the array. The visible caption under each
|
||||
circle is that image's own `titleFa`, taken as typed — even on the one
|
||||
sample where it disagreed with the photo (that same laptop image was titled
|
||||
"تبلت"), it's still the business's own editorial text for the tile, and the
|
||||
`href` (built from the `linkUrl` slug, resolved to this site's own canonical
|
||||
`/products/category/{id}/{slug}` rather than linking off-site to the literal
|
||||
`linkUrl`) is what actually has to be correct for navigation to work.
|
||||
|
||||
**Category circle hover:** use a real `border` (1px, transparent → `border-brand`
|
||||
on `group-hover`), not a `ring`/box-shadow. A ring rendered inside a Swiper
|
||||
slide painted like it was "leaking" into the row above — box-shadow doesn't
|
||||
participate in normal layout the way a border does, and interacts oddly with
|
||||
Swiper's transformed `.swiper-wrapper` track. `border` with this site's
|
||||
global `box-sizing: border-box` reset adds zero layout shift, so it's the
|
||||
safer default for any future hover-outline effect inside a carousel.
|
||||
|
||||
Homepage banner placement (mirrors the logilook.com reference given for this
|
||||
layout): hero → categories → hot deals → **three-banner-bg** → موبایل
|
||||
carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** →
|
||||
کنسول بازی → مجله.
|
||||
|
||||
## Product / blog pages
|
||||
## Static pages
|
||||
|
||||
- `/products` — full catalog, `?name=` search, `?page=`.
|
||||
- `/about` — real copy transcribed from the live moalem.shop theme's
|
||||
about-us module (verified against its cached HTML, not invented); brand
|
||||
strip is plain text wordmarks (no scraped logo images — avoids a
|
||||
trademark/asset-licensing headache for names we don't have real logo files
|
||||
for) with real product photos pulled from the API.
|
||||
- `/contact` — `ContactForm.tsx` posts to the real `POST /contact-submissions`
|
||||
(no bearer required); branch cards use `BRANCHES` from `src/data/site.ts`
|
||||
plus a key-less Google Maps `output=embed` iframe per branch (no API key
|
||||
needed for a query-based embed). On success: a toast (`useToast()`), inline
|
||||
success text, and the form **resets and stays on screen** rather than being
|
||||
swapped for a separate "thank you" panel, so another message can be sent
|
||||
right away.
|
||||
- `/installments` — flat, brand-colored hero (no photo, no gradient — this
|
||||
site stays flat) instead of the reference design's photographic banner;
|
||||
category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike
|
||||
the homepage carousel — a highlight with no matching `categories`-slot
|
||||
image (by `linkUrl` slug, same lookup as the carousel) keeps its SVG
|
||||
placeholder rather than disappearing, since this page always shows the
|
||||
same curated set. One reference category ("خانه هوشمند") has no equivalent
|
||||
on this tenant and is swapped for "لوازم برقی".
|
||||
|
||||
## Product / store-item / blog pages
|
||||
|
||||
- `/products` — full catalog, `?name=` search, `?page=`. Includes items with
|
||||
no price/stock yet (most of the catalog today).
|
||||
- `/products/category/{id}/{slug}` — category listing; merges every leaf in
|
||||
the subtree (see the leaf-category note above) and paginates in memory.
|
||||
- `/products/{id}/{slug}` — detail: `ProductGallery.tsx` (click-to-lightbox +
|
||||
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
|
||||
`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 +
|
||||
`CommentSection.tsx`. Fallback posts (see table above) only exist in the
|
||||
listing grid, not as real detail pages.
|
||||
@@ -152,8 +282,17 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
|
||||
- Product `/products/{id}/{nameFaSlug}`
|
||||
- Category `/products/category/{id}/{nameFaSlug}`
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
- `GET /meshkee/static-image-slots` — slot catalog for the dashboard Refresh button
|
||||
@@ -177,3 +316,24 @@ npm run dev # localhost:3000
|
||||
npm run build
|
||||
node scripts/gen-category-placeholders.mjs # refresh category thumb placeholders
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
`origin` is `gitea.meshkee.com/meshkee-websites/moalem-shop.git`, `main` branch.
|
||||
Whatever serves the business's preview/live URL deploys from `origin/main` —
|
||||
**committing locally is not enough**, push too. A whole session's worth of
|
||||
work (2026-09-06: about/contact/installments pages, the category-slot
|
||||
identity fix, live logo/favicon wiring) once sat 3 commits ahead of
|
||||
`origin/main` unpushed, and the business reported bugs that were already
|
||||
fixed locally — they were looking at the old deployed code. Check
|
||||
`git status` for "ahead of origin" before telling anyone a fix is live.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "c804387.parspack.net" },
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کابل و رابط">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کابل و را</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1227</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="پاور بانک">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">پاور بانک</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1228</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 581 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کارت حافظه">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کارت حافظ</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1229</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="شارژر موبایل">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">شارژر موب</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1230</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 587 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="اسپیکر سیمی و بلوتوثی">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">اسپیکر سی</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1231</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هندزفری و هدفون">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هندزفری و</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1232</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هدفون بلوتوثی">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هدفون بلو</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1233</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هدفون سیمی">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هدفون سیم</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1234</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="قاب و کیف گوشی">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">قاب و کیف</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1235</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="محافظ صفحه نمایش">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">محافظ صفح</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1236</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی تبلت">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1237</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="محافظ صفحه نمایش">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">محافظ صفح</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1238</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="قاب و کیف تبلت">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">قاب و کیف</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1239</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هارد اکسترنال">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هارد اکست</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1240</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="فلش مموری">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">فلش مموری</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1241</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 581 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="باطری">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">باطری</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1242</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="گارد هندزفری">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">گارد هندز</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1243</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 587 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="تبدیل">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">تبدیل</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1244</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="ماوس">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">ماوس</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1245</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی لپ تاپ">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1246</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="کول پد">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">کول پد</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1247</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هندزفری بلوتوثی">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هندزفری ب</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1248</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هندزفری گردنی">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هندزفری گ</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1249</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="هارد اینترنال">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">هارد اینت</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1250</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="گوشی های کارکرده">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">گوشی های </text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1252</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="لوازم جانبی کنسول">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">لوازم جان</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1257</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="دوربین عکاسی کانن">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">دوربین عک</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1262</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="رینگ لایت">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">رینگ لایت</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1265</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 581 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="نگهدارنده و پایه">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">نگهدارنده</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1266</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="میکروفون">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">میکروفون</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1267</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 579 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="خطوط کارکرده">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
<rect x="34" y="34" width="52" height="52" rx="12" fill="#fff" stroke="#e51e22" stroke-opacity="0.35" stroke-dasharray="5 4"/>
|
||||
<text x="60" y="64" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="11" fill="#7c7c7c">خطوط کارک</text>
|
||||
<text x="60" y="78" text-anchor="middle" font-family="Tahoma, sans-serif" font-size="8" fill="#c9c9c9">1270</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -1,6 +1,9 @@
|
||||
// Writes one placeholder thumbnail per top-level product category into
|
||||
// public/images/categories/{id}.svg. They are meant to be replaced with real
|
||||
// artwork one file at a time — the carousel reads the same paths either way.
|
||||
// Writes one placeholder thumbnail per product category (every level, not
|
||||
// just level-2) into public/images/categories/{id}.svg. They are meant to be
|
||||
// replaced with real artwork one file at a time — the carousel and any other
|
||||
// page that links a specific category id (e.g. /installments) read the same
|
||||
// paths either way, so every id needs a file, not just the level-2 ones the
|
||||
// homepage carousel shows by default.
|
||||
// Run: node scripts/gen-category-placeholders.mjs
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
@@ -12,14 +15,17 @@ const res = await fetch(
|
||||
);
|
||||
const { items } = await res.json();
|
||||
|
||||
const roots = items.filter((c) => c.parentId === null).map((c) => c.id);
|
||||
const level2 = items.filter((c) => c.parentId && roots.includes(c.parentId));
|
||||
// Root categories (parentId === null) have no icon of their own on this
|
||||
// site — nothing links directly to "کالای دیجیتال" itself — so only their
|
||||
// descendants get a placeholder.
|
||||
const roots = new Set(items.filter((c) => c.parentId === null).map((c) => c.id));
|
||||
const withIcons = items.filter((c) => !roots.has(c.id));
|
||||
|
||||
await mkdir(OUT, { recursive: true });
|
||||
|
||||
const escape = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
for (const cat of level2) {
|
||||
for (const cat of withIcons) {
|
||||
const label = escape(cat.nameFa || cat.name);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="${label}">
|
||||
<circle cx="60" cy="60" r="59" fill="#f6f7f9" stroke="#ececec"/>
|
||||
@@ -31,4 +37,4 @@ for (const cat of level2) {
|
||||
await writeFile(new URL(`${cat.id}.svg`, OUT), svg, "utf8");
|
||||
}
|
||||
|
||||
console.log(`wrote ${level2.length} placeholders to public/images/categories/`);
|
||||
console.log(`wrote ${withIcons.length} placeholders to public/images/categories/`);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import { CheckIcon } from "@/components/icons";
|
||||
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "درباره ما",
|
||||
description:
|
||||
"فروشگاه اینترنتی موبایل معلم، بیش از دو دهه تجربه در عرضه موبایل، لپتاپ، کنسول بازی و لوازم جانبی دیجیتال با ضمانت اصالت کالا.",
|
||||
};
|
||||
|
||||
// Real copy transcribed from the live moalem.shop theme's about-us module —
|
||||
// see the paragraph match confirmed against the cached homepage HTML.
|
||||
const INTRO =
|
||||
"فروشگاه اینترنتی موبایل معلم با گسترهای از کالاهای متنوع در زمینه دیجیتال، برای کاربران خود «تجربهای لذتبخش از یک خرید اینترنتی» را تداعی میکند. ارسال سریع، ضمانت بهترین قیمت و تضمین اصل بودن کالا، سه اصل اولیهای است که ما از نخستین روز سعی کردهایم به آن پایبند باشیم. فروشگاه موبایل معلم فعالیت خود را از سال ۱۳۸۰ با دو دهه تجربه در صنف موبایل آغاز نمود. این شرکت مفتخر است که با بهرهگیری از کادری متخصص و مجرب، توانسته گامهای بلندی در زمینه تامین و پخش تلفن همراه، لپتاپ، کنسول بازی، دوربین دیجیتال و انواع تجهیزات و اکسسوری کالای دیجیتال بردارد و سهم شایستهای در حوزه موبایل و رایانه را به خود اختصاص دهد.";
|
||||
|
||||
const ACTIVITIES = [
|
||||
"عرضه انواع گوشیهای موبایل و تبلت برندهای اصلی",
|
||||
"عرضه و فروش انواع لپتاپهای ایسوس و لنوو",
|
||||
"عرضه انواع کنسول بازی و دوربینهای دیجیتال",
|
||||
"فروش و عرضه انواع اکسسوری موبایل در بندهای مختلف",
|
||||
"ارائه پشتیبانی و خدمات پس از فروش مناسب با نیاز مشتریان",
|
||||
"تعمیرات تخصصی انواع لپتاپ، موبایل و…",
|
||||
"فروش اقساطی تمامی کالاهای دیجیتال",
|
||||
"فروش آنلاین به همکاران عزیز و مشتریان گرامی",
|
||||
];
|
||||
|
||||
const BRANDS = ["Samsung", "Apple", "Xiaomi", "Huawei", "Asus", "Lenovo", "Nokia"];
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="boxed py-8 md:py-12">
|
||||
<Breadcrumbs items={[{ label: "درباره ما" }]} />
|
||||
<h1 className="mb-8 text-xl font-bold text-ink md:text-2xl">درباره ما</h1>
|
||||
|
||||
{/* Intro */}
|
||||
<section className="grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-2 md:p-10">
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded-full bg-brand px-3 py-1.5 text-[12px] font-bold text-white">
|
||||
بیش از ۱۰۰+ فروش موفق
|
||||
</span>
|
||||
<h2 className="mt-4 text-lg font-bold text-ink md:text-xl">فروشگاه موبایل معلم را بیشتر بشناسید</h2>
|
||||
<p className="mt-4 text-[13.5px] leading-8 text-body">{INTRO}</p>
|
||||
</div>
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-white">
|
||||
<Image
|
||||
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/e5dfb9fe-8910-4ce7-a43a-6a66e327bbf6.jpg"
|
||||
alt="گوشی موبایل از فروشگاه موبایل معلم"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 500px"
|
||||
className="object-contain p-6"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Brand strip */}
|
||||
<section className="my-10 flex flex-wrap items-center justify-center gap-x-10 gap-y-4 opacity-60 grayscale">
|
||||
{BRANDS.map((b) => (
|
||||
<span key={b} className="text-lg font-bold text-ink-soft">
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Activities */}
|
||||
<section className="grid grid-cols-1 items-center gap-8 md:grid-cols-2">
|
||||
<div className="relative order-2 aspect-square w-full overflow-hidden rounded-2xl bg-surface md:order-1">
|
||||
<Image
|
||||
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/a089678c-b194-4d2c-bf4d-b920208ac2e7.jpg"
|
||||
alt="ساعت هوشمند از فروشگاه موبایل معلم"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 500px"
|
||||
className="object-contain p-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="order-1 md:order-2">
|
||||
<h2 className="text-lg font-bold text-ink md:text-xl">از ما بیشتر بدانید</h2>
|
||||
<p className="mt-2 text-[13px] text-body">زمینه فعالیت شرکت به شرح زیر میباشد:</p>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{ACTIVITIES.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2.5 text-[13.5px] leading-7 text-ink-soft">
|
||||
<CheckIcon className="mt-0.5 size-4 shrink-0 text-brand" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
همین حالا با ما تماس بگیرید
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Magazine highlight */}
|
||||
<section className="mt-14 md:mt-20">
|
||||
<h2 className="mb-5 text-lg font-bold text-ink md:text-xl">از مجله معلم بخوانید</h2>
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-white">
|
||||
{FALLBACK_BLOGS.slice(0, 3).map((post) => (
|
||||
<li key={post.id} className="flex gap-3 p-4">
|
||||
<span className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||
<Image src={post.image} alt={post.title} fill sizes="64px" className="object-cover" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-[11px] font-bold text-brand">{post.category}</span>
|
||||
<p className="clamp-2 mt-1 text-[13px] leading-6 text-ink-soft">{post.title}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<span className="relative block aspect-[16/10] w-full bg-surface lg:aspect-auto lg:h-full">
|
||||
<Image
|
||||
src={FALLBACK_BLOGS[0].image}
|
||||
alt={FALLBACK_BLOGS[0].title}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* Flat overlay, not a gradient — this site's design keeps to flat fills. */}
|
||||
<span className="absolute inset-x-0 bottom-0 bg-black/55 p-5">
|
||||
<span className="rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
|
||||
{FALLBACK_BLOGS[0].category}
|
||||
</span>
|
||||
<p className="clamp-2 mt-2 text-[14px] font-bold leading-7 text-white">
|
||||
{FALLBACK_BLOGS[0].title}
|
||||
</p>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import BlogCarousel, { type BlogCardData } from "@/components/BlogCarousel";
|
||||
import { getBlogById, getBlogs, getComments, safe } from "@/lib/api";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { blogHref } from "@/lib/slug";
|
||||
import { FALLBACK_BLOGS, getFallbackBlogById } from "@/data/fallbackBlogs";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -14,6 +15,12 @@ export async function generateMetadata({
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
|
||||
const fallback = getFallbackBlogById(id);
|
||||
if (fallback) {
|
||||
return { title: fallback.title, openGraph: { images: [{ url: fallback.image }] } };
|
||||
}
|
||||
|
||||
try {
|
||||
const { blog } = await getBlogById(id);
|
||||
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 }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const fallback = getFallbackBlogById(id);
|
||||
if (fallback) {
|
||||
return <FallbackBlogDetail post={fallback} others={FALLBACK_BLOGS} />;
|
||||
}
|
||||
|
||||
let blog;
|
||||
try {
|
||||
({ 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">
|
||||
{usingFallback
|
||||
? FALLBACK_BLOGS.map((post) => (
|
||||
// No real detail page exists yet for these — see AGENTS.md fallback
|
||||
// table — so the card is informational, not a link.
|
||||
<div key={post.id} className="overflow-hidden rounded-xl border border-line bg-white">
|
||||
<span className="relative block aspect-[16/9] w-full bg-surface">
|
||||
<Link
|
||||
key={post.id}
|
||||
href={blogHref(post.id, post.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)]"
|
||||
>
|
||||
<span className="relative block aspect-square w-full bg-surface">
|
||||
<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">
|
||||
{post.category}
|
||||
</span>
|
||||
</span>
|
||||
<p className="clamp-2 p-3 text-[13px] leading-6 text-ink-soft">{post.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
: result.items.map((post) => {
|
||||
const title = post.title ?? post.nameFa ?? "";
|
||||
@@ -62,7 +64,7 @@ export default async function BlogListPage({
|
||||
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)]"
|
||||
>
|
||||
<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" />}
|
||||
{category && (
|
||||
<span className="absolute right-2 top-2 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
import { InstagramIcon, MailIcon, MapPinIcon, PhoneIcon, TelegramIcon } from "@/components/icons";
|
||||
import { BRANCHES, MOBILE, MOBILE_HREF, PHONE, PHONE_HREF, SOCIAL } from "@/data/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "تماس با ما",
|
||||
description: "راههای تماس با فروشگاه موبایل معلم — شماره تماس، آدرس شعب و فرم ارسال پیام.",
|
||||
};
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<main className="boxed py-8 md:py-12">
|
||||
<Breadcrumbs items={[{ label: "تماس با ما" }]} />
|
||||
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">تماس با ما</h1>
|
||||
<p className="mb-8 text-[13px] text-body">
|
||||
سوالی دارید؟ فرم زیر را پر کنید یا مستقیم با ما تماس بگیرید.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_360px]">
|
||||
<div className="rounded-2xl border border-line bg-white p-6 md:p-8">
|
||||
<h2 className="mb-5 text-[15px] font-bold text-ink">فرم ارسال پیام</h2>
|
||||
<ContactForm />
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl border border-line bg-white p-6">
|
||||
<h2 className="mb-4 text-[15px] font-bold text-ink">راههای ارتباطی</h2>
|
||||
<ul className="space-y-4 text-[13px]">
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
|
||||
<PhoneIcon className="size-4.5" />
|
||||
</span>
|
||||
<a href={PHONE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
|
||||
{PHONE}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
|
||||
<PhoneIcon className="size-4.5" />
|
||||
</span>
|
||||
<a href={MOBILE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
|
||||
{MOBILE}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
|
||||
<MailIcon className="size-4.5" />
|
||||
</span>
|
||||
<span className="text-body">پاسخگویی از ۹ صبح تا ۹ شب</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 flex items-center gap-2 border-t border-line pt-5">
|
||||
<a
|
||||
href={SOCIAL.instagram}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="اینستاگرام موبایل معلم"
|
||||
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
<InstagramIcon className="size-4.5" />
|
||||
</a>
|
||||
<a
|
||||
href={SOCIAL.telegram}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="تلگرام موبایل معلم"
|
||||
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
<TelegramIcon className="size-4.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{BRANCHES.map((branch) => (
|
||||
<div key={branch.title} className="overflow-hidden rounded-2xl border border-line bg-white">
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
|
||||
<MapPinIcon className="size-4.5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[13px] font-bold text-ink">{branch.title}</p>
|
||||
<p className="mt-1 text-[12.5px] leading-6 text-body">{branch.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
title={`نقشه ${branch.title}`}
|
||||
src={`https://www.google.com/maps?q=${encodeURIComponent(branch.mapQuery)}&output=embed`}
|
||||
className="h-48 w-full border-0 grayscale"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -60,20 +60,31 @@
|
||||
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 {
|
||||
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-pagination-bullet {
|
||||
background: #ffffff;
|
||||
opacity: 0.55;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
/* Swiper theming: its own CSS reads these custom properties when drawing
|
||||
bullets (background: var(--swiper-pagination-bullet-inactive-color, #000)
|
||||
etc.) — setting a plain .swiper-pagination-bullet{background} rule has the
|
||||
same specificity as swiper's own rule and lost the cascade depending on
|
||||
chunk order, which is why the dots were showing swiper's black/blue
|
||||
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 {
|
||||
opacity: 1;
|
||||
/* The active pill shape has no swiper variable of its own — .hero-dots
|
||||
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;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import TrustBadges from "@/components/ui/TrustBadges";
|
||||
import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons";
|
||||
import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api";
|
||||
import { buildCategoryTree, findNode } from "@/lib/categoryTree";
|
||||
import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "فروش اقساطی",
|
||||
description: "خرید اقساطی موبایل، لپتاپ، تبلت، کنسول بازی و لوازم دیجیتال تا ۱۴ ماه، فقط با یک برگ چک.",
|
||||
};
|
||||
|
||||
// Categories this page highlights, mapped to real ids from
|
||||
// GET /tenants/moalem.shop/categories — "لوازم برقی" stands in for
|
||||
// "خانه هوشمند" from the reference design since this tenant has no such
|
||||
// category; every other label matches a real one.
|
||||
const HIGHLIGHT_IDS = ["1251", "1254", "1253", "1256", "1261", "1232", "1260"];
|
||||
|
||||
const TRUST_ITEMS = [
|
||||
{ icon: TruckIcon, title: "ارسال رایگان کالا", desc: "برای سفارشهای بالای ۵ میلیون تومان" },
|
||||
{ icon: ShieldIcon, title: "تضمین کیفیت", desc: "ضمانت اصالت تمامی کالاها" },
|
||||
{ icon: LockIcon, title: "حریم خصوصی", desc: "عدم ذخیرهسازی اطلاعات شما" },
|
||||
{ icon: RotateIcon, title: "برگشت پول", desc: "برای اولین سفارش شما" },
|
||||
{ icon: WalletIcon, title: "ضمانت با چک", desc: "بدون نیاز به ضامن" },
|
||||
];
|
||||
|
||||
export default async function InstallmentsPage() {
|
||||
const [roots, slots] = await Promise.all([
|
||||
safe(getCategories().then(buildCategoryTree), []),
|
||||
safe(getStaticImageSlots("home"), []),
|
||||
]);
|
||||
const categoriesSlot = findSlot(slots, "categories");
|
||||
|
||||
// Match by the slug in each image's own linkUrl (…?category=mobile), not
|
||||
// by array position — position assumes the business's upload order
|
||||
// matches this page's HIGHLIGHT_IDS order, which isn't a real contract and
|
||||
// was proven wrong in practice (a laptop photo landing on "موبایل"). See
|
||||
// AGENTS.md "Static-image slots".
|
||||
const artBySlug = new Map<string, string>();
|
||||
for (const img of categoriesSlot?.images ?? []) {
|
||||
const slug = categorySlugFromLinkUrl(img.linkUrl);
|
||||
if (slug) artBySlug.set(slug, img.url);
|
||||
}
|
||||
|
||||
const highlights = HIGHLIGHT_IDS.map((id) => {
|
||||
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
|
||||
// (1232) is a level-3 category, not level-2.
|
||||
const cat = findNode(roots, id);
|
||||
if (!cat) return null;
|
||||
const art = artBySlug.get(cat.slug) || `/images/categories/${id}.svg`;
|
||||
return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) };
|
||||
}).filter((x): x is NonNullable<typeof x> => Boolean(x));
|
||||
|
||||
return (
|
||||
<main className="boxed py-8 md:py-12">
|
||||
<Breadcrumbs items={[{ label: "فروش اقساطی" }]} />
|
||||
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">فروش اقساطی</h1>
|
||||
<p className="mb-8 text-[13px] text-body">خرید بهصرفه و مطمئن</p>
|
||||
|
||||
{/* Flat hero banner — brand-colored, no photo, no gradient. */}
|
||||
<section className="overflow-hidden rounded-2xl bg-ink p-8 text-white md:p-12">
|
||||
<p className="en text-sm font-bold tracking-widest text-white/60">INSTALLMENTS</p>
|
||||
<p className="mt-2 text-6xl font-black leading-none text-brand md:text-8xl">۱۴ ماه</p>
|
||||
<h2 className="mt-4 text-xl font-bold md:text-2xl">فروش اقساطی تمامی محصولات</h2>
|
||||
<p className="mt-1 text-[14px] text-white/70">راهی مطمئن برای خرید آسان و اقساطی</p>
|
||||
<Link
|
||||
href="/products"
|
||||
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
همین حالا خرید کنید
|
||||
<ChevronLeft className="size-4" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Goal + intro */}
|
||||
<section className="mt-10 grid grid-cols-1 items-center gap-8 md:grid-cols-[200px_1fr]">
|
||||
<div className="text-center md:text-start">
|
||||
<p className="text-7xl font-black leading-none text-brand">۱۴</p>
|
||||
<p className="en text-sm font-bold tracking-widest text-body">MONTH</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-ink md:text-xl">هدف ما، فروش اقساطی بهصورت منصفانه و متنوع</h2>
|
||||
<p className="mt-3 text-[13.5px] leading-8 text-body">
|
||||
ما فروش اقساطی لوازم دیجیتال ارائه میدهیم؛ شامل گوشی موبایل، لپتاپ، تبلت، کنسول بازی و سایر
|
||||
محصولات روز. پرداخت اقساط بهصورت اقساط تا ۱۴ ماه انجام میشود و تنها تضمین موردنیاز، چک است.
|
||||
هدف ما فراهمکردن خرید آسان، سریع و مطمئن برای مشتریان است تا بدون فشار مالی، دستگاه موردنظرشان
|
||||
را تهیه کنند.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Category highlights */}
|
||||
{highlights.length > 0 && (
|
||||
<section className="mt-14">
|
||||
<h2 className="text-lg font-bold text-ink md:text-xl">نقد و اقساط ۱۴ ماهه</h2>
|
||||
<p className="mt-1 text-[13px] text-body">
|
||||
با شرایطی آسان، هر کالای دیجیتالی و گجت هوشمند مورد علاقهتان را بهصورت اقساطی بخرید
|
||||
</p>
|
||||
<div className="mt-6 grid grid-cols-3 gap-6 sm:grid-cols-4 lg:grid-cols-7">
|
||||
{highlights.map((h) => (
|
||||
<Link key={h.id} href={h.href} className="group flex flex-col items-center gap-2">
|
||||
<span className="relative size-[84px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[96px]">
|
||||
<Image src={h.art} alt={h.label} fill sizes="96px" className="object-contain p-3" />
|
||||
</span>
|
||||
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
|
||||
{h.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Second promo block */}
|
||||
<section className="mt-14 grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-[1fr_220px] md:p-10">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-ink md:text-xl">
|
||||
فروش اقساطی تمامی اجناس و پرداخت حداکثر تا ۱۴ ماه
|
||||
</h2>
|
||||
<p className="mt-3 text-[13.5px] leading-8 text-body">
|
||||
برای سهولت بیشتر، تنها با ۲۰٪ پیشپرداخت میتوانید محصول مورد نظرتان را دریافت کنید. باقی مبلغ
|
||||
در بلندمدت تا ۱۴ ماه تسویه میشود و کارمزدها هم کاملاً منصفانه و شفاف هستند. با این شرایط، خرید
|
||||
راحت، سریع و بدون فشار مالی انجام میشود.
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
همین حالا ثبتنام بگیرید
|
||||
<ChevronLeft className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-center text-8xl font-black leading-none text-brand md:text-9xl">۱۴</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<TrustBadges items={TRUST_ITEMS} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import { getCategories, safe } from "@/lib/api";
|
||||
import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { SITE_NAME } from "@/data/site";
|
||||
import { SITE_URL } from "@/lib/config";
|
||||
import { LOGO_HEIGHT, LOGO_SRC, LOGO_WIDTH, SITE_URL } from "@/lib/config";
|
||||
import { ToastProvider } from "@/components/providers/ToastProvider";
|
||||
import { CartProvider } from "@/components/providers/CartProvider";
|
||||
|
||||
// Montserrat is the Latin face the live moalem.shop theme pairs with IRANYekan.
|
||||
const montserrat = Montserrat({
|
||||
@@ -20,26 +22,31 @@ const montserrat = Montserrat({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
const BASE_DESCRIPTION =
|
||||
"خرید اینترنتی گوشی موبایل، لپتاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.";
|
||||
|
||||
// Tab icon and og:image use the bundled brand files in /public — on purpose
|
||||
// not GET /website/favicon (that endpoint served another business's mark).
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: `${SITE_NAME} | خرید موبایل، لپتاپ و لوازم جانبی`,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description:
|
||||
"خرید اینترنتی گوشی موبایل، لپتاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
|
||||
description: BASE_DESCRIPTION,
|
||||
icons: [{ url: "/favicon.png", type: "image/png" }],
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fa_IR",
|
||||
siteName: SITE_NAME,
|
||||
title: `${SITE_NAME} | خرید موبایل، لپتاپ و لوازم جانبی`,
|
||||
description:
|
||||
"خرید اینترنتی گوشی موبایل، لپتاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
|
||||
description: BASE_DESCRIPTION,
|
||||
url: SITE_URL,
|
||||
images: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
|
||||
images: [{ url: LOGO_SRC, width: LOGO_WIDTH, height: LOGO_HEIGHT, alt: SITE_NAME }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const [categories, user] = await Promise.all([
|
||||
@@ -51,6 +58,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}>
|
||||
<body className="flex min-h-full flex-col">
|
||||
<ToastProvider>
|
||||
<CartProvider>
|
||||
<TopBar />
|
||||
<Header menu={menu} user={user} />
|
||||
<main className="flex-1 pb-6">{children}</main>
|
||||
@@ -58,6 +67,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
{/* Spacer so the fixed mobile bar never covers the end of the footer. */}
|
||||
<div className="h-[68px] lg:hidden" />
|
||||
<MobileBottomNav categories={menu} user={user} />
|
||||
</CartProvider>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getStoreSpecials,
|
||||
safe,
|
||||
} from "@/lib/api";
|
||||
import { buildCategoryTree, carouselCategories, findNode, subtreeIds } from "@/lib/categoryTree";
|
||||
import { buildCategoryTree, findNode, subtreeIds } from "@/lib/categoryTree";
|
||||
import { productToCard, specialToCards } from "@/lib/cards";
|
||||
import { blogHref, categoryHref } from "@/lib/slug";
|
||||
import { HOME_CAROUSELS } from "@/data/site";
|
||||
@@ -57,8 +57,6 @@ export default async function HomePage() {
|
||||
safe(getStaticImageSlots("home"), []),
|
||||
]);
|
||||
|
||||
const carouselCats = carouselCategories(categories);
|
||||
|
||||
// Empty or failing slots fall through to each section's own placeholder —
|
||||
// findSlot() already returns undefined for an empty/missing key.
|
||||
const sliderSlot = findSlot(imageSlots, "slider");
|
||||
@@ -124,7 +122,7 @@ export default async function HomePage() {
|
||||
}))
|
||||
: FALLBACK_BLOGS.map((b) => ({
|
||||
id: b.id,
|
||||
href: "/blog",
|
||||
href: blogHref(b.id, b.title),
|
||||
title: b.title,
|
||||
image: b.image,
|
||||
category: b.category,
|
||||
@@ -141,7 +139,7 @@ export default async function HomePage() {
|
||||
<>
|
||||
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپتاپ و لوازم جانبی</h1>
|
||||
<HeroSlider slot={sliderSlot} slides={slides} />
|
||||
<CategoryCarousel categories={carouselCats} slot={categoriesSlot} />
|
||||
<CategoryCarousel roots={categories} slot={categoriesSlot} />
|
||||
<HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
|
||||
<ThreeBanners slot={threeBannerSlot} />
|
||||
{mobileCarousel && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getComments,
|
||||
getProductById,
|
||||
getProductTechnicalInfo,
|
||||
getStoreItemsByProduct,
|
||||
getStoreItemByProduct,
|
||||
safe,
|
||||
} from "@/lib/api";
|
||||
import { getSession } from "@/lib/auth";
|
||||
@@ -50,12 +50,13 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
const { product, relatedProducts } = data;
|
||||
const title = product.nameFa || product.title;
|
||||
|
||||
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
|
||||
safe(getStoreItemsByProduct(product.id), { items: [] }),
|
||||
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] : []),
|
||||
@@ -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]">
|
||||
<ProductGallery images={images} title={title} />
|
||||
<ProductBuyPanel product={product} items={storeItems} user={user} />
|
||||
</div>
|
||||
<ProductBuyPanel
|
||||
product={product}
|
||||
variants={variants}
|
||||
gallery={<ProductGallery images={images} title={title} />}
|
||||
/>
|
||||
|
||||
<ProductInfoTabs
|
||||
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}
|
||||
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 && (
|
||||
<Image
|
||||
src={post.image}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import MixedText from "./MixedText";
|
||||
import { ChevronLeft } from "./icons";
|
||||
|
||||
export interface Crumb {
|
||||
@@ -14,10 +15,12 @@ export default function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
|
||||
{item.href ? (
|
||||
<Link href={item.href} className="transition hover:text-brand">
|
||||
{item.label}
|
||||
<MixedText>{item.label}</MixedText>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-ink-soft">{item.label}</span>
|
||||
<span className="text-ink-soft">
|
||||
<MixedText>{item.label}</MixedText>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -6,24 +6,36 @@ import { FreeMode } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import { findNodeBySlug } from "@/lib/categoryTree";
|
||||
import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
|
||||
import type { CategoryNode, StaticImageSlot } from "@/lib/types";
|
||||
|
||||
// Circular category thumbnails. The "categories" static-image slot has no
|
||||
// per-category id, so its images (kind: list, duplicatable) are matched
|
||||
// positionally to the category list, in the same order the business fills
|
||||
// them in — each still links to its real category page, only the art swaps.
|
||||
// A category past the end of the slot, or the whole thing when the slot is
|
||||
// empty, keeps its placeholder at public/images/categories/{id}.svg.
|
||||
// Fully slot-driven: each image in the "categories" slot carries its own
|
||||
// linkUrl (e.g. "...?category=mobile"), whose `category` param is the real
|
||||
// DB slug — that's what identifies which category a photo is FOR (used to
|
||||
// build the real, in-site link target). The visible caption is the
|
||||
// business's own titleFa for that image, taken as typed — a category the
|
||||
// business hasn't uploaded a photo for simply isn't shown here, no
|
||||
// placeholder, per the "only what's in the panel" brief.
|
||||
// See AGENTS.md "Static-image slots" for the mismatch this replaced.
|
||||
export default function CategoryCarousel({
|
||||
categories,
|
||||
roots,
|
||||
slot,
|
||||
}: {
|
||||
categories: CategoryNode[];
|
||||
roots: CategoryNode[];
|
||||
slot?: StaticImageSlot;
|
||||
}) {
|
||||
if (categories.length === 0) return null;
|
||||
const slotImages = slot?.images ?? [];
|
||||
const items = (slot?.images ?? [])
|
||||
.map((img) => {
|
||||
const catSlug = categorySlugFromLinkUrl(img.linkUrl);
|
||||
const cat = catSlug ? findNodeBySlug(roots, catSlug) : undefined;
|
||||
if (!cat) return null;
|
||||
const label = img.titleFa || img.titleEn || cat.nameFa || cat.name;
|
||||
return { key: img.id, art: img.url, label, href: categoryHref(cat.id, cat.nameFa, cat.name) };
|
||||
})
|
||||
.filter((x): x is { key: string; art: string; label: string; href: string } => Boolean(x));
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="boxed mt-8" aria-label="دستهبندی محصولات">
|
||||
@@ -31,30 +43,37 @@ export default function CategoryCarousel({
|
||||
modules={[FreeMode]}
|
||||
dir="rtl"
|
||||
freeMode
|
||||
slidesPerView={3.5}
|
||||
slidesPerView={2.8}
|
||||
spaceBetween={12}
|
||||
breakpoints={{
|
||||
480: { slidesPerView: 4.5 },
|
||||
768: { slidesPerView: 6.5 },
|
||||
1024: { slidesPerView: 9 },
|
||||
480: { slidesPerView: 3.6 },
|
||||
768: { slidesPerView: 5 },
|
||||
1024: { slidesPerView: 7 },
|
||||
}}
|
||||
>
|
||||
{categories.map((cat, i) => {
|
||||
const label = cat.nameFa || cat.name;
|
||||
const art = slotImages[i]?.url || `/images/categories/${cat.id}.svg`;
|
||||
return (
|
||||
<SwiperSlide key={cat.id}>
|
||||
<Link href={categoryHref(cat.id, cat.nameFa, cat.name)} className="group flex flex-col items-center gap-2">
|
||||
<span className="relative size-[90px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft">
|
||||
<Image src={art} alt={label} fill sizes="90px" className="object-contain p-2" />
|
||||
{items.map(({ key, art, label, href }) => (
|
||||
<SwiperSlide key={key}>
|
||||
<Link href={href} className="group flex flex-col items-center gap-2">
|
||||
{/* A real border, not ring/box-shadow — box-shadow rings can
|
||||
paint oddly against Swiper's transformed slide track
|
||||
(looked like the border "leaking" into the row above).
|
||||
border-box sizing (this site's global reset) means the
|
||||
1px border adds no layout shift. */}
|
||||
<span className="relative size-[104px] overflow-hidden rounded-full border border-transparent bg-surface transition-all duration-300 group-hover:border-brand group-hover:shadow-md sm:size-[124px] lg:size-[140px]">
|
||||
<Image
|
||||
src={art}
|
||||
alt={label}
|
||||
fill
|
||||
sizes="140px"
|
||||
className="rounded-full object-contain p-3 transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
|
||||
<span className="text-center text-[13.5px] text-ink transition group-hover:text-brand">
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</Swiper>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { submitContactForm } from "@/lib/api";
|
||||
import { useToast } from "./providers/ToastProvider";
|
||||
import { CheckIcon } from "./icons";
|
||||
|
||||
type Status = "idle" | "submitting" | "sent" | "error";
|
||||
|
||||
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
|
||||
// documented endpoint, no bearer required. On success: a toast, inline
|
||||
// success text, and the form resets and stays put (not swapped for a
|
||||
// separate "thank you" panel) so another message can be sent right away.
|
||||
export default function ContactForm() {
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const name = String(data.get("name") || "").trim();
|
||||
const title = String(data.get("title") || "").trim();
|
||||
const text = String(data.get("text") || "").trim();
|
||||
const email = String(data.get("email") || "").trim();
|
||||
const cellNumber = String(data.get("cellNumber") || "").trim();
|
||||
if (!name || !title || !text) return;
|
||||
|
||||
setStatus("submitting");
|
||||
try {
|
||||
await submitContactForm({
|
||||
name,
|
||||
title,
|
||||
text,
|
||||
email: email || undefined,
|
||||
cellNumber: cellNumber || undefined,
|
||||
});
|
||||
form.reset();
|
||||
setStatus("sent");
|
||||
showToast("پیام شما با موفقیت ارسال شد");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
const input =
|
||||
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => status === "sent" && setStatus("idle")} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<input name="name" required placeholder="نام و نام خانوادگی" className={input} />
|
||||
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<input name="email" type="email" placeholder="ایمیل (اختیاری)" className={input} />
|
||||
<input name="title" required placeholder="موضوع پیام" className={input} />
|
||||
</div>
|
||||
<textarea
|
||||
name="text"
|
||||
required
|
||||
rows={5}
|
||||
placeholder="متن پیام شما…"
|
||||
className="w-full rounded-xl border border-line bg-white p-4 text-sm outline-none transition focus:border-brand"
|
||||
/>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p>
|
||||
)}
|
||||
{status === "sent" && (
|
||||
<p className="flex items-center gap-1.5 text-[12.5px] font-bold text-brand">
|
||||
<CheckIcon className="size-4" />
|
||||
پیام شما ارسال شد. همکاران ما در اسرع وقت با شما تماس میگیرند.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="flex h-12 w-full items-center justify-center rounded-xl bg-brand text-sm font-bold text-white transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-10"
|
||||
>
|
||||
{status === "submitting" ? "در حال ارسال…" : "ارسال پیام"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE } from "@/data/site";
|
||||
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE, SOCIAL } from "@/data/site";
|
||||
import { LOGO_HEIGHT, LOGO_SRC, LOGO_WIDTH } from "@/lib/config";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import type { CategoryNode } from "@/lib/types";
|
||||
import { PhoneIcon } from "./icons";
|
||||
import { InstagramIcon, PhoneIcon, TelegramIcon } from "./icons";
|
||||
import { toFaDigits } from "@/lib/format";
|
||||
|
||||
export default function Footer({ categories }: { categories: CategoryNode[] }) {
|
||||
@@ -11,7 +12,13 @@ export default function Footer({ categories }: { categories: CategoryNode[] }) {
|
||||
<footer className="mt-14 border-t border-line bg-surface">
|
||||
<div className="boxed grid gap-8 py-10 md:grid-cols-4">
|
||||
<div className="md:col-span-2">
|
||||
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} />
|
||||
<Image
|
||||
src={LOGO_SRC}
|
||||
alt={SITE_NAME}
|
||||
width={LOGO_WIDTH}
|
||||
height={LOGO_HEIGHT}
|
||||
className="h-[70px] object-contain"
|
||||
/>
|
||||
<p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p>
|
||||
<a
|
||||
href={PHONE_HREF}
|
||||
@@ -20,6 +27,27 @@ export default function Footer({ categories }: { categories: CategoryNode[] }) {
|
||||
<PhoneIcon className="size-4" />
|
||||
{PHONE}
|
||||
</a>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<a
|
||||
href={SOCIAL.instagram}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="اینستاگرام موبایل معلم"
|
||||
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
<InstagramIcon className="size-4.5" />
|
||||
</a>
|
||||
<a
|
||||
href={SOCIAL.telegram}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="تلگرام موبایل معلم"
|
||||
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
<TelegramIcon className="size-4.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -5,15 +5,22 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import SearchBox from "./SearchBox";
|
||||
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 { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { LOGO_HEIGHT, LOGO_SRC, LOGO_WIDTH } from "@/lib/config";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import type { AuthUser, CategoryNode } from "@/lib/types";
|
||||
|
||||
// Two rows: logo + search + account/cart, then the product mega menu and the
|
||||
// main nav — the same split the reference storefront uses.
|
||||
export default function Header({ menu, user }: { menu: CategoryNode[]; user: AuthUser | null }) {
|
||||
export default function Header({
|
||||
menu,
|
||||
user,
|
||||
}: {
|
||||
menu: CategoryNode[];
|
||||
user: AuthUser | null;
|
||||
}) {
|
||||
const [megaOpen, setMegaOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -21,9 +28,16 @@ export default function Header({ menu, user }: { menu: CategoryNode[]; user: Aut
|
||||
<div className="boxed">
|
||||
{/* Mobile keeps the header down to the search box alone — account,
|
||||
cart and browsing live in the fixed bottom bar instead. */}
|
||||
<div className="flex h-[70px] items-center gap-4">
|
||||
<div className="flex items-center gap-4 pt-5">
|
||||
<Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}>
|
||||
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} priority />
|
||||
<Image
|
||||
src={LOGO_SRC}
|
||||
alt={SITE_NAME}
|
||||
width={LOGO_WIDTH}
|
||||
height={LOGO_HEIGHT}
|
||||
className="h-8 w-auto object-contain"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1">
|
||||
@@ -32,14 +46,7 @@ export default function Header({ menu, user }: { menu: CategoryNode[]; user: Aut
|
||||
|
||||
<div className="mr-auto hidden items-center gap-2 lg:flex">
|
||||
<AccountMenu user={user} />
|
||||
<Link
|
||||
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>
|
||||
<CartButton className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark" />
|
||||
</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 { 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 { categoryHref } from "@/lib/slug";
|
||||
import { displayName, isAdmin } from "@/lib/authUser";
|
||||
@@ -130,10 +131,12 @@ export default function MobileBottomNav({
|
||||
خانه
|
||||
</Link>
|
||||
|
||||
<Link href={`${CUSTOMER_PORTAL_URL}/cart`} className={`${item} text-ink`}>
|
||||
<CartIcon className="size-6" />
|
||||
سبد خرید
|
||||
</Link>
|
||||
<CartButton
|
||||
placement="up"
|
||||
wrapperClassName="relative flex-1"
|
||||
className={`${item} w-full text-ink`}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setSheetOpen((v) => !v)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { AUTH_TOKEN_COOKIE, CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { CartIcon, CheckIcon, ChevronLeft, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "./icons";
|
||||
import LoanCalculatorModal from "./LoanCalculatorModal";
|
||||
import MixedText from "./MixedText";
|
||||
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 type { ApiProduct, AuthUser, StoreItem } from "@/lib/types";
|
||||
import type { ApiProduct, StoreSpecialVariant } from "@/lib/types";
|
||||
|
||||
const trustPoints = [
|
||||
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" },
|
||||
@@ -14,22 +16,39 @@ const trustPoints = [
|
||||
{ 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({
|
||||
product,
|
||||
items = [],
|
||||
user,
|
||||
variants = [],
|
||||
initialVariantId,
|
||||
gallery,
|
||||
}: {
|
||||
product: ApiProduct;
|
||||
items?: StoreItem[];
|
||||
user: AuthUser | null;
|
||||
variants?: StoreSpecialVariant[];
|
||||
/** 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 [adding, setAdding] = useState(false);
|
||||
const inStockVariants = variants.filter((v) => isInStock(v.stockQuantity));
|
||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [added, setAdded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const selected = items.find((i) => i.id === selectedId) ?? items[0];
|
||||
const [loanOpen, setLoanOpen] = useState(false);
|
||||
const { addItem } = useCart();
|
||||
const { showToast } = useToast();
|
||||
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
|
||||
|
||||
const title = product.nameFa || product.title;
|
||||
const image = product.thumbnailUrl || product.image || product.thumbnail;
|
||||
const variationName = selected?.selections[0]?.variationName;
|
||||
|
||||
let price: number | null = null;
|
||||
@@ -46,77 +65,71 @@ export default function ProductBuyPanel({
|
||||
oldPrice = demo.oldPrice;
|
||||
}
|
||||
|
||||
const percent = price !== null && oldPrice !== null && oldPrice > price
|
||||
const percent =
|
||||
price !== null && oldPrice !== null && oldPrice > price
|
||||
? Math.round(((oldPrice - price) / oldPrice) * 100)
|
||||
: 0;
|
||||
|
||||
// Purchasing needs a real store-item variant (cart items are added by
|
||||
// storeItemVariantId, not productId) — until this product has one, there
|
||||
// is nothing a bearer token could add to a cart, logged in or not.
|
||||
const purchasable = items.length > 0 && Boolean(selected);
|
||||
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
|
||||
// A shopper with more than one in-stock variant must choose before adding
|
||||
// — the variant picker below doubles as that requirement, so "ready to
|
||||
// add" just means a selection exists.
|
||||
const purchasable = inStockVariants.length > 0 && Boolean(selected);
|
||||
const maxQuantity = selected?.stockQuantity == null ? 99 : Math.max(1, selected.stockQuantity);
|
||||
|
||||
async function handleAddToCart() {
|
||||
if (!selected || !user) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
|
||||
?.split("=")[1];
|
||||
if (!token) throw new Error("no-token");
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.meshkee.com/api/v1/businesses/${product.businessId}/cart/items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ storeItemVariantId: selected.storeItemId, quantity: 1 }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
function handleAddToCart() {
|
||||
if (!selected) return;
|
||||
addItem({
|
||||
id: selected.id,
|
||||
name: title,
|
||||
slug: product.slug,
|
||||
price: selected.discountedPrice ?? selected.price ?? 0,
|
||||
originalPrice: selected.discountedPrice != null ? (selected.price ?? undefined) : undefined,
|
||||
image,
|
||||
quantity,
|
||||
});
|
||||
setAdded(true);
|
||||
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
|
||||
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>
|
||||
<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.summary && (
|
||||
<div className="mt-5">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
{inStockVariants.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => {
|
||||
const rawValue = item.selections[0]?.value;
|
||||
{inStockVariants.map((variant) => {
|
||||
const rawValue = variant.selections[0]?.value;
|
||||
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
key={variant.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
disabled={item.stockQuantity <= 0}
|
||||
title={item.label}
|
||||
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
onClick={() => {
|
||||
setSelectedId(variant.id);
|
||||
setQuantity(1);
|
||||
}}
|
||||
title={variant.label}
|
||||
className={`flex items-center justify-center transition ${
|
||||
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 ${
|
||||
item.id === selectedId
|
||||
variant.id === selectedId
|
||||
? "border-brand bg-brand text-white"
|
||||
: "border-line text-ink hover:border-brand hover:text-brand"
|
||||
}`
|
||||
@@ -125,7 +138,7 @@ export default function ProductBuyPanel({
|
||||
{colorHex ? (
|
||||
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
|
||||
) : (
|
||||
item.label
|
||||
variant.label
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -149,10 +162,10 @@ export default function ProductBuyPanel({
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
|
||||
<span
|
||||
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>
|
||||
<TruckIcon className="size-5 text-body" />
|
||||
</div>
|
||||
@@ -186,19 +199,34 @@ export default function ProductBuyPanel({
|
||||
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
|
||||
این محصول هنوز برای خرید آنلاین فعال نشده است.
|
||||
</p>
|
||||
) : !user ? (
|
||||
<Link
|
||||
href={CUSTOMER_PORTAL_URL}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-brand text-sm font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
برای خرید وارد شوید
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div className="flex h-12 items-center rounded-lg border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-bold text-ink">{toFaDigits(quantity)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.min(maxQuantity, q + 1))}
|
||||
disabled={quantity >= maxQuantity}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-full w-9 items-center justify-center text-lg text-ink-soft transition hover:text-brand disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!inStock || adding}
|
||||
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 ${
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
@@ -210,12 +238,12 @@ export default function ProductBuyPanel({
|
||||
) : (
|
||||
<>
|
||||
<CartIcon className="size-4.5" />
|
||||
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
||||
افزودن به سبد خرید
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
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 CommentSection from "./CommentSection";
|
||||
import MixedText from "./MixedText";
|
||||
import { wrapLatinInHtml } from "@/lib/mixedText";
|
||||
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
|
||||
|
||||
type Tab = "description" | "technical" | "reviews";
|
||||
@@ -49,11 +51,13 @@ export default function ProductInfoTabs({
|
||||
<div className="pt-8">
|
||||
{tab === "description" && (
|
||||
<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 ? (
|
||||
<div
|
||||
className="rich-text text-sm leading-8 text-body"
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
dangerouslySetInnerHTML={{ __html: wrapLatinInHtml(descriptionHtml) }}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{specRows.map((row) => (
|
||||
<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>
|
||||
<dd className="font-bold text-ink">{row.value}</dd>
|
||||
<dt className="text-body">
|
||||
<MixedText>{row.label}</MixedText>
|
||||
</dt>
|
||||
<dd className="font-bold text-ink">
|
||||
<MixedText>{row.value}</MixedText>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SearchIcon } from "./icons";
|
||||
import MixedText from "./MixedText";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { productHref } from "@/lib/slug";
|
||||
import type { ApiProduct } from "@/lib/types";
|
||||
@@ -110,7 +111,9 @@ export default function SearchBox() {
|
||||
)}
|
||||
</span>
|
||||
<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>
|
||||
{p.store.minPrice !== null && (
|
||||
<span className="shrink-0 text-[12px] font-bold text-ink">
|
||||
|
||||
@@ -110,3 +110,46 @@ export const RotateIcon = ({ className }: P) => (
|
||||
<path d="M17.5 3.5V7H14M6.5 20.5V17H10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LockIcon = ({ className }: P) => (
|
||||
<svg {...base} className={className} aria-hidden>
|
||||
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const WalletIcon = ({ className }: P) => (
|
||||
<svg {...base} className={className} aria-hidden>
|
||||
<rect x="3.5" y="6" width="17" height="13" rx="2" />
|
||||
<path d="M3.5 10h17" />
|
||||
<circle cx="16.5" cy="14" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MapPinIcon = ({ className }: P) => (
|
||||
<svg {...base} className={className} aria-hidden>
|
||||
<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21z" />
|
||||
<circle cx="12" cy="9.5" r="2.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MailIcon = ({ className }: P) => (
|
||||
<svg {...base} className={className} aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="M3.5 6.5 12 13l8.5-6.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InstagramIcon = ({ className }: P) => (
|
||||
<svg {...base} className={className} aria-hidden>
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TelegramIcon = ({ className }: P) => (
|
||||
<svg {...base} className={className} aria-hidden>
|
||||
<path d="M21 4 3 11.4l6 2.1M21 4l-3.3 15L9 13.5M21 4 9 13.5v5.4l3-3.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -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 Link from "next/link";
|
||||
import MixedText from "@/components/MixedText";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import type { ProductCardData } from "@/lib/types";
|
||||
|
||||
@@ -34,7 +35,7 @@ export default function ProductCard({ product }: { product: ProductCardData }) {
|
||||
</span>
|
||||
|
||||
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
|
||||
{title}
|
||||
<MixedText>{title}</MixedText>
|
||||
</span>
|
||||
|
||||
{/* The old-price line always occupies its row — rendered empty when there
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface TrustBadge {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
// Shared trust-point row used on /about and /installments — same visual
|
||||
// language as the badges in ProductBuyPanel, just laid out wider.
|
||||
export default function TrustBadges({ items }: { items: TrustBadge[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-white p-5 text-center"
|
||||
>
|
||||
<item.icon className="size-7 text-brand" />
|
||||
<span className="text-[13px] font-bold text-ink">{item.title}</span>
|
||||
<span className="text-[11.5px] leading-5 text-body">{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// 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
|
||||
// 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.
|
||||
export interface FallbackBlog {
|
||||
id: string;
|
||||
@@ -73,3 +77,7 @@ export const FALLBACK_BLOGS: FallbackBlog[] = [
|
||||
category: "راهنمای خرید",
|
||||
},
|
||||
];
|
||||
|
||||
export function getFallbackBlogById(id: string): FallbackBlog | undefined {
|
||||
return FALLBACK_BLOGS.find((post) => post.id === id);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,32 @@ export const PROMO = {
|
||||
href: "/installments",
|
||||
};
|
||||
|
||||
export const PHONE = "۰۲۱-۳۳۹۰۰۰۰۰";
|
||||
export const PHONE_HREF = "tel:+982133900000";
|
||||
// Real contact data — pulled from the live moalem.shop theme (footer +
|
||||
// support badge), not invented. website/business-info on the API is still
|
||||
// blank for phones/addresses/social, so this stays hand-maintained here
|
||||
// until that's filled in; update both together when it is.
|
||||
export const PHONE = "۰۲۵-۳۲۱۰۶";
|
||||
export const PHONE_HREF = "tel:+982532106";
|
||||
export const MOBILE = "۰۹۳۵۷۵۳۲۱۰۶";
|
||||
export const MOBILE_HREF = "tel:+989357532106";
|
||||
|
||||
export const SOCIAL = {
|
||||
instagram: "https://instagram.com/mobilemoalem",
|
||||
telegram: "https://t.me/mobilemoalem",
|
||||
};
|
||||
|
||||
export const BRANCHES = [
|
||||
{
|
||||
title: "شعبه ۱",
|
||||
address: "قم، تقاطع عماریاسر، جنب پاساژ الزهرا، موبایل معلم",
|
||||
mapQuery: "قم تقاطع عماریاسر جنب پاساژ الزهرا موبایل معلم",
|
||||
},
|
||||
{
|
||||
title: "شعبه ۲",
|
||||
address: "قم، عماریاسر، نبش بازار سلام، فروشگاه معلم پلاس",
|
||||
mapQuery: "قم عماریاسر نبش بازار سلام فروشگاه معلم پلاس",
|
||||
},
|
||||
];
|
||||
|
||||
export const MAIN_NAV = [
|
||||
{ label: "برندها", href: "/brands" },
|
||||
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
SliderItem,
|
||||
StaticImageSlot,
|
||||
StoreItem,
|
||||
StoreItemVariants,
|
||||
StoreLoanCalculateResponse,
|
||||
StoreLoanMethodsResponse,
|
||||
StoreSpecial,
|
||||
Tenant,
|
||||
TechnicalInfoForm,
|
||||
@@ -159,12 +162,37 @@ export function getProductTechnicalInfo(
|
||||
return apiGet(t(`/products/by-id/${productId}/technical-info`));
|
||||
}
|
||||
|
||||
// Not /store-items/by-product/{id} — that one returns a single { storeItem }
|
||||
// (or null), not the full variant list a buy panel needs. The paginated
|
||||
// /store-items list, filtered by productId, is the one that returns every
|
||||
// purchasable variant.
|
||||
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
|
||||
return apiGet(t("/store-items"), { productId, pageSize: 100 });
|
||||
// The documented source for a product's purchasable variants (AI_PROMPT.md
|
||||
// "Shopping cart", step 1) — { storeItem: null } when the product has no
|
||||
// store-item at all. storeItem.variants is what the buy panel and the
|
||||
// add-to-cart flow both read; never call /store-items?productId= for this,
|
||||
// that's a different, unrelated listing (see getStoreItems below).
|
||||
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(
|
||||
@@ -198,6 +226,26 @@ export function submitContactForm(
|
||||
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 {
|
||||
const slot = slots.find((s) => s.key === key);
|
||||
if (!slot || slot.images.length === 0) return undefined;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getMe, safe } from "./api";
|
||||
import { AUTH_TOKEN_COOKIE } from "./config";
|
||||
import { reassembleChunkedCookie } from "./authUser";
|
||||
import type { AuthUser } from "./types";
|
||||
|
||||
export { displayName, isAdmin } from "./authUser";
|
||||
|
||||
// Server-side "am I logged in" check for the header/account menu. See the
|
||||
// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency
|
||||
// this relies on. Resolves to null whenever the cookie is absent or /auth/me
|
||||
// fails — never throws, so a missing session degrades to the anonymous view.
|
||||
// Server-side "am I logged in" check for the header/account menu. Reads the
|
||||
// cookie the customer dashboard writes on login (see AUTH_TOKEN_COOKIE in
|
||||
// config.ts and AI_PROMPT.md "Shared login") — this storefront never issues
|
||||
// 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> {
|
||||
const store = await cookies();
|
||||
const token = store.get(AUTH_TOKEN_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const raw = reassembleChunkedCookie((name) => store.get(name)?.value, AUTH_TOKEN_COOKIE);
|
||||
if (!raw) return null;
|
||||
const token = decodeURIComponent(raw);
|
||||
|
||||
const result = await safe(getMe(token), null);
|
||||
return result?.user ?? null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
|
||||
// which reads next/headers) — safe to import from client components.
|
||||
import { AUTH_TOKEN_COOKIE } from "./config";
|
||||
import type { AuthUser } from "./types";
|
||||
|
||||
// "علی عزیز" — first name (or the fullest name field available) + عزیز.
|
||||
@@ -14,3 +15,44 @@ export function displayName(user: AuthUser): string {
|
||||
export function isAdmin(user: AuthUser): boolean {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { discountPercent } from "./format";
|
||||
import { discountPercent, isInStock } from "./format";
|
||||
import { productHref } from "./slug";
|
||||
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 {
|
||||
const base = {
|
||||
@@ -29,6 +29,25 @@ export function productToCard(product: ApiProduct): ProductCardData {
|
||||
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
|
||||
// discount shown there is the real one rather than a product-level guess.
|
||||
export function specialToCards(special: StoreSpecial): ProductCardData[] {
|
||||
@@ -46,7 +65,7 @@ export function specialToCards(special: StoreSpecial): ProductCardData[] {
|
||||
price,
|
||||
oldPrice,
|
||||
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
|
||||
inStock: (variant?.stockQuantity ?? 0) > 0,
|
||||
inStock: isInStock(variant?.stockQuantity),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ export function findNode(nodes: CategoryNode[], id: string): CategoryNode | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findNodeBySlug(nodes: CategoryNode[], slug: string): CategoryNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.slug === slug) return node;
|
||||
const hit = findNodeBySlug(node.children, slug);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Products are filed against leaf categories, so a parent like "لوازم جانبی"
|
||||
// matches almost nothing on its own — a carousel for it has to ask for the
|
||||
// whole subtree. Capped for the homepage's small carousels; a full category
|
||||
|
||||
@@ -7,14 +7,25 @@ export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
|
||||
// "مدیریت وبسایت" account-menu item shown to admins.
|
||||
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
|
||||
|
||||
// Login happens on the customer portal (a separate app), not on this site.
|
||||
// For the account menu to know a visitor is signed in, that portal needs to
|
||||
// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not
|
||||
// HttpOnly) after login, so requests to this site carry it too. Until it
|
||||
// does, getSession() in src/lib/auth.ts always resolves to null and the
|
||||
// header simply shows the anonymous "ورود | ثبتنام" link — nothing breaks,
|
||||
// the logged-in state just never activates.
|
||||
export const AUTH_TOKEN_COOKIE = "meshkee_token";
|
||||
// Bundled brand mark (public/images/logo.png, 200x48 wordmark) — deliberately
|
||||
// not read from the API's /website/favicon; see AGENTS.md "Brand".
|
||||
export const LOGO_SRC = "/images/logo.png";
|
||||
export const LOGO_WIDTH = 150;
|
||||
export const LOGO_HEIGHT = 48;
|
||||
|
||||
// Written by the customer dashboard on login, on the shared parent domain
|
||||
// (".{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).
|
||||
export const BRAND = {
|
||||
|
||||
@@ -15,6 +15,13 @@ export function discountPercent(price: number, oldPrice: number): number {
|
||||
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
|
||||
// aspect-ratio property needs a slash ("16/9"), not a colon.
|
||||
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));
|
||||
}
|
||||
@@ -22,3 +22,18 @@ export function categoryHref(id: string, nameFa?: string | null, name?: string |
|
||||
export function blogHref(id: string, title?: string | null): string {
|
||||
return `/blog/${id}/${faSlug(title)}`;
|
||||
}
|
||||
|
||||
// Static-image-slot items carry a business-picked linkUrl like
|
||||
// "https://moalem.shop/products?category=mobile" — the `category` query
|
||||
// param is the DB slug (ApiCategory.slug), the one reliable way to identify
|
||||
// which category an uploaded photo is actually for. Position in the list
|
||||
// and the item's own titleFa are NOT reliable — see AGENTS.md "Static-image
|
||||
// slots" for the real mismatch this was built to fix.
|
||||
export function categorySlugFromLinkUrl(linkUrl: string | null | undefined): string | null {
|
||||
if (!linkUrl) return null;
|
||||
try {
|
||||
return new URL(linkUrl).searchParams.get("category");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,11 +165,21 @@ export interface StoreSpecialVariant {
|
||||
selections: StoreItemSelection[];
|
||||
price: number | null;
|
||||
discountedPrice: number | null;
|
||||
stockQuantity: number;
|
||||
stockQuantity: number | null;
|
||||
isFestival: boolean;
|
||||
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 {
|
||||
id: string;
|
||||
productId: string;
|
||||
@@ -300,3 +310,53 @@ export interface AuthUser {
|
||||
isAdmin?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||