Compare commits
17
Commits
18bf90bbb6
...
main
+2
-1
@@ -5,7 +5,8 @@
|
||||
"name": "sanihome-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
"port": 3000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Used by `next build` on the websites VM (production).
|
||||
# Local `next dev` still prefers `.env.local`.
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.meshkee.com/api/v1
|
||||
NEXT_PUBLIC_DOMAIN=sanihome.ir
|
||||
NEXT_PUBLIC_CUSTOMER_ORIGIN=https://customer.sanihome.ir
|
||||
NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.sanihome.ir
|
||||
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
banners.psd
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
# sanihome.ir — project context
|
||||
|
||||
> **Keep this file current.** Whenever a change touches architecture, conventions,
|
||||
> API integration, or adds/removes a major feature, update the relevant section
|
||||
> here in the same session. This file exists so any AI assistant or developer
|
||||
> picking up the repo cold — without the conversation history that produced it —
|
||||
> can get oriented in minutes instead of re-deriving everything from the code
|
||||
> and from trial-and-error against the live API.
|
||||
|
||||
## What this is
|
||||
|
||||
A Next.js 16 (App Router, Turbopack) storefront for **سانیهوم (Sanihome)**, a
|
||||
Persian-language (RTL) home-appliance retailer, backed entirely by the
|
||||
**Meshkee** commerce platform's public API. There is no local database —
|
||||
every page reads live from `https://api.meshkee.com/api/v1`, tenant domain
|
||||
`sanihome.ir`. See [`AGENTS.md`](AGENTS.md) — Next.js 16 has breaking changes
|
||||
from what most training data assumes; read `node_modules/next/dist/docs/`
|
||||
before touching App Router APIs you're not 100% sure of.
|
||||
|
||||
**Meshkee API docs** (always check before assuming a field/endpoint shape):
|
||||
- Hub: https://api.meshkee.com/docs/website
|
||||
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
|
||||
- AI brief: https://api.meshkee.com/docs/website/AI_PROMPT.md
|
||||
- The OpenAPI spec's `responses` are often undocumented (just a one-line
|
||||
description like `{ cart }`) — when a shape isn't documented, `curl` the
|
||||
live endpoint rather than guessing. Real examples discovered this way are
|
||||
noted below.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 16, Turbopack, TypeScript, Tailwind v4 (`@tailwindcss/typography` for
|
||||
rich HTML content).
|
||||
- Swiper for carousels (`src/components/ui/Carousel.tsx` for
|
||||
auto-width/free-mode rows; `FeaturedCategories.tsx` for a fixed
|
||||
slides-per-view row).
|
||||
- Two local font files supplied by the client — `public/fonts/*.woff`
|
||||
(IRANYekan Light + Bold), loaded via `next/font/local` in
|
||||
`src/app/layout.tsx`. Body text uses the light weight; **every heading tag
|
||||
(`h1`–`h6`) uses the bold weight** via a plain CSS rule in `globals.css` —
|
||||
don't reintroduce a Google-fonted Vazirmatn/etc., this was deliberately
|
||||
replaced.
|
||||
- No local dev-server port is guaranteed — `.claude/launch.json` has
|
||||
`"autoPort": true` because other projects on this machine also default to
|
||||
port 3000.
|
||||
|
||||
## Data layer (`src/lib/`)
|
||||
|
||||
- **`meshkee.ts`** — all public, unauthenticated reads (and the two
|
||||
unauthenticated writes: comments, contact form) live here. Every
|
||||
`normalize*` function reads defensively from multiple possible field names
|
||||
because different endpoints shape the "same" concept differently (see
|
||||
Data quirks below). Prefer extending an existing `normalize*`/type over
|
||||
adding a parallel one.
|
||||
- **`guestCart.ts`** — the storefront's entire cart story (see next
|
||||
section). No token, no backend call, no `businessId` — just
|
||||
`localStorage` + a shared cookie.
|
||||
|
||||
## Auth, cart & checkout: this site does none of it
|
||||
|
||||
**This was built wrong once already** — an earlier pass added real in-site
|
||||
login (password/OTP), a real backend cart (`/businesses/{businessId}/cart`),
|
||||
and a `/checkout` page. That's explicitly **not** how Meshkee storefronts
|
||||
work, per `https://api.meshkee.com/docs/website/AI_PROMPT.md`'s hard rules:
|
||||
login, register, OTP, the real cart, addresses, and payment all belong to
|
||||
**`https://customer.<domain>`** (the customer dashboard) — a *separate app*,
|
||||
not a page on this site. If you're about to add a `/login`, `/register`,
|
||||
`/checkout`, `/cart`, or `/account` route, or call `/auth/*` or
|
||||
`/businesses/{id}/cart*` from this codebase: **stop, re-read the AI_PROMPT.md
|
||||
"Shopping cart on the storefront" and "Shared login" sections first.**
|
||||
|
||||
- **Detecting a logged-in shopper**: read the `meshkee_customer_access_token`
|
||||
cookie (written by the customer dashboard with `Domain=.sanihome.ir`, so
|
||||
it's readable here too) — see `isCustomerLoggedIn()` in `guestCart.ts`.
|
||||
Never build a login UI or call `/auth/login` from this site.
|
||||
- **Guest cart** (`guestCart.ts`): lines are
|
||||
`{ id, name, slug, price, originalPrice?, image, quantity }` where `id` is
|
||||
always a `storeItemVariantId` — never a product id or store-item id.
|
||||
Persisted to `localStorage` (key `meshkee-guest-cart`) **and** a cookie of
|
||||
the same name (`Domain=.sanihome.ir`, skipped if the encoded JSON is
|
||||
>~3500 chars — rely on the URL param instead). `CartProvider`
|
||||
(`src/context/CartContext.tsx`) just wraps this in React state; there is
|
||||
no server round-trip, so `addItem`/`updateItem`/`removeItem` are
|
||||
synchronous.
|
||||
- **"Continue" in the mini-cart** (`CartPopup`) builds a URL with
|
||||
`continueToCheckoutUrl()`: the guest cart, base64url-encoded (exact
|
||||
algorithm from the spec) into a `guestCart` query param, pointed at
|
||||
`https://customer.sanihome.ir/checkout/cart?guestCart=...` if the shopper
|
||||
looks logged in, or `.../login?redirect=...` (wrapping that same cart URL)
|
||||
if not. The dashboard is responsible for decoding it and syncing it into
|
||||
the real server cart after login — this site's job ends at building that
|
||||
URL correctly.
|
||||
- **Which variant does "add to cart" target?**
|
||||
- On the product/store-item **detail** pages, `ProductPurchasePanel`
|
||||
resolves a `matchedVariant` from `storeVariants` (even for a single-SKU
|
||||
product with no selectable variations — it just uses the one variant)
|
||||
and adds `matchedVariant.id`.
|
||||
- On **cards** (`ProductCard`), only sources that already carry a specific
|
||||
variant id can quick-add: `Product.variantId`, populated in
|
||||
`normalizeProduct` from `raw.variants[0].id` (store-specials,
|
||||
brand-groups). Cards from the plain `/products` list endpoint have no
|
||||
variant info, so the button links to the detail page instead — never
|
||||
fabricate a variant id to force a quick-add.
|
||||
- The spec's fuller flow (`GET /store-items/by-product/{productId}`,
|
||||
then: hide the button if no in-stock variant exists, force a picker
|
||||
when more than one is in stock) isn't separately re-implemented per
|
||||
card — the existing variation-picker UI on the detail page already
|
||||
satisfies "shopper must choose before adding."
|
||||
|
||||
## Comments
|
||||
|
||||
`GET/POST /tenants/{domain}/comments` needs no auth on Meshkee's side —
|
||||
`authorName`/`authorEmail` are plain body fields, same as the contact form.
|
||||
`ProductTabs`' comments panel is a guest name+email(optional)+text form, no
|
||||
login involved (this also isn't gated on the dashboard cookie — keep it
|
||||
that way; there's no reason to require a customer-dashboard session just to
|
||||
leave a comment). `GET /comments` only returns **approved** comments, so a
|
||||
freshly-posted comment won't reappear on refetch until an admin approves it
|
||||
— the UI appends it optimistically to local state instead of re-fetching,
|
||||
which is correct, not a bug.
|
||||
|
||||
## Data quirks (verified against the live catalog)
|
||||
|
||||
- **Persian name always wins**: every `name`-ish field prefers `nameFa` /
|
||||
`productNameFa` over the English `title`/`name` — this is a Persian
|
||||
storefront front-to-back.
|
||||
- **Price/discount fields differ by source.** Some endpoints (plain
|
||||
`/products` list) only ever give `store.minPrice/maxPrice` with no
|
||||
original/discounted distinction. Others (store-specials, brand-groups,
|
||||
store-items) nest `price`/`discountedPrice` one level down, in
|
||||
`variants[0]` or on the item directly. `normalizeProduct` computes a
|
||||
`discount` **percent** from real price/oldPrice numbers when the API
|
||||
doesn't send one explicitly — never invent a percent without two real
|
||||
prices behind it.
|
||||
- **Most of the catalog has no price at all** (`store.minPrice: null`) and
|
||||
**`technical-info` is empty for almost every product** — this is real data
|
||||
state on the client's side, not a bug. The honest-empty-state path (a
|
||||
disabled/"تماس با فروشگاه" control, or an `EmptyState` message) is the one
|
||||
actually exercised for most products; don't "fix" it by fabricating values.
|
||||
- **Group-source `id` vs `productId`**: store-specials/brand-groups/store-items
|
||||
wrap a store-item row whose own `id` is *not* the product id — the real
|
||||
product id is `raw.productId`. `normalizeProduct` prefers `productId`, and
|
||||
cards/links that only have this id (no slug) route through
|
||||
`/products/id/[id]` (`src/app/products/id/[id]/page.tsx`), which resolves
|
||||
`GET /products/by-id/{id}` and redirects to the canonical slug URL.
|
||||
- **Store-items vs Products**: a "store item" is one purchasable
|
||||
variant/SKU (id = `storeItemVariantId` for the cart API). `/store-items`
|
||||
and `/store-items/[id]` (variant-level browsing/detail) are a separate
|
||||
surface from `/products` and `/products/[slug]` (product-level, with
|
||||
descriptions/technical-info/full variation picker) — the store-item detail
|
||||
page cross-references its parent product (`getProductById`) for
|
||||
images/description/specs, since the variant endpoint alone only has
|
||||
price/stock/selections.
|
||||
|
||||
## UI conventions (apply these by default; also noted in this session's
|
||||
persistent memory as "for all the user's sites", not just this one)
|
||||
|
||||
- **Breadcrumb on every page except the homepage** — above the `<h1>`, via
|
||||
`src/components/ui/Breadcrumb.tsx`.
|
||||
- Detail pages use `src/components/ui/PageShell.tsx` so the max width, page
|
||||
padding, and breadcrumb position cannot drift between product and blog
|
||||
detail layouts. The blog detail page also renders category-matched related
|
||||
posts in the collapsible `SimilarArticles` sidebar; its DOM position follows
|
||||
the article so it naturally moves below the article body on smaller screens.
|
||||
- **Product detail layout**: 3-column grid (desktop) — gallery
|
||||
(`ProductGallery`, ~1/3, includes hover-zoom + click-to-open lightbox) →
|
||||
title + variation pickers (~5/12) → sticky buy-box (~1/4). One
|
||||
`ProductPurchasePanel` client component owns the shared
|
||||
selection/quantity/cart state and renders as a `Fragment` with two
|
||||
grid-column-span children so it can span two non-adjacent grid cells while
|
||||
the server renders the static header content into it via a prop.
|
||||
- **Discount price display** (cards and buy-box both): old price
|
||||
strikethrough + green percent badge **on the same line, badge to the
|
||||
old price's left** — current price alone, bold, on the next line. Never a
|
||||
min–max range once a real variant-driven price exists.
|
||||
- **Equal-height cards in a `slidesPerView: "auto"` + freeMode Swiper row**:
|
||||
Swiper's own `.swiper-slide { height: 100% }` doesn't stretch to the
|
||||
tallest sibling in this mode (no definite ancestor height for the
|
||||
percentage to resolve against). Fix applied in `Carousel.tsx`: override
|
||||
the `<SwiperSlide>` to `height: "auto"` via inline style (wins over the
|
||||
stylesheet regardless of import order) so normal flex-stretch equalizes
|
||||
the row; pair with `flex h-full flex-col` on the card root and put the
|
||||
button last so it pins to the bottom.
|
||||
- **Tabbed product-detail section** (`ProductTabs`): مشخصات فنی / نقد و
|
||||
بررسی / نظرات کاربران, in that RTL order (rightmost/default-active first
|
||||
in the array — first DOM child renders rightmost under `dir="rtl"`).
|
||||
- Category tile images: 110px, no border, no border-radius
|
||||
(`FeaturedCategories.tsx`).
|
||||
- **No emoji as icons** — `src/components/ui/icons.tsx` has the shared
|
||||
light/outline SVG set (`MenuIcon`, `UserIcon`, `CartIcon`, `CloseIcon`,
|
||||
`ChevronLeftIcon`), 24x24 viewBox, `stroke="currentColor"`. Use these
|
||||
instead of characters like "☰"/"👤"/"🛒"/"✕". Every "view more"-style
|
||||
link/button (`SectionTitle`'s `action`, the mega-menu's "مشاهده همه",
|
||||
`BlogCarousel`'s "ادامه مطلب") uses `ChevronLeftIcon`, not a literal "←"
|
||||
character — keep new ones consistent with that.
|
||||
- **Header account/cart cluster** (`Header.tsx`, desktop only —
|
||||
`hidden ... sm:flex`): a borderless `bg-surface` chip holding, in this
|
||||
exact DOM order, `CartButton`, a divider, then the login link. That DOM
|
||||
order is deliberate and easy to get backwards: under `dir="rtl"` the
|
||||
first DOM child renders rightmost, so cart-first-in-DOM is what actually
|
||||
puts the cart icon on the visual **right** and the login block on the
|
||||
**left** (this was built backwards once and corrected). Same rule inside
|
||||
the login link itself — the icon badge comes before the two-line text in
|
||||
DOM so the icon renders right of the text. Neither `CartButton` nor this
|
||||
wrapper has a border (removed on request — don't reintroduce one).
|
||||
`CartButton` is a fixed 44px (`h-11 w-11`) icon-only square everywhere it
|
||||
renders (also stands alone, un-wrapped, below `sm`); its unread-count
|
||||
badge sits at `-top-1.5 -right-1.5`, not the product-card cart badge's
|
||||
`-left-1.5` — don't copy that positioning between the two, they're
|
||||
intentionally different corners for different-looking buttons.
|
||||
|
||||
## Static-image slots
|
||||
|
||||
Admin-managed marketing images (banners, category tiles) — not tied to real
|
||||
catalog data. `STATIC_IMAGE_SLOT_CATALOG` in `meshkee.ts` is the source of
|
||||
truth for which slots exist; publishing it via
|
||||
`src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`)
|
||||
is how the admin dashboard's "refresh" imports new keys. Current keys:
|
||||
`home-hero`, `two-banner`, `three-banner-row`, `featured-categories`,
|
||||
`about-us-image`. `about-us-image` is a homepage `single` slot with a `14:9`
|
||||
ratio (the original 560×360 frame). `AboutSanihome` uses its first image,
|
||||
prefers `titleFa`, then `titleEn`, then `subtext` for alt text, wraps the image
|
||||
with `linkUrl` when present, and preserves `/images/about.svg` as the empty/error
|
||||
fallback. Adding
|
||||
a slot here does **not** make images appear — someone still has to upload
|
||||
them in the dashboard; the code path just needs to fall back to whatever the
|
||||
"current" behavior was before the slot existed when it's empty (see
|
||||
`FeaturedCategories.tsx` for the pattern: real slot images when present,
|
||||
else the pre-existing UI unchanged).
|
||||
|
||||
**Static-image sizing rule:** whenever a slot's rendered image dimensions or
|
||||
aspect ratio change during development, update the matching
|
||||
`STATIC_IMAGE_SLOT_CATALOG` entry (`aspectRatio` and `recommendedWidth`) in the
|
||||
same change so `/meshkee/static-image-slots` stays accurate for the dashboard.
|
||||
|
||||
## Store-item filters
|
||||
|
||||
`/store-items` has a desktop sidebar and a mobile filter drawer implemented by
|
||||
`components/product/ProductFilters.tsx`. Categories are rendered as a nested
|
||||
accordion (CMS parent categories with their children), not one long flat list.
|
||||
The three currently supported filters
|
||||
are category (`categoryId`), price range (`minPrice` / `maxPrice`), and in-stock
|
||||
only (`inStock=true`). They are submitted as URL search params and passed to
|
||||
`getStoreItems`, so filtering happens in the API before pagination. Pagination
|
||||
links must preserve every active filter. Categories come from `getCategoryTree`
|
||||
and therefore follow CMS data rather than a hardcoded list. On mobile the
|
||||
"فیلتر محصولات" button opens a right-side modal; its submit/reset actions stay
|
||||
fixed at the bottom while filter fields scroll. Desktop filters apply in real
|
||||
time: category and stock changes submit immediately, while price inputs use a
|
||||
short debounce; only the mobile drawer keeps an explicit apply button. Desktop
|
||||
submissions use `scroll={false}` and the client component must not be keyed by
|
||||
active filters, otherwise both the page and the category list jump back to the
|
||||
top. When a parent category or one of its children becomes active, its matching
|
||||
`details` element is opened imperatively without closing other accordions.
|
||||
|
||||
Do not simulate technical-property filters by fetching every product's
|
||||
`technical-info`: the public API currently exposes technical schema/values only
|
||||
per product, and `/store-items` does not accept technical field filters. When a
|
||||
faceted category-filter endpoint and technical-field query parameters are added
|
||||
to the API, render those returned fields as additional accordions in the same
|
||||
component.
|
||||
|
||||
## Product detail tabs and technical specifications
|
||||
|
||||
`ProductTabs` wraps the specifications, review, and comments UI in one white,
|
||||
rounded, bordered card. Technical specifications render as a responsive
|
||||
two-column table (field label and submitted value), not a loose card grid.
|
||||
`getProductTechnicalInfo()` must join `values[].fieldId` to
|
||||
`form.fields[].id`, resolve option labels from each field's `options`, and sort
|
||||
by the CMS field `sortOrder`; technical `values` do not reliably carry their
|
||||
own labels. If the form or submitted values are empty, keep the existing empty
|
||||
state rather than fabricating rows.
|
||||
|
||||
## Homepage product carousels — fully dynamic, don't hardcode a name/key
|
||||
|
||||
`GET /tenants/{domain}/store-specials` returns **every** admin-managed named
|
||||
carousel as one flat list — e.g. "Special Sale", "پرفروشها", "بهترینها از
|
||||
نگاه شما" all come back from this one call, each with its own `title` and
|
||||
`items`. `StoreSpecialCarousels.tsx` maps over the **whole list** (sorted by
|
||||
`sortOrder`, empty groups skipped) and renders one titled carousel section
|
||||
per group, using `group.title` from the API as the heading — no group
|
||||
id/key/title is special-cased in code. This means a brand-new carousel
|
||||
created in the Meshkee dashboard (any name) appears on the homepage the next
|
||||
time the page renders, with zero code changes.
|
||||
|
||||
**Do not**:
|
||||
- Hardcode a Persian/English title pair for one of these carousels (that was
|
||||
the old `SpecialSaleCarousel`/`TopSellingCarousel` mistake — two separate
|
||||
components each pinned to one group, with a made-up title instead of the
|
||||
API's own `title`, and `TopSellingCarousel` didn't even read from
|
||||
store-specials, it re-queried the plain product list).
|
||||
- Reintroduce a **brand-name carousel** (`GET /website/brand-groups` — used
|
||||
to render a "سامسونگ" section). The user explicitly removed this: brand
|
||||
carousels aren't wanted on this site at all. If similar per-brand curation
|
||||
is wanted later, it should go through admin-managed store-specials groups
|
||||
(a group can be curated by brand already), not a separate brand-groups
|
||||
integration.
|
||||
|
||||
`SectionTitle`'s `enTitle` prop is optional for exactly this reason — these
|
||||
dynamic groups only have one `title` string from the API, not a Persian/
|
||||
English pair. Each carousel still shows an English caption using that same
|
||||
`enTitle` slot (inline, beside the title — the same treatment as every
|
||||
other section heading on the site, e.g. BlogCarousel's "آخرین مطالب وبلاگ /
|
||||
LATEST BLOG"; there was a brief detour through a separate "below the title,
|
||||
opacity-50, own line" `enSubtitle` prop, which the user then asked to match
|
||||
the existing inline convention instead — don't reintroduce that below-title
|
||||
variant). `StoreSpecialCarousels.englishLabelFromKey()` formats the group's
|
||||
`key` (e.g. `top-selling` → "TOP SELLING") rather than inventing a
|
||||
translation of `title`; there's no real localized-English field to
|
||||
translate from, and a made-up translation would be fabricated data. Skips
|
||||
rendering if the key has no letters at all (fell back to a bare numeric id).
|
||||
|
||||
**Banner interleaving**: the two static-image banner sections (`TwoBanners`,
|
||||
`ThreeBanners`) are rendered *inside* `StoreSpecialCarousels`, one inserted
|
||||
between every pair of carousels (never before the first or after the last),
|
||||
cycling through the two if there end up being more than two carousels. Don't
|
||||
move them back out to `page.tsx` as fixed-position siblings — that only
|
||||
works for a known, fixed carousel count, which this deliberately isn't.
|
||||
|
||||
`SectionTitle`'s title/action row uses `items-center` (not `items-end`) —
|
||||
needed once carousel titles could carry more content (the inline `enTitle`
|
||||
caption) than a single line, so bottom-aligning the row left the action
|
||||
button visibly offset from the title block. The `action` button itself is
|
||||
styled like the site's other primary buttons (filled `bg-red`, white text,
|
||||
`rounded-lg` — the same look as `ProductCard`'s "افزودن به سبد"), not a
|
||||
plain text link.
|
||||
|
||||
**"مشاهده همه" (view all)**: each carousel's action button links to
|
||||
`/store-items?specialId={group.id}`. `store-specials` groups are admin-curated,
|
||||
already-complete lists (the endpoint has no pagination) — there's no
|
||||
"more items than the carousel shows" to fetch, so `/store-items` with
|
||||
`specialId` set just re-renders that exact group's `items` in the full grid
|
||||
beside the same filter UI used by the main catalog. This is why the pattern is safe to keep
|
||||
fully generic (any group id works, forever) rather than needing a
|
||||
per-carousel filter mapping — there isn't a filter to map to; it's the same
|
||||
list either way. The header's "فروش ویژه" link is `/store-items?special=sale`, and
|
||||
the store-items page resolves the real CMS group at request time with the shared
|
||||
`isSpecialSaleGroup()` matcher (`فروش ویژه` or `special-sale`) before rendering
|
||||
that group's exact items. Do not hardcode a group id in the header.
|
||||
|
||||
`/store-items` is the one canonical catalog route. `/products` permanently
|
||||
redirects there while preserving all query parameters. Product detail URLs are
|
||||
also canonical under `/store-items/{variant-id|product-slug|product-id}`; the
|
||||
single detail page resolves all three forms and keeps an exact variant selected
|
||||
when a variant id was supplied. Legacy `/products/[slug]` and
|
||||
`/products/id/[id]` routes only redirect for backwards compatibility.
|
||||
|
||||
## Branding: logo & favicon are dynamic, not static files
|
||||
|
||||
`BusinessInfo` (`getBusinessInfo()`, already fetched in `layout.tsx`) carries
|
||||
`logoUrl`, `logoDarkUrl`, `faviconUrl` straight from the API — the same
|
||||
values `GET /tenants/{domain}` and `GET .../website/favicon` also expose.
|
||||
`Header`'s `Logo` takes a `src` prop (falls back to `public/images/logo.png`
|
||||
only if the API value is null) threaded down from `layout.tsx`; `Footer`
|
||||
prefers `logoDarkUrl` (falls back to `logoUrl`) since it sits on a dark
|
||||
background. The favicon is set via `generateMetadata()`'s `icons` field —
|
||||
**there is no `src/app/favicon.ico` file** (deliberately removed): Next.js's
|
||||
static-file favicon convention and a dynamic `metadata.icons` entry both
|
||||
render a `<link rel="icon">` and browsers aren't guaranteed to prefer the
|
||||
second one, so keeping both was silently serving the stale static icon.
|
||||
Don't add `app/favicon.ico` (or `public/favicon.ico`) back.
|
||||
|
||||
## Footer: light background, black text, sticky to viewport bottom
|
||||
|
||||
The footer is `bg-[#f1f1f1]` (exact hex given by the user, same neutral
|
||||
gray as `CartButton`'s background — not swapped for a design-token color;
|
||||
an earlier version used a red-tinted `#9a21291a`, since replaced) with
|
||||
black text throughout (`text-black`, `/70`, `/50`, `/10`, `/20` opacity
|
||||
steps) — it used to be a solid dark-red block with white text; don't revert
|
||||
to that. The footer logo (`logoDarkUrl ?? logoUrl`) is `h-[72px]`, doubled
|
||||
from its original size on request. Because `public/images/meshkee-logo-
|
||||
white.png` (the credit-widget logo below) is a solid-white asset, it gets
|
||||
`brightness-0` on this light background to render as a black silhouette
|
||||
instead of invisible white-on-near-white — the same `opacity-60 →
|
||||
group-hover:opacity-100` fade still applies on top of that.
|
||||
|
||||
`<main>` has `flex-1` in `layout.tsx` (`body` is `flex flex-col`) so the
|
||||
footer sits flush at the bottom of the viewport on short pages instead of
|
||||
leaving a gap of bare `background` color beneath it — this is the standard
|
||||
sticky-footer flex pattern. Don't remove `flex-1` from `main` without
|
||||
re-checking a short page (e.g. `/contact`) for that gap coming back.
|
||||
|
||||
## Footer: Meshkee credit widget
|
||||
|
||||
The footer's bottom-right link ("Designed And Developed By Meshkee
|
||||
E-Commerce Team" → `https://meshkee.com`) intentionally matches the credit
|
||||
badge Meshkee's own storefronts use elsewhere (reference: safeteb.com) —
|
||||
same markup shape (frame + logo + two-line label), same animated corner
|
||||
border (four 1px lines that draw in on hover, staggered L→B→R→T, and
|
||||
un-draw in reverse on mouse-leave via matching `transition-delay`s on the
|
||||
resting state). `public/images/meshkee-logo-white.png` is that reference
|
||||
site's own logo asset, pulled down and committed here since it's the
|
||||
platform's shared credit-widget logo, not something unique to safeteb.com.
|
||||
If this ever needs to change, re-derive the exact hover-timing values from
|
||||
a live Meshkee site rather than guessing new ones — the staggered reverse
|
||||
un-draw is the distinctive part, not just "a border that appears."
|
||||
|
||||
## Route map
|
||||
|
||||
| Route | Notes |
|
||||
|---|---|
|
||||
| `/` | Homepage — all the carousels/banners |
|
||||
| `/store-items`, `/store-items/[id]` | Canonical catalog and product detail; filters include category, price and stock; curated groups use `?specialId=` |
|
||||
| `/products`, `/products/[slug]`, `/products/id/[id]` | Backwards-compatible redirects to the canonical `/store-items` routes |
|
||||
| `/blog`, `/blog/[slug]` | Blog listing/detail |
|
||||
| `/contact` | Contact form → `POST /contact-submissions`, toast + reset on success |
|
||||
|
||||
No `/login`, `/register`, `/checkout`, `/cart`, or `/account` route exists
|
||||
here on purpose — see "Auth, cart & checkout" above.
|
||||
|
||||
## Known gaps / explicit scope decisions
|
||||
|
||||
- Login, registration, OTP, the real cart, addresses, and payment are all
|
||||
out of scope for this codebase by design — they live on
|
||||
`customer.sanihome.ir`. Don't reintroduce them here.
|
||||
- A throwaway test customer account was created against the live business
|
||||
database while an earlier (since-reverted) pass tested a real backend
|
||||
cart: `+989120000001` / "Test Dev". Safe to delete from the Meshkee admin
|
||||
panel — it's not used by anything in this codebase anymore.
|
||||
@@ -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",
|
||||
allowedDevOrigins: ["sanihome.com", "localhost"],
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -1,12 +1,21 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { getBlog } from "@/lib/meshkee";
|
||||
import Link from "next/link";
|
||||
import { SimilarArticles } from "@/components/blog/SimilarArticles";
|
||||
import { PageShell } from "@/components/ui/PageShell";
|
||||
import { getBlog, getBlogs } from "@/lib/meshkee";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "long" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
function withResponsiveTables(html: string) {
|
||||
return html.replace(
|
||||
/(<table\b[\s\S]*?<\/table>)/gi,
|
||||
'<div class="article-table-scroll" role="region" aria-label="جدول مقاله" tabindex="0">$1</div>',
|
||||
);
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -17,46 +26,59 @@ export default async function BlogDetailPage({
|
||||
|
||||
if (!post) notFound();
|
||||
|
||||
const { items: relatedCandidates } = await getBlogs({
|
||||
categoryId: post.categoryId,
|
||||
pageSize: post.categoryId ? 8 : 50,
|
||||
});
|
||||
const similarPosts = relatedCandidates
|
||||
.filter((candidate) => candidate.id !== post.id)
|
||||
.filter((candidate) => !post.categoryName || candidate.categoryName === post.categoryName)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "وبلاگ", href: "/blog" },
|
||||
{ label: post.title },
|
||||
]}
|
||||
/>
|
||||
<PageShell
|
||||
breadcrumbItems={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "وبلاگ", href: "/blog" },
|
||||
{ label: post.title },
|
||||
]}
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-start">
|
||||
<article className="min-w-0">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.publishedAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{post.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.author}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-2 text-xs text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.publishedAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{post.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.author}</span>
|
||||
</>
|
||||
)}
|
||||
<h1 className="mb-6 text-xl font-bold leading-relaxed text-red-dark md:text-3xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="prose article-content max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: withResponsiveTables(post.mainTextHtml) }}
|
||||
/>
|
||||
|
||||
<div className="mt-10 border-t border-border pt-6">
|
||||
<Link href="/blog" className="text-sm font-medium text-red hover:text-red-dark">
|
||||
→ بازگشت به وبلاگ
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<SimilarArticles posts={similarPosts} />
|
||||
</div>
|
||||
|
||||
<h1 className="mb-6 text-2xl font-bold leading-relaxed text-red-dark md:text-3xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: post.mainTextHtml }}
|
||||
/>
|
||||
|
||||
<div className="mt-10 border-t border-border pt-6">
|
||||
<a href="/blog" className="text-sm font-medium text-red hover:text-red-dark">
|
||||
→ بازگشت به وبلاگ
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Toast } from "@/components/ui/Toast";
|
||||
import { submitContactForm } from "@/lib/meshkee";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-red";
|
||||
|
||||
const initialForm = { title: "", name: "", email: "", cellNumber: "", text: "" };
|
||||
|
||||
export default function ContactPage() {
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [toast, setToast] = useState(false);
|
||||
|
||||
const update = (field: keyof typeof form) => (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
const res = await submitContactForm({
|
||||
title: form.title,
|
||||
name: form.name,
|
||||
email: form.email || undefined,
|
||||
cellNumber: form.cellNumber || undefined,
|
||||
text: form.text,
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? "ارسال پیام ناموفق بود.");
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setForm(initialForm);
|
||||
setToast(true);
|
||||
setTimeout(() => setToast(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-10">
|
||||
<Breadcrumb items={[{ label: "خانه", href: "/" }, { label: "تماس با ما" }]} />
|
||||
<h1 className="mb-2 text-2xl font-bold text-red-dark">تماس با ما</h1>
|
||||
<p className="mb-6 text-sm text-muted">
|
||||
سوال یا پیشنهادی دارید؟ فرم زیر را پر کنید تا در اسرع وقت پاسخ دهیم.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
placeholder="نام و نام خانوادگی"
|
||||
value={form.name}
|
||||
onChange={update("name")}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
placeholder="موضوع پیام"
|
||||
value={form.title}
|
||||
onChange={update("title")}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="ایمیل (اختیاری)"
|
||||
value={form.email}
|
||||
onChange={update("email")}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="شماره تماس (اختیاری)"
|
||||
value={form.cellNumber}
|
||||
onChange={update("cellNumber")}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
placeholder="متن پیام"
|
||||
value={form.text}
|
||||
onChange={update("text")}
|
||||
className={inputClass}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-dark">{error}</p>}
|
||||
{success && <p className="text-sm text-green-700">پیام شما با موفقیت ارسال شد. متشکریم!</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-xl bg-red px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Toast message="پیام شما با موفقیت ارسال شد ✓" show={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -100,3 +100,117 @@ h6 {
|
||||
height: auto;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Rich-text tables arrive as raw CMS HTML. Keep every article table aligned
|
||||
with the full article column while allowing a readable horizontal scroll on
|
||||
narrow screens instead of squeezing several columns into tiny cells. */
|
||||
.article-table-scroll {
|
||||
width: 100%;
|
||||
margin-block: 2rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--surface);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--red-muted) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.article-table-scroll:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.article-content table {
|
||||
width: 100% !important;
|
||||
min-width: 600px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
overflow: hidden;
|
||||
border-radius: 0.875rem;
|
||||
background: var(--surface);
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-content thead {
|
||||
background: var(--red-light);
|
||||
}
|
||||
|
||||
.article-content th,
|
||||
.article-content td {
|
||||
padding: 0.9rem 1rem;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.article-content th {
|
||||
color: var(--red-dark);
|
||||
font-family: var(--font-iranyekan-bold), var(--font-iranyekan), system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-content th:last-child,
|
||||
.article-content td:last-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.article-content tbody tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.article-content tbody tr {
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.article-content tbody tr:hover {
|
||||
background: var(--red-light);
|
||||
}
|
||||
|
||||
.article-content tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.article-content h2 {
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.article-content h4 {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.article-table-scroll {
|
||||
margin-inline: 0;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.article-content table {
|
||||
min-width: 560px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.article-content th,
|
||||
.article-content td {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-9
@@ -5,7 +5,13 @@ import "./globals.css";
|
||||
import { TopBar } from "@/components/home/TopBar";
|
||||
import { Header } from "@/components/home/Header";
|
||||
import { Footer } from "@/components/home/Footer";
|
||||
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee";
|
||||
import { Providers } from "@/context/Providers";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getCategories,
|
||||
getCategoryTree,
|
||||
parseMetaTagHtml,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
// Body text — headings use the bold weight instead, see globals.css.
|
||||
const iranyekan = localFont({
|
||||
@@ -26,10 +32,15 @@ const syne = Syne({
|
||||
weight: ["600", "700", "800"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const businessInfo = await getBusinessInfo();
|
||||
|
||||
return {
|
||||
title: "صنیع هوم | فروشگاه لوازم خانگی",
|
||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||
icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const [categoryTree, categories, businessInfo] = await Promise.all([
|
||||
@@ -38,17 +49,28 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
getBusinessInfo(),
|
||||
]);
|
||||
|
||||
const cmsMetaTags = (businessInfo?.metaTags ?? [])
|
||||
.map((tag, index) => {
|
||||
const attrs = parseMetaTagHtml(tag.html ?? "");
|
||||
if (!attrs) return null;
|
||||
return <meta key={`cms-meta-${index}`} {...attrs} />;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`}
|
||||
>
|
||||
{cmsMetaTags.length > 0 ? <head>{cmsMetaTags}</head> : null}
|
||||
<body className="min-h-full flex flex-col">
|
||||
<TopBar />
|
||||
<Header categoryTree={categoryTree} categories={categories} />
|
||||
<main>{children}</main>
|
||||
<Footer businessInfo={businessInfo} categories={categories} />
|
||||
<Providers>
|
||||
<TopBar />
|
||||
<Header categoryTree={categoryTree} categories={categories} logoUrl={businessInfo?.logoUrl} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer businessInfo={businessInfo} categories={categories} />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+16
-30
@@ -1,46 +1,30 @@
|
||||
import { FeatureBar } from "@/components/home/FeatureBar";
|
||||
import { HeroSlider } from "@/components/home/HeroSlider";
|
||||
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
|
||||
import { SpecialSaleCarousel } from "@/components/home/SpecialSaleCarousel";
|
||||
import { TwoBanners } from "@/components/home/TwoBanners";
|
||||
import { TopSellingCarousel } from "@/components/home/TopSellingCarousel";
|
||||
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
||||
import { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels";
|
||||
import { StoreSpecialCarousels } from "@/components/home/StoreSpecialCarousels";
|
||||
import { AboutSanihome } from "@/components/home/AboutSanihome";
|
||||
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
||||
import {
|
||||
getBlogs,
|
||||
getBusinessInfo,
|
||||
getCategories,
|
||||
getProducts,
|
||||
getBrandGroups,
|
||||
getStoreSpecials,
|
||||
getStaticImageSlots,
|
||||
firstImageForSlot,
|
||||
imagesForSlot,
|
||||
STATIC_IMAGE_KEYS,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
export default async function Home() {
|
||||
const [
|
||||
categories,
|
||||
storeSpecials,
|
||||
productsResult,
|
||||
brandGroups,
|
||||
staticImageSlots,
|
||||
businessInfo,
|
||||
blogsResult,
|
||||
] = await Promise.all([
|
||||
const [categories, storeSpecials, staticImageSlots, businessInfo, blogsResult] = await Promise.all([
|
||||
getCategories(),
|
||||
getStoreSpecials(),
|
||||
getProducts({ pageSize: 12 }),
|
||||
getBrandGroups(),
|
||||
getStaticImageSlots(),
|
||||
getBusinessInfo(),
|
||||
getBlogs({ pageSize: 8 }),
|
||||
]);
|
||||
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const specialSaleProducts = storeSpecials[0]?.items ?? [];
|
||||
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
|
||||
const threeBannerSlot = staticImageSlots.find(
|
||||
(slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow,
|
||||
@@ -51,20 +35,22 @@ export default async function Home() {
|
||||
return (
|
||||
<>
|
||||
<HeroSlider images={heroImages} />
|
||||
<SpecialSaleCarousel products={specialSaleProducts} contactPhone={contactPhone} />
|
||||
<FeatureBar />
|
||||
<FeaturedCategories categories={categories} images={featuredCategoryImages} />
|
||||
<TwoBanners
|
||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
||||
aspectRatio={twoBannerSlot?.aspectRatio}
|
||||
<StoreSpecialCarousels
|
||||
groups={storeSpecials}
|
||||
contactPhone={contactPhone}
|
||||
twoBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
||||
twoBannerAspectRatio={twoBannerSlot?.aspectRatio}
|
||||
threeBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
|
||||
/>
|
||||
<TopSellingCarousel products={productsResult.items} contactPhone={contactPhone} />
|
||||
<ThreeBanners
|
||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||
aspectRatio={threeBannerSlot?.aspectRatio}
|
||||
<FeatureBar />
|
||||
<AboutSanihome
|
||||
image={firstImageForSlot(staticImageSlots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
||||
aspectRatio={staticImageSlots.find(
|
||||
(slot) => slot.key === STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
)?.aspectRatio}
|
||||
/>
|
||||
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
|
||||
<AboutSanihome />
|
||||
<BlogCarousel posts={blogsResult.items} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { ProductGallery } from "@/components/product/ProductGallery";
|
||||
import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel";
|
||||
import { ProductTabs } from "@/components/product/ProductTabs";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getProduct,
|
||||
getProductStoreVariants,
|
||||
getProductTechnicalInfo,
|
||||
getProductVariations,
|
||||
} from "@/lib/meshkee";
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function ProductDetailPage({
|
||||
params,
|
||||
@@ -17,62 +6,5 @@ export default async function ProductDetailPage({
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const [product, technicalInfo, variations, businessInfo] = await Promise.all([
|
||||
getProduct(slug),
|
||||
getProductTechnicalInfo(slug),
|
||||
getProductVariations(slug),
|
||||
getBusinessInfo(),
|
||||
]);
|
||||
|
||||
if (!product) notFound();
|
||||
|
||||
const storeVariants = await getProductStoreVariants(product.id);
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const images = product.images.length > 0 ? product.images : product.image ? [product.image] : [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
...(product.categoryName
|
||||
? [{ label: product.categoryName, href: `/products?categoryId=${product.categoryId}` }]
|
||||
: []),
|
||||
{ label: product.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-4">
|
||||
<ProductGallery images={images} alt={product.name} />
|
||||
</div>
|
||||
|
||||
<ProductPurchasePanel
|
||||
header={
|
||||
<>
|
||||
{product.brand && (
|
||||
<p className="font-display mb-2 text-xs tracking-wide text-muted uppercase">
|
||||
{product.brand}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-xl font-bold leading-relaxed md:text-2xl">{product.name}</h1>
|
||||
</>
|
||||
}
|
||||
variations={variations}
|
||||
storeVariants={storeVariants}
|
||||
fallbackPrice={{
|
||||
minPrice: product.minPrice,
|
||||
maxPrice: product.maxPrice,
|
||||
oldPrice: product.oldPrice,
|
||||
discount: product.discount,
|
||||
}}
|
||||
inStock={product.inStock}
|
||||
contactPhone={contactPhone}
|
||||
storeName={businessInfo?.nameFa}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductTabs technicalInfo={technicalInfo} descriptionHtml={product.descriptionHtml} />
|
||||
</div>
|
||||
);
|
||||
permanentRedirect(`/store-items/${slug}`);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default async function ProductByIdPage({
|
||||
|
||||
if (!product?.slug) notFound();
|
||||
|
||||
redirect(`/products/${product.slug}`);
|
||||
redirect(`/store-items/${product.slug}`);
|
||||
}
|
||||
|
||||
+9
-115
@@ -1,124 +1,18 @@
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import {
|
||||
type CategoryTreeGroup,
|
||||
getBusinessInfo,
|
||||
getCategoryTree,
|
||||
getProducts,
|
||||
productHref,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
function findCategoryLabel(tree: CategoryTreeGroup[], id: string): string | undefined {
|
||||
for (const group of tree) {
|
||||
for (const item of group.items) {
|
||||
if (item.id === id) return item.nameFa;
|
||||
const child = item.children.find((c) => c.id === id);
|
||||
if (child) return child.nameFa;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ categoryId?: string; q?: string; page?: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||
const [{ items, total }, businessInfo, categoryTree] = await Promise.all([
|
||||
getProducts({
|
||||
categoryId: params.categoryId,
|
||||
name: params.q,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
getBusinessInfo(),
|
||||
params.categoryId ? getCategoryTree() : Promise.resolve([]),
|
||||
]);
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const isFiltered = Boolean(params.q || params.categoryId);
|
||||
const categoryLabel = params.categoryId ? findCategoryLabel(categoryTree, params.categoryId) : undefined;
|
||||
const query = new URLSearchParams();
|
||||
|
||||
const pageHref = (p: number) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.categoryId) sp.set("categoryId", params.categoryId);
|
||||
if (params.q) sp.set("q", params.q);
|
||||
sp.set("page", String(p));
|
||||
return `/products?${sp.toString()}`;
|
||||
};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (Array.isArray(value)) value.forEach((item) => query.append(key, item));
|
||||
else if (value != null) query.set(key, value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "محصولات", href: isFiltered ? "/products" : undefined },
|
||||
...(params.q ? [{ label: `نتایج جستجو برای «${params.q}»` }] : []),
|
||||
...(categoryLabel ? [{ label: categoryLabel }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
{params.q ? `نتایج جستجو برای «${params.q}»` : "محصولات"}
|
||||
</h1>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState message="محصولی یافت نشد." />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
fluid
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{page > 1 ? (
|
||||
<a
|
||||
href={pageHref(page - 1)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
قبلی
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||
قبلی
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted">
|
||||
صفحه {page} از {totalPages}
|
||||
</span>
|
||||
{page < totalPages ? (
|
||||
<a
|
||||
href={pageHref(page + 1)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
بعدی
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||
بعدی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const suffix = query.toString();
|
||||
permanentRedirect(`/store-items${suffix ? `?${suffix}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageShell } from "@/components/ui/PageShell";
|
||||
import { ProductGallery } from "@/components/product/ProductGallery";
|
||||
import { ProductPurchasePanel } from "@/components/product/ProductPurchasePanel";
|
||||
import { ProductTabs } from "@/components/product/ProductTabs";
|
||||
import { SimilarProducts } from "@/components/product/SimilarProducts";
|
||||
import {
|
||||
getBusinessInfo,
|
||||
getComments,
|
||||
getProduct,
|
||||
getProductById,
|
||||
getProductStoreVariants,
|
||||
getProductTechnicalInfo,
|
||||
getProductVariations,
|
||||
getStoreItemVariant,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
export default async function StoreItemDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const variant = await getStoreItemVariant(id);
|
||||
const product = variant
|
||||
? await getProductById(variant.productId)
|
||||
: (await getProduct(id)) ?? (await getProductById(id));
|
||||
if (!product) notFound();
|
||||
|
||||
const [technicalInfo, variations, businessInfo, comments] = await Promise.all([
|
||||
getProductTechnicalInfo(product.slug),
|
||||
getProductVariations(product.slug),
|
||||
getBusinessInfo(),
|
||||
getComments("product", product.id),
|
||||
]);
|
||||
const storeVariants = await getProductStoreVariants(product.id);
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const images = product.images.length > 0 ? product.images : product.image ? [product.image] : [];
|
||||
|
||||
// Land on the exact variant the shopper opened, not the first option of
|
||||
// every variation.
|
||||
const initialSelections = Object.fromEntries(
|
||||
(variant?.selections ?? []).map((s) => [s.variationId, s.optionId]),
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
breadcrumbItems={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "فروشگاه", href: "/store-items" },
|
||||
...(product.categoryName
|
||||
? [{ label: product.categoryName, href: `/store-items?categoryId=${product.categoryId}` }]
|
||||
: []),
|
||||
{ label: product.name },
|
||||
]}
|
||||
>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-4">
|
||||
<ProductGallery images={images} alt={product.name} />
|
||||
</div>
|
||||
|
||||
<ProductPurchasePanel
|
||||
header={
|
||||
<>
|
||||
{product.brand && (
|
||||
<p className="font-display mb-2 text-xs tracking-wide text-muted uppercase">
|
||||
{product.brand}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-xl font-bold leading-relaxed md:text-2xl">{product.name}</h1>
|
||||
</>
|
||||
}
|
||||
variations={variations}
|
||||
storeVariants={storeVariants}
|
||||
fallbackPrice={{
|
||||
minPrice: product.minPrice,
|
||||
maxPrice: product.maxPrice,
|
||||
oldPrice: product.oldPrice,
|
||||
discount: product.discount,
|
||||
}}
|
||||
inStock={product.inStock}
|
||||
contactPhone={contactPhone}
|
||||
storeName={businessInfo?.nameFa}
|
||||
initialSelections={initialSelections}
|
||||
productName={product.name}
|
||||
productSlug={product.slug}
|
||||
productImage={product.image ?? images[0] ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductTabs
|
||||
technicalInfo={technicalInfo}
|
||||
descriptionHtml={product.descriptionHtml}
|
||||
entityId={product.id}
|
||||
initialComments={comments}
|
||||
/>
|
||||
|
||||
<SimilarProducts
|
||||
categoryId={product.categoryId}
|
||||
excludeProductId={product.id}
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import {
|
||||
ProductFilters,
|
||||
type ProductFilterCategory,
|
||||
} from "@/components/product/ProductFilters";
|
||||
import {
|
||||
getCategoryTree,
|
||||
getStoreItems,
|
||||
getStoreSpecials,
|
||||
isSpecialSaleGroup,
|
||||
productHref,
|
||||
storeItemHref,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export default async function StoreItemsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
categoryId?: string;
|
||||
q?: string;
|
||||
page?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
inStock?: string;
|
||||
specialId?: string;
|
||||
special?: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number(params.page ?? 1) || 1);
|
||||
const parsePrice = (value?: string) => {
|
||||
const number = Number(value);
|
||||
return value && Number.isFinite(number) && number >= 0 ? number : undefined;
|
||||
};
|
||||
const minPrice = parsePrice(params.minPrice);
|
||||
const maxPrice = parsePrice(params.maxPrice);
|
||||
const inStock = params.inStock === "true";
|
||||
const categoryId = params.categoryId || undefined;
|
||||
|
||||
const specialMode = Boolean(params.specialId || params.special === "sale");
|
||||
const [categoryTree, specialGroups] = await Promise.all([
|
||||
getCategoryTree(),
|
||||
specialMode ? getStoreSpecials() : Promise.resolve([]),
|
||||
]);
|
||||
const categories: ProductFilterCategory[] = categoryTree.flatMap((group) =>
|
||||
group.items.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.nameFa,
|
||||
children: category.children.map((child) => ({
|
||||
id: child.id,
|
||||
label: child.nameFa,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
const filterValues = {
|
||||
categoryId,
|
||||
minPrice: minPrice != null ? String(minPrice) : undefined,
|
||||
maxPrice: maxPrice != null ? String(maxPrice) : undefined,
|
||||
inStock,
|
||||
q: params.q,
|
||||
specialId: params.specialId,
|
||||
special: params.special,
|
||||
};
|
||||
|
||||
const specialGroup = params.specialId
|
||||
? specialGroups.find((group) => group.id === params.specialId)
|
||||
: specialGroups.find(isSpecialSaleGroup);
|
||||
const selectedCategoryIds = new Set<string>();
|
||||
if (categoryId) {
|
||||
selectedCategoryIds.add(categoryId);
|
||||
const parent = categories.find((item) => item.id === categoryId);
|
||||
parent?.children.forEach((child) => selectedCategoryIds.add(child.id));
|
||||
}
|
||||
|
||||
const specialItems = (specialGroup?.items ?? []).filter((product) => {
|
||||
const matchesCategory = !categoryId || (product.categoryId && selectedCategoryIds.has(product.categoryId));
|
||||
const matchesQuery = !params.q || product.name.toLocaleLowerCase("fa").includes(params.q.toLocaleLowerCase("fa"));
|
||||
const matchesMin = minPrice == null || (product.price != null && product.price >= minPrice);
|
||||
const matchesMax = maxPrice == null || (product.price != null && product.price <= maxPrice);
|
||||
const matchesStock = !inStock || (product.stockQuantity != null && product.stockQuantity > 0);
|
||||
return matchesCategory && matchesQuery && matchesMin && matchesMax && matchesStock;
|
||||
});
|
||||
|
||||
const storeResult = specialMode
|
||||
? { items: [], total: 0 }
|
||||
: await getStoreItems({
|
||||
categoryId,
|
||||
name: params.q,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
inStock: inStock || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(storeResult.total / PAGE_SIZE));
|
||||
|
||||
const pageHref = (p: number) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (categoryId) sp.set("categoryId", categoryId);
|
||||
if (params.q) sp.set("q", params.q);
|
||||
if (minPrice != null) sp.set("minPrice", String(minPrice));
|
||||
if (maxPrice != null) sp.set("maxPrice", String(maxPrice));
|
||||
if (inStock) sp.set("inStock", "true");
|
||||
sp.set("page", String(p));
|
||||
return `/store-items?${sp.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "فروشگاه" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{params.q && (
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
نتایج جستجو برای «{params.q}»
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="md:flex md:items-start md:gap-6">
|
||||
<ProductFilters
|
||||
categories={categories}
|
||||
values={filterValues}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{specialMode && (
|
||||
<h1 className="mb-6 text-2xl font-bold text-red-dark">
|
||||
{specialGroup?.title ?? "فروش ویژه"}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{(specialMode ? specialItems : storeResult.items).length === 0 ? (
|
||||
<EmptyState message="کالایی با این فیلترها یافت نشد." />
|
||||
) : specialMode ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{specialItems.map((product) => (
|
||||
<ProductCard
|
||||
key={`${product.id}-${product.variantId ?? "product"}`}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
fluid
|
||||
variantId={product.variantId}
|
||||
slug={product.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{storeResult.items.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
brand={item.label}
|
||||
price={item.price}
|
||||
oldPrice={item.oldPrice}
|
||||
discount={item.discount}
|
||||
image={item.image ?? "/images/placeholder.svg"}
|
||||
href={storeItemHref(item)}
|
||||
fluid
|
||||
variantId={item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!specialMode && totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{page > 1 ? (
|
||||
<a
|
||||
href={pageHref(page - 1)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
قبلی
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||
قبلی
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted">
|
||||
صفحه {page} از {totalPages}
|
||||
</span>
|
||||
{page < totalPages ? (
|
||||
<a
|
||||
href={pageHref(page + 1)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
بعدی
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-lg border border-border px-4 py-2 text-sm text-muted/40">
|
||||
بعدی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import type { BlogSummary } from "@/lib/meshkee";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function SimilarArticles({ posts }: { posts: BlogSummary[] }) {
|
||||
if (posts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<aside className="lg:sticky lg:top-32">
|
||||
<details open className="group rounded-2xl border border-border bg-surface">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-4 font-bold text-red-dark">
|
||||
مقالات مشابه
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="h-5 w-5 shrink-0 text-muted transition-transform group-open:rotate-180"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="m7 10 5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="divide-y divide-border">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group/article flex gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Placeholder
|
||||
src={post.image ?? "/images/placeholder.svg"}
|
||||
alt={post.title}
|
||||
width="72px"
|
||||
height="72px"
|
||||
fit="cover"
|
||||
className="shrink-0 rounded-lg"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 text-sm font-bold leading-relaxed transition-colors group-hover/article:text-red">
|
||||
{post.title}
|
||||
</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
|
||||
{post.categoryName && <span>{post.categoryName}</span>}
|
||||
{post.categoryName && post.publishedAt && <span>•</span>}
|
||||
{post.publishedAt && <span>{formatDate(post.publishedAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { CartIcon } from "@/components/ui/icons";
|
||||
|
||||
export function CartButton() {
|
||||
const { itemCount, openPopup } = useCart();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPopup}
|
||||
aria-label="سبد خرید"
|
||||
className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#f1f1f1] text-foreground transition-colors hover:text-red"
|
||||
>
|
||||
<CartIcon className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red px-1 text-[10px] font-bold text-white">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { continueToCheckoutUrl } from "@/lib/guestCart";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export function CartPopup() {
|
||||
const { items, subtotal, popupOpen, closePopup, updateItem, removeItem } = useCart();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = popupOpen ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [popupOpen]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div aria-hidden={!popupOpen}>
|
||||
<div
|
||||
onClick={closePopup}
|
||||
className={`fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity duration-300 ${
|
||||
popupOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`fixed top-0 right-0 z-[110] flex h-dvh w-[90%] max-w-md flex-col bg-surface shadow-2xl transition-transform duration-300 ease-out ${
|
||||
popupOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border p-5">
|
||||
<h2 className="text-lg font-bold">سبد خرید</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePopup}
|
||||
aria-label="بستن"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-xl text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState message="سبد خرید شما خالی است." />
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex gap-3 border-b border-border pb-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.image || "/images/placeholder.svg"}
|
||||
alt={item.name}
|
||||
className="h-16 w-16 shrink-0 rounded-lg border border-border object-contain p-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-sm font-medium">{item.name}</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItem(item.id, item.quantity + 1)}
|
||||
className="px-2 py-1 text-sm"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="min-w-6 text-center text-xs font-medium">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItem(item.id, item.quantity - 1)}
|
||||
className="px-2 py-1 text-sm"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-red-dark">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
<span className="mr-0.5 text-[11px] font-normal text-muted">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
aria-label="حذف از سبد"
|
||||
className="shrink-0 self-start text-muted transition-colors hover:text-red-dark"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-border p-5">
|
||||
<div className="mb-4 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">جمع کل</span>
|
||||
<span className="text-base font-bold text-red-dark">
|
||||
{formatPrice(subtotal)}
|
||||
<span className="mr-0.5 text-xs font-normal text-muted">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={continueToCheckoutUrl(items)}
|
||||
className="block w-full rounded-xl bg-red py-3 text-center text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
ادامه فرآیند خرید
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { AboutSlotImage } from "@/components/home/AboutSlotImage";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
export function AboutSanihome() {
|
||||
export function AboutSanihome({
|
||||
image,
|
||||
aspectRatio,
|
||||
}: {
|
||||
image?: StaticImageWithUrl;
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-y border-red-light bg-gradient-to-br from-red-light/40 via-surface to-background py-12 md:py-16">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
@@ -8,7 +15,7 @@ export function AboutSanihome() {
|
||||
<div className="animate-fade-up">
|
||||
<div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 className="text-2xl font-bold text-red-dark md:text-3xl">
|
||||
درباره سانیهوم
|
||||
درباره صنیع هوم
|
||||
</h2>
|
||||
<span
|
||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||
@@ -19,7 +26,7 @@ export function AboutSanihome() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 leading-relaxed text-muted">
|
||||
سانیهوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||
صنیع هوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||
خانگی در ایران فعالیت میکند. ما با ارائه محصولات اصل از برندهای
|
||||
معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربهای مطمئن برای
|
||||
خرید لوازم خانگی فراهم کردهایم.
|
||||
@@ -43,14 +50,7 @@ export function AboutSanihome() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Placeholder
|
||||
src="/images/about.svg"
|
||||
alt="درباره سانیهوم"
|
||||
width="100%"
|
||||
height="360px"
|
||||
label="560×360"
|
||||
className="w-full rounded-2xl"
|
||||
/>
|
||||
<AboutSlotImage image={image} aspectRatio={aspectRatio} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
export function AboutSlotImage({
|
||||
image,
|
||||
aspectRatio,
|
||||
}: {
|
||||
image?: StaticImageWithUrl;
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!image || failed) {
|
||||
return (
|
||||
<Placeholder
|
||||
src="/images/about.svg"
|
||||
alt="درباره صنیع هوم"
|
||||
width="100%"
|
||||
height="360px"
|
||||
label="560×360"
|
||||
className="w-full rounded-2xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const frame = (
|
||||
// Meshkee slot URLs are rendered as-is; a failed remote image falls back locally.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.titleFa ?? image.titleEn ?? image.subtext ?? "درباره صنیع هوم"}
|
||||
onError={() => setFailed(true)}
|
||||
style={{ aspectRatio: (aspectRatio ?? "14:9").replace(":", " / ") }}
|
||||
className="h-auto w-full rounded-2xl object-cover"
|
||||
/>
|
||||
);
|
||||
|
||||
return image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
import type { BlogSummary } from "@/lib/meshkee";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
@@ -17,6 +18,7 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
|
||||
title="آخرین مطالب وبلاگ"
|
||||
enTitle="Latest Blog"
|
||||
action="وبلاگ"
|
||||
actionHref="/blog"
|
||||
/>
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState message="هنوز مطلبی ثبت نشده است." />
|
||||
@@ -49,8 +51,9 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
|
||||
{post.abstract}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-3 block text-xs font-medium text-red">
|
||||
ادامه مطلب ←
|
||||
<span className="mt-3 flex items-center gap-1 text-xs font-medium text-red">
|
||||
ادامه مطلب
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { productHref, type ProductGroup } from "@/lib/meshkee";
|
||||
|
||||
export function BrandGroupCarousels({
|
||||
brandGroups,
|
||||
contactPhone,
|
||||
}: {
|
||||
brandGroups: ProductGroup[];
|
||||
contactPhone?: string;
|
||||
}) {
|
||||
if (brandGroups.length === 0) {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle title="محصولات برندها" enTitle="Brand Collections" />
|
||||
<EmptyState message="هنوز محصولی برای برندها ثبت نشده است." />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{brandGroups.map((group) => (
|
||||
<section key={group.id} className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle title={group.title} enTitle={group.title} action="مشاهده همه" />
|
||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||
{group.items.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
variant="brand"
|
||||
href={productHref(product)}
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FreeMode } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
@@ -34,13 +33,8 @@ export function FeaturedCategories({
|
||||
images?: StaticImageWithUrl[];
|
||||
}) {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<section className="py-4 md:py-6">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="دستهبندیهای پرطرفدار"
|
||||
enTitle="Featured Categories"
|
||||
action="همه دستهها"
|
||||
/>
|
||||
{images.length > 0 ? (
|
||||
<Swiper
|
||||
modules={[FreeMode]}
|
||||
|
||||
@@ -27,29 +27,35 @@ export function Footer({
|
||||
);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-red-dark/20 bg-red-dark text-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||
<footer className="border-t border-[#dddddd] bg-[#f1f1f1] text-black">
|
||||
<div className="mx-auto max-w-7xl px-4 pt-10 pb-3">
|
||||
<div className="grid gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white text-base font-bold text-red-dark">
|
||||
S
|
||||
</div>
|
||||
<span className="font-display text-lg font-bold tracking-tight">
|
||||
SANIHOME
|
||||
</span>
|
||||
{businessInfo?.logoDarkUrl || businessInfo?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
|
||||
alt={businessInfo.nameFa?.replace(/سانی[ ]?هوم/g, "صنیع هوم") || "صنیع هوم"}
|
||||
className="h-[72px] w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-display text-lg font-bold tracking-tight">
|
||||
SANIHOME
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{businessInfo?.about && (
|
||||
<p className="text-sm leading-relaxed text-white/70">{businessInfo.about}</p>
|
||||
<p className="text-sm leading-relaxed text-black/70">{businessInfo.about}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">دسترسی سریع</h4>
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<ul className="space-y-2 text-sm text-black/70">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link}>
|
||||
<a href="#" className="transition-colors hover:text-white">
|
||||
<a href="#" className="transition-colors hover:text-black">
|
||||
{link}
|
||||
</a>
|
||||
</li>
|
||||
@@ -60,12 +66,12 @@ export function Footer({
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">دستهبندیها</h4>
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-sm text-white/50">هنوز دستهبندیای ثبت نشده است.</p>
|
||||
<p className="text-sm text-black/50">هنوز دستهبندیای ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<ul className="space-y-2 text-sm text-black/70">
|
||||
{categories.slice(0, 5).map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<a href="#" className="transition-colors hover:text-white">
|
||||
<a href="#" className="transition-colors hover:text-black">
|
||||
{cat.name}
|
||||
</a>
|
||||
</li>
|
||||
@@ -77,7 +83,7 @@ export function Footer({
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-bold">تماس با ما</h4>
|
||||
{hasContactInfo ? (
|
||||
<ul className="space-y-2 text-sm text-white/70">
|
||||
<ul className="space-y-2 text-sm text-black/70">
|
||||
{businessInfo?.addresses.map((address) => <li key={address}>{address}</li>)}
|
||||
{businessInfo?.phoneNumbers.map((phone) => (
|
||||
<li key={phone}>تلفن: {phone}</li>
|
||||
@@ -85,7 +91,7 @@ export function Footer({
|
||||
{businessInfo?.emails.map((email) => <li key={email}>ایمیل: {email}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-white/50">هنوز اطلاعات تماس ثبت نشده است.</p>
|
||||
<p className="text-sm text-black/50">هنوز اطلاعات تماس ثبت نشده است.</p>
|
||||
)}
|
||||
{socialEntries.length > 0 && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
@@ -95,7 +101,7 @@ export function Footer({
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-display flex h-8 w-8 items-center justify-center rounded-full border border-white/20 text-[10px] font-bold transition-colors hover:border-white hover:bg-white/10"
|
||||
className="font-display flex h-8 w-8 items-center justify-center rounded-full border border-black/20 text-[10px] font-bold transition-colors hover:border-black hover:bg-black/5"
|
||||
>
|
||||
{socialLabels[key] ?? key.slice(0, 2).toUpperCase()}
|
||||
</a>
|
||||
@@ -105,11 +111,48 @@ export function Footer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-white/10 pt-6 text-xs text-white/50 md:flex-row">
|
||||
<p>© ۱۴۰۵ سانیهوم — تمامی حقوق محفوظ است.</p>
|
||||
<p className="font-display tracking-wide uppercase">
|
||||
Designed for Sanihome
|
||||
</p>
|
||||
{businessInfo?.trustBadges && businessInfo.trustBadges.length > 0 ? (
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-4 border-t border-black/10 pt-6">
|
||||
{businessInfo.trustBadges.map((badge) => (
|
||||
<div
|
||||
key={badge.kind}
|
||||
className="trust-badge [&_img]:max-h-16 [&_a]:inline-block"
|
||||
dangerouslySetInnerHTML={{ __html: badge.embedHtml }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-black/10 pt-6 text-xs text-black/50 md:flex-row">
|
||||
<p>© ۱۴۰۵ صنیع هوم — تمامی حقوق محفوظ است.</p>
|
||||
<a
|
||||
href="https://meshkee.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
dir="ltr"
|
||||
className="group relative inline-flex items-center gap-2.5 px-2.5 py-1.5 text-black/50 transition-colors duration-300 hover:text-black"
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
<span className="absolute top-0 left-0 h-full w-px origin-top scale-y-0 bg-black/40 transition-transform duration-[280ms] delay-[420ms] group-hover:scale-y-100 group-hover:delay-0" />
|
||||
<span className="absolute bottom-0 left-0 h-px w-full origin-left scale-x-0 bg-black/40 transition-transform duration-[280ms] delay-[280ms] group-hover:scale-x-100 group-hover:delay-[140ms]" />
|
||||
<span className="absolute top-0 right-0 h-full w-px origin-bottom scale-y-0 bg-black/40 transition-transform duration-[280ms] delay-[140ms] group-hover:scale-y-100 group-hover:delay-[280ms]" />
|
||||
<span className="absolute top-0 left-0 h-px w-full origin-right scale-x-0 bg-black/40 transition-transform duration-[280ms] delay-0 group-hover:scale-x-100 group-hover:delay-[420ms]" />
|
||||
</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/images/meshkee-logo-white.png"
|
||||
alt="Meshkee"
|
||||
width={42}
|
||||
height={42}
|
||||
className="relative block h-[42px] w-auto shrink-0 object-contain opacity-60 brightness-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
<span className="relative flex min-h-[42px] flex-col justify-center leading-[1.25]">
|
||||
<span className="text-[11px]">Designed And Developed By</span>
|
||||
<span className="text-[13px] font-semibold text-black/70 transition-colors duration-300 group-hover:text-black">
|
||||
Meshkee E-Commerce Team
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -4,28 +4,31 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Category, CategoryNode, CategoryTreeGroup } from "@/lib/meshkee";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { CartButton } from "@/components/cart/CartButton";
|
||||
import { ChevronLeftIcon, CloseIcon, MenuIcon, UserIcon } from "@/components/ui/icons";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "فروش ویژه", href: "#" },
|
||||
{ label: "فروشگاه", href: "/store-items" },
|
||||
{ label: "فروش ویژه", href: "/store-items?special=sale" },
|
||||
{ label: "درباره ما", href: "#" },
|
||||
{ label: "سوالات متداول", href: "#" },
|
||||
{ label: "تماس با ما", href: "#" },
|
||||
{ label: "تماس با ما", href: "/contact" },
|
||||
];
|
||||
|
||||
const LOGIN_URL = "https://customer.sanihome.ir";
|
||||
|
||||
function Logo({ large = false }: { large?: boolean }) {
|
||||
function Logo({ src, large = false }: { src?: string | null; large?: boolean }) {
|
||||
return (
|
||||
<a href="/" className="flex shrink-0 items-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/images/logo.png"
|
||||
alt="سانیهوم"
|
||||
src={src || "/images/logo.png"}
|
||||
alt="صنیع هوم"
|
||||
className={
|
||||
large
|
||||
? "h-[42px] w-auto object-contain md:h-[46px]"
|
||||
: "h-8 w-auto md:h-9"
|
||||
? "h-[65px] w-auto object-contain"
|
||||
: "h-[65px] w-auto object-contain"
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
@@ -54,10 +57,12 @@ function MobileDrawer({
|
||||
open,
|
||||
onClose,
|
||||
categoryTree,
|
||||
logoUrl,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categoryTree: CategoryTreeGroup[];
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [categoriesOpen, setCategoriesOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -98,14 +103,14 @@ function MobileDrawer({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border p-5">
|
||||
<Logo />
|
||||
<Logo src={logoUrl} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن منو"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-xl text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
✕
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +148,7 @@ function MobileDrawer({
|
||||
{level2.map((node) => (
|
||||
<li key={node.id}>
|
||||
<a
|
||||
href={`/products?categoryId=${node.id}`}
|
||||
href={`/store-items?categoryId=${node.id}`}
|
||||
className="block rounded-lg px-3 py-1.5 font-medium text-foreground transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
{node.nameFa}
|
||||
@@ -153,7 +158,7 @@ function MobileDrawer({
|
||||
{node.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<a
|
||||
href={`/products?categoryId=${child.id}`}
|
||||
href={`/store-items?categoryId=${child.id}`}
|
||||
className="block rounded-lg px-3 py-1 text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
{child.nameFa}
|
||||
@@ -184,7 +189,7 @@ function MobileDrawer({
|
||||
href={LOGIN_URL}
|
||||
className="flex items-center gap-2 px-5 py-4 text-right font-medium transition-colors hover:bg-red-light/40 hover:text-red"
|
||||
>
|
||||
<span aria-hidden>👤</span>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
ورود
|
||||
</a>
|
||||
</nav>
|
||||
@@ -197,9 +202,11 @@ function MobileDrawer({
|
||||
export function Header({
|
||||
categoryTree,
|
||||
categories,
|
||||
logoUrl,
|
||||
}: {
|
||||
categoryTree: CategoryTreeGroup[];
|
||||
categories: Category[];
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -219,24 +226,24 @@ export function Header({
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface/95 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="grid grid-cols-3 items-center gap-4 py-4 min-[1200px]:flex min-[1200px]:justify-between">
|
||||
<div className="grid grid-cols-3 items-center gap-4 py-2 min-[1200px]:flex min-[1200px]:justify-between">
|
||||
<div className="flex items-center justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label="منو"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-border text-xl transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-border transition-colors hover:border-red hover:text-red min-[1200px]:hidden"
|
||||
>
|
||||
<span aria-hidden>☰</span>
|
||||
<MenuIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="hidden min-[1200px]:block">
|
||||
<Logo />
|
||||
<Logo src={logoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
|
||||
<div className="min-[1200px]:hidden">
|
||||
<Logo large />
|
||||
<Logo src={logoUrl} large />
|
||||
</div>
|
||||
<div className="hidden w-full min-[1200px]:block">
|
||||
<SearchBar />
|
||||
@@ -244,23 +251,27 @@ export function Header({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 text-sm">
|
||||
<a
|
||||
href={LOGIN_URL}
|
||||
className="hidden items-center gap-1.5 text-muted transition-colors hover:text-red sm:flex"
|
||||
>
|
||||
<span>👤</span>
|
||||
<span>ورود</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
<span>🛒</span>
|
||||
<span className="hidden sm:inline">سبد خرید</span>
|
||||
<span className="absolute -top-1.5 -left-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red text-[10px] font-bold text-white">
|
||||
۲
|
||||
</span>
|
||||
</button>
|
||||
<div className="hidden items-center gap-2 rounded-2xl bg-surface p-1.5 sm:flex">
|
||||
<CartButton />
|
||||
<span className="h-8 w-px bg-border" aria-hidden />
|
||||
<a
|
||||
href={LOGIN_URL}
|
||||
className="flex items-center gap-2 rounded-xl py-1 pr-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red text-white">
|
||||
<UserIcon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-right leading-tight">
|
||||
<span className="block text-sm font-bold whitespace-nowrap text-foreground">
|
||||
حساب کاربری
|
||||
</span>
|
||||
<span className="block text-xs whitespace-nowrap text-muted">ورود/ثبتنام</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<CartButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,7 +293,7 @@ export function Header({
|
||||
aria-haspopup="true"
|
||||
className="flex shrink-0 items-center gap-2 rounded-lg bg-red px-4 py-2 font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
<span aria-hidden>☰</span>
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
دستهبندیها
|
||||
</button>
|
||||
|
||||
@@ -296,10 +307,11 @@ export function Header({
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/products"
|
||||
className="text-xs font-medium text-red hover:text-red-dark"
|
||||
href="/store-items"
|
||||
className="flex items-center gap-1 text-xs font-medium text-red hover:text-red-dark"
|
||||
>
|
||||
مشاهده همه ←
|
||||
مشاهده همه
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +322,7 @@ export function Header({
|
||||
{level2.map((node) => (
|
||||
<div key={node.id} className="mb-4 break-inside-avoid">
|
||||
<a
|
||||
href={`/products?categoryId=${node.id}`}
|
||||
href={`/store-items?categoryId=${node.id}`}
|
||||
className="text-sm font-bold text-red-dark transition-colors hover:text-red"
|
||||
>
|
||||
{node.nameFa}
|
||||
@@ -320,7 +332,7 @@ export function Header({
|
||||
{node.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<a
|
||||
href={`/products?categoryId=${child.id}`}
|
||||
href={`/store-items?categoryId=${child.id}`}
|
||||
className="text-sm text-muted transition-colors hover:text-red"
|
||||
>
|
||||
{child.nameFa}
|
||||
@@ -339,7 +351,7 @@ export function Header({
|
||||
{categories.slice(0, 4).map((cat) => (
|
||||
<a
|
||||
key={cat.id}
|
||||
href={`/products?categoryId=${cat.id}`}
|
||||
href={`/store-items?categoryId=${cat.id}`}
|
||||
className="rounded-lg bg-red-light/50 px-3 py-2 text-center text-xs font-medium text-red-dark transition-colors hover:bg-red-light"
|
||||
>
|
||||
{cat.name}
|
||||
@@ -367,6 +379,7 @@ export function Header({
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
categoryTree={categoryTree}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import type { Product } from "@/lib/meshkee";
|
||||
|
||||
export function SearchBar() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Product[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -15,8 +17,6 @@ export function SearchBar() {
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length <= 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function SearchBar() {
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) window.location.href = `/products?q=${encodeURIComponent(trimmed)}`;
|
||||
if (trimmed) router.push(`/store-items?q=${encodeURIComponent(trimmed)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -64,7 +64,14 @@ export function SearchBar() {
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setQuery(value);
|
||||
if (value.trim().length <= 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onFocus={() => results.length > 0 && setOpen(true)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
placeholder="جستجوی محصول، برند یا دستهبندی..."
|
||||
@@ -89,7 +96,7 @@ export function SearchBar() {
|
||||
{results.map((product) => (
|
||||
<li key={product.id}>
|
||||
<a
|
||||
href={`/products/${product.slug}`}
|
||||
href={`/store-items/${product.slug}`}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-red-light/40"
|
||||
>
|
||||
<Placeholder
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { productHref, type Product } from "@/lib/meshkee";
|
||||
|
||||
function SpecialSaleBanner() {
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
className="relative w-[220px] shrink-0 self-stretch overflow-hidden rounded-xl border border-border md:w-[240px]"
|
||||
>
|
||||
<Placeholder
|
||||
src="/images/sale-banner.svg"
|
||||
alt="فروش ویژه"
|
||||
width="100%"
|
||||
className="h-full min-h-[380px] w-full rounded-none border-0"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialSaleCarousel({
|
||||
products,
|
||||
contactPhone,
|
||||
}: {
|
||||
products: Product[];
|
||||
contactPhone?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-y border-red-light bg-red-light/30 py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="فروش ویژه"
|
||||
enTitle="Special Sale"
|
||||
action="مشاهده همه"
|
||||
/>
|
||||
{products.length === 0 ? (
|
||||
<EmptyState message="هنوز محصولی برای فروش ویژه ثبت نشده است." />
|
||||
) : (
|
||||
<Carousel ariaLabel="کاروسل فروش ویژه">
|
||||
<SpecialSaleBanner />
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
variant="sale"
|
||||
href={productHref(product)}
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type RemainingTime = {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
function getRemainingTime(): RemainingTime {
|
||||
const now = new Date();
|
||||
const endOfDay = new Date(now);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
const totalSeconds = Math.max(0, Math.floor((endOfDay.getTime() - now.getTime()) / 1000));
|
||||
|
||||
return {
|
||||
hours: Math.floor(totalSeconds / 3600),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
};
|
||||
}
|
||||
|
||||
function TimeBox({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center rounded-xl bg-white/15 px-1 py-2.5 md:py-3">
|
||||
<strong className="text-lg leading-none tabular-nums md:text-2xl" suppressHydrationWarning>
|
||||
{String(value).padStart(2, "0")}
|
||||
</strong>
|
||||
<span className="mt-2 text-[10px] text-white/80 md:text-xs">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialSaleCountdown() {
|
||||
const [remaining, setRemaining] = useState(getRemainingTime);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setRemaining(getRemainingTime()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-3 gap-1.5 md:gap-2" dir="ltr">
|
||||
<TimeBox value={remaining.hours} label="ساعت" />
|
||||
<TimeBox value={remaining.minutes} label="دقیقه" />
|
||||
<TimeBox value={remaining.seconds} label="ثانیه" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Fragment } from "react";
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
import { TwoBanners } from "@/components/home/TwoBanners";
|
||||
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
||||
import { SpecialSaleCountdown } from "@/components/home/SpecialSaleCountdown";
|
||||
import {
|
||||
isSpecialSaleGroup,
|
||||
productHref,
|
||||
type ProductGroup,
|
||||
type StaticImageWithUrl,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
// The API only gives one `title` string per group (sometimes already
|
||||
// English, e.g. "Special Sale", sometimes Persian) — there's no separate
|
||||
// localized English field to pull a real translation from, and inventing
|
||||
// one would be fabricated data. `key` is real data and usually a readable
|
||||
// English slug (e.g. "top-selling"), so it's what gets formatted into the
|
||||
// English caption beside the title (same inline treatment as every other
|
||||
// section heading on the site, e.g. BlogCarousel's "LATEST BLOG"). Skip it
|
||||
// if the key has no letters at all (e.g. it fell back to a bare numeric id).
|
||||
function englishLabelFromKey(key: string): string | undefined {
|
||||
const label = key.replace(/[-_]+/g, " ").trim();
|
||||
return /[a-zA-Z]/.test(label) ? label.toUpperCase() : undefined;
|
||||
}
|
||||
|
||||
function SpecialSaleSection({ group }: { group: ProductGroup }) {
|
||||
return (
|
||||
<section className="bg-red py-6 md:py-8">
|
||||
<div className="mx-auto flex max-w-7xl items-stretch gap-3 px-4 md:gap-5">
|
||||
<aside className="my-2 flex w-[calc(50%-1rem)] shrink-0 flex-col items-center justify-center rounded-2xl bg-red-dark/35 p-3 text-center text-white md:w-[240px] md:p-6">
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
className="mb-3 h-11 w-11 md:h-14 md:w-14"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform="matrix(1.05 0 0 1.05 -.8 -.8006)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m30.25 14.182-.876-.876c-.36-.36-.577-1.168-.445-1.661l.32-1.197a2.54 2.54 0 0 0-.259-1.949 2.55 2.55 0 0 0-1.559-1.198l-1.196-.321c-.492-.132-1.084-.724-1.217-1.215l-.32-1.197C24.34 3.232 22.899 2.39 21.55 2.75l-1.196.321c-.459.122-1.316-.101-1.66-.445l-.877-.876c-.969-.969-2.666-.969-3.635 0l-.877.877c-.344.344-1.201.567-1.661.445l-1.195-.321c-1.349-.361-2.79.481-3.148 1.818l-.32 1.195c-.132.494-.724 1.085-1.216 1.217l-1.197.321c-.662.177-1.216.603-1.56 1.199s-.435 1.288-.257 1.947l.319 1.198c.132.493-.084 1.3-.444 1.661l-.876.876C1.267 14.667 1 15.313 1 16s.267 1.333.751 1.817l.876.876c.36.36.577 1.168.445 1.661l-.32 1.197a2.54 2.54 0 0 0 .259 1.949 2.55 2.55 0 0 0 1.559 1.198l1.195.32c.493.132 1.085.724 1.218 1.215l.319 1.197c.358 1.336 1.804 2.175 3.147 1.817l1.197-.32c.46-.122 1.317.101 1.661.445l.876.875c.484.486 1.13.753 1.818.753s1.334-.267 1.817-.75l.877-.876c.345-.344 1.205-.565 1.661-.445l1.195.32q.326.087.662.087h.001a2.575 2.575 0 0 0 2.485-1.905l.319-1.194c.133-.494.725-1.085 1.217-1.218l1.196-.32c.662-.177 1.216-.604 1.56-1.2s.435-1.288.257-1.947l-.319-1.198c-.132-.493.084-1.3.444-1.661l.876-.876c.484-.484.751-1.13.751-1.817a2.55 2.55 0 0 0-.75-1.818M9 12c0-1.654 1.346-3 3-3s3 1.346 3 3-1.346 3-3 3-3-1.346-3-3m3.707 8.707a.997.997 0 0 1-1.414 0 1 1 0 0 1 0-1.414l8-8a.999.999 0 1 1 1.414 1.414zM20 23c-1.654 0-3-1.346-3-3s1.346-3 3-3 3 1.346 3 3-1.346 3-3 3"
|
||||
/>
|
||||
<circle cx="20" cy="20" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
<h2 className="text-lg font-bold md:text-2xl">{group.title}</h2>
|
||||
<p className="mt-1.5 mb-4 text-xs text-white/80 md:text-sm">تا پایان فروش ویژه</p>
|
||||
<SpecialSaleCountdown />
|
||||
<a
|
||||
href={`/store-items?specialId=${group.id}`}
|
||||
className="mt-4 flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black transition-colors hover:bg-white/90"
|
||||
>
|
||||
مشاهده همه
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Carousel
|
||||
ariaLabel={`کاروسل ${group.title}`}
|
||||
slideClassName="!w-full md:!w-[240px]"
|
||||
>
|
||||
{group.items.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
variant="sale"
|
||||
fluid
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Renders every admin-managed carousel from GET /store-specials — whatever
|
||||
// groups exist there, in their configured order. Nothing here is keyed off
|
||||
// a specific group id/key/title, so a new carousel created in the Meshkee
|
||||
// dashboard (any name) shows up on the homepage automatically, with no code
|
||||
// change. Groups with no items are skipped (honest empty state — no heading
|
||||
// over nothing).
|
||||
//
|
||||
// One of the two banner sections is inserted between every pair of
|
||||
// carousels (never before the first or after the last), cycling if there
|
||||
// end up being more than two carousels.
|
||||
export function StoreSpecialCarousels({
|
||||
groups,
|
||||
contactPhone,
|
||||
twoBannerImages,
|
||||
twoBannerAspectRatio,
|
||||
threeBannerImages,
|
||||
threeBannerAspectRatio,
|
||||
}: {
|
||||
groups: ProductGroup[];
|
||||
contactPhone?: string;
|
||||
twoBannerImages?: StaticImageWithUrl[];
|
||||
twoBannerAspectRatio?: string;
|
||||
threeBannerImages?: StaticImageWithUrl[];
|
||||
threeBannerAspectRatio?: string;
|
||||
}) {
|
||||
const visibleGroups = groups.filter((group) => group.items.length > 0);
|
||||
const banners = [
|
||||
<TwoBanners key="two" images={twoBannerImages} aspectRatio={twoBannerAspectRatio} />,
|
||||
<ThreeBanners key="three" images={threeBannerImages} aspectRatio={threeBannerAspectRatio} />,
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleGroups.map((group, i) => (
|
||||
<Fragment key={group.id}>
|
||||
{i > 0 && banners[(i - 1) % banners.length]}
|
||||
{isSpecialSaleGroup(group) ? (
|
||||
<SpecialSaleSection group={group} />
|
||||
) : (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title={group.title}
|
||||
enTitle={englishLabelFromKey(group.key)}
|
||||
action="مشاهده همه"
|
||||
actionHref={`/store-items?specialId=${group.id}`}
|
||||
/>
|
||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||
{group.items.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
contactPhone={contactPhone}
|
||||
variantId={product.variantId}
|
||||
slug={product.slug}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { productHref, type Product } from "@/lib/meshkee";
|
||||
|
||||
export function TopSellingCarousel({
|
||||
products,
|
||||
contactPhone,
|
||||
}: {
|
||||
products: Product[];
|
||||
contactPhone?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="پرفروشترینها"
|
||||
enTitle="Top Selling"
|
||||
action="مشاهده همه"
|
||||
/>
|
||||
{products.length === 0 ? (
|
||||
<EmptyState message="هنوز محصولی ثبت نشده است." />
|
||||
) : (
|
||||
<Carousel ariaLabel="کاروسل پرفروشترینها">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Form from "next/form";
|
||||
import { CloseIcon } from "@/components/ui/icons";
|
||||
|
||||
export type ProductFilterCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
children: { id: string; label: string }[];
|
||||
};
|
||||
|
||||
export type ProductFilterValues = {
|
||||
categoryId?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
inStock: boolean;
|
||||
q?: string;
|
||||
specialId?: string;
|
||||
special?: string;
|
||||
};
|
||||
|
||||
function FilterIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 6h16M7 12h10M10 18h4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterFields({
|
||||
categories,
|
||||
values,
|
||||
}: {
|
||||
categories: ProductFilterCategory[];
|
||||
values: ProductFilterValues;
|
||||
}) {
|
||||
const categoryListRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!values.categoryId) return;
|
||||
const activeParent = categories.find(
|
||||
(category) =>
|
||||
category.id === values.categoryId ||
|
||||
(category.children ?? []).some((child) => child.id === values.categoryId),
|
||||
);
|
||||
if (!activeParent) return;
|
||||
const accordion = categoryListRef.current?.querySelector<HTMLDetailsElement>(
|
||||
`[data-category-id="${CSS.escape(activeParent.id)}"]`,
|
||||
);
|
||||
if (accordion) accordion.open = true;
|
||||
}, [categories, values.categoryId]);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{values.q && <input type="hidden" name="q" value={values.q} />}
|
||||
{values.specialId && <input type="hidden" name="specialId" value={values.specialId} />}
|
||||
{values.special && <input type="hidden" name="special" value={values.special} />}
|
||||
|
||||
<details className="group py-4" open>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between font-medium">
|
||||
دستهبندی
|
||||
<span className="text-lg text-muted transition-transform group-open:rotate-45">+</span>
|
||||
</summary>
|
||||
<div ref={categoryListRef} className="mt-3 max-h-72 overflow-y-auto pl-1">
|
||||
<label className="mb-2 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors hover:bg-red-light">
|
||||
<input
|
||||
type="radio"
|
||||
name="categoryId"
|
||||
value=""
|
||||
defaultChecked={!values.categoryId}
|
||||
className="accent-red"
|
||||
/>
|
||||
همه دستهبندیها
|
||||
</label>
|
||||
{categories.map((category) => (
|
||||
(category.children ?? []).length > 0 ? (
|
||||
<details
|
||||
key={category.id}
|
||||
data-category-id={category.id}
|
||||
className="group/category border-t border-border/70"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 rounded-lg px-2 py-2.5 text-sm transition-colors hover:bg-red-light">
|
||||
<label
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="categoryId"
|
||||
value={category.id}
|
||||
defaultChecked={values.categoryId === category.id}
|
||||
className="accent-red"
|
||||
/>
|
||||
<span className="truncate">{category.label}</span>
|
||||
</label>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="h-4 w-4 shrink-0 text-muted transition-transform group-open/category:rotate-180"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="m7 10 5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="mb-2 mr-5 space-y-0.5 border-r border-border pr-3">
|
||||
{(category.children ?? []).map((child) => (
|
||||
<label
|
||||
key={child.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-muted transition-colors hover:bg-red-light hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="categoryId"
|
||||
value={child.id}
|
||||
defaultChecked={values.categoryId === child.id}
|
||||
className="accent-red"
|
||||
/>
|
||||
{child.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<label
|
||||
key={category.id}
|
||||
className="flex cursor-pointer items-center gap-2 border-t border-border/70 px-2 py-2.5 text-sm transition-colors hover:bg-red-light"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="categoryId"
|
||||
value={category.id}
|
||||
defaultChecked={values.categoryId === category.id}
|
||||
className="accent-red"
|
||||
/>
|
||||
{category.label}
|
||||
</label>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="group py-4" open>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between font-medium">
|
||||
محدوده قیمت
|
||||
<span className="text-lg text-muted transition-transform group-open:rotate-45">+</span>
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<label className="text-xs text-muted">
|
||||
از قیمت
|
||||
<input
|
||||
type="number"
|
||||
name="minPrice"
|
||||
min="0"
|
||||
inputMode="numeric"
|
||||
defaultValue={values.minPrice}
|
||||
placeholder="۰"
|
||||
className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-red"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-muted">
|
||||
تا قیمت
|
||||
<input
|
||||
type="number"
|
||||
name="maxPrice"
|
||||
min="0"
|
||||
inputMode="numeric"
|
||||
defaultValue={values.maxPrice}
|
||||
placeholder="بدون محدودیت"
|
||||
className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-red"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-muted">مبالغ به تومان هستند.</p>
|
||||
</details>
|
||||
|
||||
<div className="py-4">
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
|
||||
فقط کالاهای موجود
|
||||
<input
|
||||
type="checkbox"
|
||||
name="inStock"
|
||||
value="true"
|
||||
defaultChecked={values.inStock}
|
||||
className="h-4 w-4 accent-red"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterActions({ resetHref }: { resetHref: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-red px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
اعمال فیلترها
|
||||
</button>
|
||||
<a
|
||||
href={resetHref}
|
||||
className="rounded-lg border border-border px-3 py-2.5 text-center text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
پاک کردن
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductFilters({
|
||||
categories,
|
||||
values,
|
||||
}: {
|
||||
categories: ProductFilterCategory[];
|
||||
values: ProductFilterValues;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const desktopPriceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeCount = [values.categoryId, values.minPrice || values.maxPrice, values.inStock].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
const resetParams = new URLSearchParams();
|
||||
if (values.q) resetParams.set("q", values.q);
|
||||
if (values.specialId) resetParams.set("specialId", values.specialId);
|
||||
if (values.special) resetParams.set("special", values.special);
|
||||
const resetQuery = resetParams.toString();
|
||||
const resetHref = `/store-items${resetQuery ? `?${resetQuery}` : ""}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (desktopPriceTimer.current) clearTimeout(desktopPriceTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const submitDesktopFilters = (event: React.ChangeEvent<HTMLFormElement>) => {
|
||||
const form = event.currentTarget;
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
if (desktopPriceTimer.current) clearTimeout(desktopPriceTimer.current);
|
||||
if (target.type === "number") {
|
||||
desktopPriceTimer.current = setTimeout(() => form.requestSubmit(), 600);
|
||||
return;
|
||||
}
|
||||
form.requestSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden w-64 shrink-0 md:block">
|
||||
<Form
|
||||
action="/store-items"
|
||||
scroll={false}
|
||||
onChange={submitDesktopFilters}
|
||||
className="sticky top-32 rounded-2xl border border-border bg-surface p-4"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-border pb-4">
|
||||
<FilterIcon className="h-5 w-5 text-red" />
|
||||
<h2 className="font-bold">فیلتر محصولات</h2>
|
||||
</div>
|
||||
<FilterFields categories={categories} values={values} />
|
||||
{activeCount > 0 && (
|
||||
<a
|
||||
href={resetHref}
|
||||
className="block w-full rounded-lg border border-border px-3 py-2.5 text-center text-sm text-muted transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
پاک کردن فیلترها
|
||||
</a>
|
||||
)}
|
||||
</Form>
|
||||
</aside>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="mb-5 flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 text-sm font-medium md:hidden"
|
||||
>
|
||||
<FilterIcon className="h-5 w-5 text-red" />
|
||||
فیلتر محصولات
|
||||
{activeCount > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-red px-1.5 text-[11px] text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] bg-black/45 md:hidden"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mobile-filter-title"
|
||||
className="ml-auto flex h-full w-[min(90vw,380px)] flex-col bg-surface shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 id="mobile-filter-title" className="font-bold">
|
||||
فیلتر محصولات
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن فیلترها"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg text-muted transition-colors hover:bg-red-light hover:text-red"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action="/store-items"
|
||||
onSubmit={() => setOpen(false)}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<FilterFields categories={categories} values={values} />
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-border bg-surface p-4 shadow-[0_-8px_24px_rgba(0,0,0,0.06)]">
|
||||
<FilterActions resetHref={resetHref} />
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
function ChevronIcon({ direction, className }: { direction: "prev" | "next"; className?: string }) {
|
||||
const d = direction === "prev" ? "M14.5 6.5 9 12l5.5 5.5" : "M9.5 6.5 15 12l-5.5 5.5";
|
||||
@@ -11,10 +12,110 @@ function ChevronIcon({ direction, className }: { direction: "prev" | "next"; cla
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({
|
||||
images,
|
||||
alt,
|
||||
index,
|
||||
onIndexChange,
|
||||
onClose,
|
||||
}: {
|
||||
images: string[];
|
||||
alt: string;
|
||||
index: number;
|
||||
onIndexChange: (i: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const hasMultiple = images.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key === "ArrowLeft") onIndexChange((index + 1) % images.length);
|
||||
if (event.key === "ArrowRight") onIndexChange((index - 1 + images.length) % images.length);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [index, images.length, onClose, onIndexChange]);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[120] flex flex-col bg-black/95" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="absolute top-4 left-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<CloseIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center p-6" onClick={(e) => e.stopPropagation()}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={images[index]} alt={alt} className="max-h-full max-w-full object-contain" />
|
||||
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onIndexChange((index - 1 + images.length) % images.length)}
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute top-1/2 right-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<ChevronIcon direction="next" className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onIndexChange((index + 1) % images.length)}
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute top-1/2 left-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<ChevronIcon direction="prev" className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMultiple && (
|
||||
<div
|
||||
className="flex justify-center gap-2 overflow-x-auto p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<button
|
||||
key={img + i}
|
||||
type="button"
|
||||
onClick={() => onIndexChange(i)}
|
||||
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2 transition-colors ${
|
||||
i === index ? "border-red" : "border-white/20"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductGallery({ images, alt }: { images: string[]; alt: string }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const [origin, setOrigin] = useState({ x: 50, y: 50 });
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const list = images.length > 0 ? images : ["/images/placeholder.svg"];
|
||||
const hasMultiple = list.length > 1;
|
||||
@@ -41,11 +142,12 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
<img
|
||||
src={list[index]}
|
||||
alt={alt}
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
className="h-full w-full object-contain transition-transform duration-200 ease-out"
|
||||
style={{
|
||||
transform: zoomed ? "scale(2)" : "scale(1)",
|
||||
transformOrigin: `${origin.x}% ${origin.y}%`,
|
||||
cursor: zoomed ? "zoom-out" : "zoom-in",
|
||||
cursor: zoomed ? "zoom-in" : "zoom-in",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -57,7 +159,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute top-1/2 right-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
<ChevronIcon direction="prev" className="h-5 w-5" />
|
||||
<ChevronIcon direction="next" className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -65,7 +167,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute top-1/2 left-3 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-surface/90 text-foreground shadow-sm transition-colors hover:border-red hover:text-red"
|
||||
>
|
||||
<ChevronIcon direction="next" className="h-5 w-5" />
|
||||
<ChevronIcon direction="prev" className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -89,6 +191,16 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
images={list}
|
||||
alt={alt}
|
||||
index={index}
|
||||
onIndexChange={setIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import type { ProductStoreVariant, ProductVariation } from "@/lib/meshkee";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -26,6 +27,10 @@ export function ProductPurchasePanel({
|
||||
inStock,
|
||||
contactPhone,
|
||||
storeName,
|
||||
initialSelections,
|
||||
productName,
|
||||
productSlug,
|
||||
productImage,
|
||||
}: {
|
||||
header: ReactNode;
|
||||
variations: ProductVariation[];
|
||||
@@ -39,8 +44,18 @@ export function ProductPurchasePanel({
|
||||
inStock: boolean;
|
||||
contactPhone?: string;
|
||||
storeName?: string;
|
||||
// Pre-select the variant a shopper landed on directly (e.g. a store-item
|
||||
// detail page for one specific variant), instead of defaulting to the
|
||||
// first option of every variation.
|
||||
initialSelections?: Record<string, string>;
|
||||
// For the guest-cart line (id alone isn't enough to render a mini-cart row).
|
||||
productName: string;
|
||||
productSlug: string;
|
||||
productImage: string;
|
||||
}) {
|
||||
const { addItem } = useCart();
|
||||
const [selected, setSelected] = useState<Record<string, string>>(() => {
|
||||
if (initialSelections && Object.keys(initialSelections).length > 0) return initialSelections;
|
||||
const initial: Record<string, string> = {};
|
||||
for (const variation of variations) {
|
||||
if (variation.options[0]) initial[variation.id] = variation.options[0].id;
|
||||
@@ -48,9 +63,11 @@ export function ProductPurchasePanel({
|
||||
return initial;
|
||||
});
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [cartStatus, setCartStatus] = useState<"idle" | "done">("idle");
|
||||
|
||||
const matchedVariant = useMemo(() => {
|
||||
if (variations.length === 0) return null;
|
||||
if (storeVariants.length === 0) return null;
|
||||
if (variations.length === 0) return storeVariants[0] ?? null;
|
||||
return (
|
||||
storeVariants.find((variant) =>
|
||||
variations.every((variation) => {
|
||||
@@ -84,10 +101,25 @@ export function ProductPurchasePanel({
|
||||
? (oldPrice != null && price != null ? Math.round((1 - price / oldPrice) * 100) : null)
|
||||
: (fallbackPrice.discount ?? null);
|
||||
|
||||
const stockQuantity = hasVariations ? (matchedVariant?.stockQuantity ?? 0) : null;
|
||||
const available = hasVariations ? Boolean(matchedVariant) && stockQuantity! > 0 : inStock;
|
||||
const stockQuantity = matchedVariant ? matchedVariant.stockQuantity : null;
|
||||
const available = matchedVariant ? stockQuantity! > 0 : inStock;
|
||||
const maxQty = stockQuantity && stockQuantity > 0 ? stockQuantity : 99;
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!matchedVariant || price == null) return;
|
||||
addItem({
|
||||
id: matchedVariant.id,
|
||||
name: productName,
|
||||
slug: productSlug,
|
||||
price,
|
||||
originalPrice: oldPrice ?? undefined,
|
||||
image: productImage,
|
||||
quantity,
|
||||
});
|
||||
setCartStatus("done");
|
||||
setTimeout(() => setCartStatus("idle"), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:col-span-5">
|
||||
@@ -197,10 +229,11 @@ export function ProductPurchasePanel({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!available}
|
||||
className="w-full rounded-xl bg-red py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark disabled:cursor-not-allowed disabled:bg-red/60"
|
||||
>
|
||||
افزودن به سبد خرید
|
||||
{cartStatus === "done" ? "به سبد اضافه شد ✓" : "افزودن به سبد خرید"}
|
||||
</button>
|
||||
</>
|
||||
) : contactPhone ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import type { TechnicalInfoField } from "@/lib/meshkee";
|
||||
import { postComment, type EntityComment, type TechnicalInfoField } from "@/lib/meshkee";
|
||||
|
||||
type Tab = "specs" | "review" | "comments";
|
||||
|
||||
@@ -12,19 +12,115 @@ const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "comments", label: "نظرات کاربران" },
|
||||
];
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
function CommentsPanel({ entityId, initialComments }: { entityId: string; initialComments: EntityComment[] }) {
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || !text.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await postComment({
|
||||
entityType: "product",
|
||||
entityId,
|
||||
authorName: name.trim(),
|
||||
authorEmail: email.trim() || undefined,
|
||||
text: text.trim(),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? "ثبت نظر ناموفق بود.");
|
||||
return;
|
||||
}
|
||||
setComments((prev) => [{ id: `local-${Date.now()}`, authorName: name.trim(), text: text.trim() }, ...prev]);
|
||||
setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="mb-6 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
placeholder="نام شما"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-red"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="ایمیل (اختیاری)"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-red"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="نظر خود را درباره این محصول بنویسید..."
|
||||
className="w-full rounded-xl border border-border bg-background p-3 text-sm outline-none transition-colors focus:border-red"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-dark">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !name.trim() || !text.trim()}
|
||||
className="rounded-lg bg-red px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? "در حال ثبت..." : "ثبت نظر"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<EmptyState message="هنوز نظری برای این محصول ثبت نشده است." />
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="rounded-xl bg-background p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-bold">{comment.authorName}</span>
|
||||
{comment.createdAt && (
|
||||
<span className="text-xs text-muted">{formatDate(comment.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted">{comment.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTabs({
|
||||
technicalInfo,
|
||||
descriptionHtml,
|
||||
entityId,
|
||||
initialComments = [],
|
||||
}: {
|
||||
technicalInfo: TechnicalInfoField[];
|
||||
descriptionHtml?: string;
|
||||
entityId: string;
|
||||
initialComments?: EntityComment[];
|
||||
}) {
|
||||
const [active, setActive] = useState<Tab>("specs");
|
||||
const activeLabel = TABS.find((t) => t.id === active)?.label ?? "";
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex items-center gap-8 border-b border-border">
|
||||
<section className="mt-10 overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="flex flex-wrap items-center gap-x-7 border-b border-border px-5 pt-5 md:gap-x-10 md:px-7">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -42,7 +138,7 @@ export function ProductTabs({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-8">
|
||||
<div className="p-5 md:p-7">
|
||||
<h2 className="mb-5 flex items-center gap-2 text-lg font-bold">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red" aria-hidden />
|
||||
{activeLabel}
|
||||
@@ -52,13 +148,27 @@ export function ProductTabs({
|
||||
(technicalInfo.length === 0 ? (
|
||||
<EmptyState message="هنوز مشخصات فنیای برای این محصول ثبت نشده است." />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{technicalInfo.map((field) => (
|
||||
<div key={field.label} className="rounded-xl bg-background p-4">
|
||||
<p className="mb-1 text-sm font-bold">{field.label} :</p>
|
||||
<p className="text-sm text-muted">{field.value}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-right text-sm">
|
||||
<tbody>
|
||||
{technicalInfo.map((field, index) => (
|
||||
<tr
|
||||
key={`${field.label}-${index}`}
|
||||
className="border-b border-border last:border-b-0 odd:bg-background/60"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-[30%] px-4 py-3.5 align-top font-bold text-foreground md:w-[22%] md:px-5"
|
||||
>
|
||||
{field.label}
|
||||
</th>
|
||||
<td className="px-4 py-3.5 leading-7 text-muted md:px-5">
|
||||
{field.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -72,10 +182,8 @@ export function ProductTabs({
|
||||
<EmptyState message="هنوز نقد و بررسیای برای این محصول ثبت نشده است." />
|
||||
))}
|
||||
|
||||
{active === "comments" && (
|
||||
<EmptyState message="هنوز نظری برای این محصول ثبت نشده است." />
|
||||
)}
|
||||
{active === "comments" && <CommentsPanel entityId={entityId} initialComments={initialComments} />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { getProducts, productHref, type Product } from "@/lib/meshkee";
|
||||
|
||||
export async function SimilarProducts({
|
||||
categoryId,
|
||||
excludeProductId,
|
||||
contactPhone,
|
||||
}: {
|
||||
categoryId?: string;
|
||||
excludeProductId: string;
|
||||
contactPhone?: string;
|
||||
}) {
|
||||
if (!categoryId) return null;
|
||||
|
||||
const { items } = await getProducts({ categoryId, pageSize: 9 });
|
||||
const products: Product[] = items.filter((p) => p.id !== excludeProductId).slice(0, 8);
|
||||
|
||||
if (products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-12 border-t border-border pt-10">
|
||||
<h2 className="mb-5 flex items-center gap-2 text-lg font-bold">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red" aria-hidden />
|
||||
محصولات مشابه
|
||||
</h2>
|
||||
<Carousel ariaLabel="محصولات مشابه">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
name={product.name}
|
||||
brand={product.brand ?? ""}
|
||||
price={product.price}
|
||||
oldPrice={product.oldPrice}
|
||||
discount={product.discount}
|
||||
image={product.image ?? "/images/placeholder.svg"}
|
||||
href={productHref(product)}
|
||||
contactPhone={contactPhone}
|
||||
variantId={product.variantId}
|
||||
slug={product.slug}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useRef } from "react";
|
||||
import { Children, useRef, useState } from "react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FreeMode } from "swiper/modules";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
@@ -10,6 +10,7 @@ import "swiper/css/free-mode";
|
||||
type CarouselProps = {
|
||||
children: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
slideClassName?: string;
|
||||
};
|
||||
|
||||
function ChevronLeftIcon() {
|
||||
@@ -40,11 +41,12 @@ function ChevronRightIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
export function Carousel({ children, ariaLabel, slideClassName }: CarouselProps) {
|
||||
const swiperRef = useRef<SwiperClass | null>(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
const btnClass =
|
||||
"absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-white/40 text-red shadow-sm backdrop-blur-md transition-colors hover:border-red/40 hover:bg-white/60 hover:text-red-dark";
|
||||
"absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-white/40 text-red shadow-sm backdrop-blur-md transition-colors hover:border-red/40 hover:bg-white/60 hover:text-red-dark disabled:pointer-events-none disabled:opacity-0";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -52,6 +54,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
type="button"
|
||||
onClick={() => swiperRef.current?.slidePrev()}
|
||||
aria-label="قبلی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} right-1`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
@@ -60,6 +63,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
type="button"
|
||||
onClick={() => swiperRef.current?.slideNext()}
|
||||
aria-label="بعدی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} left-1`}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
@@ -68,10 +72,17 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
modules={[FreeMode]}
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
setIsLocked(swiper.isLocked);
|
||||
}}
|
||||
onLock={(swiper) => {
|
||||
swiper.slideTo(0, 0);
|
||||
setIsLocked(true);
|
||||
}}
|
||||
onUnlock={() => setIsLocked(false)}
|
||||
dir="rtl"
|
||||
slidesPerView="auto"
|
||||
spaceBetween={16}
|
||||
watchOverflow
|
||||
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
||||
grabCursor
|
||||
role="region"
|
||||
@@ -83,7 +94,12 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
// wrapper height — resolves per-slide instead of stretching to match
|
||||
// the tallest sibling. height:auto (inline, so it wins) lets normal
|
||||
// flex stretch equalize card heights across a row.
|
||||
<SwiperSlide style={{ width: "auto", height: "auto" }}>{child}</SwiperSlide>
|
||||
<SwiperSlide
|
||||
className={slideClassName}
|
||||
style={slideClassName ? { height: "auto" } : { width: "auto", height: "auto" }}
|
||||
>
|
||||
{child}
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
|
||||
type PageShellBreadcrumbItem = { label: string; href?: string };
|
||||
|
||||
export function PageShell({
|
||||
breadcrumbItems,
|
||||
children,
|
||||
}: {
|
||||
breadcrumbItems: PageShellBreadcrumbItem[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-10">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ type ProductCardProps = {
|
||||
href?: string;
|
||||
fluid?: boolean;
|
||||
contactPhone?: string;
|
||||
variantId?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export function ProductCard({
|
||||
@@ -24,7 +26,6 @@ export function ProductCard({
|
||||
variant = "default",
|
||||
href,
|
||||
fluid = false,
|
||||
contactPhone,
|
||||
}: ProductCardProps) {
|
||||
const info = (
|
||||
<>
|
||||
@@ -42,19 +43,21 @@ export function ProductCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 pb-0">
|
||||
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
||||
{brand}
|
||||
</p>
|
||||
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
||||
<div className="px-4 pb-4">
|
||||
{brand && (
|
||||
<p className="font-display mb-1 line-clamp-2 h-8 text-[11px] leading-4 tracking-wide text-muted uppercase">
|
||||
{brand}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-12 flex-col items-end justify-end space-y-1 text-left">
|
||||
{oldPrice != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||
<span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||
{discount != null && (
|
||||
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-[11px] font-bold text-green-700">
|
||||
<span className="rounded-md bg-red px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||
{discount}٪
|
||||
</span>
|
||||
)}
|
||||
@@ -86,32 +89,6 @@ export function ProductCard({
|
||||
) : (
|
||||
<div className="flex-1">{info}</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
{price != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
افزودن به سبد
|
||||
</button>
|
||||
) : contactPhone ? (
|
||||
<a
|
||||
href={`tel:${contactPhone}`}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
<span aria-hidden>📞</span>
|
||||
تماس با فروشگاه
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-full cursor-not-allowed rounded-lg bg-red/60 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
تماس با فروشگاه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
|
||||
type SectionTitleProps = {
|
||||
title: string;
|
||||
enTitle: string;
|
||||
enTitle?: string;
|
||||
subtitle?: string;
|
||||
action?: string;
|
||||
actionHref?: string;
|
||||
};
|
||||
|
||||
export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleProps) {
|
||||
export function SectionTitle({ title, enTitle, subtitle, action, actionHref }: SectionTitleProps) {
|
||||
return (
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 className="text-xl font-bold text-red-dark md:text-2xl">{title}</h2>
|
||||
<span
|
||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||
lang="en"
|
||||
dir="ltr"
|
||||
>
|
||||
{enTitle}
|
||||
</span>
|
||||
{enTitle && (
|
||||
<span
|
||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||
lang="en"
|
||||
dir="ltr"
|
||||
>
|
||||
{enTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-sm font-medium text-red transition-colors hover:text-red-dark"
|
||||
{action && actionHref && (
|
||||
<a
|
||||
href={actionHref}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-red px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||||
>
|
||||
{action} ←
|
||||
</button>
|
||||
{action}
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function Toast({ message, show }: { message: string; show: boolean }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed bottom-6 left-1/2 z-[130] -translate-x-1/2 rounded-xl bg-foreground px-5 py-3 text-sm text-white shadow-lg transition-all duration-300 ${
|
||||
show ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-4 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
type IconProps = { className?: string };
|
||||
|
||||
// Shared light/outline icon set — thin stroke (1.75), rounded caps/joins,
|
||||
// 24x24 viewBox, matching the chevrons already used in Carousel/ProductGallery.
|
||||
// Prefer these over emoji anywhere in the UI.
|
||||
|
||||
export function MenuIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 7h16M4 12h16M4 17h16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.75" />
|
||||
<path
|
||||
d="M4.75 19.25c0-3.45 3.24-6.25 7.25-6.25s7.25 2.8 7.25 6.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M3.5 4.5h1.6l1.1 2M6.2 6.5l1.65 7.3a1.5 1.5 0 0 0 1.46 1.17h6.9a1.5 1.5 0 0 0 1.46-1.15l1.23-5.32H6.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="10" cy="19.5" r="1.15" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="16.5" cy="19.5" r="1.15" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M6 6l12 12M18 6 6 18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M14.5 6.5 9 12l5.5 5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
addToGuestCart,
|
||||
type GuestCartLine,
|
||||
getGuestCart,
|
||||
removeFromGuestCart,
|
||||
setGuestCartQuantity,
|
||||
} from "@/lib/guestCart";
|
||||
|
||||
type CartContextValue = {
|
||||
items: GuestCartLine[];
|
||||
itemCount: number;
|
||||
subtotal: number;
|
||||
popupOpen: boolean;
|
||||
openPopup: () => void;
|
||||
closePopup: () => void;
|
||||
addItem: (line: Omit<GuestCartLine, "quantity"> & { quantity?: number }) => void;
|
||||
updateItem: (id: string, quantity: number) => void;
|
||||
removeItem: (id: string) => void;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<GuestCartLine[]>([]);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
|
||||
// Guest cart lives in localStorage/cookies only — nothing to fetch from a
|
||||
// server, just read what's already there once mounted in the browser.
|
||||
useEffect(() => {
|
||||
setItems(getGuestCart());
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback((line: Omit<GuestCartLine, "quantity"> & { quantity?: number }) => {
|
||||
setItems(addToGuestCart(line));
|
||||
setPopupOpen(true);
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback((id: string, quantity: number) => {
|
||||
setItems(setGuestCartQuantity(id, quantity));
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems(removeFromGuestCart(id));
|
||||
}, []);
|
||||
|
||||
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
|
||||
const value = useMemo<CartContextValue>(
|
||||
() => ({
|
||||
items,
|
||||
itemCount,
|
||||
subtotal,
|
||||
popupOpen,
|
||||
openPopup: () => setPopupOpen(true),
|
||||
closePopup: () => setPopupOpen(false),
|
||||
addItem,
|
||||
updateItem,
|
||||
removeItem,
|
||||
}),
|
||||
[items, itemCount, subtotal, popupOpen, addItem, updateItem, removeItem],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { CartProvider } from "@/context/CartContext";
|
||||
import { CartPopup } from "@/components/cart/CartPopup";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<CartProvider>
|
||||
{children}
|
||||
<CartPopup />
|
||||
</CartProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Storefront mini-cart per Meshkee's contract (api.meshkee.com/docs/website/AI_PROMPT.md
|
||||
// — "Shopping cart on the storefront"). This site never calls the real cart/checkout
|
||||
// API (/businesses/{id}/cart...) — that's the customer dashboard's job. This is a
|
||||
// local, guest-only cart that gets handed off to customer.<domain> on "Continue".
|
||||
const STORAGE_KEY = "meshkee-guest-cart";
|
||||
const DOMAIN = "sanihome.ir";
|
||||
const MAX_COOKIE_LEN = 3500;
|
||||
const THIRTY_DAYS = 30 * 24 * 60 * 60;
|
||||
|
||||
export type GuestCartLine = {
|
||||
id: string; // storeItemVariantId — never a product id or store-item id
|
||||
name: string;
|
||||
slug: string;
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
image: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
function readCart(): GuestCartLine[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as GuestCartLine[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeCart(items: GuestCartLine[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const json = JSON.stringify(items);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, json);
|
||||
} catch {
|
||||
// ignore — private browsing / storage disabled
|
||||
}
|
||||
try {
|
||||
const encoded = encodeURIComponent(json);
|
||||
// Skip the cookie (rely on localStorage + the URL param on Continue)
|
||||
// when it would blow past a sane cookie size.
|
||||
if (encoded.length <= MAX_COOKIE_LEN) {
|
||||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${STORAGE_KEY}=${encoded}; Domain=.${DOMAIN}; Path=/; SameSite=Lax; Max-Age=${THIRTY_DAYS}${secure}`;
|
||||
}
|
||||
} catch {
|
||||
// Domain=.sanihome.ir is rejected on hosts that don't match (e.g. local
|
||||
// dev on localhost) — localStorage still works, cross-domain handoff
|
||||
// just isn't testable outside the real domain.
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuestCart(): GuestCartLine[] {
|
||||
return readCart();
|
||||
}
|
||||
|
||||
export function addToGuestCart(line: Omit<GuestCartLine, "quantity"> & { quantity?: number }): GuestCartLine[] {
|
||||
const items = readCart();
|
||||
const quantity = line.quantity ?? 1;
|
||||
const existing = items.findIndex((i) => i.id === line.id);
|
||||
if (existing >= 0) {
|
||||
items[existing] = { ...items[existing], quantity: items[existing].quantity + quantity };
|
||||
} else {
|
||||
items.push({
|
||||
id: line.id,
|
||||
name: line.name,
|
||||
slug: line.slug,
|
||||
price: line.price,
|
||||
originalPrice: line.originalPrice,
|
||||
image: line.image,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
writeCart(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function setGuestCartQuantity(id: string, quantity: number): GuestCartLine[] {
|
||||
let items = readCart();
|
||||
items = quantity <= 0 ? items.filter((i) => i.id !== id) : items.map((i) => (i.id === id ? { ...i, quantity } : i));
|
||||
writeCart(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function removeFromGuestCart(id: string): GuestCartLine[] {
|
||||
return setGuestCartQuantity(id, 0);
|
||||
}
|
||||
|
||||
// Base64url-encode the cart for the `guestCart` query param — exact algorithm
|
||||
// from the AI_PROMPT.md spec so the customer dashboard can decode it.
|
||||
export function encodeGuestCart(items: GuestCartLine[]): string {
|
||||
const json = JSON.stringify(items);
|
||||
return btoa(unescape(encodeURIComponent(json)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
// The customer dashboard writes this on login (Domain=.sanihome.ir), so it's
|
||||
// readable here too — chunked as `_0`, `_1`, ... when long.
|
||||
export function isCustomerLoggedIn(): boolean {
|
||||
return (
|
||||
readCookie("meshkee_customer_access_token") != null ||
|
||||
readCookie("meshkee_customer_access_token_0") != null
|
||||
);
|
||||
}
|
||||
|
||||
export function customerDashboardOrigin(): string {
|
||||
return `https://customer.${DOMAIN}`;
|
||||
}
|
||||
|
||||
// Where the mini-cart's "Continue" button sends the shopper: the dashboard's
|
||||
// cart page carrying the guest cart, or its login page (redirecting back to
|
||||
// that same cart page) when no session cookie is present yet.
|
||||
export function continueToCheckoutUrl(items: GuestCartLine[]): string {
|
||||
const encoded = encodeGuestCart(items);
|
||||
const cartPath = `/checkout/cart?guestCart=${encoded}`;
|
||||
if (isCustomerLoggedIn()) return `${customerDashboardOrigin()}${cartPath}`;
|
||||
return `${customerDashboardOrigin()}/login?redirect=${encodeURIComponent(cartPath)}`;
|
||||
}
|
||||
+231
-28
@@ -21,6 +21,54 @@ async function apiGet<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// POST helper for the public, unauthenticated write endpoints this storefront
|
||||
// is allowed to call directly (contact form, comments). Cart/checkout/auth
|
||||
// are the customer dashboard's job, not this site's — see lib/guestCart.ts.
|
||||
async function apiPost<T>(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: true; data: T } | { ok: false; message: string }> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: (data as { message?: string })?.message ?? "خطایی رخ داد." };
|
||||
}
|
||||
return { ok: true, data: data as T };
|
||||
} catch {
|
||||
return { ok: false, message: "ارتباط با سرور برقرار نشد." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Full sanitized `<meta …>` HTML from business-info. */
|
||||
export type BusinessMetaTag = {
|
||||
html: string;
|
||||
};
|
||||
|
||||
/** Parse a single `<meta …>` tag into React/Next attribute props. */
|
||||
export function parseMetaTagHtml(html: string): Record<string, string> | null {
|
||||
const match = html.trim().match(/^<meta\b([^>]*)\/?\s*>$/i);
|
||||
if (!match) return null;
|
||||
const attrs: Record<string, string> = {};
|
||||
const attrRe = /([:\w.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = attrRe.exec(match[1]))) {
|
||||
const key = m[1].toLowerCase();
|
||||
if (key === '/') continue;
|
||||
attrs[key] = m[2] ?? m[3] ?? m[4] ?? '';
|
||||
}
|
||||
return Object.keys(attrs).length > 0 ? attrs : null;
|
||||
}
|
||||
|
||||
export type BusinessTrustBadge = {
|
||||
kind: "enamad" | "samandehi";
|
||||
embedHtml: string;
|
||||
};
|
||||
|
||||
export type BusinessInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,6 +76,7 @@ export type BusinessInfo = {
|
||||
about: string;
|
||||
vision: string;
|
||||
logoUrl: string | null;
|
||||
logoDarkUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
emails: string[];
|
||||
phoneNumbers: string[];
|
||||
@@ -40,6 +89,8 @@ export type BusinessInfo = {
|
||||
aparat?: string;
|
||||
};
|
||||
addresses: string[];
|
||||
metaTags?: BusinessMetaTag[];
|
||||
trustBadges?: BusinessTrustBadge[];
|
||||
};
|
||||
|
||||
export async function getBusinessInfo(): Promise<BusinessInfo | null> {
|
||||
@@ -116,6 +167,12 @@ export type Product = {
|
||||
oldPrice?: number | null;
|
||||
discount?: number;
|
||||
image?: string | null;
|
||||
// The specific purchasable store-item variant this card came from (only
|
||||
// known for sources that already resolved one, e.g. store-specials,
|
||||
// brand-groups, store-items — lets "add to cart" skip a variant picker).
|
||||
variantId?: string;
|
||||
stockQuantity?: number;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
@@ -163,6 +220,15 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
? brand
|
||||
: ((brand as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||
(raw.brandName as string | undefined),
|
||||
variantId: firstVariant?.id != null ? String(firstVariant.id) : undefined,
|
||||
stockQuantity:
|
||||
firstVariant?.stockQuantity != null ? Number(firstVariant.stockQuantity) : undefined,
|
||||
categoryId:
|
||||
raw.categoryId != null
|
||||
? String(raw.categoryId)
|
||||
: (raw.category as Record<string, unknown> | undefined)?.id != null
|
||||
? String((raw.category as Record<string, unknown>).id)
|
||||
: undefined,
|
||||
price: numericPrice,
|
||||
oldPrice: numericOldPrice,
|
||||
discount,
|
||||
@@ -179,11 +245,12 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
}
|
||||
|
||||
export function productHref(product: Product): string {
|
||||
if (product.slug) return `/products/${product.slug}`;
|
||||
if (product.variantId) return `/store-items/${product.variantId}`;
|
||||
if (product.slug) return `/store-items/${product.slug}`;
|
||||
// No slug (store-specials/brand-groups/store-items don't return one) — route
|
||||
// through the id-based resolver, which redirects to the canonical slug page.
|
||||
if (product.id) return `/products/id/${product.id}`;
|
||||
return `/products?q=${encodeURIComponent(product.name)}`;
|
||||
if (product.id) return `/store-items/${product.id}`;
|
||||
return `/store-items?q=${encodeURIComponent(product.name)}`;
|
||||
}
|
||||
|
||||
export async function getProducts(params?: {
|
||||
@@ -312,39 +379,85 @@ export async function getProductStoreVariants(productId: string): Promise<Produc
|
||||
export type TechnicalInfoField = { label: string; value: string };
|
||||
|
||||
export async function getProductTechnicalInfo(slug: string): Promise<TechnicalInfoField[]> {
|
||||
const res = await apiGet<{ form: unknown; values: Record<string, unknown>[] }>(
|
||||
const res = await apiGet<{
|
||||
form: { fields?: Record<string, unknown>[] } | null;
|
||||
values: Record<string, unknown>[];
|
||||
}>(
|
||||
`/tenants/${DOMAIN}/products/${slug}/technical-info`,
|
||||
);
|
||||
return (res?.values ?? []).map((v) => ({
|
||||
label: String(v.label ?? v.name ?? v.key ?? ""),
|
||||
value: String(v.value ?? ""),
|
||||
}));
|
||||
const submittedValues = res?.values ?? [];
|
||||
const valuesByFieldId = new Map(
|
||||
submittedValues
|
||||
.filter((value) => value.fieldId != null)
|
||||
.map((value) => [String(value.fieldId), value]),
|
||||
);
|
||||
const valuesByFieldKey = new Map(
|
||||
submittedValues
|
||||
.filter((value) => value.fieldKey != null)
|
||||
.map((value) => [String(value.fieldKey), value]),
|
||||
);
|
||||
|
||||
return [...(res?.form?.fields ?? [])]
|
||||
.sort((a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0))
|
||||
.map((field) => {
|
||||
const fieldId = String(field.id ?? "");
|
||||
const fieldKey = String(field.key ?? "");
|
||||
const submitted = valuesByFieldId.get(fieldId) ?? valuesByFieldKey.get(fieldKey);
|
||||
if (!submitted) return null;
|
||||
|
||||
const options = (field.options as Record<string, unknown>[] | undefined) ?? [];
|
||||
const optionLabel = (optionId: unknown) => {
|
||||
const option = options.find((item) => String(item.id ?? "") === String(optionId ?? ""));
|
||||
return option ? String(option.label ?? option.value ?? option.name ?? "") : "";
|
||||
};
|
||||
const selectedOptionIds = Array.isArray(submitted.optionIds) ? submitted.optionIds : [];
|
||||
const value =
|
||||
String(submitted.textValue ?? submitted.value ?? "").trim() ||
|
||||
optionLabel(submitted.optionId) ||
|
||||
selectedOptionIds.map(optionLabel).filter(Boolean).join("، ");
|
||||
const label = String(field.label ?? field.labelFa ?? field.name ?? field.key ?? "").trim();
|
||||
|
||||
return label && value ? { label, value } : null;
|
||||
})
|
||||
.filter((field): field is TechnicalInfoField => field !== null);
|
||||
}
|
||||
|
||||
export type StoreItem = {
|
||||
id: string;
|
||||
id: string; // the store-item VARIANT id — what the cart API calls storeItemVariantId
|
||||
productId: string;
|
||||
name: string;
|
||||
label: string;
|
||||
image: string | null;
|
||||
price: number | null;
|
||||
discountedPrice: number | null;
|
||||
oldPrice: number | null;
|
||||
discount?: number;
|
||||
stockQuantity: number;
|
||||
isFestival: boolean;
|
||||
};
|
||||
|
||||
function normalizeStoreItem(raw: Record<string, unknown>): StoreItem {
|
||||
const rawPrice = raw.price != null ? Number(raw.price) : null;
|
||||
const rawDiscounted = raw.discountedPrice != null ? Number(raw.discountedPrice) : null;
|
||||
const hasDiscount = rawDiscounted != null && rawPrice != null && rawDiscounted !== rawPrice;
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
productId: String(raw.productId ?? ""),
|
||||
name: String(raw.productTitle ?? raw.productNameFa ?? raw.label ?? ""),
|
||||
// Prefer the Persian name — sanihome.ir is a Persian storefront.
|
||||
name: String(raw.productNameFa ?? raw.productTitle ?? raw.label ?? ""),
|
||||
label: String(raw.label ?? ""),
|
||||
image: (raw.productImage as string | undefined) ?? null,
|
||||
price: raw.price != null ? Number(raw.price) : null,
|
||||
discountedPrice: raw.discountedPrice != null ? Number(raw.discountedPrice) : null,
|
||||
price: hasDiscount ? rawDiscounted : rawPrice,
|
||||
oldPrice: hasDiscount ? rawPrice : null,
|
||||
discount: hasDiscount ? Math.round((1 - rawDiscounted! / rawPrice!) * 100) : undefined,
|
||||
stockQuantity: Number(raw.stockQuantity ?? 0),
|
||||
isFestival: Boolean(raw.isFestival),
|
||||
};
|
||||
}
|
||||
|
||||
export function storeItemHref(item: { id: string }): string {
|
||||
return `/store-items/${item.id}`;
|
||||
}
|
||||
|
||||
export async function getStoreItems(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -364,6 +477,30 @@ export async function getStoreItems(params?: {
|
||||
return { items: (res?.items ?? []).map(normalizeStoreItem), total: res?.total ?? 0 };
|
||||
}
|
||||
|
||||
// One specific variant's full detail — cross-referenced with its parent
|
||||
// product (getProductById) for images/description/technical-info, since the
|
||||
// variant endpoint itself only carries price/stock/selections.
|
||||
export type StoreItemVariantDetail = StoreItem & {
|
||||
selections: { variationId: string; variationName: string; optionId: string; value: string }[];
|
||||
};
|
||||
|
||||
export async function getStoreItemVariant(variantId: string): Promise<StoreItemVariantDetail | null> {
|
||||
const res = await apiGet<{ variant: Record<string, unknown> }>(
|
||||
`/tenants/${DOMAIN}/store-items/${variantId}`,
|
||||
);
|
||||
if (!res?.variant) return null;
|
||||
const raw = res.variant;
|
||||
return {
|
||||
...normalizeStoreItem(raw),
|
||||
selections: ((raw.selections as Record<string, unknown>[] | undefined) ?? []).map((s) => ({
|
||||
variationId: String(s.variationId ?? ""),
|
||||
variationName: String(s.variationName ?? ""),
|
||||
optionId: String(s.optionId ?? ""),
|
||||
value: String(s.value ?? ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type BlogSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -372,6 +509,7 @@ export type BlogSummary = {
|
||||
image?: string | null;
|
||||
publishedAt?: string;
|
||||
categoryName?: string;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||
@@ -383,6 +521,7 @@ function normalizeBlog(raw: Record<string, unknown>): BlogSummary {
|
||||
image: (raw.titleImageUrl as string | undefined) ?? null,
|
||||
publishedAt: raw.publishedAt as string | undefined,
|
||||
categoryName: raw.categoryName as string | undefined,
|
||||
categoryId: raw.categoryId != null ? String(raw.categoryId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -423,27 +562,32 @@ export type ProductGroup = {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
sortOrder: number;
|
||||
items: Product[];
|
||||
};
|
||||
|
||||
export function isSpecialSaleGroup(group: Pick<ProductGroup, "title" | "key">): boolean {
|
||||
return /فروش\s*ویژه|special[\s_-]*sale/i.test(`${group.title} ${group.key}`);
|
||||
}
|
||||
|
||||
function normalizeProductGroups(items: Record<string, unknown>[] | undefined): ProductGroup[] {
|
||||
return (items ?? []).map((group) => ({
|
||||
id: String(group.id ?? group.key ?? ""),
|
||||
key: String(group.key ?? group.id ?? ""),
|
||||
title: String(group.title ?? ""),
|
||||
items: Array.isArray(group.items)
|
||||
? (group.items as Record<string, unknown>[]).map(normalizeProduct)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBrandGroups(): Promise<ProductGroup[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(
|
||||
`/tenants/${DOMAIN}/website/brand-groups`,
|
||||
);
|
||||
return normalizeProductGroups(res?.items);
|
||||
return (items ?? [])
|
||||
.map((group) => ({
|
||||
id: String(group.id ?? group.key ?? ""),
|
||||
key: String(group.key ?? group.id ?? ""),
|
||||
title: String(group.title ?? ""),
|
||||
sortOrder: Number(group.sortOrder ?? 0),
|
||||
items: Array.isArray(group.items)
|
||||
? (group.items as Record<string, unknown>[]).map(normalizeProduct)
|
||||
: [],
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
// Admin-managed, named product carousels ("Special Sale", "پرفروشها",
|
||||
// "بهترینها از نگاه شما", ...) — the whole list, not a fixed set of known
|
||||
// keys, so a new one created in the dashboard shows up on the homepage
|
||||
// automatically. See StoreSpecialCarousels.tsx.
|
||||
export async function getStoreSpecials(): Promise<ProductGroup[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(
|
||||
`/tenants/${DOMAIN}/store-specials`,
|
||||
@@ -514,6 +658,7 @@ export const STATIC_IMAGE_KEYS = {
|
||||
twoBanner: "two-banner",
|
||||
threeBannerRow: "three-banner-row",
|
||||
featuredCategories: "featured-categories",
|
||||
aboutUsImage: "about-us-image",
|
||||
} as const;
|
||||
|
||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
@@ -553,6 +698,15 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
itemCount: null,
|
||||
recommendedWidth: 300,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.aboutUsImage,
|
||||
label: "Homepage about-us image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "14:9",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 560,
|
||||
},
|
||||
];
|
||||
|
||||
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
|
||||
@@ -575,3 +729,52 @@ export function firstImageForSlot(
|
||||
): StaticImageWithUrl | undefined {
|
||||
return imagesForSlot(slots, key)[0];
|
||||
}
|
||||
|
||||
// Comments — public read, public write (POST /comments needs no auth on
|
||||
// Meshkee's side), but the user wants the storefront itself to only let
|
||||
// logged-in customers post; see ProductTabs' comments panel.
|
||||
export type EntityComment = {
|
||||
id: string;
|
||||
authorName: string;
|
||||
text: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export async function getComments(
|
||||
entityType: "product" | "blog" | "portfolio" | "video",
|
||||
entityId: string,
|
||||
): Promise<EntityComment[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(`/tenants/${DOMAIN}/comments`, {
|
||||
entityType,
|
||||
entityId,
|
||||
});
|
||||
return (res?.items ?? []).map((c) => ({
|
||||
id: String(c.id ?? ""),
|
||||
authorName: String(c.authorName ?? ""),
|
||||
text: String(c.text ?? ""),
|
||||
createdAt: c.createdAt as string | undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function postComment(input: {
|
||||
entityType: "product" | "blog" | "portfolio" | "video";
|
||||
entityId: string;
|
||||
authorName: string;
|
||||
authorEmail?: string;
|
||||
text: string;
|
||||
}): Promise<{ ok: boolean; message?: string }> {
|
||||
const res = await apiPost(`/tenants/${DOMAIN}/comments`, input);
|
||||
return res.ok ? { ok: true } : { ok: false, message: res.message };
|
||||
}
|
||||
|
||||
// Contact form
|
||||
export async function submitContactForm(input: {
|
||||
title: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
text: string;
|
||||
}): Promise<{ ok: boolean; message?: string }> {
|
||||
const res = await apiPost(`/tenants/${DOMAIN}/contact-submissions`, input);
|
||||
return res.ok ? { ok: true } : { ok: false, message: res.message };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user